diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index ecfb8b8a..6f1a5730 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -925,6 +925,93 @@ def test_chart_scope_overlays_collector_runway_history_from_db(monkeypatch): assert history[-1] == {"time": "2026-06-06T05:28:00+00:00", "temp": 24.8} +def test_chart_data_cache_hit_does_not_start_full_stale_refresh(monkeypatch): + import asyncio + + class FakeCache: + def get_city_cache(self, kind, city): + assert kind == "full" + return { + "payload": { + "name": city, + "display_name": city.title(), + "hourly": {"times": ["13:00"], "temps": [25.0]}, + }, + } + + def get_runway_obs_recent(self, icao, minutes=60): + return [] + + monkeypatch.setattr(city_api.legacy_routes, "_CACHE_DB", FakeCache()) + monkeypatch.setattr( + city_api.legacy_routes, + "_city_cache_is_fresh", + lambda entry, ttl: False, + ) + monkeypatch.setattr( + city_api, + "_start_city_full_stale_refresh", + lambda city: (_ for _ in ()).throw(AssertionError("chart scope must not start full stale refresh")), + ) + monkeypatch.setattr( + city_api.legacy_routes, + "_overlay_latest_wunderground_current", + lambda city, payload: payload, + ) + + payload = asyncio.run(city_api._get_city_chart_data("paris", force_refresh=False)) + + assert payload["hourly"]["temps"] == [25.0] + + +def test_chart_data_returns_cached_payload_when_optional_overlay_times_out(monkeypatch): + import asyncio + + class FakeCache: + def get_city_cache(self, kind, city): + assert kind == "full" + return { + "payload": { + "name": city, + "display_name": city.title(), + "risk": {"icao": "ZSPD"}, + "hourly": {"times": ["13:00"], "temps": [25.0]}, + "runway_plate_history": { + "35R/17L": [{"time": "2026-06-06T05:21:00+00:00", "temp": 24.2}] + }, + }, + } + + def get_runway_obs_recent(self, icao, minutes=60): + return [ + { + "runway": "35R/17L", + "target_runway_max": 24.8, + "otime_utc": "2026-06-06T05:28:00+00:00", + } + ] + + async def fake_run_in_threadpool(fn, *args, **kwargs): + if fn is city_api._overlay_cached_runway_history_from_db: + await asyncio.sleep(0.05) + return fn(*args, **kwargs) + + monkeypatch.setenv("POLYWEATHER_CITY_CHART_OPTIONAL_OVERLAY_TIMEOUT_MS", "1") + monkeypatch.setattr(city_api, "run_in_threadpool", fake_run_in_threadpool) + monkeypatch.setattr(city_api.legacy_routes, "_CACHE_DB", FakeCache()) + monkeypatch.setattr( + city_api.legacy_routes, + "_overlay_latest_wunderground_current", + lambda city, payload: payload, + ) + + payload = asyncio.run(city_api._get_city_chart_data("shanghai", force_refresh=False)) + + assert payload["runway_plate_history"]["35R/17L"] == [ + {"time": "2026-06-06T05:21:00+00:00", "temp": 24.2} + ] + + def test_chart_detail_payload_uses_threadpool_and_reuses_short_cache(monkeypatch): import asyncio diff --git a/web/services/city_api.py b/web/services/city_api.py index 4147193e..c7dcc0d9 100644 --- a/web/services/city_api.py +++ b/web/services/city_api.py @@ -78,6 +78,49 @@ def _city_force_refresh_timeout_sec() -> float: return max(0.01, min(30.0, value)) +def _city_chart_optional_overlay_timeout_sec() -> float: + try: + timeout_ms = int( + os.getenv("POLYWEATHER_CITY_CHART_OPTIONAL_OVERLAY_TIMEOUT_MS", "500") + or "500" + ) + except ValueError: + timeout_ms = 500 + return max(0.001, min(3.0, timeout_ms / 1000.0)) + + +async def _run_optional_city_chart_overlay( + *, + city: str, + overlay_name: str, + payload: Dict[str, Any], + fn: Callable[..., Dict[str, Any]], + args: Tuple[Any, ...], +) -> Dict[str, Any]: + timeout_sec = _city_chart_optional_overlay_timeout_sec() + try: + return await asyncio.wait_for( + run_in_threadpool(fn, *args), + timeout=timeout_sec, + ) + except asyncio.TimeoutError: + logger.warning( + "city chart optional overlay timed out city={} overlay={} timeout_sec={}; returning cached payload", + city, + overlay_name, + timeout_sec, + ) + return payload + except Exception as exc: + logger.debug( + "city chart optional overlay skipped city={} overlay={}: {}", + city, + overlay_name, + exc, + ) + return payload + + async def _get_cached_city_payload(city: str, kind: str) -> Dict[str, Any]: cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, kind, city) if not isinstance(cached_entry, dict): @@ -354,14 +397,20 @@ async def _get_city_chart_data(city: str, *, force_refresh: bool) -> Dict[str, A if cached_entry: payload = cached_entry.get("payload") or {} if payload: - if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_FULL_CACHE_TTL_SEC): - _start_city_full_stale_refresh(city) - payload = await run_in_threadpool( - _overlay_cached_runway_history_from_db, - city, - payload, + payload = await _run_optional_city_chart_overlay( + city=city, + overlay_name="runway_history", + payload=payload, + fn=_overlay_cached_runway_history_from_db, + args=(city, payload), + ) + return await _run_optional_city_chart_overlay( + city=city, + overlay_name="wunderground_current", + payload=payload, + fn=legacy_routes._overlay_latest_wunderground_current, + args=(city, payload), ) - return await _overlay_cached_wunderground(city, payload) return { "name": city,