diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 38e3875f..1496bbcd 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -87,6 +87,10 @@ const TrainingDashboard = dynamic( ); const ONLINE_USERS_REFRESH_MS = 5 * 60_000; +const AUTH_PROFILE_REQUEST_TIMEOUT_MS = 4500; +const AUTH_DECISION_RECOVERY_MS = 10_000; +const ACTIVE_ACCESS_CACHE_KEY = "polyweather_terminal_active_access_v1"; +const ACTIVE_ACCESS_CACHE_TTL_MS = 6 * 60 * 60 * 1000; function createEmptyAccess(loading = true): ProAccessState { return { @@ -118,6 +122,67 @@ function createLocalAccess(): ProAccessState { }; } +function isFutureAccessExpiry(value: string | null | undefined, now = Date.now()) { + if (!value) return true; + const ts = Date.parse(value); + return Number.isFinite(ts) && ts > now; +} + +function readCachedActiveAccess(now = Date.now()): ProAccessState | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(ACTIVE_ACCESS_CACHE_KEY); + if (!raw) return null; + const cached = JSON.parse(raw); + const access = cached?.access as Partial | undefined; + const ts = Number(cached?.ts || 0); + if (!access?.authenticated || !access.subscriptionActive) return null; + if (!ts || now - ts < 0 || now - ts > ACTIVE_ACCESS_CACHE_TTL_MS) return null; + if (!isFutureAccessExpiry(access.subscriptionTotalExpiresAt || access.subscriptionExpiresAt, now)) { + return null; + } + return { + loading: false, + authenticated: true, + userId: access.userId ?? null, + subscriptionActive: true, + subscriptionPlanCode: access.subscriptionPlanCode ?? null, + subscriptionExpiresAt: access.subscriptionExpiresAt ?? null, + subscriptionTotalExpiresAt: + access.subscriptionTotalExpiresAt ?? access.subscriptionExpiresAt ?? null, + subscriptionQueuedDays: Number(access.subscriptionQueuedDays ?? 0), + points: Number(access.points ?? 0), + error: null, + }; + } catch { + return null; + } +} + +function writeCachedActiveAccess(access: ProAccessState) { + if (typeof window === "undefined") return; + if (!access.authenticated || !access.subscriptionActive) return; + if (!isFutureAccessExpiry(access.subscriptionTotalExpiresAt || access.subscriptionExpiresAt)) return; + try { + window.localStorage.setItem( + ACTIVE_ACCESS_CACHE_KEY, + JSON.stringify({ + ts: Date.now(), + access: { + authenticated: access.authenticated, + userId: access.userId, + subscriptionActive: access.subscriptionActive, + subscriptionPlanCode: access.subscriptionPlanCode, + subscriptionExpiresAt: access.subscriptionExpiresAt, + subscriptionTotalExpiresAt: access.subscriptionTotalExpiresAt, + subscriptionQueuedDays: access.subscriptionQueuedDays, + points: access.points, + }, + }), + ); + } catch {} +} + function createTransientAccess(error: unknown): ProAccessState { return { ...createEmptyAccess(true), @@ -971,6 +1036,73 @@ function PolyWeatherTerminal({ ); } +function AuthSyncRecoveryScreen({ + isEn, + onRetry, + rootClassName, + retrying, + themeMode, + userLocalTime, +}: { + isEn: boolean; + onRetry: () => void; + rootClassName: string; + retrying: boolean; + themeMode: "dark" | "light"; + userLocalTime: string; +}) { + return ( +
+
+
+
+ ); +} + function ScanTerminalScreen() { const [proAccess, setProAccess] = useState(() => createEmptyAccess(true), @@ -984,17 +1116,26 @@ function ScanTerminalScreen() { const headers: Record = { Accept: "application/json" }; const token = String(accessToken || "").trim(); if (token) headers.Authorization = `Bearer ${token}`; - const response = await fetch( - options?.preferSnapshot - ? "/api/auth/me?prefer_snapshot=1" - : "/api/auth/me", - { - cache: "no-store", - headers, - }, - ); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json() as Promise; + const controller = typeof AbortController !== "undefined" ? new AbortController() : null; + const timeoutId = controller + ? globalThis.setTimeout(() => controller.abort(), AUTH_PROFILE_REQUEST_TIMEOUT_MS) + : null; + try { + const response = await fetch( + options?.preferSnapshot + ? "/api/auth/me?prefer_snapshot=1" + : "/api/auth/me", + { + cache: "no-store", + headers, + signal: controller?.signal, + }, + ); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; + } finally { + if (timeoutId !== null) globalThis.clearTimeout(timeoutId); + } }, [], ); @@ -1031,6 +1172,7 @@ function ScanTerminalScreen() { hasSupabasePublicEnv: supabaseEnabled, loadAuthProfile: (accessToken) => loadAuthProfile(accessToken, { preferSnapshot: false }), + timeoutMs: AUTH_PROFILE_REQUEST_TIMEOUT_MS + 2000, }); setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload)); }, [loadAuthProfile]); @@ -1093,13 +1235,17 @@ function ScanTerminalScreen() { setLocale((prev) => (prev === "zh-CN" ? "en-US" : "zh-CN")); const [hydrated, setHydrated] = useState(false); const [localFullAccess, setLocalFullAccess] = useState(false); + const [authWaitExpired, setAuthWaitExpired] = useState(false); + const [authRetrying, setAuthRetrying] = useState(false); const canUseLocalFullAccess = hydrated && localFullAccess; const isAuthenticated = hydrated && (proAccess.authenticated || canUseLocalFullAccess); const isPro = hydrated && (proAccess.subscriptionActive || canUseLocalFullAccess); const accessDecisionPending = - !hydrated || (proAccess.loading && !canUseLocalFullAccess); + !hydrated || (proAccess.loading && !canUseLocalFullAccess && !authWaitExpired); + const authSyncRecoveryNeeded = + hydrated && proAccess.loading && !canUseLocalFullAccess && authWaitExpired; const shouldShowPaywall = !accessDecisionPending && (!isAuthenticated || !isPro); const userLocalTime = useUserLocalClock(); const { themeMode } = useScanTerminalTheme(); @@ -1139,6 +1285,10 @@ function ScanTerminalScreen() { cancelled = true; }; } + const cachedActiveAccess = readCachedActiveAccess(); + if (cachedActiveAccess) { + setProAccess(cachedActiveAccess); + } if (typeof fetch !== "function") { setProAccess(createEmptyAccess(false)); return () => { @@ -1153,6 +1303,7 @@ function ScanTerminalScreen() { : Promise.resolve({ data: { session: null } }), hasSupabasePublicEnv: supabaseEnabled, loadAuthProfile, + timeoutMs: AUTH_PROFILE_REQUEST_TIMEOUT_MS + 2000, }) .then((payload) => { if (cancelled) return; @@ -1176,6 +1327,23 @@ function ScanTerminalScreen() { }; }, [loadAuthProfile, refreshLiveAuthProfile]); + useEffect(() => { + if (!hydrated || !proAccess.loading || canUseLocalFullAccess) { + setAuthWaitExpired(false); + return; + } + const timer = window.setTimeout(() => { + setAuthWaitExpired(true); + }, AUTH_DECISION_RECOVERY_MS); + return () => window.clearTimeout(timer); + }, [canUseLocalFullAccess, hydrated, proAccess.loading]); + + useEffect(() => { + if (hydrated && proAccess.authenticated && proAccess.subscriptionActive) { + writeCachedActiveAccess(proAccess); + } + }, [hydrated, proAccess]); + useEffect(() => { if ( !hydrated || @@ -1199,6 +1367,7 @@ function ScanTerminalScreen() { : Promise.resolve({ data: { session: null } }), hasSupabasePublicEnv: supabaseEnabled, loadAuthProfile, + timeoutMs: AUTH_PROFILE_REQUEST_TIMEOUT_MS + 2000, }); if (cancelled) return; setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload)); @@ -1264,6 +1433,17 @@ function ScanTerminalScreen() { clearCityDetailCache(); refreshScanTerminalManually(); }, [refreshScanTerminalManually]); + const handleRetryAuthSync = useCallback(() => { + setAuthRetrying(true); + setAuthWaitExpired(false); + void refreshLiveAuthProfile() + .catch((error) => { + setProAccess((prev) => ({ ...prev, error: String(error) })); + }) + .finally(() => { + setAuthRetrying(false); + }); + }, [refreshLiveAuthProfile]); const [cityFallbackRows, setCityFallbackRows] = useState(() => cityListItemsToScanRows(readCachedCityList() || STATIC_CITY_LIST), @@ -1356,6 +1536,19 @@ function ScanTerminalScreen() { ); } + if (authSyncRecoveryNeeded) { + return ( + + ); + } + if (shouldShowPaywall) { return ( (); + const timedCookieResult = await loadTerminalAuthProfile({ + hasSupabasePublicEnv: true, + getSession: () => slowDegradedSession.promise, + timeoutMs: 1, + loadAuthProfile: (accessToken) => { + assert(!accessToken, "timeout fallback should use the settled cookie profile when bearer session is still pending"); + return Promise.resolve({ + authenticated: true, + user_id: "cookie-user", + subscription_active: null, + degraded_auth_profile: true, + }); + }, + }); + assert( + timedCookieResult.user_id === "cookie-user" && + timedCookieResult.subscription_active === null, + "terminal auth bootstrap timeout must resolve with a known degraded cookie profile instead of spinning forever", + ); + + const neverCookie = deferred(); + const neverSession = deferred<{ data: { session: { access_token: string } | null } }>(); + let timeoutRejected = false; + try { + await loadTerminalAuthProfile({ + hasSupabasePublicEnv: true, + getSession: () => neverSession.promise, + timeoutMs: 1, + loadAuthProfile: () => neverCookie.promise, + }); + } catch (error) { + timeoutRejected = String(error).includes("Terminal auth bootstrap timeout"); + } + assert( + timeoutRejected, + "terminal auth bootstrap must reject instead of leaving the loading screen unresolved when no profile request settles", + ); + const delayedBearerSession = deferred<{ data: { session: { access_token: string } } }>(); let coldStartSettled = false; const coldStartResultPromise = loadTerminalAuthProfile({ diff --git a/frontend/components/dashboard/scan-terminal/terminal-auth-bootstrap.ts b/frontend/components/dashboard/scan-terminal/terminal-auth-bootstrap.ts index 25991aed..b350ad81 100644 --- a/frontend/components/dashboard/scan-terminal/terminal-auth-bootstrap.ts +++ b/frontend/components/dashboard/scan-terminal/terminal-auth-bootstrap.ts @@ -17,6 +17,7 @@ type LoadTerminalAuthProfileOptions = { accessToken?: string | null, options?: { preferSnapshot?: boolean }, ) => Promise; + timeoutMs?: number; }; type SettledProfile = @@ -87,11 +88,14 @@ export async function loadTerminalAuthProfile({ getSession, hasSupabasePublicEnv, loadAuthProfile, + timeoutMs = 6500, }: LoadTerminalAuthProfileOptions) { let resolvedAuthenticated = false; let resolveAuthenticated: | ((payload: TerminalAuthProfilePayload) => void) | null = null; + let latestCookiePayload: TerminalAuthProfilePayload | null = null; + let latestBearerPayload: TerminalAuthProfilePayload | null = null; const authenticatedProfile = new Promise((resolve) => { resolveAuthenticated = resolve; @@ -105,6 +109,7 @@ export async function loadTerminalAuthProfile({ const cookieProfile = settleProfile( loadAuthProfile(null, { preferSnapshot: true }).then((payload) => { + latestCookiePayload = payload; resolveIfAuthenticated(payload); return payload; }), @@ -120,6 +125,7 @@ export async function loadTerminalAuthProfile({ ).trim(); if (!accessToken) return null; const payload = await loadAuthProfile(accessToken, { preferSnapshot: true }); + latestBearerPayload = payload; resolveIfAuthenticated(payload); return payload; })(), @@ -129,5 +135,20 @@ export async function loadTerminalAuthProfile({ ([cookieResult, bearerResult]) => firstKnownProfile(cookieResult, bearerResult), ); - return Promise.race([authenticatedProfile, fallbackProfile]); + const timeoutProfile = new Promise((resolve, reject) => { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return; + globalThis.setTimeout(() => { + if (latestBearerPayload) { + resolve(latestBearerPayload); + return; + } + if (latestCookiePayload) { + resolve(latestCookiePayload); + return; + } + reject(new Error("Terminal auth bootstrap timeout")); + }, timeoutMs); + }); + + return Promise.race([authenticatedProfile, fallbackProfile, timeoutProfile]); } diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 89d603f0..943de5b7 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -775,6 +775,43 @@ def test_city_detail_batch_chart_scope_returns_only_chart_fields(monkeypatch): assert "ai_analysis" not in detail +def test_chart_detail_payload_avoids_threadpool_queue_and_reuses_short_cache(monkeypatch): + import asyncio + + build_calls = 0 + + async def fail_threadpool(*_args, **_kwargs): + raise AssertionError("chart payload should not wait behind the shared threadpool") + + def build_chart_detail(data, resolution): + nonlocal build_calls + build_calls += 1 + return { + "city": data["city"], + "resolution": resolution, + "hourly": data["hourly"], + } + + city_api._CITY_CHART_DETAIL_PAYLOAD_CACHE.clear() + city_api._CITY_CHART_DETAIL_PAYLOAD_CACHE_TS.clear() + monkeypatch.setenv("POLYWEATHER_CITY_DETAIL_PAYLOAD_CACHE_TTL_SEC", "20") + monkeypatch.setattr(city_api, "run_in_threadpool", fail_threadpool) + monkeypatch.setattr(city_api.legacy_routes, "_build_city_chart_detail_payload", build_chart_detail) + + data = { + "city": "paris", + "updated_at": "2026-05-30T15:00:00Z", + "hourly": {"times": ["2026-05-30T15:00:00Z"], "temps": [20.0]}, + } + + first = asyncio.run(city_api._build_city_chart_detail_payload(data, "10m")) + second = asyncio.run(city_api._build_city_chart_detail_payload(data, "10m")) + + assert first == second + assert first["resolution"] == "10m" + assert build_calls == 1 + + def test_city_detail_batch_endpoint_limits_backend_concurrency(monkeypatch): import asyncio diff --git a/web/services/city_api.py b/web/services/city_api.py index 331bd6c2..230ea265 100644 --- a/web/services/city_api.py +++ b/web/services/city_api.py @@ -27,12 +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_REFRESH_LOCK = asyncio.Lock() CityDetailPayloadCacheKey = Tuple[str, str, str, str, str, int] +CityChartDetailPayloadCacheKey = Tuple[str, str, str, int] CityDetailBatchResponseCacheKey = Tuple[Tuple[str, ...], bool, str, str, str, str] _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() +_CITY_CHART_DETAIL_PAYLOAD_CACHE: Dict[CityChartDetailPayloadCacheKey, Dict[str, Any]] = {} +_CITY_CHART_DETAIL_PAYLOAD_CACHE_TS: Dict[CityChartDetailPayloadCacheKey, float] = {} +_CITY_CHART_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]]"] = {} @@ -102,6 +106,11 @@ async def _invalidate_city_detail_payload_cache(city: str) -> None: for key in old_keys: _CITY_DETAIL_PAYLOAD_CACHE.pop(key, None) _CITY_DETAIL_PAYLOAD_CACHE_TS.pop(key, None) + async with _CITY_CHART_DETAIL_PAYLOAD_LOCK: + old_chart_keys = [key for key in _CITY_CHART_DETAIL_PAYLOAD_CACHE if key[0] == normalized] + for key in old_chart_keys: + _CITY_CHART_DETAIL_PAYLOAD_CACHE.pop(key, None) + _CITY_CHART_DETAIL_PAYLOAD_CACHE_TS.pop(key, None) async def _refresh_city_full_data(city: str, force_refresh: bool) -> Dict[str, Any]: @@ -189,6 +198,27 @@ def _city_detail_payload_cache_key( ) +def _city_chart_detail_payload_cache_key( + data: Dict[str, Any], + resolution: Optional[str], +) -> CityChartDetailPayloadCacheKey: + 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"), + fingerprint, + generation, + ) + + async def _build_city_detail_payload_cached( data: Dict[str, Any], market_slug: Optional[str], @@ -250,11 +280,33 @@ async def _build_city_chart_detail_payload( data: Dict[str, Any], resolution: Optional[str], ) -> Dict[str, Any]: - return await run_in_threadpool( - legacy_routes._build_city_chart_detail_payload, - data, - resolution, - ) + ttl = _city_detail_payload_cache_ttl() + if ttl <= 0: + return legacy_routes._build_city_chart_detail_payload(data, resolution) + + key = _city_chart_detail_payload_cache_key(data, resolution) + now_ts = time.time() + async with _CITY_CHART_DETAIL_PAYLOAD_LOCK: + cached = _CITY_CHART_DETAIL_PAYLOAD_CACHE.get(key) + cached_ts = _CITY_CHART_DETAIL_PAYLOAD_CACHE_TS.get(key, 0.0) + if cached is not None and now_ts - cached_ts < ttl: + return cached + + payload = legacy_routes._build_city_chart_detail_payload(data, resolution) + + async with _CITY_CHART_DETAIL_PAYLOAD_LOCK: + _CITY_CHART_DETAIL_PAYLOAD_CACHE[key] = payload + _CITY_CHART_DETAIL_PAYLOAD_CACHE_TS[key] = time.time() + if len(_CITY_CHART_DETAIL_PAYLOAD_CACHE) > 256: + oldest_keys = sorted( + _CITY_CHART_DETAIL_PAYLOAD_CACHE_TS, + key=lambda item: _CITY_CHART_DETAIL_PAYLOAD_CACHE_TS.get(item, 0.0), + )[:64] + for old_key in oldest_keys: + _CITY_CHART_DETAIL_PAYLOAD_CACHE.pop(old_key, None) + _CITY_CHART_DETAIL_PAYLOAD_CACHE_TS.pop(old_key, None) + return payload + def _default_deb_recent() -> Dict[str, object]: