Optimize terminal batching and cache guards

This commit is contained in:
2569718930@qq.com
2026-05-31 18:28:50 +08:00
parent a4253c224a
commit 668f4d9bd3
10 changed files with 473 additions and 79 deletions
@@ -77,6 +77,8 @@ const TrainingDashboard = dynamic(
}, },
); );
const ONLINE_USERS_REFRESH_MS = 5 * 60_000;
function createEmptyAccess(loading = true): ProAccessState { function createEmptyAccess(loading = true): ProAccessState {
return { return {
loading, loading,
@@ -425,14 +427,22 @@ function PolyWeatherTerminal({
useEffect(() => { useEffect(() => {
const fetchOnline = () => { const fetchOnline = () => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
fetch("/api/ops/online-users", { headers: { Accept: "application/json" } }) fetch("/api/ops/online-users", { headers: { Accept: "application/json" } })
.then((r) => (r.ok ? r.json() : null)) .then((r) => (r.ok ? r.json() : null))
.then((d) => { if (d?.online != null) setOnlineCount(d.online); }) .then((d) => { if (d?.online != null) setOnlineCount(d.online); })
.catch(() => {}); .catch(() => {});
}; };
fetchOnline(); fetchOnline();
const id = setInterval(fetchOnline, 60_000); const handleVisibilityChange = () => {
return () => clearInterval(id); if (document.visibilityState === "visible") fetchOnline();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
const id = setInterval(fetchOnline, ONLINE_USERS_REFRESH_MS);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
clearInterval(id);
};
}, []); }, []);
const [gridCols, setGridCols] = useState<number>(() => { const [gridCols, setGridCols] = useState<number>(() => {
@@ -101,6 +101,10 @@ function formatCityLocalDate(tzOffsetSeconds: number | null | undefined) {
return `${y}-${m}-${d}`; return `${y}-${m}-${d}`;
} }
function getLiveTempFromHourly(data: HourlyForecast) {
return validNumber(data?.airportCurrent?.temp) ?? validNumber(data?.airportPrimary?.temp) ?? null;
}
function getWundergroundDailyHigh(hourly: HourlyForecast) { function getWundergroundDailyHigh(hourly: HourlyForecast) {
return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null; return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null;
} }
@@ -365,6 +369,8 @@ export function LiveTemperatureThresholdChart({
.then((data) => { .then((data) => {
if (cancelled || !data) return; if (cancelled || !data) return;
hasLoadedHourlyDetailRef.current = true; hasLoadedHourlyDetailRef.current = true;
const temp = getLiveTempFromHourly(data);
if (temp !== null) setLiveTemp(temp);
setHourly(data); setHourly(data);
}) })
.catch(() => {}) .catch(() => {})
@@ -377,15 +383,6 @@ export function LiveTemperatureThresholdChart({
if (typeof document !== "undefined" && document.visibilityState === "hidden") return; if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
if (Date.now() - lastPatchAtRef.current < 2 * 60_000) return; if (Date.now() - lastPatchAtRef.current < 2 * 60_000) return;
fetch(`/api/city/${encodeURIComponent(city)}/summary`)
.then((res) => (res.ok ? res.json() : null))
.then((payload) => {
if (cancelled || !payload) return;
const temp = validNumber(payload?.current?.temp);
if (temp !== null) setLiveTemp(temp);
})
.catch(() => {});
refreshFullDetail(); refreshFullDetail();
}; };
@@ -403,19 +400,12 @@ export function LiveTemperatureThresholdChart({
const refreshForegroundFullDetail = () => { const refreshForegroundFullDetail = () => {
lastPatchAtRef.current = Date.now(); lastPatchAtRef.current = Date.now();
fetch(`/api/city/${encodeURIComponent(city)}/summary`)
.then((res) => (res.ok ? res.json() : null))
.then((payload) => {
if (cancelled || !payload) return;
const temp = validNumber(payload?.current?.temp);
if (temp !== null) setLiveTemp(temp);
})
.catch(() => {});
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution }) fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
.then((data) => { .then((data) => {
if (cancelled || !data) return; if (cancelled || !data) return;
hasLoadedHourlyDetailRef.current = true; hasLoadedHourlyDetailRef.current = true;
const temp = getLiveTempFromHourly(data);
if (temp !== null) setLiveTemp(temp);
setHourly(data); setHourly(data);
}) })
.catch(() => {}); .catch(() => {});
@@ -49,6 +49,10 @@ export async function runTests() {
path.join(projectRoot, "components", "dashboard", "scan-terminal", "temperature-chart-logic.ts"), path.join(projectRoot, "components", "dashboard", "scan-terminal", "temperature-chart-logic.ts"),
"utf8", "utf8",
); );
const dashboardSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"),
"utf8",
);
assert( assert(
querySource.includes("useSsePatchVersion") && querySource.includes("useSsePatchVersion") &&
@@ -91,10 +95,16 @@ export async function runTests() {
chartLogicSource.includes("primeCityDetailCache"), chartLogicSource.includes("primeCityDetailCache"),
"visible terminal chart detail fetches should be coalesced into one batch request and prime the shared chart cache", "visible terminal chart detail fetches should be coalesced into one batch request and prime the shared chart cache",
); );
const fetchHourlyBlock = chartLogicSource.match(/async function fetchHourlyForecastForCity[\s\S]*?\n}\n\nfunction fetchCityDetailWithTimeout/)?.[0] || "";
assert( assert(
chartLogicSource.includes("options.ignoreCache\n ? runQueuedHourlyDetailRequest") && fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam)") &&
chartLogicSource.includes(": queueCityDetailBatch(city, resParam)"), !fetchHourlyBlock.includes("runQueuedHourlyDetailRequest"),
"normal first-paint city detail requests should enter the batch queue before single-request concurrency limiting", "first-paint and background city detail refreshes should both enter the batch queue before falling back to single requests",
);
assert(
dashboardSource.includes("ONLINE_USERS_REFRESH_MS = 5 * 60_000") &&
dashboardSource.includes('document.visibilityState === "hidden"'),
"terminal online-user presence should refresh slowly and pause while the browser tab is hidden",
); );
assert( assert(
chartSource.includes("IntersectionObserver") && chartSource.includes("IntersectionObserver") &&
@@ -182,6 +182,10 @@ export function runTests() {
!foregroundRefreshBlock.includes("setIsHourlyLoading(true)"), !foregroundRefreshBlock.includes("setIsHourlyLoading(true)"),
"foreground resume refresh should update full detail immediately in the background without showing the loading overlay", "foreground resume refresh should update full detail immediately in the background without showing the loading overlay",
); );
assert(
!chart.includes("/api/city/${encodeURIComponent(city)}/summary"),
"visible chart fallback and foreground refresh should not issue a separate summary request after requesting full detail",
);
assert(chart.includes("viewMode"), "temperature chart must expose a view mode for DEB-peak auto view versus full-day view"); assert(chart.includes("viewMode"), "temperature chart must expose a view mode for DEB-peak auto view versus full-day view");
assert(chart.includes('useState<"auto" | "full">("full")'), "temperature chart must default every city panel to the all-day view"); assert(chart.includes('useState<"auto" | "full">("full")'), "temperature chart must default every city panel to the all-day view");
assert( assert(
@@ -1098,7 +1098,10 @@ async function flushCityDetailBatch(resolution: string) {
return; return;
} }
try { try {
resolveBatchWaiters(waiters, await fetchSingleHourlyForecastForCity(city, resolution)); resolveBatchWaiters(
waiters,
await runQueuedHourlyDetailRequest(() => fetchSingleHourlyForecastForCity(city, resolution)),
);
} catch (error) { } catch (error) {
rejectBatchWaiters(waiters, error); rejectBatchWaiters(waiters, error);
} }
@@ -1109,7 +1112,10 @@ async function flushCityDetailBatch(resolution: string) {
cities.map(async (city) => { cities.map(async (city) => {
const waiters = queue.waiters.get(city); const waiters = queue.waiters.get(city);
try { try {
resolveBatchWaiters(waiters, await fetchSingleHourlyForecastForCity(city, resolution)); resolveBatchWaiters(
waiters,
await runQueuedHourlyDetailRequest(() => fetchSingleHourlyForecastForCity(city, resolution)),
);
} catch (fallbackError) { } catch (fallbackError) {
rejectBatchWaiters(waiters, fallbackError || error); rejectBatchWaiters(waiters, fallbackError || error);
} }
@@ -1158,11 +1164,7 @@ async function fetchHourlyForecastForCity(
const pending = _hourlyRequestCache.get(requestKey); const pending = _hourlyRequestCache.get(requestKey);
if (pending) return pending; if (pending) return pending;
const request = ( const request = queueCityDetailBatch(city, resParam)
options.ignoreCache
? runQueuedHourlyDetailRequest(() => fetchSingleHourlyForecastForCity(city, resParam))
: queueCityDetailBatch(city, resParam)
)
.finally(() => { .finally(() => {
_hourlyRequestCache.delete(requestKey); _hourlyRequestCache.delete(requestKey);
}); });
+3
View File
@@ -946,6 +946,9 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
for key in list(self._wunderground_historical_cache.keys()): for key in list(self._wunderground_historical_cache.keys()):
if key.startswith(prefix): if key.startswith(prefix):
self._wunderground_historical_cache.pop(key, None) self._wunderground_historical_cache.pop(key, None)
for key in list(getattr(self, "_wunderground_negative_cache", {}).keys()):
if key.startswith(prefix):
self._wunderground_negative_cache.pop(key, None)
def _uses_fahrenheit(self, city_lower: str) -> bool: def _uses_fahrenheit(self, city_lower: str) -> bool:
return city_lower in self.US_CITIES return city_lower in self.US_CITIES
@@ -73,6 +73,13 @@ class WundergroundHistoricalMixin:
self._wunderground_historical_cache = {} self._wunderground_historical_cache = {}
if not hasattr(self, "_wunderground_historical_cache_lock"): if not hasattr(self, "_wunderground_historical_cache_lock"):
self._wunderground_historical_cache_lock = threading.Lock() self._wunderground_historical_cache_lock = threading.Lock()
if not hasattr(self, "_wunderground_negative_cache"):
self._wunderground_negative_cache = {}
if not hasattr(self, "wunderground_negative_cache_ttl_sec"):
self.wunderground_negative_cache_ttl_sec = max(
60,
int(os.getenv("WUNDERGROUND_NEGATIVE_CACHE_TTL_SEC", "900")),
)
if not hasattr(self, "wunderground_historical_cache_ttl_sec"): if not hasattr(self, "wunderground_historical_cache_ttl_sec"):
self.wunderground_historical_cache_ttl_sec = max( self.wunderground_historical_cache_ttl_sec = max(
30, 30,
@@ -239,6 +246,15 @@ class WundergroundHistoricalMixin:
cached = self._wunderground_historical_cache.get(cache_key) cached = self._wunderground_historical_cache.get(cache_key)
if cached and now_ts - float(cached.get("t", 0)) < self.wunderground_historical_cache_ttl_sec: if cached and now_ts - float(cached.get("t", 0)) < self.wunderground_historical_cache_ttl_sec:
return dict(cached["d"]) return dict(cached["d"])
negative_cached = self._wunderground_negative_cache.get(cache_key)
if negative_cached and now_ts - float(negative_cached.get("t", 0)) < self.wunderground_negative_cache_ttl_sec:
record_source_call(
"wunderground",
"historical",
"negative_cached",
(time.perf_counter() - started) * 1000.0,
)
return None
current_url = ( current_url = (
"https://api.weather.com/v1/location/" "https://api.weather.com/v1/location/"
@@ -286,6 +302,16 @@ class WundergroundHistoricalMixin:
location_id, location_id,
exc, exc,
) )
if (
isinstance(exc, httpx.HTTPStatusError)
and exc.response is not None
and 400 <= exc.response.status_code < 500
):
with self._wunderground_historical_cache_lock:
self._wunderground_negative_cache[cache_key] = {
"t": time.time(),
"status": exc.response.status_code,
}
record_source_call( record_source_call(
"wunderground", "wunderground",
"historical", "historical",
@@ -396,6 +422,7 @@ class WundergroundHistoricalMixin:
"t": time.time(), "t": time.time(),
"d": payload, "d": payload,
} }
self._wunderground_negative_cache.pop(cache_key, None)
record_source_call( record_source_call(
"wunderground", "wunderground",
"historical", "historical",
+177 -1
View File
@@ -564,7 +564,183 @@ def test_city_detail_batch_endpoint_builds_multiple_cached_details(monkeypatch):
assert sorted(payload["details"]) == ["paris", "shanghai"] assert sorted(payload["details"]) == ["paris", "shanghai"]
assert payload["details"]["shanghai"]["resolution"] == "10m" assert payload["details"]["shanghai"]["resolution"] == "10m"
assert payload["details"]["paris"]["overlay_city"] == "paris" assert payload["details"]["paris"]["overlay_city"] == "paris"
assert calls == [("shanghai", "10m"), ("paris", "10m")] assert sorted(calls) == [("paris", "10m"), ("shanghai", "10m")]
def test_city_detail_batch_endpoint_limits_backend_concurrency(monkeypatch):
import asyncio
active = 0
max_active = 0
async def fake_run_in_threadpool(fn, *args, **kwargs):
nonlocal active, max_active
active += 1
max_active = max(max_active, active)
try:
await asyncio.sleep(0.01)
return fn(*args, **kwargs)
finally:
active -= 1
monkeypatch.setenv("POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY", "2")
monkeypatch.setattr(city_api, "run_in_threadpool", fake_run_in_threadpool)
monkeypatch.setattr(city_api.legacy_routes, "_assert_entitlement", lambda request: None)
monkeypatch.setattr(city_api.legacy_routes, "_normalize_city_or_404", lambda name: name.strip().lower())
monkeypatch.setattr(city_api.legacy_routes, "_city_cache_is_fresh", lambda entry, ttl: False)
class FakeCache:
def get_city_cache(self, kind, city):
assert kind == "full"
return None
def refresh_full(city, force_refresh):
return {
"city": city,
"hourly": {"times": ["2026-05-30T00:00:00Z"], "temps": [20.0]},
}
def build_detail(data, market_slug, target_date, resolution):
return {
"city": data["city"],
"hourly": data["hourly"],
"resolution": resolution,
}
monkeypatch.setattr(city_api.legacy_routes, "_CACHE_DB", FakeCache())
monkeypatch.setattr(city_api.legacy_routes, "_refresh_city_full_cache", refresh_full)
monkeypatch.setattr(city_api.legacy_routes, "_build_city_detail_payload", build_detail)
response = client.get("/api/cities/detail-batch?cities=a,b,c,d,e&resolution=10m&limit=5")
assert response.status_code == 200
assert response.json()["cities"] == ["a", "b", "c", "d", "e"]
assert max_active <= 2
def test_concurrent_city_detail_requests_share_same_full_cache_refresh(monkeypatch):
import asyncio
refresh_calls = 0
build_calls = 0
class FakeCache:
payload = None
def get_city_cache(self, kind, city):
assert kind == "full"
if self.payload is None:
return None
return {"payload": self.payload}
fake_cache = FakeCache()
async def fake_run_in_threadpool(fn, *args, **kwargs):
if fn is city_api.legacy_routes._refresh_city_full_cache:
await asyncio.sleep(0.02)
return fn(*args, **kwargs)
def refresh_full(city, force_refresh):
nonlocal refresh_calls
refresh_calls += 1
fake_cache.payload = {
"city": city,
"hourly": {"times": ["2026-05-30T00:00:00Z"], "temps": [20.0]},
}
return fake_cache.payload
def build_detail(data, market_slug, target_date, resolution):
nonlocal build_calls
build_calls += 1
return {
"city": data["city"],
"hourly": data["hourly"],
"resolution": resolution,
}
monkeypatch.setattr(city_api, "run_in_threadpool", fake_run_in_threadpool)
monkeypatch.setattr(city_api.legacy_routes, "_assert_entitlement", lambda request: None)
monkeypatch.setattr(city_api.legacy_routes, "_normalize_city_or_404", lambda name: name.strip().lower())
monkeypatch.setattr(city_api.legacy_routes, "_CACHE_DB", fake_cache)
monkeypatch.setattr(city_api.legacy_routes, "_refresh_city_full_cache", refresh_full)
monkeypatch.setattr(city_api.legacy_routes, "_build_city_detail_payload", build_detail)
async def run_two_requests():
return await asyncio.gather(
city_api.get_city_detail_aggregate_payload(object(), "Paris", resolution="10m"),
city_api.get_city_detail_aggregate_payload(object(), "Paris", resolution="10m"),
)
results = asyncio.run(run_two_requests())
assert [item["city"] for item in results] == ["paris", "paris"]
assert refresh_calls == 1
assert build_calls == 1
def test_force_refresh_invalidates_short_city_detail_payload_cache(monkeypatch):
import asyncio
build_calls = 0
refreshed_payloads = [
{
"city": "paris",
"local_date": "2026-05-30",
"hourly": {"times": ["2026-05-30T00:00:00Z"], "temps": [20.0]},
},
{
"city": "paris",
"local_date": "2026-05-30",
"hourly": {"times": ["2026-05-30T00:00:00Z"], "temps": [21.0]},
},
]
class FakeCache:
def get_city_cache(self, kind, city):
assert kind == "full"
return None
async def fake_run_in_threadpool(fn, *args, **kwargs):
return fn(*args, **kwargs)
def refresh_full(city, force_refresh):
assert city == "paris"
assert refreshed_payloads
return refreshed_payloads.pop(0)
def build_detail(data, market_slug, target_date, resolution):
nonlocal build_calls
build_calls += 1
return {
"city": data["city"],
"live_temp": data["hourly"]["temps"][0],
"resolution": resolution,
}
city_api._CITY_DETAIL_PAYLOAD_CACHE.clear()
city_api._CITY_DETAIL_PAYLOAD_CACHE_TS.clear()
city_api._CITY_DETAIL_PAYLOAD_INFLIGHT.clear()
monkeypatch.setattr(city_api, "run_in_threadpool", fake_run_in_threadpool)
monkeypatch.setattr(city_api.legacy_routes, "_assert_entitlement", lambda request: None)
monkeypatch.setattr(city_api.legacy_routes, "_normalize_city_or_404", lambda name: name.strip().lower())
monkeypatch.setattr(city_api.legacy_routes, "_CACHE_DB", FakeCache())
monkeypatch.setattr(city_api.legacy_routes, "_refresh_city_full_cache", refresh_full)
monkeypatch.setattr(city_api.legacy_routes, "_build_city_detail_payload", build_detail)
first = asyncio.run(city_api.get_city_detail_aggregate_payload(object(), "Paris", resolution="10m"))
second = asyncio.run(
city_api.get_city_detail_aggregate_payload(
object(),
"Paris",
resolution="10m",
force_refresh=True,
),
)
assert first["live_temp"] == 20.0
assert second["live_temp"] == 21.0
assert build_calls == 2
def test_payment_runtime_endpoint_returns_shape(): def test_payment_runtime_endpoint_returns_shape():
+49
View File
@@ -180,6 +180,55 @@ def test_fetch_wunderground_historical_keeps_fahrenheit_for_us_cities(monkeypatc
assert payload["max_so_far"] == 75 assert payload["max_so_far"] == 75
def test_fetch_wunderground_historical_negative_caches_client_errors(monkeypatch, tmp_path):
collector = _collector(monkeypatch, tmp_path)
monkeypatch.setenv("WUNDERGROUND_NEGATIVE_CACHE_TTL_SEC", "900")
calls = []
def fake_get(url: str, **_kwargs):
calls.append(url)
return httpx.Response(
400,
json={"errors": [{"message": "invalid location"}]},
request=httpx.Request("GET", url),
)
monkeypatch.setattr(collector, "_http_get", fake_get)
first = collector.fetch_wunderground_historical(
"istanbul",
use_fahrenheit=False,
utc_offset=10800,
local_date="2026-05-31",
)
second = collector.fetch_wunderground_historical(
"istanbul",
use_fahrenheit=False,
utc_offset=10800,
local_date="2026-05-31",
)
assert first is None
assert second is None
assert len(calls) == 2
collector._evict_city_caches(
city="istanbul",
lat=None,
lon=None,
use_fahrenheit=False,
)
third = collector.fetch_wunderground_historical(
"istanbul",
use_fahrenheit=False,
utc_offset=10800,
local_date="2026-05-31",
)
assert third is None
assert len(calls) == 4
def test_fetch_all_sources_attaches_wunderground_historical(monkeypatch, tmp_path): def test_fetch_all_sources_attaches_wunderground_historical(monkeypatch, tmp_path):
collector = _collector(monkeypatch, tmp_path) collector = _collector(monkeypatch, tmp_path)
called = {} called = {}
+171 -48
View File
@@ -22,6 +22,22 @@ _RECENT_DEB_CACHE_TTL_SEC = max(
60, 60,
int(os.getenv("POLYWEATHER_CITIES_DEB_RECENT_CACHE_TTL_SEC", "300") or "300"), int(os.getenv("POLYWEATHER_CITIES_DEB_RECENT_CACHE_TTL_SEC", "300") or "300"),
) )
_CITY_FULL_REFRESH_INFLIGHT: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {}
_CITY_FULL_REFRESH_LOCK = asyncio.Lock()
CityDetailPayloadCacheKey = Tuple[str, str, str, str, str, int]
_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()
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))
async def _overlay_cached_wunderground(city: str, payload: Dict[str, Any]) -> Dict[str, Any]: async def _overlay_cached_wunderground(city: str, payload: Dict[str, Any]) -> Dict[str, Any]:
@@ -32,6 +48,134 @@ async def _overlay_cached_wunderground(city: str, payload: Dict[str, Any]) -> Di
) )
async def _refresh_city_full_cache_singleflight(city: str, force_refresh: bool) -> Dict[str, Any]:
key = f"{city}:{bool(force_refresh)}"
async with _CITY_FULL_REFRESH_LOCK:
task = _CITY_FULL_REFRESH_INFLIGHT.get(key)
if task is None:
task = asyncio.create_task(
run_in_threadpool(legacy_routes._refresh_city_full_cache, city, force_refresh),
)
_CITY_FULL_REFRESH_INFLIGHT[key] = task
try:
return await task
finally:
if task.done():
async with _CITY_FULL_REFRESH_LOCK:
if _CITY_FULL_REFRESH_INFLIGHT.get(key) is task:
_CITY_FULL_REFRESH_INFLIGHT.pop(key, None)
async def _invalidate_city_detail_payload_cache(city: str) -> None:
normalized = str(city or "").strip().lower()
if not normalized:
return
async with _CITY_DETAIL_PAYLOAD_LOCK:
_CITY_DETAIL_PAYLOAD_EPOCH[normalized] = _CITY_DETAIL_PAYLOAD_EPOCH.get(normalized, 0) + 1
old_keys = [key for key in _CITY_DETAIL_PAYLOAD_CACHE if key[0] == normalized]
for key in old_keys:
_CITY_DETAIL_PAYLOAD_CACHE.pop(key, None)
_CITY_DETAIL_PAYLOAD_CACHE_TS.pop(key, None)
async def _refresh_city_full_data(city: str, force_refresh: bool) -> Dict[str, Any]:
await _invalidate_city_detail_payload_cache(city)
return await _refresh_city_full_cache_singleflight(city, force_refresh)
async def _get_city_full_data(city: str, *, force_refresh: bool) -> Dict[str, Any]:
if force_refresh:
return await _refresh_city_full_data(city, True)
cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "full", city)
if cached_entry:
if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_FULL_CACHE_TTL_SEC):
return await _refresh_city_full_data(city, False)
return await _overlay_cached_wunderground(city, cached_entry.get("payload") or {})
return await _refresh_city_full_data(city, False)
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,
)
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
def _default_deb_recent() -> Dict[str, object]: def _default_deb_recent() -> Dict[str, object]:
return { return {
"tier": "other", "tier": "other",
@@ -167,14 +311,7 @@ async def get_city_detail_payload(
else: else:
detail_mode = "panel" detail_mode = "panel"
if detail_mode == "full": if detail_mode == "full":
if force_refresh: return await _get_city_full_data(city, force_refresh=force_refresh)
return await run_in_threadpool(legacy_routes._refresh_city_full_cache, city, True)
cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "full", city)
if cached_entry:
if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_FULL_CACHE_TTL_SEC):
return await run_in_threadpool(legacy_routes._refresh_city_full_cache, city, False)
return await _overlay_cached_wunderground(city, cached_entry.get("payload") or {})
return await run_in_threadpool(legacy_routes._refresh_city_full_cache, city, False)
if detail_mode == "panel": if detail_mode == "panel":
if force_refresh: if force_refresh:
return await run_in_threadpool(legacy_routes._refresh_city_panel_cache, city, True) return await run_in_threadpool(legacy_routes._refresh_city_panel_cache, city, True)
@@ -233,20 +370,9 @@ async def get_city_detail_aggregate_payload(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
legacy_routes._assert_entitlement(request) legacy_routes._assert_entitlement(request)
city = legacy_routes._normalize_city_or_404(name) city = legacy_routes._normalize_city_or_404(name)
if force_refresh: data = await _get_city_full_data(city, force_refresh=force_refresh)
data = await run_in_threadpool(legacy_routes._refresh_city_full_cache, city, True)
else:
cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "full", city)
if cached_entry:
if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_FULL_CACHE_TTL_SEC):
data = await run_in_threadpool(legacy_routes._refresh_city_full_cache, city, False)
else:
data = await _overlay_cached_wunderground(city, cached_entry.get("payload") or {})
else:
data = await run_in_threadpool(legacy_routes._refresh_city_full_cache, city, False)
return await run_in_threadpool( return await _build_city_detail_payload_cached(
legacy_routes._build_city_detail_payload,
data, data,
market_slug, market_slug,
target_date, target_date,
@@ -271,7 +397,7 @@ def _parse_batch_city_names(raw_cities: str, *, limit: int) -> List[str]:
return out return out
def _build_city_detail_batch_item( async def _build_city_detail_batch_item_async(
city: str, city: str,
*, *,
force_refresh: bool, force_refresh: bool,
@@ -279,22 +405,8 @@ def _build_city_detail_batch_item(
target_date: Optional[str], target_date: Optional[str],
resolution: Optional[str], resolution: Optional[str],
) -> Tuple[str, Dict[str, Any]]: ) -> Tuple[str, Dict[str, Any]]:
if force_refresh: data = await _get_city_full_data(city, force_refresh=force_refresh)
data = legacy_routes._refresh_city_full_cache(city, True) detail = await _build_city_detail_payload_cached(
else:
cached_entry = legacy_routes._CACHE_DB.get_city_cache("full", city)
if cached_entry and legacy_routes._city_cache_is_fresh(
cached_entry,
legacy_routes.CITY_FULL_CACHE_TTL_SEC,
):
data = legacy_routes._overlay_latest_wunderground_current(
city,
cached_entry.get("payload") or {},
)
else:
data = legacy_routes._refresh_city_full_cache(city, False)
detail = legacy_routes._build_city_detail_payload(
data, data,
market_slug, market_slug,
target_date, target_date,
@@ -303,6 +415,14 @@ def _build_city_detail_batch_item(
return city, detail return city, detail
def _city_detail_batch_concurrency() -> int:
try:
value = int(os.getenv("POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY", "3") or "3")
except ValueError:
value = 3
return max(1, min(6, value))
async def get_city_detail_batch_payload( async def get_city_detail_batch_payload(
request: Request, request: Request,
*, *,
@@ -318,15 +438,20 @@ async def get_city_detail_batch_payload(
if not city_names: if not city_names:
return {"cities": [], "details": {}, "errors": {}} return {"cities": [], "details": {}, "errors": {}}
semaphore = asyncio.Semaphore(_city_detail_batch_concurrency())
async def _build_with_limit(city: str) -> Tuple[str, Dict[str, Any]]:
async with semaphore:
return await _build_city_detail_batch_item_async(
city,
force_refresh=force_refresh,
market_slug=market_slug,
target_date=target_date,
resolution=resolution,
)
tasks = [ tasks = [
run_in_threadpool( _build_with_limit(city)
_build_city_detail_batch_item,
city,
force_refresh=force_refresh,
market_slug=market_slug,
target_date=target_date,
resolution=resolution,
)
for city in city_names for city in city_names
] ]
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -344,5 +469,3 @@ async def get_city_detail_batch_payload(
"details": details, "details": details,
"errors": errors, "errors": errors,
} }