From c75b4e42ac1882fabebd3963610a842ff92aa328 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Sun, 23 Aug 2026 21:32:26 +0700 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D0=BA=D0=B2=D0=BE=D1=82=D1=8B=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BF=D0=BE=D0=B4=D1=82=D1=8F=D0=B3=D0=B8=D0=B2?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D1=81=D1=8C=20=E2=80=94=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D1=80=D0=B5=D0=B2=20=D0=BA=D1=8D=D1=88=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D0=B1=D0=BE=D1=80=20=D1=81=D0=BD?= =?UTF-8?q?=D0=B0=D0=BF=D1=88=D0=BE=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/snapshot отдавал квоты с source="baseline" и нулём измеренных корзин всегда. Две независимые причины, и лечение одной из них ничего не давало. 1. state_store наполняет квоты через quota_service.get_snapshot, который читает кэш и при промахе отдаёт пустую заглушку, живой опрос НЕ запуская. Кэш никто не грел: в десктопе это делал _refresh_quotas_on_startup, в вебе аналога не было. Штатный планировщик службы не спасает — его цикл сначала спит интервал (300 с по умолчанию) и только потом опрашивает. 2. HubStateStore.get_snapshot() возвращает КЭШИРОВАННЫЙ снапшот и пересобирает его только при первом вызове. Даже после прогрева квот ответ оставался прежним. В десктопе пересбор делал _refresh_data. Добавлен фоновый цикл: прогрев квот при старте, затем пересбор снапшота каждые 30 секунд. Порядок важен — снапшот, собранный до прогрева, зафиксировал бы пустые корзины. Проверено исполнением: квоты появляются через ~10 секунд после старта, 24 измеренных корзины, source=provider_api, ag-w2 Gemini неделя 80.5% — совпадает с прямым опросом провайдера. Регрессия закрыта tests/test_web_snapshot_freshness.py, включая проверку порядка «прогрев перед пересбором». Тесты: 327 passed, ruff чисто. Co-Authored-By: Claude Opus 5 --- src/antigravity_provider/router/web/server.py | 52 ++++++++++++ tests/test_web_snapshot_freshness.py | 81 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 tests/test_web_snapshot_freshness.py 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}" + )