From 8d26afdec052c36eec793f581f600aa9e778dd4e Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 31 May 2026 19:21:53 +0800 Subject: [PATCH] Reduce terminal auth and analytics stalls --- frontend/app/api/analytics/events/route.ts | 104 ++++++++++++++---- .../dashboard/ScanTerminalDashboard.tsx | 29 ++++- .../__tests__/refreshCadencePolicy.test.ts | 2 +- .../__tests__/terminalAuthBootstrap.test.ts | 36 ++++++ .../scan-terminal/terminal-auth-bootstrap.ts | 26 +++++ .../__tests__/apiPerformanceTiming.test.ts | 22 ++++ tests/test_web_observability.py | 64 +++++++++++ web/services/city_api.py | 43 +++++++- 8 files changed, 295 insertions(+), 31 deletions(-) diff --git a/frontend/app/api/analytics/events/route.ts b/frontend/app/api/analytics/events/route.ts index 9561b0d6..a4f909dc 100644 --- a/frontend/app/api/analytics/events/route.ts +++ b/frontend/app/api/analytics/events/route.ts @@ -7,25 +7,46 @@ import { buildProxyExceptionResponse, buildUpstreamErrorResponse, } from "@/lib/api-proxy"; +import { + createProxyTimer, + finishProxyTimedResponse, +} from "@/lib/proxy-timing"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const ANALYTICS_ENABLED = process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS !== "false"; +const ANALYTICS_PROXY_TIMEOUT_MS = Math.max( + 250, + Number(process.env.POLYWEATHER_ANALYTICS_PROXY_TIMEOUT_MS || "1500") || 1500, +); export async function POST(req: NextRequest) { + const timer = createProxyTimer(req, "analytics_events"); if (!ANALYTICS_ENABLED) { - return new NextResponse(null, { status: 204 }); - } - - if (!API_BASE) { - return NextResponse.json( - { error: "POLYWEATHER_API_BASE_URL is not configured" }, - { status: 500 }, + return finishProxyTimedResponse( + new NextResponse(null, { status: 204 }), + timer, + "disabled", ); } + if (!API_BASE) { + return finishProxyTimedResponse( + NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ), + timer, + "missing_api_base", + ); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), ANALYTICS_PROXY_TIMEOUT_MS); + let auth: Awaited> | null = null; + try { - const body = await req.json(); + const body = await timer.measure("request_read", () => req.json()); const payload = body && typeof body.payload === "object" && body.payload != null ? body.payload @@ -42,30 +63,65 @@ export async function POST(req: NextRequest) { referer_header: req.headers.get("referer") || "", }, }; - const auth = await buildBackendRequestHeaders(req, { - includeSupabaseIdentity: false, - }); + auth = await timer.measure("auth_headers", () => + buildBackendRequestHeaders(req, { + includeSupabaseIdentity: false, + }), + ); const headers = new Headers(auth.headers); headers.set("Content-Type", "application/json"); - const res = await fetch(`${API_BASE}/api/analytics/events`, { - method: "POST", - headers, - body: JSON.stringify(enrichedBody), - cache: "no-store", - }); + const res = await timer.measure("backend_fetch", () => + fetch(`${API_BASE}/api/analytics/events`, { + method: "POST", + headers, + body: JSON.stringify(enrichedBody), + cache: "no-store", + signal: controller.signal, + }), + ); + const backendServerTiming = res.headers.get("server-timing") || ""; if (!res.ok) { - const raw = await res.text(); + const raw = await timer.measure("backend_read", () => res.text()); const response = buildUpstreamErrorResponse(res.status, raw, { detailLimit: 260, }); - return applyAuthResponseCookies(response, auth.response); + return finishProxyTimedResponse( + applyAuthResponseCookies(response, auth.response), + timer, + `upstream_${res.status}`, + { backendServerTiming }, + ); } - const data = await res.json(); + const data = await timer.measure("backend_read", () => res.json()); const response = NextResponse.json(data); - return applyAuthResponseCookies(response, auth.response); + return finishProxyTimedResponse( + applyAuthResponseCookies(response, auth.response), + timer, + "ok", + { backendServerTiming }, + ); } catch (error) { - return buildProxyExceptionResponse(error, { - publicMessage: "Failed to track analytics event", - }); + const timedOut = controller.signal.aborted; + const response = timedOut + ? NextResponse.json( + { + ok: false, + accepted: true, + dropped: true, + reason: "timeout", + }, + { status: 202 }, + ) + : buildProxyExceptionResponse(error, { + publicMessage: "Failed to track analytics event", + }); + const withCookies = auth ? applyAuthResponseCookies(response, auth.response) : response; + return finishProxyTimedResponse( + withCookies, + timer, + timedOut ? "timeout_accepted" : "exception", + ); + } finally { + clearTimeout(timeoutId); } } diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 133b8be6..ad5acd1f 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -55,7 +55,10 @@ import { mergeAccessStateWithAuthPayload, type AuthProfilePayload, } from "@/components/dashboard/scan-terminal/terminal-access-state"; -import { loadTerminalAuthProfile } from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap"; +import { + createAuthProfileRequestCache, + loadTerminalAuthProfile, +} from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap"; import { cityListItemsToScanRows, mergeScanRowsWithCityFallbackRows, @@ -962,7 +965,7 @@ function ScanTerminalScreen() { createEmptyAccess(true), ); - const loadAuthProfile = useCallback( + const rawLoadAuthProfile = useCallback( async ( accessToken?: string | null, options?: { preferSnapshot?: boolean }, @@ -984,6 +987,28 @@ function ScanTerminalScreen() { }, [], ); + const authProfileRequestCacheRef = useRef<{ + load: typeof rawLoadAuthProfile; + cached: ReturnType; + } | null>(null); + const loadAuthProfile = useCallback( + ( + accessToken?: string | null, + options?: { preferSnapshot?: boolean }, + ): Promise => { + const current = authProfileRequestCacheRef.current; + if (current?.load === rawLoadAuthProfile) { + return current.cached(accessToken, options); + } + const next = { + load: rawLoadAuthProfile, + cached: createAuthProfileRequestCache(rawLoadAuthProfile), + }; + authProfileRequestCacheRef.current = next; + return next.cached(accessToken, options); + }, + [rawLoadAuthProfile], + ); const refreshLiveAuthProfile = useCallback(async () => { const supabaseEnabled = hasSupabasePublicEnv(); diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index 3aed6f05..d17b05de 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -96,7 +96,7 @@ export async function runTests() { chartLogicSource.includes("primeCityDetailCache"), "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] || ""; + const fetchHourlyBlock = chartLogicSource.match(/async function fetchHourlyForecastForCity[\s\S]*?\r?\n}\r?\n\r?\nfunction fetchCityDetailWithTimeout/)?.[0] || ""; assert( fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam)") && !fetchHourlyBlock.includes("runQueuedHourlyDetailRequest"), diff --git a/frontend/components/dashboard/scan-terminal/__tests__/terminalAuthBootstrap.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/terminalAuthBootstrap.test.ts index 7e307953..01722680 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/terminalAuthBootstrap.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/terminalAuthBootstrap.test.ts @@ -1,4 +1,5 @@ import { + createAuthProfileRequestCache, loadTerminalAuthProfile, type TerminalAuthProfilePayload, } from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap"; @@ -27,6 +28,41 @@ async function flushMicrotasks() { } export async function runTests() { + const pendingProfile = deferred(); + let underlyingProfileLoads = 0; + const cachedLoadAuthProfile = createAuthProfileRequestCache(( + accessToken?: string | null, + options?: { preferSnapshot?: boolean }, + ) => { + underlyingProfileLoads += 1; + assert( + accessToken === "shared-token" && options?.preferSnapshot === true, + "auth profile request cache should pass through the original token and snapshot preference", + ); + return pendingProfile.promise; + }); + const firstCachedProfile = cachedLoadAuthProfile("shared-token", { + preferSnapshot: true, + }); + const secondCachedProfile = cachedLoadAuthProfile("shared-token", { + preferSnapshot: true, + }); + assert( + firstCachedProfile === secondCachedProfile && underlyingProfileLoads === 1, + "auth profile request cache should dedupe identical in-flight profile loads", + ); + pendingProfile.resolve({ + authenticated: true, + user_id: "shared-user", + subscription_active: true, + }); + await firstCachedProfile; + await cachedLoadAuthProfile("shared-token", { preferSnapshot: true }); + assert( + underlyingProfileLoads === 2, + "auth profile request cache should clear a key after the in-flight load settles", + ); + const slowCookieProfile = deferred(); const fastSession = deferred<{ data: { session: { access_token: string } } }>(); const calls: string[] = []; diff --git a/frontend/components/dashboard/scan-terminal/terminal-auth-bootstrap.ts b/frontend/components/dashboard/scan-terminal/terminal-auth-bootstrap.ts index 85f2727e..25991aed 100644 --- a/frontend/components/dashboard/scan-terminal/terminal-auth-bootstrap.ts +++ b/frontend/components/dashboard/scan-terminal/terminal-auth-bootstrap.ts @@ -57,6 +57,32 @@ function canResolveProfileImmediately( return payload?.authenticated === true && payload.subscription_active === true; } +function authProfileRequestCacheKey( + accessToken?: string | null, + options?: { preferSnapshot?: boolean }, +) { + const token = String(accessToken || "").trim(); + const scope = token ? `bearer:${token}` : "cookie"; + const mode = options?.preferSnapshot ? "snapshot" : "live"; + return `${mode}:${scope}`; +} + +export function createAuthProfileRequestCache( + loadAuthProfile: LoadTerminalAuthProfileOptions["loadAuthProfile"], +): LoadTerminalAuthProfileOptions["loadAuthProfile"] { + const pending = new Map>(); + return (accessToken, options) => { + const key = authProfileRequestCacheKey(accessToken, options); + const existing = pending.get(key); + if (existing) return existing; + const request = loadAuthProfile(accessToken, options).finally(() => { + pending.delete(key); + }); + pending.set(key, request); + return request; + }; +} + export async function loadTerminalAuthProfile({ getSession, hasSupabasePublicEnv, diff --git a/frontend/components/ops/__tests__/apiPerformanceTiming.test.ts b/frontend/components/ops/__tests__/apiPerformanceTiming.test.ts index 7ee539f0..967f2a45 100644 --- a/frontend/components/ops/__tests__/apiPerformanceTiming.test.ts +++ b/frontend/components/ops/__tests__/apiPerformanceTiming.test.ts @@ -57,4 +57,26 @@ export function runTests() { for (const stage of ["auth_headers", "ops_auth", "backend_fetch", "backend_read"]) { assert.match(onlineUsersProxy, new RegExp(stage)); } + + const analyticsProxy = readFrontend("app", "api", "analytics", "events", "route.ts"); + assert.match( + analyticsProxy, + /ANALYTICS_PROXY_TIMEOUT_MS/, + "analytics event proxy should use a short dedicated timeout instead of waiting for long backend stalls", + ); + assert.match( + analyticsProxy, + /createProxyTimer\(req,\s*"analytics_events"\)/, + "analytics event proxy should expose Server-Timing for HAR inspection", + ); + assert.match( + analyticsProxy, + /AbortController/, + "analytics event proxy should abort slow upstream tracking requests", + ); + assert.match( + analyticsProxy, + /status:\s*202/, + "analytics event proxy timeout should stay non-blocking for the fire-and-forget client event", + ); } diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 7cb39344..1753dcf1 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -678,6 +678,70 @@ def test_concurrent_city_detail_requests_share_same_full_cache_refresh(monkeypat assert build_calls == 1 +def test_stale_city_detail_uses_cached_full_payload_while_refreshing(monkeypatch): + import asyncio + + refresh_calls = 0 + build_inputs = [] + + class FakeCache: + def get_city_cache(self, kind, city): + assert kind == "full" + assert city == "paris" + return { + "payload": { + "city": "paris", + "hourly": {"times": ["2026-05-30T00:00:00Z"], "temps": [20.0]}, + }, + } + + async def fake_run_in_threadpool(fn, *args, **kwargs): + if fn is city_api.legacy_routes._refresh_city_full_cache: + await asyncio.sleep(0.01) + return fn(*args, **kwargs) + + def refresh_full(city, force_refresh): + nonlocal refresh_calls + refresh_calls += 1 + return { + "city": city, + "hourly": {"times": ["2026-05-30T00:00:00Z"], "temps": [21.0]}, + } + + def build_detail(data, market_slug, target_date, resolution): + build_inputs.append(data["hourly"]["temps"][0]) + return { + "city": data["city"], + "live_temp": data["hourly"]["temps"][0], + "resolution": resolution, + } + + city_api._CITY_FULL_REFRESH_INFLIGHT.clear() + 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, "_city_cache_is_fresh", lambda entry, ttl: False) + monkeypatch.setattr(city_api.legacy_routes, "_overlay_latest_wunderground_current", lambda city, payload: payload) + 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_request(): + payload = await city_api.get_city_detail_aggregate_payload(object(), "Paris", resolution="10m") + await asyncio.sleep(0.03) + return payload + + result = asyncio.run(run_request()) + + assert result["live_temp"] == 20.0 + assert build_inputs == [20.0] + assert refresh_calls == 1 + + def test_force_refresh_invalidates_short_city_detail_payload_cache(monkeypatch): import asyncio diff --git a/web/services/city_api.py b/web/services/city_api.py index ec165e1a..c497cd2e 100644 --- a/web/services/city_api.py +++ b/web/services/city_api.py @@ -24,6 +24,7 @@ _RECENT_DEB_CACHE_TTL_SEC = max( 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_STALE_REFRESH_TASKS: 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]] = {} @@ -54,9 +55,17 @@ async def _refresh_city_full_cache_singleflight(city: str, force_refresh: bool) 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), - ) + async def _run_refresh() -> Dict[str, Any]: + try: + return await run_in_threadpool( + legacy_routes._refresh_city_full_cache, + city, + force_refresh, + ) + finally: + await _invalidate_city_detail_payload_cache(city) + + task = asyncio.create_task(_run_refresh()) _CITY_FULL_REFRESH_INFLIGHT[key] = task try: return await task @@ -84,14 +93,40 @@ async def _refresh_city_full_data(city: str, force_refresh: bool) -> Dict[str, A return await _refresh_city_full_cache_singleflight(city, force_refresh) +def _start_city_full_stale_refresh(city: str) -> None: + normalized = str(city or "").strip().lower() + if not normalized: + return + existing = _CITY_FULL_STALE_REFRESH_TASKS.get(normalized) + if existing is not None and not existing.done(): + return + + task = asyncio.create_task(_refresh_city_full_data(city, False)) + _CITY_FULL_STALE_REFRESH_TASKS[normalized] = task + + def _cleanup(done: "asyncio.Task[Dict[str, Any]]") -> None: + if _CITY_FULL_STALE_REFRESH_TASKS.get(normalized) is done: + _CITY_FULL_STALE_REFRESH_TASKS.pop(normalized, None) + try: + done.result() + except Exception as exc: # pragma: no cover - defensive background guard + logger.warning("city full stale refresh failed city={}: {}", city, exc) + + task.add_done_callback(_cleanup) + + 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: + 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) return await _refresh_city_full_data(city, False) - return await _overlay_cached_wunderground(city, cached_entry.get("payload") or {}) + return await _overlay_cached_wunderground(city, payload) return await _refresh_city_full_data(city, False)