Add production cache warmer

This commit is contained in:
2569718930@qq.com
2026-06-14 04:23:15 +08:00
parent 4009a26431
commit 25408797f1
7 changed files with 539 additions and 10 deletions
+36
View File
@@ -207,6 +207,37 @@ warm_public_route() {
return 0
}
wait_for_scan_terminal_ready() {
local name="$1"
local url="$2"
local timeout="${3:-35}"
local attempts="${4:-8}"
local delay="${5:-5}"
local output=""
local compact=""
local status=""
for i in $(seq 1 "$attempts"); do
if output=$(curl -fsS --max-time "$timeout" "$url" 2>&1); then
compact="$(printf '%s' "$output" | tr -d '\n\r\t ')"
if printf '%s' "$compact" | grep -q '"status":"ready"'; then
echo "$name ready after attempt $i/$attempts"
return 0
fi
status="$(printf '%s' "$compact" | sed -n 's/.*"status":"\([^"]*\)".*/\1/p' | head -n 1)"
echo " $name not ready attempt $i/$attempts status=${status:-unknown}"
else
echo " $name request failed attempt $i/$attempts ($output)"
fi
if [ "$i" != "$attempts" ]; then
sleep "$delay"
fi
done
echo "$name did not return status=ready"
return 1
}
read_env_file_value() {
local key="$1"
if [ ! -f ".env" ]; then
@@ -278,12 +309,16 @@ fi
echo "Updating observation collector..."
compose_up_retry "observation collector" -d --no-deps polyweather_collector
echo "Updating cache warmer..."
compose_up_retry "cache warmer" -d --no-deps polyweather_warmer
echo "Updating frontend..."
compose_up_retry "frontend" -d --no-deps polyweather_frontend
echo "Waiting for frontend..."
wait_for_local_service "frontend root" "http://127.0.0.1:3001/" 5 40 2 || FAILED_FRONTEND=1
wait_for_local_service "frontend terminal" "http://127.0.0.1:3001/terminal" 10 20 2 || FAILED_FRONTEND=1
wait_for_scan_terminal_ready "scan terminal snapshot" "http://127.0.0.1:3001/api/scan/terminal" 35 8 5 || FAILED_FRONTEND=1
FAILED_FRONTEND="${FAILED_FRONTEND:-0}"
if [ "$FAILED_FRONTEND" = "1" ]; then
echo "❌ Frontend did not become healthy"
@@ -292,6 +327,7 @@ if [ "$FAILED_FRONTEND" = "1" ]; then
fi
warm_public_route "terminal" "https://polyweather.top/terminal" 20 4 3
warm_public_route "scan terminal" "https://polyweather.top/api/scan/terminal" 35 3 2
warm_public_route "auth snapshot" "https://polyweather.top/api/auth/me?prefer_snapshot=1" 10 3 2
warm_public_route "local cities recent stats" "http://127.0.0.1:8000/api/cities?refresh_deb_recent=1" 15 2 2
warm_public_route "cities" "https://polyweather.top/api/cities" 20 3 2
+55 -5
View File
@@ -2,7 +2,7 @@ services:
polyweather_redis:
image: redis:7-alpine
container_name: polyweather_redis
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy noeviction
command: redis-server --appendonly yes --maxmemory ${POLYWEATHER_REDIS_MAXMEMORY:-512mb} --maxmemory-policy noeviction
restart: unless-stopped
healthcheck:
interval: 10s
@@ -100,9 +100,13 @@ services:
env_file: *id001
environment:
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}
POLYWEATHER_REDIS_REQUIRED: ${POLYWEATHER_REDIS_REQUIRED:-true}
POLYWEATHER_REDIS_STREAM_MAXLEN: ${POLYWEATHER_REDIS_STREAM_MAXLEN:-100000}
POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_ENABLED: ${POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_ENABLED:-true}
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''
POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-1}
POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-1}
POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-2}
POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-2}
POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS:-3000}
POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS:-8000}
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
@@ -114,8 +118,8 @@ services:
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC: '30'
POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS: ${POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS:-1}
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS: ${POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS:-4}
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: ${POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED:-false}
POLYWEATHER_SERVICE_ROLE: web
UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
healthcheck:
@@ -164,6 +168,7 @@ services:
POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-15}
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
POLYWEATHER_OBSERVATION_COLLECTOR_CACHE_REFRESH_WORKERS: ${POLYWEATHER_OBSERVATION_COLLECTOR_CACHE_REFRESH_WORKERS:-2}
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
POLYWEATHER_SERVICE_ROLE: collector
@@ -186,6 +191,51 @@ services:
volumes:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
polyweather_warmer:
command: python -m web.cache_warmer_worker
container_name: polyweather_warmer
depends_on:
polyweather_redis:
condition: service_healthy
polyweather_web:
condition: service_started
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
cpus: ${POLYWEATHER_WARMER_CPUS:-0.75}
env_file: *id001
environment:
POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
POLYWEATHER_SERVICE_ROLE: warmer
POLYWEATHER_WARMER_CITY_BATCH_SIZE: ${POLYWEATHER_WARMER_CITY_BATCH_SIZE:-8}
POLYWEATHER_WARMER_CITY_INTERVAL_SEC: ${POLYWEATHER_WARMER_CITY_INTERVAL_SEC:-60}
POLYWEATHER_WARMER_ENABLED: ${POLYWEATHER_WARMER_ENABLED:-true}
POLYWEATHER_WARMER_SCAN_INTERVAL_SEC: ${POLYWEATHER_WARMER_SCAN_INTERVAL_SEC:-300}
POLYWEATHER_WARMER_TICK_SEC: ${POLYWEATHER_WARMER_TICK_SEC:-60}
healthcheck:
interval: 60s
retries: 3
test:
- CMD
- python
- -c
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
c.execute('SELECT 1'); c.close()
timeout: 10s
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
mem_limit: ${POLYWEATHER_WARMER_MEM_LIMIT:-768m}
memswap_limit: ${POLYWEATHER_WARMER_MEMSWAP_LIMIT:-1g}
pids_limit: 256
restart: unless-stopped
user: ${UID:-1000}:${GID:-1000}
volumes:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
x-polyweather-base:
env_file: *id001
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
@@ -28,11 +28,18 @@ export function runTests() {
deployScript.includes("http://127.0.0.1:3001/terminal"),
"deploy script must wait for the local frontend before relying on Cloudflare/public smoke checks",
);
assert(
deployScript.includes("wait_for_scan_terminal_ready") &&
deployScript.includes("http://127.0.0.1:3001/api/scan/terminal") &&
deployScript.includes('"status":"ready"'),
"deploy script must wait for the scan terminal snapshot to be ready so first users do not see initialization payloads",
);
assert(
deployScript.includes("warm_public_route") &&
deployScript.includes("https://polyweather.top/terminal") &&
deployScript.includes("https://polyweather.top/api/scan/terminal") &&
deployScript.includes("https://polyweather.top/api/auth/me?prefer_snapshot=1"),
"deploy script must warm terminal and auth snapshot routes after container replacement",
"deploy script must warm terminal, scan terminal, and auth snapshot routes after container replacement",
);
assert(
deployScript.includes("validate_frontend_api_base_url") &&
+75
View File
@@ -0,0 +1,75 @@
from datetime import datetime, timezone
from web.cache_warmer_service import CacheWarmer, build_priority_city_batch
def test_priority_city_batch_prefers_local_active_hours_over_night():
cities = {
"alpha day": {"tz": 0},
"beta night": {"tz": 12 * 3600},
"gamma morning": {"tz": -4 * 3600},
}
selected = build_priority_city_batch(
cities,
now_utc=datetime(2026, 6, 14, 12, 0, tzinfo=timezone.utc),
batch_size=2,
)
assert selected == ["alpha day", "gamma morning"]
def test_cache_warmer_warms_scan_and_city_panel_without_force_refresh():
now_ts = datetime(2026, 6, 14, 12, 0, tzinfo=timezone.utc).timestamp()
scan_calls = []
city_calls = []
def scan_warmer(filters, *, force_refresh):
scan_calls.append((filters, force_refresh))
return {"status": "ready"}
def city_panel_warmer(city, *, force_refresh):
city_calls.append((city, force_refresh))
return {"city": city}
warmer = CacheWarmer(
city_provider=lambda: {
"alpha day": {"tz": 0},
"beta night": {"tz": 12 * 3600},
},
scan_warmer=scan_warmer,
city_panel_warmer=city_panel_warmer,
scan_interval_sec=300,
city_interval_sec=60,
city_batch_size=1,
)
completed = warmer.run_due_once(now_ts=now_ts)
assert completed == 2
assert scan_calls == [({}, False)]
assert city_calls == [("alpha day", False)]
def test_cache_warmer_skips_work_when_intervals_are_not_due():
now_ts = datetime(2026, 6, 14, 12, 0, tzinfo=timezone.utc).timestamp()
scan_calls = []
city_calls = []
warmer = CacheWarmer(
city_provider=lambda: {"alpha day": {"tz": 0}},
scan_warmer=lambda filters, *, force_refresh: scan_calls.append(True),
city_panel_warmer=lambda city, *, force_refresh: city_calls.append(city),
scan_interval_sec=300,
city_interval_sec=60,
city_batch_size=1,
)
warmer.run_due_once(now_ts=now_ts)
scan_calls.clear()
city_calls.clear()
completed = warmer.run_due_once(now_ts=now_ts + 20)
assert completed == 0
assert scan_calls == []
assert city_calls == []
+30 -4
View File
@@ -83,6 +83,10 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
1,
)[0]
collector_block = compose.split(" polyweather_collector:", 1)[1].split(
"\n polyweather_warmer:",
1,
)[0]
warmer_block = compose.split(" polyweather_warmer:", 1)[1].split(
"\nx-polyweather-base:",
1,
)[0]
@@ -90,16 +94,24 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
assert "POLYWEATHER_SERVICE_ROLE: web" in compose
assert "POLYWEATHER_SERVICE_ROLE: bot" in compose
assert "POLYWEATHER_SERVICE_ROLE: collector" in collector_block
assert "POLYWEATHER_SERVICE_ROLE: warmer" in warmer_block
assert "redis-server --appendonly yes --maxmemory ${POLYWEATHER_REDIS_MAXMEMORY:-512mb} --maxmemory-policy noeviction" in compose
assert "POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'" in bot_block
assert "POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'" in web_block
assert "POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}" in web_block
assert "POLYWEATHER_REDIS_REQUIRED: ${POLYWEATHER_REDIS_REQUIRED:-true}" in web_block
assert "POLYWEATHER_REDIS_STREAM_MAXLEN: ${POLYWEATHER_REDIS_STREAM_MAXLEN:-100000}" in web_block
assert "POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_ENABLED: ${POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_ENABLED:-true}" in web_block
assert "POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: ${POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED:-false}" in web_block
assert "POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC: '30'" in web_block
assert "POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS: ${POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS:-1}" in web_block
assert "POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS: ${POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS:-4}" in web_block
assert "POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'" in collector_block
assert "POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'" in warmer_block
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in bot_block
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in web_block
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'true'" in collector_block
assert "POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-1}" in web_block
assert "POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-1}" in web_block
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in warmer_block
assert "POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-2}" in web_block
assert "POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-2}" in web_block
assert "POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS:-3000}" in web_block
assert "POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS:-8000}" in web_block
assert "UVICORN_WORKERS: ${UVICORN_WORKERS:-2}" in web_block
@@ -111,7 +123,15 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
in collector_block
)
assert "command: python -m web.observation_collector_worker" in collector_block
assert "command: python -m web.cache_warmer_worker" in warmer_block
assert "POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-60}" in collector_block
assert "POLYWEATHER_OBSERVATION_COLLECTOR_CACHE_REFRESH_WORKERS: ${POLYWEATHER_OBSERVATION_COLLECTOR_CACHE_REFRESH_WORKERS:-2}" in collector_block
assert "POLYWEATHER_WARMER_ENABLED: ${POLYWEATHER_WARMER_ENABLED:-true}" in warmer_block
assert "POLYWEATHER_WARMER_TICK_SEC: ${POLYWEATHER_WARMER_TICK_SEC:-60}" in warmer_block
assert "POLYWEATHER_WARMER_SCAN_INTERVAL_SEC: ${POLYWEATHER_WARMER_SCAN_INTERVAL_SEC:-300}" in warmer_block
assert "POLYWEATHER_WARMER_CITY_INTERVAL_SEC: ${POLYWEATHER_WARMER_CITY_INTERVAL_SEC:-60}" in warmer_block
assert "POLYWEATHER_WARMER_CITY_BATCH_SIZE: ${POLYWEATHER_WARMER_CITY_BATCH_SIZE:-8}" in warmer_block
assert "cpus: ${POLYWEATHER_WARMER_CPUS:-0.75}" in warmer_block
assert "TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC: ${POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC:-60}" in bot_block
assert "POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}" in collector_block
@@ -186,8 +206,13 @@ def test_deploy_script_retries_startup_smoke_checks():
script = (ROOT / "deploy.sh").read_text(encoding="utf-8")
assert "smoke_check()" in script
assert "wait_for_scan_terminal_ready()" in script
assert '"status":"ready"' in script
assert 'wait_for_scan_terminal_ready "scan terminal snapshot" "http://127.0.0.1:3001/api/scan/terminal"' in script
assert script.index("wait_for_scan_terminal_ready") < script.index("run_public_smoke_checks")
assert 'smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5' in script
assert 'warm_public_route "local cities recent stats" "http://127.0.0.1:8000/api/cities?refresh_deb_recent=1"' in script
assert 'warm_public_route "scan terminal" "https://polyweather.top/api/scan/terminal"' in script
assert 'smoke_check "local cities" "http://127.0.0.1:8000/api/cities" 10 6 3' not in script
assert 'smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5' in script
assert 'smoke_check "frontend" "https://www.polyweather.top/" 15 3 5' in script
@@ -211,6 +236,7 @@ def test_deploy_script_retries_compose_recreate_races():
assert "removal of container .* is already in progress" in script
assert 'compose_up_retry "backend services" -d --no-deps polyweather_web polyweather' in script
assert 'compose_up_retry "observation collector" -d --no-deps polyweather_collector' in script
assert 'compose_up_retry "cache warmer" -d --no-deps polyweather_warmer' in script
assert 'compose_up_retry "frontend" -d --no-deps polyweather_frontend' in script
+282
View File
@@ -0,0 +1,282 @@
"""Low-priority cache warmer for user-facing aggregate payloads."""
from __future__ import annotations
import os
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Callable, Iterable, List, Mapping, Optional, Sequence
from loguru import logger
ASIA_CORE_CITIES = (
"hong kong",
"taipei",
"tokyo",
"seoul",
"busan",
"shanghai",
"beijing",
"guangzhou",
"qingdao",
"shenzhen",
"chongqing",
"chengdu",
"singapore",
"kuala lumpur",
"jakarta",
)
EUROPE_CORE_CITIES = (
"istanbul",
"ankara",
"moscow",
"tel aviv",
"london",
"paris",
"madrid",
"milan",
"warsaw",
"amsterdam",
"helsinki",
)
US_CORE_CITIES = (
"new york",
"los angeles",
"san francisco",
"austin",
"houston",
"chicago",
"dallas",
"miami",
"atlanta",
"seattle",
)
DEFAULT_HOT_CITIES = ASIA_CORE_CITIES + EUROPE_CORE_CITIES + US_CORE_CITIES
SOURCE_PRIORITY_WEIGHT = {
"hko": 10,
"cwa": 9,
"noaa": 8,
"mgm": 8,
"metar": 6,
"wunderground": 4,
}
def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _env_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
return int(raw)
except Exception:
return default
def _normalize_city_name(city: str) -> str:
return str(city or "").strip().lower().replace("-", " ")
def _parse_city_list(raw: Optional[str]) -> Sequence[str]:
if not raw:
return ()
out: List[str] = []
for part in raw.split(","):
city = _normalize_city_name(part)
if city and city not in out:
out.append(city)
return tuple(out)
def _coerce_utc_offset_seconds(meta: Mapping[str, Any]) -> int:
for key in ("tz", "tz_offset", "utc_offset_seconds"):
raw = meta.get(key)
if raw is None:
continue
try:
value = float(raw)
except Exception:
continue
if abs(value) <= 24:
value *= 3600
return int(value)
return 0
def _local_hour(meta: Mapping[str, Any], now_utc: datetime) -> int:
offset = _coerce_utc_offset_seconds(meta)
return int((now_utc + timedelta(seconds=offset)).hour)
@dataclass(frozen=True)
class WarmCandidate:
city: str
score: int
local_hour: int
settlement_source: str
def rank_priority_cities(
cities: Mapping[str, Mapping[str, Any]],
*,
now_utc: Optional[datetime] = None,
hot_cities: Optional[Iterable[str]] = None,
) -> List[WarmCandidate]:
"""Rank cities by local activity window and source usefulness."""
now = now_utc or datetime.now(timezone.utc)
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
hot_set = {
_normalize_city_name(city)
for city in (DEFAULT_HOT_CITIES if hot_cities is None else hot_cities)
}
ranked: List[WarmCandidate] = []
for raw_city, raw_meta in cities.items():
city = _normalize_city_name(raw_city)
if not city:
continue
meta = raw_meta or {}
hour = _local_hour(meta, now)
source = str(meta.get("settlement_source") or "metar").strip().lower()
score = SOURCE_PRIORITY_WEIGHT.get(source, 3)
if city in hot_set:
score += 30
if 9 <= hour < 18:
score += 40
elif 6 <= hour < 22:
score += 25
else:
score += 2
ranked.append(
WarmCandidate(
city=city,
score=score,
local_hour=hour,
settlement_source=source,
)
)
ranked.sort(key=lambda item: (-item.score, item.local_hour, item.city))
return ranked
def build_priority_city_batch(
cities: Mapping[str, Mapping[str, Any]],
*,
now_utc: Optional[datetime] = None,
batch_size: int = 8,
hot_cities: Optional[Iterable[str]] = None,
) -> List[str]:
ranked = rank_priority_cities(cities, now_utc=now_utc, hot_cities=hot_cities)
limit = max(0, int(batch_size or 0))
return [candidate.city for candidate in ranked[:limit]]
class CacheWarmer:
def __init__(
self,
*,
city_provider: Callable[[], Mapping[str, Mapping[str, Any]]],
scan_warmer: Callable[..., Any],
city_panel_warmer: Callable[..., Any],
scan_interval_sec: int = 300,
city_interval_sec: int = 60,
city_batch_size: int = 8,
hot_cities: Optional[Iterable[str]] = None,
) -> None:
self.city_provider = city_provider
self.scan_warmer = scan_warmer
self.city_panel_warmer = city_panel_warmer
self.scan_interval_sec = max(60, int(scan_interval_sec or 300))
self.city_interval_sec = max(30, int(city_interval_sec or 60))
self.city_batch_size = max(1, min(32, int(city_batch_size or 8)))
self.hot_cities = tuple(hot_cities or DEFAULT_HOT_CITIES)
self._last_scan_ts = 0.0
self._last_city_ts = 0.0
self._city_cursor = 0
def run_due_once(self, *, now_ts: Optional[float] = None) -> int:
now = float(time.time() if now_ts is None else now_ts)
completed = 0
if now - self._last_scan_ts >= self.scan_interval_sec:
self._last_scan_ts = now
if self._warm_scan():
completed += 1
if now - self._last_city_ts >= self.city_interval_sec:
self._last_city_ts = now
completed += self._warm_city_batch(now_ts=now)
return completed
def _warm_scan(self) -> bool:
try:
self.scan_warmer({}, force_refresh=False)
return True
except Exception as exc:
logger.warning("cache warmer scan payload failed: {}", exc)
return False
def _warm_city_batch(self, *, now_ts: float) -> int:
try:
cities = self.city_provider() or {}
except Exception as exc:
logger.warning("cache warmer city provider failed: {}", exc)
return 0
ranked = rank_priority_cities(
cities,
now_utc=datetime.fromtimestamp(now_ts, timezone.utc),
hot_cities=self.hot_cities,
)
if not ranked:
return 0
start = self._city_cursor % len(ranked)
self._city_cursor += self.city_batch_size
selected = ranked[start : start + self.city_batch_size]
if len(selected) < self.city_batch_size:
selected.extend(ranked[: self.city_batch_size - len(selected)])
completed = 0
for candidate in selected:
try:
self.city_panel_warmer(candidate.city, force_refresh=False)
completed += 1
except Exception as exc:
logger.warning(
"cache warmer city panel failed city={} source={}: {}",
candidate.city,
candidate.settlement_source,
exc,
)
return completed
def build_default_cache_warmer() -> CacheWarmer:
from web.core import CITIES
from web.scan_terminal_service import build_scan_terminal_payload
from web.services.city_runtime import _refresh_city_panel_cache
hot_cities = _parse_city_list(os.getenv("POLYWEATHER_WARMER_HOT_CITIES"))
return CacheWarmer(
city_provider=lambda: CITIES,
scan_warmer=build_scan_terminal_payload,
city_panel_warmer=_refresh_city_panel_cache,
scan_interval_sec=_env_int("POLYWEATHER_WARMER_SCAN_INTERVAL_SEC", 300),
city_interval_sec=_env_int("POLYWEATHER_WARMER_CITY_INTERVAL_SEC", 60),
city_batch_size=_env_int("POLYWEATHER_WARMER_CITY_BATCH_SIZE", 8),
hot_cities=hot_cities or DEFAULT_HOT_CITIES,
)
def warmer_enabled() -> bool:
return _env_bool("POLYWEATHER_WARMER_ENABLED", True)
def warmer_tick_sec() -> int:
return max(10, _env_int("POLYWEATHER_WARMER_TICK_SEC", 60))
+53
View File
@@ -0,0 +1,53 @@
"""Standalone cache warmer process for production aggregate payloads."""
from __future__ import annotations
import signal
import threading
from types import FrameType
from typing import Optional
from loguru import logger
from web.cache_warmer_service import (
build_default_cache_warmer,
warmer_enabled,
warmer_tick_sec,
)
_STOP_EVENT = threading.Event()
def _handle_stop_signal(signum: int, _frame: Optional[FrameType]) -> None:
logger.info("cache warmer worker stopping signal={}", signum)
_STOP_EVENT.set()
def main() -> None:
signal.signal(signal.SIGINT, _handle_stop_signal)
signal.signal(signal.SIGTERM, _handle_stop_signal)
if not warmer_enabled():
logger.warning("cache warmer worker disabled; idling")
while not _STOP_EVENT.wait(3600):
pass
return
warmer = build_default_cache_warmer()
tick_sec = warmer_tick_sec()
logger.info(
"cache warmer worker started tick={}s city_batch={}",
tick_sec,
warmer.city_batch_size,
)
while not _STOP_EVENT.is_set():
try:
completed = warmer.run_due_once()
logger.debug("cache warmer run completed work_items={}", completed)
except Exception as exc:
logger.exception("cache warmer loop failed: {}", exc)
_STOP_EVENT.wait(tick_sec)
if __name__ == "__main__":
main()