diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index 1d781c0..57d2a0c 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -2,6 +2,7 @@ import json import os import sys import threading +import time import dataclasses import logging from typing import Any, Dict @@ -163,3 +164,54 @@ if _STATIC_DIR.is_dir(): @app.get("/snapshot.example.json") def _fixture(): return FileResponse(str(_STATIC_DIR / "snapshot.example.json"), media_type="application/json") + + +# ───────────────────────────────────────────────────────────── +# Фоновое обновление: прогрев квот и пересбор снапшота. +# +# Две причины, по которым /api/snapshot отдавал пустые квоты навсегда: +# +# 1. state_store наполняет квоты через quota_service.get_snapshot, который +# читает кэш и при промахе отдаёт пустую заглушку, живой опрос НЕ +# запуская. В десктопе кэш грел _refresh_quotas_on_startup; в вебе +# такого не было. Штатный планировщик службы сам по себе не спасает: +# его цикл сначала спит интервал (по умолчанию 300 с) и только потом +# опрашивает. +# +# 2. HubStateStore.get_snapshot() возвращает КЭШИРОВАННЫЙ снапшот и +# пересобирает его лишь при самом первом вызове. Даже после прогрева +# квот ответ оставался прежним. В десктопе пересбор делал _refresh_data. +# ───────────────────────────────────────────────────────────── + +_SNAPSHOT_REFRESH_SEC = 30 + + +def _background_refresh_loop() -> None: + from antigravity_provider.router.quota_collector import AccountQuotaService + + try: + AccountQuotaService.get().fetch_all_configured(force=True) + logger.info("Quota cache warmed on startup") + except Exception as exc: + logger.warning("Quota warm-up failed: %s", exc) + + while True: + try: + HubStateStore.get().refresh(force_scan=False) + except Exception as exc: + logger.warning("Snapshot refresh failed: %s", exc) + time.sleep(_SNAPSHOT_REFRESH_SEC) + + +@app.on_event("startup") +def _start_background_refresh() -> None: + # В фоне: опрос ходит по сети к нескольким провайдерам, держать на нём + # старт сервера нельзя. + threading.Thread(target=_background_refresh_loop, daemon=True, name="hub-web-refresh").start() + + from antigravity_provider.router.quota_collector import AccountQuotaService + + try: + AccountQuotaService.get().start_background_scheduler() + except Exception as exc: + logger.warning("Could not start quota scheduler: %s", exc) diff --git a/tests/test_web_snapshot_freshness.py b/tests/test_web_snapshot_freshness.py new file mode 100644 index 0000000..92600ea --- /dev/null +++ b/tests/test_web_snapshot_freshness.py @@ -0,0 +1,81 @@ +"""Веб-сервер обязан прогревать квоты и пересобирать снапшот. + +Дефект, ради которого написан файл: /api/snapshot отдавал квоты с +source="baseline" и нулём измеренных корзин ВСЕГДА. Две независимые +причины, и лечение только одной из них ничего не давало: + +1. state_store наполняет квоты через quota_service.get_snapshot, который + читает кэш и при промахе отдаёт пустую заглушку, живой опрос не + запуская. Кэш никто не грел: в десктопе это делал + _refresh_quotas_on_startup, в вебе аналога не было. + +2. HubStateStore.get_snapshot() возвращает кэшированный снапшот и + пересобирает его только при первом вызове. Даже после прогрева квот + ответ оставался прежним. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("fastapi") + + +def test_server_declares_startup_refresh(): + """Фоновое обновление должно быть подключено к старту приложения.""" + from antigravity_provider.router.web import server + + assert hasattr(server, "_background_refresh_loop"), ( + "нет фонового цикла: квоты останутся пустыми навсегда" + ) + + handlers = [ + getattr(h, "__name__", "") for h in server.app.router.on_startup + ] + assert any("refresh" in name for name in handlers), ( + f"обработчик старта не зарегистрирован, найдено: {handlers}" + ) + + +def test_refresh_loop_warms_quota_cache_then_rebuilds_snapshot(monkeypatch): + """Прогрев квот обязан предшествовать пересбору снапшота. + + Порядок важен: снапшот, собранный до прогрева, зафиксирует пустые + корзины, и следующий пересбор случится только через интервал. + """ + from antigravity_provider.router.web import server + + order: list[str] = [] + + class _Quota: + @staticmethod + def get(): + return _Quota() + + def fetch_all_configured(self, force: bool = False): + order.append("quota_fetch") + return {} + + def start_background_scheduler(self): + return None + + class _Store: + @staticmethod + def get(): + return _Store() + + def refresh(self, force_scan: bool = True): + order.append("snapshot_refresh") + raise KeyboardInterrupt # прерываем бесконечный цикл + + import antigravity_provider.router.quota_collector as qc + + monkeypatch.setattr(qc, "AccountQuotaService", _Quota) + monkeypatch.setattr(server, "HubStateStore", _Store) + + with pytest.raises(KeyboardInterrupt): + server._background_refresh_loop() + + assert order == ["quota_fetch", "snapshot_refresh"], ( + f"нарушен порядок прогрева и пересбора: {order}" + )