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:
parent
4e2e8a9781
commit
c75b4e42ac
2 changed files with 133 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
81
tests/test_web_snapshot_freshness.py
Normal file
81
tests/test_web_snapshot_freshness.py
Normal 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}"
|
||||
)
|
||||
Loading…
Reference in a new issue