Reject stale scan terminal panel data

This commit is contained in:
2569718930@qq.com
2026-06-30 11:46:53 +08:00
parent f9b7b49690
commit ad9b33f957
2 changed files with 151 additions and 5 deletions
+112 -2
View File
@@ -1,3 +1,6 @@
import time
from datetime import datetime, timedelta, timezone
from web.scan_terminal_filters import normalize_scan_terminal_filters from web.scan_terminal_filters import normalize_scan_terminal_filters
from web import scan_terminal_cache from web import scan_terminal_cache
from web import scan_terminal_service from web import scan_terminal_service
@@ -18,6 +21,10 @@ from web.routers.scan import router as scan_router
from web.scan_terminal_service import _scan_terminal_prewarm_filters from web.scan_terminal_service import _scan_terminal_prewarm_filters
def _local_date_for_offset(offset_seconds):
return (datetime.now(timezone.utc) + timedelta(seconds=offset_seconds)).strftime("%Y-%m-%d")
class _FakeRedis: class _FakeRedis:
def __init__(self): def __init__(self):
self.data = {} self.data = {}
@@ -257,7 +264,7 @@ def test_scan_terminal_prewarm_queues_city_refresh_without_analyze(monkeypatch):
def test_scan_city_terminal_rows_reuses_persisted_panel_cache(monkeypatch): def test_scan_city_terminal_rows_reuses_persisted_panel_cache(monkeypatch):
payload = { payload = {
"display_name": "Paris", "display_name": "Paris",
"local_date": "2026-06-10", "local_date": _local_date_for_offset(3600),
"local_time": "12:00", "local_time": "12:00",
"current": {"temp": 18.0}, "current": {"temp": 18.0},
"risk": {}, "risk": {},
@@ -270,7 +277,7 @@ def test_scan_city_terminal_rows_reuses_persisted_panel_cache(monkeypatch):
@staticmethod @staticmethod
def get_city_cache(kind, city): def get_city_cache(kind, city):
assert (kind, city) == ("panel", "paris") assert (kind, city) == ("panel", "paris")
return {"payload": payload} return {"payload": payload, "updated_at_ts": time.time()}
monkeypatch.setattr(scan_terminal_city_row, "_PANEL_CACHE_DB", _Cache()) monkeypatch.setattr(scan_terminal_city_row, "_PANEL_CACHE_DB", _Cache())
monkeypatch.setattr( monkeypatch.setattr(
@@ -291,6 +298,109 @@ def test_scan_city_terminal_rows_reuses_persisted_panel_cache(monkeypatch):
assert result["rows"][0]["current_temp"] == 18.0 assert result["rows"][0]["current_temp"] == 18.0
def test_scan_city_terminal_rows_rejects_expired_panel_cache(monkeypatch):
enqueued = []
payload = {
"display_name": "Paris",
"local_date": _local_date_for_offset(3600),
"local_time": "12:00",
"current": {"temp": 18.0},
"risk": {},
"deb": {"prediction": 20.0},
"probabilities": {},
"multi_model_daily": {},
}
class _Cache:
@staticmethod
def get_city_cache(kind, city):
assert (kind, city) == ("panel", "paris")
return {"payload": payload, "updated_at_ts": 1}
@staticmethod
def get_canonical_temperature(city):
assert city == "paris"
return None
@staticmethod
def enqueue_observation_refresh_request(**kwargs):
enqueued.append(kwargs)
return True
monkeypatch.setattr(scan_terminal_city_row, "_PANEL_CACHE_DB", _Cache())
result = scan_terminal_city_row._scan_city_terminal_rows(
"paris",
{"market_type": "maxtemp"},
force_refresh=False,
)
assert result["city"] == "paris"
assert result["rows"] == []
assert enqueued == [
{
"city": "paris",
"kind": "panel",
"priority": "high",
"reason": "scan_terminal_stale_panel",
}
]
def test_scan_city_terminal_rows_rejects_wrong_local_date_panel_cache(monkeypatch):
enqueued = []
payload = {
"display_name": "Paris",
"local_date": "2000-01-01",
"local_time": "12:00",
"current": {"temp": 18.0},
"risk": {},
"deb": {"prediction": 20.0},
"probabilities": {},
"multi_model_daily": {
"2000-01-01": {
"deb": {"prediction": 20.0},
"models": {"ECMWF": 19.0},
}
},
}
class _Cache:
@staticmethod
def get_city_cache(kind, city):
assert (kind, city) == ("panel", "paris")
return {"payload": payload, "updated_at_ts": time.time()}
@staticmethod
def get_canonical_temperature(city):
assert city == "paris"
return None
@staticmethod
def enqueue_observation_refresh_request(**kwargs):
enqueued.append(kwargs)
return True
monkeypatch.setattr(scan_terminal_city_row, "_PANEL_CACHE_DB", _Cache())
result = scan_terminal_city_row._scan_city_terminal_rows(
"paris",
{"market_type": "maxtemp"},
force_refresh=False,
)
assert result["city"] == "paris"
assert result["rows"] == []
assert enqueued == [
{
"city": "paris",
"kind": "panel",
"priority": "high",
"reason": "scan_terminal_stale_panel_date",
}
]
def test_scan_city_terminal_rows_uses_canonical_without_analyze(monkeypatch): def test_scan_city_terminal_rows_uses_canonical_without_analyze(monkeypatch):
enqueued = [] enqueued = []
+39 -3
View File
@@ -2,10 +2,12 @@ from __future__ import annotations
import hashlib import hashlib
import re import re
from datetime import datetime, timedelta import time
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from src.database.db_manager import DBManager from src.database.db_manager import DBManager
from src.utils.refresh_policy import SCAN_ROWS_REFRESH_SEC
from web.core import CITIES, _sf as _safe_float from web.core import CITIES, _sf as _safe_float
from web.scan_terminal_filters import ( from web.scan_terminal_filters import (
market_region_from_tz_offset as _market_region_from_tz_offset, market_region_from_tz_offset as _market_region_from_tz_offset,
@@ -19,6 +21,7 @@ SCAN_ROW_RUNWAY_HISTORY_RESOLUTION = "10m"
SCAN_ROW_MAX_RUNWAY_POINTS = 144 SCAN_ROW_MAX_RUNWAY_POINTS = 144
_PANEL_CACHE_DB = DBManager() _PANEL_CACHE_DB = DBManager()
_analyze = None # compatibility hook for tests that assert scan terminal stays cache-only. _analyze = None # compatibility hook for tests that assert scan terminal stays cache-only.
SCAN_PANEL_CACHE_MAX_AGE_SEC = max(300, int(SCAN_ROWS_REFRESH_SEC) * 3)
def _compact_runway_plate_history_for_scan(raw_history: Any) -> Dict[str, List[Dict[str, Any]]]: def _compact_runway_plate_history_for_scan(raw_history: Any) -> Dict[str, List[Dict[str, Any]]]:
@@ -47,12 +50,44 @@ def _enqueue_scan_terminal_refresh(city: str, *, reason: str) -> None:
return return
def _city_local_date(city: str, utc_offset_seconds: Optional[int] = None) -> str:
city_meta = CITIES.get(city) or {}
offset = utc_offset_seconds
if offset is None:
offset = _safe_int(city_meta.get("tz"), 0)
try:
offset = int(offset or 0)
except Exception:
offset = 0
return (datetime.now(timezone.utc) + timedelta(seconds=offset)).strftime("%Y-%m-%d")
def _panel_cache_stale_reason(city: str, cached_entry: Dict[str, Any], payload: Dict[str, Any]) -> Optional[str]:
updated_at_ts = _safe_float(cached_entry.get("updated_at_ts"))
if updated_at_ts is None or time.time() - updated_at_ts > SCAN_PANEL_CACHE_MAX_AGE_SEC:
return "scan_terminal_stale_panel"
tz_offset = payload.get("utc_offset_seconds")
if tz_offset is None:
tz_offset = (CITIES.get(city) or {}).get("tz")
expected_date = _city_local_date(city, _safe_int(tz_offset, 0))
payload_date = str(payload.get("local_date") or "").strip()
if payload_date and payload_date != expected_date:
return "scan_terminal_stale_panel_date"
return None
def _load_scan_panel_payload(city: str, *, force_refresh: bool) -> Optional[Dict[str, Any]]: def _load_scan_panel_payload(city: str, *, force_refresh: bool) -> Optional[Dict[str, Any]]:
refresh_already_queued = False
if not force_refresh: if not force_refresh:
cached_entry = _PANEL_CACHE_DB.get_city_cache("panel", city) cached_entry = _PANEL_CACHE_DB.get_city_cache("panel", city)
cached_payload = cached_entry.get("payload") if isinstance(cached_entry, dict) else None cached_payload = cached_entry.get("payload") if isinstance(cached_entry, dict) else None
if isinstance(cached_payload, dict): if isinstance(cached_payload, dict):
return cached_payload stale_reason = _panel_cache_stale_reason(city, cached_entry, cached_payload)
if stale_reason is None:
return cached_payload
_enqueue_scan_terminal_refresh(city, reason=stale_reason)
refresh_already_queued = True
canonical_getter = getattr(_PANEL_CACHE_DB, "get_canonical_temperature", None) canonical_getter = getattr(_PANEL_CACHE_DB, "get_canonical_temperature", None)
canonical_entry = canonical_getter(city) if callable(canonical_getter) else None canonical_entry = canonical_getter(city) if callable(canonical_getter) else None
@@ -69,7 +104,8 @@ def _load_scan_panel_payload(city: str, *, force_refresh: bool) -> Optional[Dict
_enqueue_scan_terminal_refresh(city, reason="scan_terminal_canonical_fallback") _enqueue_scan_terminal_refresh(city, reason="scan_terminal_canonical_fallback")
return payload return payload
_enqueue_scan_terminal_refresh(city, reason="scan_terminal_cold_start") if not refresh_already_queued:
_enqueue_scan_terminal_refresh(city, reason="scan_terminal_cold_start")
return None return None