Prevent terminal auth spinner deadlock
This commit is contained in:
@@ -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<ProAccessState> | 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 (
|
||||
<div className={rootClassName}>
|
||||
<div className={clsx("flex h-screen w-full bg-[#e9edf3] text-slate-950", themeMode === "light" && "light")}>
|
||||
<aside className="w-[52px] bg-[#171d24]" />
|
||||
<main className="flex flex-1 flex-col">
|
||||
<header className="flex h-[52px] items-center justify-between border-b border-slate-200 bg-white px-4">
|
||||
<Link href="/" className="flex items-center gap-2 hover:opacity-90">
|
||||
<img src="/logo.png" alt="PolyWeather" className="h-7 w-auto object-contain" />
|
||||
<span className="text-sm font-semibold tracking-tight text-slate-900">Terminal</span>
|
||||
</Link>
|
||||
<div className="font-mono text-sm text-slate-500">{userLocalTime}</div>
|
||||
</header>
|
||||
<section className="grid flex-1 place-items-center p-6">
|
||||
<div className="w-full max-w-md rounded-[6px] border border-amber-200 bg-white p-6 shadow-sm">
|
||||
<div className="mb-3 inline-flex rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-[11px] font-black text-amber-700">
|
||||
{isEn ? "Access sync timeout" : "权限同步超时"}
|
||||
</div>
|
||||
<h1 className="text-xl font-black text-slate-950">
|
||||
{isEn ? "We could not confirm your subscription yet" : "暂时未能确认你的订阅状态"}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-600">
|
||||
{isEn
|
||||
? "The session or entitlement service is taking too long. Retry access sync; if you just paid, wait a few seconds and try again."
|
||||
: "当前会话或会员服务响应过慢。请先重新同步权限;如果刚完成支付,等待几秒后再试。"}
|
||||
</p>
|
||||
<div className="mt-5 flex flex-col gap-3 sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
disabled={retrying}
|
||||
className="inline-flex flex-1 items-center justify-center gap-2 rounded-[4px] bg-blue-600 px-4 py-2.5 text-sm font-black text-white transition hover:bg-blue-700 disabled:cursor-wait disabled:opacity-70"
|
||||
>
|
||||
{retrying && (
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" />
|
||||
)}
|
||||
{isEn ? "Retry access sync" : "重新同步权限"}
|
||||
</button>
|
||||
<Link
|
||||
href="/account"
|
||||
className="inline-flex flex-1 items-center justify-center rounded-[4px] border border-slate-200 bg-white px-4 py-2.5 text-sm font-black text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
{isEn ? "Open account" : "打开账户页"}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScanTerminalScreen() {
|
||||
const [proAccess, setProAccess] = useState<ProAccessState>(() =>
|
||||
createEmptyAccess(true),
|
||||
@@ -984,17 +1116,26 @@ function ScanTerminalScreen() {
|
||||
const headers: Record<string, string> = { 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<AuthProfilePayload>;
|
||||
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<AuthProfilePayload>;
|
||||
} 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<ScanOpportunityRow[]>(() =>
|
||||
cityListItemsToScanRows(readCachedCityList() || STATIC_CITY_LIST),
|
||||
@@ -1356,6 +1536,19 @@ function ScanTerminalScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (authSyncRecoveryNeeded) {
|
||||
return (
|
||||
<AuthSyncRecoveryScreen
|
||||
isEn={isEn}
|
||||
onRetry={handleRetryAuthSync}
|
||||
rootClassName={scanRootClass}
|
||||
retrying={authRetrying}
|
||||
themeMode={themeMode}
|
||||
userLocalTime={userLocalTime}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldShowPaywall) {
|
||||
return (
|
||||
<ProductAccessRequired
|
||||
|
||||
@@ -124,6 +124,45 @@ export async function runTests() {
|
||||
"terminal auth bootstrap should accept an authenticated cookie profile immediately",
|
||||
);
|
||||
|
||||
const slowDegradedSession = deferred<{ data: { session: { access_token: string } | null } }>();
|
||||
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<TerminalAuthProfilePayload>();
|
||||
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({
|
||||
|
||||
@@ -17,6 +17,7 @@ type LoadTerminalAuthProfileOptions = {
|
||||
accessToken?: string | null,
|
||||
options?: { preferSnapshot?: boolean },
|
||||
) => Promise<TerminalAuthProfilePayload>;
|
||||
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<TerminalAuthProfilePayload>((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<TerminalAuthProfilePayload>((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]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user