fix(web): квоты не подтягивались — прогрев кэша и пересбор снапшота

/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 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-23 21:32:26 +07:00
parent 4e2e8a9781
commit c75b4e42ac
2 changed files with 133 additions and 0 deletions

View file

@ -2,6 +2,7 @@ import json
import os import os
import sys import sys
import threading import threading
import time
import dataclasses import dataclasses
import logging import logging
from typing import Any, Dict from typing import Any, Dict
@ -163,3 +164,54 @@ if _STATIC_DIR.is_dir():
@app.get("/snapshot.example.json") @app.get("/snapshot.example.json")
def _fixture(): def _fixture():
return FileResponse(str(_STATIC_DIR / "snapshot.example.json"), media_type="application/json") 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)

View file

@ -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}"
)