Files
PolyWeather/web/services/city_api.py
T
2026-06-15 18:47:25 +08:00

1402 lines
51 KiB
Python

"""City API service functions used by the city router."""
from __future__ import annotations
import os
import asyncio
import threading
import time
from copy import deepcopy
from typing import Any, Callable, Dict, List, Optional, Tuple
from fastapi import HTTPException, Request
from fastapi.concurrency import run_in_threadpool
from loguru import logger
import web.routes as legacy_routes
from web.analysis_service import _runway_history_temp_for_city
from web.services.canonical_temperature import build_city_weather_from_canonical
from web.services.latest_observation_overlay import overlay_latest_amsc_observation
from web.services.request_timing import ServerTimingRecorder
_RECENT_DEB_CACHE: Optional[Dict[str, Dict[str, object]]] = None
_RECENT_DEB_CACHE_TS = 0.0
_RECENT_DEB_REFRESHING = False
_RECENT_DEB_LOCK = threading.Lock()
_RECENT_DEB_CACHE_TTL_SEC = max(
60,
int(os.getenv("POLYWEATHER_CITIES_DEB_RECENT_CACHE_TTL_SEC", "300") or "300"),
)
CityDetailPayloadCacheKey = Tuple[str, str, str, str, str, int]
CityChartDetailPayloadCacheKey = Tuple[str, str, str, int]
CityDetailBatchResponseCacheKey = Tuple[Tuple[str, ...], bool, str, str, str, str]
_CITY_DETAIL_PAYLOAD_CACHE: Dict[CityDetailPayloadCacheKey, Dict[str, Any]] = {}
_CITY_DETAIL_PAYLOAD_CACHE_TS: Dict[CityDetailPayloadCacheKey, float] = {}
_CITY_DETAIL_PAYLOAD_INFLIGHT: Dict[CityDetailPayloadCacheKey, "asyncio.Task[Dict[str, Any]]"] = {}
_CITY_DETAIL_PAYLOAD_EPOCH: Dict[str, int] = {}
_CITY_DETAIL_PAYLOAD_LOCK = asyncio.Lock()
_CITY_CHART_DETAIL_PAYLOAD_CACHE: Dict[CityChartDetailPayloadCacheKey, Dict[str, Any]] = {}
_CITY_CHART_DETAIL_PAYLOAD_CACHE_TS: Dict[CityChartDetailPayloadCacheKey, float] = {}
_CITY_CHART_DETAIL_PAYLOAD_LOCK = asyncio.Lock()
_CITY_DETAIL_BATCH_RESPONSE_CACHE: Dict[CityDetailBatchResponseCacheKey, Dict[str, Any]] = {}
_CITY_DETAIL_BATCH_RESPONSE_CACHE_TS: Dict[CityDetailBatchResponseCacheKey, float] = {}
_CITY_DETAIL_BATCH_RESPONSE_INFLIGHT: Dict[CityDetailBatchResponseCacheKey, "asyncio.Task[Dict[str, Any]]"] = {}
_CITY_DETAIL_BATCH_RESPONSE_LOCK = asyncio.Lock()
_CITY_DETAIL_BATCH_BUILD_SEMAPHORE: Optional[threading.BoundedSemaphore] = None
_CITY_DETAIL_BATCH_BUILD_SEMAPHORE_SIZE = 0
_CITY_DETAIL_BATCH_BUILD_SEMAPHORE_LOCK = threading.Lock()
def _city_detail_payload_cache_ttl() -> float:
try:
value = float(os.getenv("POLYWEATHER_CITY_DETAIL_PAYLOAD_CACHE_TTL_SEC", "8") or "8")
except ValueError:
value = 8.0
return max(0.0, min(30.0, value))
def _city_detail_batch_response_cache_ttl() -> float:
try:
value = float(
os.getenv("POLYWEATHER_CITY_DETAIL_BATCH_RESPONSE_CACHE_TTL_SEC", "12")
or "12"
)
except ValueError:
value = 12.0
return max(0.0, 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):
return {}
payload = cached_entry.get("payload")
return payload if isinstance(payload, dict) else {}
async def _get_canonical_city_payload(city: str, *, detail_depth: str = "panel") -> Dict[str, Any]:
try:
row = await run_in_threadpool(legacy_routes._CACHE_DB.get_canonical_temperature, city)
except Exception:
return {}
if not isinstance(row, dict):
return {}
canonical = row.get("payload") or row
if not isinstance(canonical, dict):
return {}
payload = build_city_weather_from_canonical(city, canonical)
if not isinstance(payload, dict) or not payload:
return {}
city_meta = legacy_routes.CITY_REGISTRY.get(city, {}) or {}
city_info = legacy_routes.CITIES.get(city, {}) or {}
risk = legacy_routes.CITY_RISK_PROFILES.get(city, {}) or {}
payload.update(
{
"detail_depth": detail_depth,
"display_name": str(city_meta.get("display_name") or city_meta.get("name") or city.title()),
"lat": city_info.get("lat"),
"lon": city_info.get("lon"),
"temp_symbol": canonical.get("temp_symbol") or payload.get("temp_symbol") or ("°F" if city_info.get("f") else "°C"),
"risk": {
"level": risk.get("risk_level", "low"),
"emoji": risk.get("risk_emoji", "🟢"),
"airport": risk.get("airport_name", ""),
"icao": risk.get("icao", ""),
"distance_km": risk.get("distance_km", 0),
"warning": risk.get("warning", ""),
},
"probabilities": {"mu": None, "distribution": []},
}
)
return await run_in_threadpool(
overlay_latest_amsc_observation,
legacy_routes._CACHE_DB,
city,
payload,
)
def _enqueue_collector_refresh_request(
city: str,
kind: str,
*,
reason: str = "canonical_fallback",
) -> bool:
try:
enqueue = getattr(legacy_routes._CACHE_DB, "enqueue_observation_refresh_request", None)
if not callable(enqueue):
return False
return bool(
enqueue(
city=city,
kind=kind,
priority="high",
reason=reason,
)
)
except Exception as exc:
logger.debug("collector refresh enqueue failed city={} kind={}: {}", city, kind, exc)
return False
def _request_city_cache_refresh(
city: str,
kind: str,
) -> None:
_enqueue_collector_refresh_request(city, kind)
def _request_city_full_refresh(city: str) -> None:
_enqueue_collector_refresh_request(city, "full")
def _build_initializing_city_payload(city: str, *, detail_depth: str) -> Dict[str, Any]:
city_meta = legacy_routes.CITY_REGISTRY.get(city, {}) or {}
city_info = legacy_routes.CITIES.get(city, {}) or {}
risk = legacy_routes.CITY_RISK_PROFILES.get(city, {}) or {}
return {
"city": city,
"name": city,
"display_name": str(city_meta.get("display_name") or city_meta.get("name") or city.title()),
"detail_depth": detail_depth,
"status": "initializing",
"stale": True,
"stale_reason": "collector_refresh_queued",
"lat": city_info.get("lat"),
"lon": city_info.get("lon"),
"temp_symbol": "°F" if city_info.get("f") else "°C",
"risk": {
"level": risk.get("risk_level", "low"),
"emoji": risk.get("risk_emoji", "🟢"),
"airport": risk.get("airport_name", ""),
"icao": risk.get("icao", ""),
"distance_km": risk.get("distance_km", 0),
"warning": risk.get("warning", ""),
},
"current": {
"temp": None,
"source_code": None,
"settlement_source": None,
"settlement_source_label": None,
"obs_time": None,
"freshness": {
"freshness_status": "missing",
"freshness_reason": "collector_refresh_queued",
},
"observation_status": "initializing",
},
"airport_current": {},
"airport_primary": {},
"canonical_temperature": None,
"deb": {"prediction": None},
"probabilities": {"mu": None, "distribution": []},
"hourly": {"times": [], "temps": []},
"multi_model_daily": {},
}
def _queue_and_build_initializing_city_payload(city: str, *, kind: str) -> Dict[str, Any]:
_enqueue_collector_refresh_request(city, kind, reason="cold_start")
return _build_initializing_city_payload(city, detail_depth=kind)
async def _refresh_city_payload_with_stale_timeout(
city: str,
kind: str,
) -> Dict[str, Any]:
cached_before_refresh = await _get_cached_city_payload(city, kind)
if not cached_before_refresh:
canonical_payload = await _get_canonical_city_payload(city, detail_depth=kind)
if canonical_payload:
_enqueue_collector_refresh_request(city, kind, reason="force_refresh")
logger.warning(
"city force refresh returning canonical latest without sync refresh city={} kind={}",
city,
kind,
)
return canonical_payload
return _queue_and_build_initializing_city_payload(city, kind=kind)
_enqueue_collector_refresh_request(city, kind, reason="force_refresh")
logger.warning(
"city force refresh queued collector refresh city={} kind={}; returning cached payload",
city,
kind,
)
latest_payload = await run_in_threadpool(
overlay_latest_amsc_observation,
legacy_routes._CACHE_DB,
city,
cached_before_refresh,
)
return await _overlay_cached_wunderground(city, latest_payload)
async def _refresh_city_cache_with_stale_timeout(
city: str,
kind: str,
) -> Dict[str, Any]:
return await _refresh_city_payload_with_stale_timeout(
city,
kind,
)
def _start_city_cache_stale_refresh(
city: str,
kind: str,
) -> None:
normalized = str(city or "").strip().lower()
cache_kind = str(kind or "").strip().lower()
if not normalized or not cache_kind:
return
_enqueue_collector_refresh_request(normalized, cache_kind, reason="stale_refresh")
async def _overlay_cached_wunderground(city: str, payload: Dict[str, Any]) -> Dict[str, Any]:
latest_payload = await run_in_threadpool(
overlay_latest_amsc_observation,
legacy_routes._CACHE_DB,
city,
payload,
)
return await run_in_threadpool(
legacy_routes._overlay_latest_wunderground_current,
city,
latest_payload,
)
def _overlay_cached_runway_history_from_db(city: str, payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict) or not payload:
return payload
normalized_city = str(city or payload.get("name") or payload.get("city") or "").strip().lower()
if not normalized_city:
return payload
risk = payload.get("risk") if isinstance(payload.get("risk"), dict) else {}
city_risk = legacy_routes.CITY_RISK_PROFILES.get(normalized_city, {}) or {}
city_meta = legacy_routes.CITY_REGISTRY.get(normalized_city, {}) or {}
icao = str(
risk.get("icao")
or city_risk.get("icao")
or city_meta.get("icao")
or ""
).strip().upper()
if not icao:
return payload
try:
rows = legacy_routes._CACHE_DB.get_runway_obs_recent(icao, minutes=36 * 60)
except Exception as exc:
logger.debug("chart runway DB overlay skipped city={} icao={}: {}", normalized_city, icao, exc)
return payload
if not rows:
return payload
use_fahrenheit = (
"F" in str(payload.get("temp_symbol") or "").upper()
or bool((legacy_routes.CITIES.get(normalized_city, {}) or {}).get("f"))
)
runway_history: Dict[str, List[Dict[str, Any]]] = {}
for row in rows:
if not isinstance(row, dict):
continue
runway = str(row.get("runway") or "").strip().upper()
time_val = row.get("otime_utc") or row.get("created_at")
if not runway or not time_val:
continue
temp_val = _runway_history_temp_for_city(normalized_city, row)
if temp_val is None:
continue
if use_fahrenheit:
temp_val = temp_val * 9.0 / 5.0 + 32.0
runway_history.setdefault(runway, []).append(
{
"time": str(time_val),
"temp": round(float(temp_val), 1),
}
)
if not runway_history:
return payload
next_payload = deepcopy(payload)
next_payload["runway_plate_history"] = runway_history
return next_payload
def _start_city_full_stale_refresh(city: str) -> None:
normalized = str(city or "").strip().lower()
if not normalized:
return
_enqueue_collector_refresh_request(normalized, "full", reason="stale_refresh")
async def _get_city_full_data(city: str, *, force_refresh: bool) -> Dict[str, Any]:
if force_refresh:
return await _refresh_city_payload_with_stale_timeout(
city,
"full",
)
cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "full", city)
if cached_entry:
payload = cached_entry.get("payload") or {}
if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_FULL_CACHE_TTL_SEC):
if payload:
_start_city_full_stale_refresh(city)
return await _overlay_cached_wunderground(city, payload)
canonical_payload = await _get_canonical_city_payload(city, detail_depth="full")
if canonical_payload:
_request_city_full_refresh(city)
return canonical_payload
return _queue_and_build_initializing_city_payload(city, kind="full")
return await _overlay_cached_wunderground(city, payload)
canonical_payload = await _get_canonical_city_payload(city, detail_depth="full")
if canonical_payload:
_request_city_full_refresh(city)
return canonical_payload
return _queue_and_build_initializing_city_payload(city, kind="full")
async def _get_city_chart_data(city: str, *, force_refresh: bool) -> Dict[str, Any]:
if force_refresh:
payload = await _get_city_full_data(city, force_refresh=True)
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),
)
payload = await _run_optional_city_chart_overlay(
city=city,
overlay_name="amsc_latest_raw",
payload=payload,
fn=overlay_latest_amsc_observation,
args=(legacy_routes._CACHE_DB, 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),
)
cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "full", city)
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_optional_city_chart_overlay(
city=city,
overlay_name="runway_history",
payload=payload,
fn=_overlay_cached_runway_history_from_db,
args=(city, payload),
)
payload = await _run_optional_city_chart_overlay(
city=city,
overlay_name="amsc_latest_raw",
payload=payload,
fn=overlay_latest_amsc_observation,
args=(legacy_routes._CACHE_DB, 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 {
"name": city,
"display_name": str((legacy_routes.CITY_REGISTRY.get(city, {}) or {}).get("display_name") or city.title()),
}
def _city_detail_payload_cache_key(
data: Dict[str, Any],
market_slug: Optional[str],
target_date: Optional[str],
resolution: Optional[str],
) -> CityDetailPayloadCacheKey:
city = str(data.get("city") or data.get("name") or "").strip().lower()
fingerprint = str(
data.get("updated_at_ts")
or data.get("updated_at")
or data.get("local_time")
or data.get("local_date")
or id(data)
)
generation = _CITY_DETAIL_PAYLOAD_EPOCH.get(city, 0)
return (
city,
str(resolution or "10m"),
str(market_slug or ""),
str(target_date or ""),
fingerprint,
generation,
)
def _city_chart_detail_payload_cache_key(
data: Dict[str, Any],
resolution: Optional[str],
) -> CityChartDetailPayloadCacheKey:
city = str(data.get("city") or data.get("name") or "").strip().lower()
fingerprint = str(
data.get("updated_at_ts")
or data.get("updated_at")
or data.get("local_time")
or data.get("local_date")
or id(data)
)
generation = _CITY_DETAIL_PAYLOAD_EPOCH.get(city, 0)
return (
city,
str(resolution or "10m"),
fingerprint,
generation,
)
async def _build_city_detail_payload_cached(
data: Dict[str, Any],
market_slug: Optional[str],
target_date: Optional[str],
resolution: Optional[str],
) -> Dict[str, Any]:
ttl = _city_detail_payload_cache_ttl()
if ttl <= 0:
return await run_in_threadpool(
legacy_routes._build_city_detail_payload,
data,
market_slug,
target_date,
resolution,
)
key = _city_detail_payload_cache_key(data, market_slug, target_date, resolution)
now_ts = time.time()
async with _CITY_DETAIL_PAYLOAD_LOCK:
cached = _CITY_DETAIL_PAYLOAD_CACHE.get(key)
cached_ts = _CITY_DETAIL_PAYLOAD_CACHE_TS.get(key, 0.0)
if cached is not None and now_ts - cached_ts < ttl:
return cached
task = _CITY_DETAIL_PAYLOAD_INFLIGHT.get(key)
if task is None:
task = asyncio.create_task(
run_in_threadpool(
legacy_routes._build_city_detail_payload,
data,
market_slug,
target_date,
resolution,
),
)
_CITY_DETAIL_PAYLOAD_INFLIGHT[key] = task
try:
payload = await task
finally:
if task.done():
async with _CITY_DETAIL_PAYLOAD_LOCK:
if _CITY_DETAIL_PAYLOAD_INFLIGHT.get(key) is task:
_CITY_DETAIL_PAYLOAD_INFLIGHT.pop(key, None)
async with _CITY_DETAIL_PAYLOAD_LOCK:
_CITY_DETAIL_PAYLOAD_CACHE[key] = payload
_CITY_DETAIL_PAYLOAD_CACHE_TS[key] = time.time()
if len(_CITY_DETAIL_PAYLOAD_CACHE) > 256:
oldest_keys = sorted(
_CITY_DETAIL_PAYLOAD_CACHE_TS,
key=lambda item: _CITY_DETAIL_PAYLOAD_CACHE_TS.get(item, 0.0),
)[:64]
for old_key in oldest_keys:
_CITY_DETAIL_PAYLOAD_CACHE.pop(old_key, None)
_CITY_DETAIL_PAYLOAD_CACHE_TS.pop(old_key, None)
return payload
async def _build_city_chart_detail_payload(
data: Dict[str, Any],
resolution: Optional[str],
) -> Dict[str, Any]:
ttl = _city_detail_payload_cache_ttl()
if ttl <= 0:
return await run_in_threadpool(
legacy_routes._build_city_chart_detail_payload,
data,
resolution,
)
key = _city_chart_detail_payload_cache_key(data, resolution)
now_ts = time.time()
async with _CITY_CHART_DETAIL_PAYLOAD_LOCK:
cached = _CITY_CHART_DETAIL_PAYLOAD_CACHE.get(key)
cached_ts = _CITY_CHART_DETAIL_PAYLOAD_CACHE_TS.get(key, 0.0)
if cached is not None and now_ts - cached_ts < ttl:
return cached
payload = await run_in_threadpool(
legacy_routes._build_city_chart_detail_payload,
data,
resolution,
)
async with _CITY_CHART_DETAIL_PAYLOAD_LOCK:
_CITY_CHART_DETAIL_PAYLOAD_CACHE[key] = payload
_CITY_CHART_DETAIL_PAYLOAD_CACHE_TS[key] = time.time()
if len(_CITY_CHART_DETAIL_PAYLOAD_CACHE) > 256:
oldest_keys = sorted(
_CITY_CHART_DETAIL_PAYLOAD_CACHE_TS,
key=lambda item: _CITY_CHART_DETAIL_PAYLOAD_CACHE_TS.get(item, 0.0),
)[:64]
for old_key in oldest_keys:
_CITY_CHART_DETAIL_PAYLOAD_CACHE.pop(old_key, None)
_CITY_CHART_DETAIL_PAYLOAD_CACHE_TS.pop(old_key, None)
return payload
def _default_deb_recent() -> Dict[str, object]:
return {
"tier": "other",
"hit_rate": None,
"sample_count": 0,
"mae": None,
"last_date": None,
}
def _refresh_recent_deb_cache() -> Dict[str, Dict[str, object]]:
global _RECENT_DEB_CACHE, _RECENT_DEB_CACHE_TS, _RECENT_DEB_REFRESHING
try:
index = legacy_routes._build_recent_deb_performance_index()
with _RECENT_DEB_LOCK:
_RECENT_DEB_CACHE = index
_RECENT_DEB_CACHE_TS = time.time()
return index
except Exception as exc:
logger.warning(f"Recent DEB performance cache refresh failed: {exc}")
with _RECENT_DEB_LOCK:
return _RECENT_DEB_CACHE or {}
finally:
with _RECENT_DEB_LOCK:
_RECENT_DEB_REFRESHING = False
def _get_recent_deb_cache() -> Optional[Dict[str, Dict[str, object]]]:
with _RECENT_DEB_LOCK:
if (
_RECENT_DEB_CACHE is not None
and time.time() - _RECENT_DEB_CACHE_TS < _RECENT_DEB_CACHE_TTL_SEC
):
return _RECENT_DEB_CACHE
return None
def _start_recent_deb_refresh() -> None:
global _RECENT_DEB_REFRESHING
with _RECENT_DEB_LOCK:
if _RECENT_DEB_REFRESHING:
return
_RECENT_DEB_REFRESHING = True
thread = threading.Thread(
target=_refresh_recent_deb_cache,
name="cities-recent-deb-refresh",
daemon=True,
)
thread.start()
def _build_cities_payload(
deb_recent_index: Optional[Dict[str, Dict[str, object]]] = None,
) -> Dict[str, Any]:
out = []
deb_recent_index = deb_recent_index or {}
for name, info in legacy_routes.CITIES.items():
risk = legacy_routes.CITY_RISK_PROFILES.get(name, {})
city_meta = legacy_routes.CITY_REGISTRY.get(name, {}) or {}
deb_recent = deb_recent_index.get(name, _default_deb_recent())
settlement_source = str(info.get("settlement_source") or "metar").strip().lower() or "metar"
provider = legacy_routes.get_country_network_provider(name)
out.append(
{
"name": name,
"display_name": str(city_meta.get("display_name") or city_meta.get("name") or name.title()),
"lat": info["lat"],
"lon": info["lon"],
"utc_offset_seconds": legacy_routes.get_city_utc_offset_seconds(name),
"risk_level": risk.get("risk_level", "low"),
"risk_emoji": risk.get("risk_emoji", "🟢"),
"airport": risk.get("airport_name", ""),
"icao": risk.get("icao", ""),
"temp_unit": "fahrenheit" if info["f"] else "celsius",
"is_major": city_meta.get("is_major", True),
"settlement_source": settlement_source,
"settlement_source_label": legacy_routes.SETTLEMENT_SOURCE_LABELS.get(
settlement_source,
settlement_source.upper(),
),
"settlement_station_code": city_meta.get("settlement_station_code") or city_meta.get("icao"),
"settlement_station_label": city_meta.get("settlement_station_label") or city_meta.get("airport_name"),
"network_provider": provider.provider_code,
"network_provider_label": provider.provider_label,
"deb_recent_tier": deb_recent.get("tier", "other"),
"deb_recent_hit_rate": deb_recent.get("hit_rate"),
"deb_recent_sample_count": deb_recent.get("sample_count", 0),
"deb_recent_mae": deb_recent.get("mae"),
"deb_recent_last_date": deb_recent.get("last_date"),
}
)
return {"cities": out}
async def list_cities_payload(request: Request) -> Dict[str, Any]:
try:
refresh_recent = str(
request.query_params.get("refresh_deb_recent") or "",
).strip().lower() in {"1", "true", "yes"}
if refresh_recent:
deb_recent_index = await run_in_threadpool(_refresh_recent_deb_cache)
else:
deb_recent_index = _get_recent_deb_cache()
if deb_recent_index is None:
_start_recent_deb_refresh()
deb_recent_index = {}
return await run_in_threadpool(_build_cities_payload, deb_recent_index)
except Exception as exc:
logger.error(f"Error in list_cities: {exc}")
raise HTTPException(status_code=500, detail=str(exc)) from exc
async def get_city_detail_payload(
request: Request,
name: str,
*,
force_refresh: bool = False,
depth: str = "panel",
) -> Dict[str, Any]:
city = legacy_routes._normalize_city_or_404(name)
normalized_depth = str(depth or "panel").strip().lower()
if normalized_depth == "full":
legacy_routes._assert_entitlement(request)
detail_mode = "full"
elif normalized_depth == "market":
legacy_routes._assert_entitlement(request)
detail_mode = "market"
elif normalized_depth == "nearby":
detail_mode = "nearby"
else:
detail_mode = "panel"
if detail_mode == "full":
return await _get_city_full_data(city, force_refresh=force_refresh)
if detail_mode == "panel":
if force_refresh:
return await _refresh_city_cache_with_stale_timeout(
city,
"panel",
)
cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "panel", city)
if cached_entry:
if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_PANEL_CACHE_TTL_SEC):
payload = cached_entry.get("payload") or {}
if payload:
_start_city_cache_stale_refresh(city, "panel")
return await _overlay_cached_wunderground(city, payload)
return _queue_and_build_initializing_city_payload(city, kind="panel")
return await _overlay_cached_wunderground(city, cached_entry.get("payload") or {})
canonical_payload = await _get_canonical_city_payload(city, detail_depth="panel")
if canonical_payload:
_request_city_cache_refresh(city, "panel")
return canonical_payload
return _queue_and_build_initializing_city_payload(city, kind="panel")
if detail_mode == "nearby":
if force_refresh:
return await _refresh_city_cache_with_stale_timeout(
city,
"nearby",
)
cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "nearby", city)
if cached_entry:
if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_NEARBY_CACHE_TTL_SEC):
payload = cached_entry.get("payload") or {}
if payload:
_start_city_cache_stale_refresh(city, "nearby")
return await _overlay_cached_wunderground(city, payload)
canonical_payload = await _get_canonical_city_payload(city, detail_depth="nearby")
if canonical_payload:
_request_city_cache_refresh(city, "nearby")
return canonical_payload
return _queue_and_build_initializing_city_payload(city, kind="nearby")
return await _overlay_cached_wunderground(city, cached_entry.get("payload") or {})
canonical_payload = await _get_canonical_city_payload(city, detail_depth="nearby")
if canonical_payload:
_request_city_cache_refresh(city, "nearby")
return canonical_payload
return _queue_and_build_initializing_city_payload(city, kind="nearby")
if detail_mode == "market":
if force_refresh:
return await _refresh_city_cache_with_stale_timeout(
city,
"market",
)
cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "market", city)
if cached_entry:
if not legacy_routes._market_analysis_cache_is_fresh(cached_entry):
payload = cached_entry.get("payload") or {}
if payload:
_start_city_cache_stale_refresh(city, "market")
return await _overlay_cached_wunderground(city, payload)
canonical_payload = await _get_canonical_city_payload(city, detail_depth="market")
if canonical_payload:
_request_city_cache_refresh(city, "market")
return canonical_payload
return _queue_and_build_initializing_city_payload(city, kind="market")
return await _overlay_cached_wunderground(city, cached_entry.get("payload") or {})
canonical_payload = await _get_canonical_city_payload(city, detail_depth="market")
if canonical_payload:
_request_city_cache_refresh(city, "market")
return canonical_payload
return _queue_and_build_initializing_city_payload(city, kind="market")
return _queue_and_build_initializing_city_payload(city, kind=detail_mode)
async def get_city_summary_payload(
_request: Request,
name: str,
*,
force_refresh: bool = False,
) -> Dict[str, Any]:
city = legacy_routes._normalize_city_or_404(name)
if force_refresh:
return await _refresh_city_cache_with_stale_timeout(
city,
"summary",
)
cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "summary", city)
if cached_entry:
if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_SUMMARY_CACHE_TTL_SEC):
payload = cached_entry.get("payload") or {}
if payload:
_start_city_cache_stale_refresh(city, "summary")
return await _overlay_cached_wunderground(city, payload)
return _queue_and_build_initializing_city_payload(city, kind="summary")
return await _overlay_cached_wunderground(city, cached_entry.get("payload") or {})
canonical_payload = await _get_canonical_city_payload(city, detail_depth="summary")
if canonical_payload:
_request_city_cache_refresh(city, "summary")
return canonical_payload
return _queue_and_build_initializing_city_payload(city, kind="summary")
async def get_city_detail_aggregate_payload(
request: Request,
name: str,
*,
force_refresh: bool = False,
market_slug: Optional[str] = None,
target_date: Optional[str] = None,
resolution: Optional[str] = "10m",
) -> Dict[str, Any]:
timer = ServerTimingRecorder(
request,
log_name="city_detail_timing",
prefix="city_detail",
state_attr="city_detail_server_timing",
)
outcome = "ok"
status_code = 200
try:
timer.measure("assert_entitlement", lambda: legacy_routes._assert_entitlement(request))
city = timer.measure("normalize_city", lambda: legacy_routes._normalize_city_or_404(name))
data = await timer.measure_async(
"full_data",
lambda: _get_city_full_data(city, force_refresh=force_refresh),
)
if isinstance(data, dict) and data.get("status") == "initializing":
return data
return await timer.measure_async(
"detail_payload",
lambda: _build_city_detail_payload_cached(
data,
market_slug,
target_date,
resolution,
),
)
except HTTPException as exc:
outcome = f"http_{exc.status_code}"
status_code = exc.status_code
raise
except Exception:
outcome = "exception"
status_code = 500
raise
finally:
timer.finish(outcome=outcome, status_code=status_code)
def _parse_batch_city_names(raw_cities: str, *, limit: int) -> List[str]:
seen = set()
out: List[str] = []
for item in str(raw_cities or "").split(","):
raw = item.strip()
if not raw:
continue
city = legacy_routes._normalize_city_or_404(raw)
if city in seen:
continue
seen.add(city)
out.append(city)
if len(out) >= limit:
break
return out
def _city_detail_batch_response_cache_key(
city_names: List[str],
*,
force_refresh: bool,
market_slug: Optional[str],
target_date: Optional[str],
resolution: Optional[str],
scope: str,
) -> CityDetailBatchResponseCacheKey:
return (
tuple(city_names),
bool(force_refresh),
str(market_slug or ""),
str(target_date or ""),
str(resolution or "10m"),
str(scope or "full"),
)
def _normalize_city_detail_scope(scope: Optional[str]) -> str:
raw = str(scope or "full").strip().lower()
if raw in {"chart", "charts", "terminal", "terminal_chart"}:
return "chart"
return "full"
def _chart_scoped_city_detail(detail: Dict[str, Any]) -> Dict[str, Any]:
overview = detail.get("overview") if isinstance(detail.get("overview"), dict) else {}
timeseries = detail.get("timeseries") if isinstance(detail.get("timeseries"), dict) else {}
forecast = detail.get("forecast") if isinstance(detail.get("forecast"), dict) else {}
airport_primary_today_obs = (
detail.get("airport_primary_today_obs")
or overview.get("airport_primary_today_obs")
or []
)
forecast_daily = (
(forecast.get("daily") if isinstance(forecast, dict) else None)
or timeseries.get("forecast_daily")
or []
)
local_date = detail.get("local_date") or overview.get("local_date")
local_time = detail.get("local_time") or overview.get("local_time")
scoped = {
"city": detail.get("city") or overview.get("name"),
"fetched_at": detail.get("fetched_at"),
"local_date": local_date,
"local_time": local_time,
"overview": {
"name": overview.get("name"),
"display_name": overview.get("display_name"),
"local_date": local_date,
"local_time": local_time,
"temp_symbol": overview.get("temp_symbol"),
"current_temp": overview.get("current_temp"),
"deb_prediction": overview.get("deb_prediction"),
"settlement_source": overview.get("settlement_source"),
"settlement_source_label": overview.get("settlement_source_label"),
},
"timeseries": {
"hourly": timeseries.get("hourly") or detail.get("hourly") or {},
"metar_today_obs": timeseries.get("metar_today_obs") or [],
"settlement_today_obs": timeseries.get("settlement_today_obs") or [],
"forecast_daily": forecast_daily,
},
"hourly": timeseries.get("hourly") or detail.get("hourly") or {},
"models_hourly": detail.get("models_hourly") or {},
"deb": detail.get("deb") or {},
"forecast": {
"today_high": forecast.get("today_high") if isinstance(forecast, dict) else None,
"daily": forecast_daily,
},
"multi_model_daily": detail.get("multi_model_daily") or {},
"probabilities": detail.get("probabilities") or {"mu": None, "distribution": []},
"runway_plate_history": detail.get("runway_plate_history") or {},
"runway_band_history": detail.get("runway_band_history") or [],
"amos": detail.get("amos") or {},
"airport_current": detail.get("airport_current") or {},
"airport_primary": detail.get("airport_primary") or overview.get("airport_primary") or {},
"airport_primary_today_obs": airport_primary_today_obs,
"official": {"airport_primary_today_obs": airport_primary_today_obs},
"wunderground_current": detail.get("wunderground_current") or {},
"settlement_station": detail.get("settlement_station") or overview.get("settlement_station") or {},
}
return scoped
def _apply_city_detail_scope(detail: Dict[str, Any], scope: str) -> Dict[str, Any]:
if scope == "chart":
return _chart_scoped_city_detail(detail)
return detail
async def _build_city_detail_batch_item_async(
city: str,
*,
force_refresh: bool,
market_slug: Optional[str],
target_date: Optional[str],
resolution: Optional[str],
detail_scope: str = "full",
timing_recorder: Optional[ServerTimingRecorder] = None,
) -> Tuple[str, Dict[str, Any]]:
if detail_scope == "chart":
if timing_recorder is not None:
data = await timing_recorder.measure_async(
f"chart_data_{city}",
lambda: _get_city_chart_data(city, force_refresh=force_refresh),
)
detail = await timing_recorder.measure_async(
f"chart_payload_{city}",
lambda: _build_city_chart_detail_payload(data, resolution),
)
else:
data = await _get_city_chart_data(city, force_refresh=force_refresh)
detail = await _build_city_chart_detail_payload(data, resolution)
return city, detail
if timing_recorder is not None:
data = await timing_recorder.measure_async(
f"full_data_{city}",
lambda: _get_city_full_data(city, force_refresh=force_refresh),
)
detail = await timing_recorder.measure_async(
f"detail_payload_{city}",
lambda: _build_city_detail_payload_cached(
data,
market_slug,
target_date,
resolution,
),
)
else:
data = await _get_city_full_data(city, force_refresh=force_refresh)
detail = await _build_city_detail_payload_cached(
data,
market_slug,
target_date,
resolution,
)
return city, _apply_city_detail_scope(detail, detail_scope)
def _city_detail_batch_concurrency() -> int:
try:
value = int(os.getenv("POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY", "1") or "1")
except ValueError:
value = 1
return max(1, min(4, value))
def _city_detail_batch_global_concurrency() -> int:
try:
value = int(os.getenv("POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY", "1") or "1")
except ValueError:
value = 1
return max(1, min(4, value))
def _city_detail_batch_queue_wait_seconds() -> float:
try:
wait_ms = int(
os.getenv("POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS", "3000")
or "3000"
)
except ValueError:
wait_ms = 3000
return max(0.0, min(5.0, wait_ms / 1000.0))
def _city_detail_batch_build_semaphore() -> threading.BoundedSemaphore:
global _CITY_DETAIL_BATCH_BUILD_SEMAPHORE, _CITY_DETAIL_BATCH_BUILD_SEMAPHORE_SIZE
size = _city_detail_batch_global_concurrency()
with _CITY_DETAIL_BATCH_BUILD_SEMAPHORE_LOCK:
if _CITY_DETAIL_BATCH_BUILD_SEMAPHORE is None or _CITY_DETAIL_BATCH_BUILD_SEMAPHORE_SIZE != size:
_CITY_DETAIL_BATCH_BUILD_SEMAPHORE = threading.BoundedSemaphore(size)
_CITY_DETAIL_BATCH_BUILD_SEMAPHORE_SIZE = size
return _CITY_DETAIL_BATCH_BUILD_SEMAPHORE
def _city_detail_batch_partial_timeout_seconds() -> Optional[float]:
try:
timeout_ms = int(
os.getenv("POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS", "8000")
or "8000"
)
except ValueError:
timeout_ms = 8000
if timeout_ms <= 0:
return None
return max(0.001, min(60.0, timeout_ms / 1000.0))
def _city_detail_batch_partial_timeout_ms() -> Optional[int]:
timeout_sec = _city_detail_batch_partial_timeout_seconds()
if timeout_sec is None:
return None
return int(round(timeout_sec * 1000.0))
def _city_detail_batch_partial_reason(
*,
busy: bool,
missing: List[str],
errors: Dict[str, str],
) -> Optional[str]:
if busy:
return "busy"
if missing and errors:
return "timeout_error"
if missing:
return "timeout"
if errors:
return "error"
return None
def _build_city_detail_batch_diagnostics(
*,
city_names: List[str],
details: Dict[str, Any],
errors: Dict[str, str],
missing: List[str],
resolution: Optional[str],
detail_scope: str,
force_refresh: bool,
response_source: str,
busy: bool = False,
city_durations_ms: Optional[Dict[str, float]] = None,
) -> Dict[str, Any]:
city_durations_ms = city_durations_ms or {}
missing_set = set(missing)
error_set = set(errors)
detail_set = set(details)
partial_reason = _city_detail_batch_partial_reason(
busy=busy,
missing=missing,
errors=errors,
)
city_status: Dict[str, Dict[str, Any]] = {}
for city in city_names:
if busy:
status = "busy"
elif city in missing_set:
status = "timeout"
elif city in error_set:
status = "error"
elif city in detail_set:
status = "ok"
else:
status = "missing"
city_status[city] = {
"status": status,
"duration_ms": city_durations_ms.get(city),
}
if city in errors:
city_status[city]["error"] = errors[city]
return {
"version": 1,
"response_source": response_source,
"partial": bool(partial_reason),
"partial_reason": partial_reason,
"requested_count": len(city_names),
"completed_count": len(details),
"missing_count": len(missing),
"error_count": len(errors),
"batch_concurrency": _city_detail_batch_concurrency(),
"global_concurrency": _city_detail_batch_global_concurrency(),
"partial_timeout_ms": _city_detail_batch_partial_timeout_ms(),
"force_refresh": force_refresh,
"resolution": resolution,
"scope": detail_scope,
"city_status": city_status,
}
async def get_city_detail_batch_payload(
request: Request,
*,
cities: str,
force_refresh: bool = False,
market_slug: Optional[str] = None,
target_date: Optional[str] = None,
resolution: Optional[str] = "10m",
scope: Optional[str] = "full",
limit: int = 12,
) -> Dict[str, Any]:
timer = ServerTimingRecorder(
request,
log_name="city_detail_batch_timing",
prefix="city_detail_batch",
state_attr="city_detail_batch_server_timing",
)
outcome = "ok"
status_code = 200
try:
timer.measure("assert_entitlement", lambda: legacy_routes._assert_entitlement(request))
city_names = timer.measure(
"parse_cities",
lambda: _parse_batch_city_names(
cities,
limit=max(1, min(24, int(limit or 12))),
),
)
if not city_names:
return {
"cities": [],
"details": {},
"errors": {},
"missing": [],
"partial": False,
}
detail_scope = _normalize_city_detail_scope(scope)
async def _build_uncached_payload() -> Dict[str, Any]:
build_semaphore = _city_detail_batch_build_semaphore()
queue_wait_seconds = _city_detail_batch_queue_wait_seconds()
acquired = await timer.measure_async(
"wait_builder_slot",
lambda: run_in_threadpool(
build_semaphore.acquire,
True,
queue_wait_seconds,
),
)
if not acquired:
missing = list(city_names)
errors: Dict[str, str] = {}
details: Dict[str, Any] = {}
return {
"cities": city_names,
"details": details,
"errors": errors,
"missing": missing,
"partial": True,
"busy": True,
"stale_reason": "city detail batch builder is busy",
"diagnostics": _build_city_detail_batch_diagnostics(
city_names=city_names,
details=details,
errors=errors,
missing=missing,
resolution=resolution,
detail_scope=detail_scope,
force_refresh=force_refresh,
response_source="busy",
busy=True,
),
}
try:
semaphore = asyncio.Semaphore(_city_detail_batch_concurrency())
city_durations_ms: Dict[str, float] = {}
async def _build_with_limit(city: str) -> Tuple[str, Dict[str, Any]]:
async with semaphore:
started = time.perf_counter()
try:
return await _build_city_detail_batch_item_async(
city,
force_refresh=force_refresh,
market_slug=market_slug,
target_date=target_date,
resolution=resolution,
detail_scope=detail_scope,
timing_recorder=timer,
)
finally:
city_durations_ms[city] = round(
(time.perf_counter() - started) * 1000.0,
1,
)
task_by_city = {
city: asyncio.create_task(_build_with_limit(city))
for city in city_names
}
task_city_lookup = {task: city for city, task in task_by_city.items()}
done, pending = await timer.measure_async(
"build_details",
lambda: asyncio.wait(
task_by_city.values(),
timeout=_city_detail_batch_partial_timeout_seconds(),
),
)
details: Dict[str, Any] = {}
errors: Dict[str, str] = {}
missing: List[str] = []
for task in done:
city = task_city_lookup[task]
try:
result_city, payload = task.result()
except Exception as exc:
errors[city] = str(exc)
continue
details[result_city] = payload
for task in pending:
city = task_city_lookup[task]
missing.append(city)
task.cancel()
missing_set = set(missing)
missing = [city for city in city_names if city in missing_set]
return {
"cities": city_names,
"details": details,
"errors": errors,
"missing": missing,
"partial": bool(missing or errors),
"diagnostics": _build_city_detail_batch_diagnostics(
city_names=city_names,
details=details,
errors=errors,
missing=missing,
resolution=resolution,
detail_scope=detail_scope,
force_refresh=force_refresh,
response_source="fresh_build",
city_durations_ms=city_durations_ms,
),
}
finally:
build_semaphore.release()
cache_ttl = _city_detail_batch_response_cache_ttl()
cache_key = _city_detail_batch_response_cache_key(
city_names,
force_refresh=force_refresh,
market_slug=market_slug,
target_date=target_date,
resolution=resolution,
scope=detail_scope,
)
if cache_ttl > 0 and not force_refresh:
now_ts = time.time()
async with _CITY_DETAIL_BATCH_RESPONSE_LOCK:
cached = _CITY_DETAIL_BATCH_RESPONSE_CACHE.get(cache_key)
cached_ts = _CITY_DETAIL_BATCH_RESPONSE_CACHE_TS.get(cache_key, 0.0)
if cached is not None and now_ts - cached_ts < cache_ttl:
outcome = "cache_hit"
return cached
task = _CITY_DETAIL_BATCH_RESPONSE_INFLIGHT.get(cache_key)
owner = False
if task is None:
owner = True
task = asyncio.create_task(_build_uncached_payload())
_CITY_DETAIL_BATCH_RESPONSE_INFLIGHT[cache_key] = task
try:
payload = await timer.measure_async(
"build_or_wait_cached_batch",
lambda: task,
)
finally:
if owner and task.done():
async with _CITY_DETAIL_BATCH_RESPONSE_LOCK:
if _CITY_DETAIL_BATCH_RESPONSE_INFLIGHT.get(cache_key) is task:
_CITY_DETAIL_BATCH_RESPONSE_INFLIGHT.pop(cache_key, None)
if payload.get("partial"):
outcome = "partial"
elif not owner:
outcome = "shared_inflight"
if owner:
async with _CITY_DETAIL_BATCH_RESPONSE_LOCK:
if not payload.get("partial"):
_CITY_DETAIL_BATCH_RESPONSE_CACHE[cache_key] = payload
_CITY_DETAIL_BATCH_RESPONSE_CACHE_TS[cache_key] = time.time()
if len(_CITY_DETAIL_BATCH_RESPONSE_CACHE) > 128:
oldest_keys = sorted(
_CITY_DETAIL_BATCH_RESPONSE_CACHE_TS,
key=lambda item: _CITY_DETAIL_BATCH_RESPONSE_CACHE_TS.get(item, 0.0),
)[:32]
for old_key in oldest_keys:
_CITY_DETAIL_BATCH_RESPONSE_CACHE.pop(old_key, None)
_CITY_DETAIL_BATCH_RESPONSE_CACHE_TS.pop(old_key, None)
return payload
payload = await _build_uncached_payload()
if payload.get("partial"):
outcome = "partial"
return payload
except HTTPException as exc:
outcome = f"http_{exc.status_code}"
status_code = exc.status_code
raise
except Exception:
outcome = "exception"
status_code = 500
raise
finally:
timer.finish(outcome=outcome, status_code=status_code)