Preserve scan cache on timeout
This commit is contained in:
@@ -2233,6 +2233,71 @@ def test_scan_terminal_service_returns_stale_payload_after_failed_refresh(monkey
|
|||||||
assert stale["stale_reason"] == "upstream 504"
|
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):
|
def test_scan_terminal_service_returns_failed_without_success_snapshot(monkeypatch):
|
||||||
filters = {"scan_mode": "tradable", "limit": 5}
|
filters = {"scan_mode": "tradable", "limit": 5}
|
||||||
scan_terminal_cache._SCAN_TERMINAL_CACHE.clear()
|
scan_terminal_cache._SCAN_TERMINAL_CACHE.clear()
|
||||||
|
|||||||
@@ -47,6 +47,35 @@ def _normalize_city_key(value: Any) -> str:
|
|||||||
return ALIASES.get(text, text)
|
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:
|
def _start_scan_terminal_background_refresh(filters: Dict[str, Any]) -> bool:
|
||||||
if not mark_scan_terminal_refreshing(filters):
|
if not mark_scan_terminal_refreshing(filters):
|
||||||
return False
|
return False
|
||||||
@@ -189,6 +218,16 @@ def _build_scan_terminal_payload_uncached(
|
|||||||
|
|
||||||
summary = ranked_result["summary"]
|
summary = ranked_result["summary"]
|
||||||
top_signal = ranked_result["top_signal"]
|
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 = {
|
payload = {
|
||||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||||
"filters": filters,
|
"filters": filters,
|
||||||
|
|||||||
Reference in New Issue
Block a user