From 22f4ac1f26c4bc00306dc1dfec1568590ccde600 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 31 May 2026 23:54:35 +0800 Subject: [PATCH] Preserve scan cache on timeout --- tests/test_web_observability.py | 65 +++++++++++++++++++++++++++++++++ web/scan_terminal_service.py | 39 ++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 417555c0..4ce29d37 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -2233,6 +2233,71 @@ def test_scan_terminal_service_returns_stale_payload_after_failed_refresh(monkey assert stale["stale_reason"] == "upstream 504" +def test_scan_terminal_timeout_does_not_replace_better_cached_snapshot(monkeypatch): + import time + + filters = {"scan_mode": "tradable", "limit": 5} + normalized_filters = scan_terminal_service._normalize_scan_terminal_filters(filters) + scan_terminal_cache._SCAN_TERMINAL_CACHE.clear() + previous_payload = { + "generated_at": "2026-05-31T00:00:00Z", + "snapshot_id": "scan-existing", + "filters": normalized_filters, + "summary": {"candidate_total": 2, "visible_count": 2}, + "top_signal": {"id": "old-1"}, + "rows": [{"id": "old-1"}, {"id": "old-2"}], + "status": "ready", + "stale": False, + "stale_reason": None, + "last_success_at": None, + "last_failed_at": None, + } + scan_terminal_cache.set_cached_scan_terminal_payload( + normalized_filters, + previous_payload, + ) + + monkeypatch.setattr( + scan_terminal_service, + "CITIES", + {"fast": {"tz": 0}, "slow": {"tz": 0}}, + ) + monkeypatch.setattr(scan_terminal_service, "SCAN_TERMINAL_BUILD_TIMEOUT_SEC", 0.01) + monkeypatch.setattr(scan_terminal_service, "SCAN_TERMINAL_MAX_WORKERS", 2) + + def _scan_city(city_name, *_args, **_kwargs): + if city_name == "slow": + time.sleep(0.05) + return { + "city": city_name, + "candidate_total": 1, + "primary_scores": [80.0], + "rows": [ + { + "id": f"{city_name}-row", + "market_key": f"{city_name}-market", + "edge_percent": 4.0, + "final_score": 80.0, + "volume": 1000, + } + ], + } + + monkeypatch.setattr(scan_terminal_service, "_scan_city_terminal_rows", _scan_city) + + stale = scan_terminal_service.build_scan_terminal_payload(filters, force_refresh=True) + + assert stale["status"] == "stale" + assert stale["stale"] is True + assert [row["id"] for row in stale["rows"]] == ["old-1", "old-2"] + assert stale["stale_reason"].startswith("scan terminal build timed out") + cached = scan_terminal_cache.get_cached_scan_terminal_payload( + normalized_filters, + ttl_sec=3600, + ) + assert [row["id"] for row in cached["rows"]] == ["old-1", "old-2"] + + def test_scan_terminal_service_returns_failed_without_success_snapshot(monkeypatch): filters = {"scan_mode": "tradable", "limit": 5} scan_terminal_cache._SCAN_TERMINAL_CACHE.clear() diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index 9e229888..730a3bb8 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -47,6 +47,35 @@ def _normalize_city_key(value: Any) -> str: return ALIASES.get(text, text) +def _rows_count(payload: Dict[str, Any]) -> int: + rows = payload.get("rows") + return len(rows) if isinstance(rows, list) else 0 + + +def _build_stale_payload_for_timeout_if_better_cached( + *, + filters: Dict[str, Any], + cached_entry: Dict[str, Any], + ranked_rows: List[Dict[str, Any]], + timeout_message: Optional[str], +) -> Optional[Dict[str, Any]]: + success_payload = cached_entry.get("success_payload") + if not isinstance(success_payload, dict) or not success_payload.get("rows"): + return None + if _rows_count(success_payload) < len(ranked_rows): + return None + + error_message = timeout_message or "市场扫描快照正在刷新中" + set_scan_terminal_failure_state(filters, error_message=error_message) + failed_entry = get_scan_terminal_cache_entry(filters) or cached_entry + return build_stale_scan_terminal_payload( + filters=filters, + success_payload=success_payload, + error_message=error_message, + failed_at=failed_entry.get("last_failed_at"), + ) + + def _start_scan_terminal_background_refresh(filters: Dict[str, Any]) -> bool: if not mark_scan_terminal_refreshing(filters): return False @@ -189,6 +218,16 @@ def _build_scan_terminal_payload_uncached( summary = ranked_result["summary"] top_signal = ranked_result["top_signal"] + if timed_out: + stale_payload = _build_stale_payload_for_timeout_if_better_cached( + filters=filters, + cached_entry=cached_entry, + ranked_rows=ranked_rows, + timeout_message=timeout_message, + ) + if stale_payload is not None: + return stale_payload + payload = { "generated_at": datetime.utcnow().isoformat() + "Z", "filters": filters,