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 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)

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