Files
PolyWeather/src/utils/prewarm_dashboard.py
T
2026-04-16 15:05:14 +08:00

404 lines
14 KiB
Python

from __future__ import annotations
import json
import os
import random
import threading
import time
from datetime import datetime
from typing import Any, Dict, Optional
import httpx
from loguru import logger
from src.database.db_manager import DBManager
DEFAULT_CITIES = [
"ankara",
"istanbul",
"shanghai",
"beijing",
"shenzhen",
"guangzhou",
"wuhan",
"chengdu",
"chongqing",
"hong kong",
"taipei",
"singapore",
"tokyo",
"seoul",
"busan",
"london",
"paris",
"madrid",
]
_RUNTIME_LOCK = threading.Lock()
_WORKER_THREAD: Optional[threading.Thread] = None
_DB = DBManager()
_RUNTIME_STATE_KEY = "dashboard_prewarm"
_RUNTIME_STATE: Dict[str, Any] = {
"cycle_count": 0,
"success_count": 0,
"failure_count": 0,
"last_started_at": None,
"last_finished_at": None,
"last_duration_sec": None,
"last_success": None,
"last_http_status": None,
"last_error": None,
"last_requested_cities": [],
"last_requested_city_count": 0,
"last_include_detail": False,
"last_include_market": False,
"last_force_refresh": False,
"last_warmed_count": 0,
"last_summary_ok": 0,
"last_detail_ok": 0,
"last_market_ok": 0,
"last_failed_count": 0,
"last_heartbeat_ts": None,
"writer_mode": None,
"writer_pid": None,
"writer_thread_name": None,
}
def _truthy_env(value: str, default: bool = False) -> bool:
raw = str(os.getenv(value) or "").strip().lower()
if not raw:
return default
return raw in {"1", "true", "yes", "on"}
def _parse_cities(value: str) -> list[str]:
items = [item.strip() for item in str(value or "").split(",")]
return [item for item in items if item]
def _update_runtime_state(**kwargs: Any) -> None:
with _RUNTIME_LOCK:
_RUNTIME_STATE.update(kwargs)
def _runtime_mode() -> str:
current = threading.current_thread()
if current.name == "dashboard-prewarm-worker":
return "embedded_thread"
if str(os.getenv("POLYWEATHER_PREWARM_WORKER_MODE") or "").strip():
return str(os.getenv("POLYWEATHER_PREWARM_WORKER_MODE") or "").strip().lower()
return "standalone_process"
def _snapshot_runtime_state() -> Dict[str, Any]:
with _RUNTIME_LOCK:
snapshot = dict(_RUNTIME_STATE)
snapshot["last_heartbeat_ts"] = time.time()
snapshot["writer_mode"] = _runtime_mode()
snapshot["writer_pid"] = os.getpid()
snapshot["writer_thread_name"] = threading.current_thread().name
return snapshot
def _persist_runtime_state() -> Dict[str, Any]:
snapshot = _snapshot_runtime_state()
with _RUNTIME_LOCK:
_RUNTIME_STATE.update(
{
"last_heartbeat_ts": snapshot["last_heartbeat_ts"],
"writer_mode": snapshot["writer_mode"],
"writer_pid": snapshot["writer_pid"],
"writer_thread_name": snapshot["writer_thread_name"],
}
)
try:
_DB.set_payment_runtime_state(_RUNTIME_STATE_KEY, snapshot)
except Exception as exc:
logger.debug("dashboard prewarm runtime persist failed: {}", exc)
return snapshot
def _record_prewarm_result(
*,
ok: bool,
duration_sec: float,
http_status: Optional[int],
error: Optional[str],
warmed_count: int,
summary_ok: int,
detail_ok: int,
market_ok: int,
failed_count: int,
) -> None:
with _RUNTIME_LOCK:
_RUNTIME_STATE["cycle_count"] = int(_RUNTIME_STATE.get("cycle_count") or 0) + 1
if ok:
_RUNTIME_STATE["success_count"] = int(_RUNTIME_STATE.get("success_count") or 0) + 1
else:
_RUNTIME_STATE["failure_count"] = int(_RUNTIME_STATE.get("failure_count") or 0) + 1
_RUNTIME_STATE["last_finished_at"] = datetime.now().isoformat(timespec="seconds")
_RUNTIME_STATE["last_duration_sec"] = round(float(duration_sec), 2)
_RUNTIME_STATE["last_success"] = bool(ok)
_RUNTIME_STATE["last_http_status"] = http_status
_RUNTIME_STATE["last_error"] = error
_RUNTIME_STATE["last_warmed_count"] = int(warmed_count or 0)
_RUNTIME_STATE["last_summary_ok"] = int(summary_ok or 0)
_RUNTIME_STATE["last_detail_ok"] = int(detail_ok or 0)
_RUNTIME_STATE["last_market_ok"] = int(market_ok or 0)
_RUNTIME_STATE["last_failed_count"] = int(failed_count or 0)
_persist_runtime_state()
def _parse_iso_timestamp(value: Any) -> float:
text = str(value or "").strip()
if not text:
return 0.0
try:
return datetime.fromisoformat(text).timestamp()
except Exception:
return 0.0
def _runtime_sort_key(payload: Dict[str, Any]) -> float:
if not isinstance(payload, dict):
return 0.0
candidates = [
float(payload.get("last_heartbeat_ts") or 0.0),
_parse_iso_timestamp(payload.get("last_finished_at")),
_parse_iso_timestamp(payload.get("last_started_at")),
]
return max(candidates)
def _load_shared_runtime_state() -> Dict[str, Any]:
try:
payload = _DB.get_payment_runtime_state(_RUNTIME_STATE_KEY)
except Exception as exc:
logger.debug("dashboard prewarm runtime load failed: {}", exc)
return {}
return payload if isinstance(payload, dict) else {}
def get_prewarm_runtime_summary() -> Dict[str, Any]:
configured_cities = _parse_cities(str(os.getenv("POLYWEATHER_PREWARM_CITIES") or ",".join(DEFAULT_CITIES)))
with _RUNTIME_LOCK:
local_runtime = dict(_RUNTIME_STATE)
shared_runtime = _load_shared_runtime_state()
runtime = local_runtime
if _runtime_sort_key(shared_runtime) > _runtime_sort_key(local_runtime):
runtime = shared_runtime
interval_sec = max(30, int(os.getenv("POLYWEATHER_PREWARM_INTERVAL_SEC", "300")))
jitter_sec = max(0, int(os.getenv("POLYWEATHER_PREWARM_JITTER_SEC", "20")))
heartbeat_age_sec = None
last_heartbeat_ts = float(runtime.get("last_heartbeat_ts") or 0.0)
if last_heartbeat_ts > 0:
heartbeat_age_sec = max(0.0, time.time() - last_heartbeat_ts)
shared_alive = bool(
last_heartbeat_ts > 0
and heartbeat_age_sec is not None
and heartbeat_age_sec <= float(interval_sec + jitter_sec + 90)
)
return {
"enabled": _truthy_env("POLYWEATHER_DASHBOARD_PREWARM_ENABLED", False),
"base_url": str(os.getenv("POLYWEATHER_BACKEND_URL") or "http://127.0.0.1:8000").strip(),
"configured_cities": configured_cities,
"configured_city_count": len(configured_cities),
"interval_sec": interval_sec,
"jitter_sec": jitter_sec,
"include_detail": _truthy_env("POLYWEATHER_PREWARM_INCLUDE_DETAIL", True),
"include_market": _truthy_env("POLYWEATHER_PREWARM_INCLUDE_MARKET", False),
"force_refresh": _truthy_env("POLYWEATHER_PREWARM_FORCE_REFRESH", False),
"thread_alive": bool(_WORKER_THREAD and _WORKER_THREAD.is_alive()) or shared_alive,
"heartbeat_age_sec": None if heartbeat_age_sec is None else round(heartbeat_age_sec, 2),
"runtime": runtime,
}
def run_prewarm(
*,
base_url: str,
cities: str,
force_refresh: bool,
include_detail: bool,
include_market: bool,
) -> int:
token = str(os.getenv("POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN") or "").strip()
requested_cities = _parse_cities(cities)
started = time.perf_counter()
_update_runtime_state(
last_started_at=datetime.now().isoformat(timespec="seconds"),
last_requested_cities=requested_cities,
last_requested_city_count=len(requested_cities),
last_include_detail=bool(include_detail),
last_include_market=bool(include_market),
last_force_refresh=bool(force_refresh),
last_error=None,
last_http_status=None,
)
_persist_runtime_state()
if not token:
_record_prewarm_result(
ok=False,
duration_sec=time.perf_counter() - started,
http_status=None,
error="missing_backend_token",
warmed_count=0,
summary_ok=0,
detail_ok=0,
market_ok=0,
failed_count=0,
)
print(
json.dumps(
{
"ok": False,
"reason": "missing_backend_token",
"detail": "POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN is required",
},
ensure_ascii=False,
)
)
return 1
try:
with httpx.Client(timeout=180, follow_redirects=True) as client:
response = client.post(
f"{base_url.rstrip('/')}/api/system/prewarm",
params={
"cities": cities,
"force_refresh": str(bool(force_refresh)).lower(),
"include_detail": str(bool(include_detail)).lower(),
"include_market": str(bool(include_market)).lower(),
},
headers={
"Accept": "application/json",
"x-polyweather-entitlement": token,
},
)
try:
payload = response.json()
except Exception:
payload = {"ok": False, "raw": (response.text or "")[:500]}
warmed_count = int((payload or {}).get("count") or 0) if isinstance(payload, dict) else 0
summary_ok = int((payload or {}).get("summary_ok") or 0) if isinstance(payload, dict) else 0
detail_ok = int((payload or {}).get("detail_ok") or 0) if isinstance(payload, dict) else 0
market_ok = int((payload or {}).get("market_ok") or 0) if isinstance(payload, dict) else 0
failed_count = int((payload or {}).get("failed_count") or 0) if isinstance(payload, dict) else 0
ok = bool(response.is_success and (not isinstance(payload, dict) or payload.get("ok", True)))
_record_prewarm_result(
ok=ok,
duration_sec=time.perf_counter() - started,
http_status=response.status_code,
error=None if ok else str((payload or {}).get("detail") or (payload or {}).get("reason") or response.text[:200]),
warmed_count=warmed_count,
summary_ok=summary_ok,
detail_ok=detail_ok,
market_ok=market_ok,
failed_count=failed_count,
)
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0 if response.is_success else 1
except Exception as exc:
_record_prewarm_result(
ok=False,
duration_sec=time.perf_counter() - started,
http_status=None,
error=str(exc),
warmed_count=0,
summary_ok=0,
detail_ok=0,
market_ok=0,
failed_count=0,
)
print(json.dumps({"ok": False, "reason": "request_failed", "detail": str(exc)}, ensure_ascii=False, indent=2))
return 1
def run_worker_loop(
*,
base_url: str,
cities: str,
interval_sec: int,
jitter_sec: int,
force_refresh: bool,
include_detail: bool,
include_market: bool,
once: bool = False,
) -> int:
interval_sec = max(30, int(interval_sec))
jitter_sec = max(0, int(jitter_sec))
logger.info(
"dashboard prewarm worker started base_url={} cities={} interval_sec={} jitter_sec={} include_detail={} include_market={} force_refresh={} once={}",
base_url,
cities,
interval_sec,
jitter_sec,
bool(include_detail),
bool(include_market),
bool(force_refresh),
bool(once),
)
_persist_runtime_state()
while True:
started = time.perf_counter()
exit_code = run_prewarm(
base_url=base_url,
cities=cities,
force_refresh=bool(force_refresh),
include_detail=bool(include_detail),
include_market=bool(include_market),
)
elapsed = time.perf_counter() - started
logger.info(
"dashboard prewarm worker cycle exit_code={} elapsed_sec={} finished_at={}",
exit_code,
round(elapsed, 2),
datetime.now().isoformat(timespec="seconds"),
)
if once:
return exit_code
sleep_sec = max(5.0, interval_sec - elapsed)
if jitter_sec > 0:
sleep_sec += random.randint(0, jitter_sec)
logger.info("dashboard prewarm worker sleeping sleep_sec={}", round(sleep_sec, 2))
time.sleep(sleep_sec)
def start_prewarm_worker_thread() -> Optional[threading.Thread]:
enabled = str(os.getenv("POLYWEATHER_DASHBOARD_PREWARM_ENABLED") or "").strip().lower()
if enabled not in {"1", "true", "yes", "on"}:
return None
base_url = str(os.getenv("POLYWEATHER_BACKEND_URL") or "http://127.0.0.1:8000").strip()
cities = str(os.getenv("POLYWEATHER_PREWARM_CITIES") or ",".join(DEFAULT_CITIES)).strip()
interval_sec = int(os.getenv("POLYWEATHER_PREWARM_INTERVAL_SEC", "300"))
jitter_sec = int(os.getenv("POLYWEATHER_PREWARM_JITTER_SEC", "20"))
force_refresh = str(os.getenv("POLYWEATHER_PREWARM_FORCE_REFRESH") or "").strip().lower() in {"1", "true", "yes", "on"}
include_detail = str(os.getenv("POLYWEATHER_PREWARM_INCLUDE_DETAIL", "true")).strip().lower() in {"1", "true", "yes", "on"}
include_market = str(os.getenv("POLYWEATHER_PREWARM_INCLUDE_MARKET", "false")).strip().lower() in {"1", "true", "yes", "on"}
thread = threading.Thread(
target=run_worker_loop,
kwargs={
"base_url": base_url,
"cities": cities,
"interval_sec": interval_sec,
"jitter_sec": jitter_sec,
"force_refresh": force_refresh,
"include_detail": include_detail,
"include_market": include_market,
"once": False,
},
name="dashboard-prewarm-worker",
daemon=True,
)
thread.start()
global _WORKER_THREAD
_WORKER_THREAD = thread
return thread