Reduce duplicate terminal detail loads

This commit is contained in:
2569718930@qq.com
2026-05-31 22:14:17 +08:00
parent 38c6cefb27
commit d911af1225
6 changed files with 262 additions and 51 deletions
@@ -56,6 +56,7 @@ const PEAK_GLOW_BADGE_CLASS = {
} as const; } as const;
const PROBABILITY_REFRESH_AFTER_PATCH_MS = 60_000; const PROBABILITY_REFRESH_AFTER_PATCH_MS = 60_000;
const FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS = 90_000;
const DETAIL_LOAD_BATCH_DELAY_MS = 0; const DETAIL_LOAD_BATCH_DELAY_MS = 0;
const TemperatureChartCanvas = dynamic( const TemperatureChartCanvas = dynamic(
@@ -165,6 +166,7 @@ export function LiveTemperatureThresholdChart({
const lastPatchAtRef = useRef<number>(Date.now()); const lastPatchAtRef = useRef<number>(Date.now());
const lastAppliedPatchRevisionRef = useRef<number>(0); const lastAppliedPatchRevisionRef = useRef<number>(0);
const lastProbabilityRefreshAtRef = useRef<number>(0); const lastProbabilityRefreshAtRef = useRef<number>(0);
const lastForegroundRefreshAtRef = useRef<number>(0);
const localDayRolloverFetchDateRef = useRef<string>(""); const localDayRolloverFetchDateRef = useRef<string>("");
const [isChartVisible, setIsChartVisible] = useState( const [isChartVisible, setIsChartVisible] = useState(
() => typeof IntersectionObserver === "undefined", () => typeof IntersectionObserver === "undefined",
@@ -197,6 +199,7 @@ export function LiveTemperatureThresholdChart({
lastPatchAtRef.current = Date.now(); lastPatchAtRef.current = Date.now();
lastAppliedPatchRevisionRef.current = 0; lastAppliedPatchRevisionRef.current = 0;
lastProbabilityRefreshAtRef.current = 0; lastProbabilityRefreshAtRef.current = 0;
lastForegroundRefreshAtRef.current = 0;
localDayRolloverFetchDateRef.current = ""; localDayRolloverFetchDateRef.current = "";
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds)); setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
}, [city]); }, [city]);
@@ -395,7 +398,19 @@ export function LiveTemperatureThresholdChart({
let cancelled = false; let cancelled = false;
const refreshForegroundFullDetail = () => { const refreshForegroundFullDetail = () => {
lastPatchAtRef.current = Date.now(); const now = Date.now();
const cacheKey = `${city}:${targetResolution}`;
const cached = _hourlyCache.get(cacheKey);
const cacheAge = cached ? now - Number(cached.ts || 0) : Number.POSITIVE_INFINITY;
if (
now - lastForegroundRefreshAtRef.current < FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS ||
(cacheAge >= 0 && cacheAge < FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS)
) {
return;
}
lastForegroundRefreshAtRef.current = now;
lastPatchAtRef.current = now;
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution }) fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
.then((data) => { .then((data) => {
@@ -102,6 +102,10 @@ 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",
); );
assert(
chartLogicSource.includes("CITY_DETAIL_BATCH_WINDOW_MS = 100"),
"visible terminal chart detail fetches should use a wide enough batch window to coalesce cards mounted across adjacent frames",
);
assert( assert(
chartLogicSource.includes("partial?: boolean") && chartLogicSource.includes("partial?: boolean") &&
chartLogicSource.includes("missing?: string[]"), chartLogicSource.includes("missing?: string[]"),
@@ -198,8 +198,9 @@ export function runTests() {
assert( assert(
foregroundRefreshBlock.includes("ignoreCache: true") && foregroundRefreshBlock.includes("ignoreCache: true") &&
foregroundRefreshBlock.includes("fetchHourlyForecastForCity") && foregroundRefreshBlock.includes("fetchHourlyForecastForCity") &&
foregroundRefreshBlock.includes("FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS") &&
!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 in the background without showing the loading overlay or refetching fresh detail",
); );
assert( assert(
!chart.includes("/api/city/${encodeURIComponent(city)}/summary"), !chart.includes("/api/city/${encodeURIComponent(city)}/summary"),
@@ -988,7 +988,7 @@ type CityDetailBatchQueue = {
timer: ReturnType<typeof setTimeout> | null; timer: ReturnType<typeof setTimeout> | null;
}; };
const CITY_DETAIL_BATCH_WINDOW_MS = 25; const CITY_DETAIL_BATCH_WINDOW_MS = 100;
const CITY_DETAIL_BATCH_MAX_CITIES = 12; const CITY_DETAIL_BATCH_MAX_CITIES = 12;
const _cityDetailBatchQueues = new Map<string, CityDetailBatchQueue>(); const _cityDetailBatchQueues = new Map<string, CityDetailBatchQueue>();
+103
View File
@@ -767,6 +767,109 @@ def test_city_detail_batch_returns_completed_details_when_one_city_is_slow(monke
assert "slow" not in completed assert "slow" not in completed
def test_city_detail_batch_response_cache_keeps_entitlement_check(monkeypatch):
import asyncio
entitlement_calls = 0
build_calls = 0
async def build_batch_item(city, **kwargs):
nonlocal build_calls
build_calls += 1
return city, {
"city": city,
"hourly": {"times": ["2026-05-30T00:00:00Z"], "temps": [20.0]},
"resolution": kwargs.get("resolution"),
}
def assert_entitlement(request):
nonlocal entitlement_calls
entitlement_calls += 1
city_api._CITY_DETAIL_BATCH_RESPONSE_CACHE.clear()
city_api._CITY_DETAIL_BATCH_RESPONSE_CACHE_TS.clear()
city_api._CITY_DETAIL_BATCH_RESPONSE_INFLIGHT.clear()
monkeypatch.setenv("POLYWEATHER_CITY_DETAIL_BATCH_RESPONSE_CACHE_TTL_SEC", "20")
monkeypatch.setattr(city_api.legacy_routes, "_assert_entitlement", assert_entitlement)
monkeypatch.setattr(city_api.legacy_routes, "_normalize_city_or_404", lambda name: name.strip().lower())
monkeypatch.setattr(city_api, "_build_city_detail_batch_item_async", build_batch_item)
first = asyncio.run(
city_api.get_city_detail_batch_payload(
object(),
cities="Paris",
resolution="10m",
limit=12,
)
)
second = asyncio.run(
city_api.get_city_detail_batch_payload(
object(),
cities="Paris",
resolution="10m",
limit=12,
)
)
assert first == second
assert first["details"]["paris"]["resolution"] == "10m"
assert entitlement_calls == 2
assert build_calls == 1
def test_concurrent_city_detail_batch_requests_share_inflight_response(monkeypatch):
import asyncio
entitlement_calls = 0
build_calls = 0
async def build_batch_item(city, **kwargs):
nonlocal build_calls
build_calls += 1
await asyncio.sleep(0.02)
return city, {
"city": city,
"hourly": {"times": ["2026-05-30T00:00:00Z"], "temps": [20.0]},
"resolution": kwargs.get("resolution"),
}
def assert_entitlement(request):
nonlocal entitlement_calls
entitlement_calls += 1
city_api._CITY_DETAIL_BATCH_RESPONSE_CACHE.clear()
city_api._CITY_DETAIL_BATCH_RESPONSE_CACHE_TS.clear()
city_api._CITY_DETAIL_BATCH_RESPONSE_INFLIGHT.clear()
monkeypatch.setenv("POLYWEATHER_CITY_DETAIL_BATCH_RESPONSE_CACHE_TTL_SEC", "20")
monkeypatch.setattr(city_api.legacy_routes, "_assert_entitlement", assert_entitlement)
monkeypatch.setattr(city_api.legacy_routes, "_normalize_city_or_404", lambda name: name.strip().lower())
monkeypatch.setattr(city_api, "_build_city_detail_batch_item_async", build_batch_item)
async def run_requests():
return await asyncio.gather(
city_api.get_city_detail_batch_payload(
object(),
cities="Paris",
resolution="10m",
limit=12,
),
city_api.get_city_detail_batch_payload(
object(),
cities="Paris",
resolution="10m",
limit=12,
),
)
first, second = asyncio.run(run_requests())
assert first == second
assert entitlement_calls == 2
assert build_calls == 1
def test_concurrent_city_detail_requests_share_same_full_cache_refresh(monkeypatch): def test_concurrent_city_detail_requests_share_same_full_cache_refresh(monkeypatch):
import asyncio import asyncio
+136 -48
View File
@@ -27,11 +27,16 @@ _CITY_FULL_REFRESH_INFLIGHT: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {}
_CITY_FULL_STALE_REFRESH_TASKS: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {} _CITY_FULL_STALE_REFRESH_TASKS: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {}
_CITY_FULL_REFRESH_LOCK = asyncio.Lock() _CITY_FULL_REFRESH_LOCK = asyncio.Lock()
CityDetailPayloadCacheKey = Tuple[str, str, str, str, str, int] CityDetailPayloadCacheKey = Tuple[str, str, str, str, str, int]
CityDetailBatchResponseCacheKey = Tuple[Tuple[str, ...], bool, str, str, str]
_CITY_DETAIL_PAYLOAD_CACHE: Dict[CityDetailPayloadCacheKey, Dict[str, Any]] = {} _CITY_DETAIL_PAYLOAD_CACHE: Dict[CityDetailPayloadCacheKey, Dict[str, Any]] = {}
_CITY_DETAIL_PAYLOAD_CACHE_TS: Dict[CityDetailPayloadCacheKey, float] = {} _CITY_DETAIL_PAYLOAD_CACHE_TS: Dict[CityDetailPayloadCacheKey, float] = {}
_CITY_DETAIL_PAYLOAD_INFLIGHT: Dict[CityDetailPayloadCacheKey, "asyncio.Task[Dict[str, Any]]"] = {} _CITY_DETAIL_PAYLOAD_INFLIGHT: Dict[CityDetailPayloadCacheKey, "asyncio.Task[Dict[str, Any]]"] = {}
_CITY_DETAIL_PAYLOAD_EPOCH: Dict[str, int] = {} _CITY_DETAIL_PAYLOAD_EPOCH: Dict[str, int] = {}
_CITY_DETAIL_PAYLOAD_LOCK = asyncio.Lock() _CITY_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()
def _city_detail_payload_cache_ttl() -> float: def _city_detail_payload_cache_ttl() -> float:
@@ -42,6 +47,17 @@ def _city_detail_payload_cache_ttl() -> float:
return max(0.0, min(30.0, value)) 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))
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]:
return await run_in_threadpool( return await run_in_threadpool(
legacy_routes._overlay_latest_wunderground_current, legacy_routes._overlay_latest_wunderground_current,
@@ -458,6 +474,23 @@ def _parse_batch_city_names(raw_cities: str, *, limit: int) -> List[str]:
return out 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],
) -> CityDetailBatchResponseCacheKey:
return (
tuple(city_names),
bool(force_refresh),
str(market_slug or ""),
str(target_date or ""),
str(resolution or "10m"),
)
async def _build_city_detail_batch_item_async( async def _build_city_detail_batch_item_async(
city: str, city: str,
*, *,
@@ -549,61 +582,116 @@ async def get_city_detail_batch_payload(
"partial": False, "partial": False,
} }
semaphore = asyncio.Semaphore(_city_detail_batch_concurrency()) async def _build_uncached_payload() -> Dict[str, Any]:
semaphore = asyncio.Semaphore(_city_detail_batch_concurrency())
async def _build_with_limit(city: str) -> Tuple[str, Dict[str, Any]]: async def _build_with_limit(city: str) -> Tuple[str, Dict[str, Any]]:
async with semaphore: async with semaphore:
return await _build_city_detail_batch_item_async( return await _build_city_detail_batch_item_async(
city, city,
force_refresh=force_refresh, force_refresh=force_refresh,
market_slug=market_slug, market_slug=market_slug,
target_date=target_date, target_date=target_date,
resolution=resolution, resolution=resolution,
timing_recorder=timer, timing_recorder=timer,
) )
task_by_city = { task_by_city = {
city: asyncio.create_task(_build_with_limit(city)) city: asyncio.create_task(_build_with_limit(city))
for city in city_names for city in city_names
} }
task_city_lookup = {task: city for city, task in task_by_city.items()} task_city_lookup = {task: city for city, task in task_by_city.items()}
done, pending = await timer.measure_async( done, pending = await timer.measure_async(
"build_details", "build_details",
lambda: asyncio.wait( lambda: asyncio.wait(
task_by_city.values(), task_by_city.values(),
timeout=_city_detail_batch_partial_timeout_seconds(), 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),
}
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,
) )
details: Dict[str, Any] = {} if cache_ttl > 0 and not force_refresh:
errors: Dict[str, str] = {} now_ts = time.time()
missing: List[str] = [] async with _CITY_DETAIL_BATCH_RESPONSE_LOCK:
for task in done: cached = _CITY_DETAIL_BATCH_RESPONSE_CACHE.get(cache_key)
city = task_city_lookup[task] 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: try:
result_city, payload = task.result() payload = await timer.measure_async(
except Exception as exc: "build_or_wait_cached_batch",
errors[city] = str(exc) lambda: task,
continue )
details[result_city] = payload 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"
for task in pending: if owner:
city = task_city_lookup[task] async with _CITY_DETAIL_BATCH_RESPONSE_LOCK:
missing.append(city) if not payload.get("partial"):
task.cancel() _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
missing_set = set(missing) payload = await _build_uncached_payload()
missing = [city for city in city_names if city in missing_set] if payload.get("partial"):
partial = bool(missing or errors)
if partial:
outcome = "partial" outcome = "partial"
return payload
return {
"cities": city_names,
"details": details,
"errors": errors,
"missing": missing,
"partial": partial,
}
except HTTPException as exc: except HTTPException as exc:
outcome = f"http_{exc.status_code}" outcome = f"http_{exc.status_code}"
status_code = exc.status_code status_code = exc.status_code