diff --git a/src/app/api/eterra/uats/route.ts b/src/app/api/eterra/uats/route.ts index a7541b0..80f9667 100644 --- a/src/app/api/eterra/uats/route.ts +++ b/src/app/api/eterra/uats/route.ts @@ -15,41 +15,46 @@ export const dynamic = "force-dynamic"; const gCache = globalThis as { __featureCountCache?: { map: Map; ts: number }; + __featureCountInFlight?: Promise | null; }; -async function getCachedFeatureCounts(): Promise> { +// NEVER block the response on the counts query — the groupBy takes ~30s +// on 9.7M GisFeature rows and the in-memory cache dies on every redeploy, +// which used to freeze the UAT selector for the first ~30s after a deploy +// (2026-06-04 incident). Counts only feed the decorative "N local" badge; +// a cold start simply renders the badge as absent until the first refresh +// lands. Single-flight so concurrent cold requests don't pile up 30s +// queries on the DB. +function getCachedFeatureCounts(): Map { const TTL = 5 * 60 * 1000; // 5 minutes - const now = Date.now(); + const cached = gCache.__featureCountCache; - if (gCache.__featureCountCache && now - gCache.__featureCountCache.ts < TTL) { - return gCache.__featureCountCache.map; - } - - // Run in background if cache exists but expired (return stale, refresh async) - if (gCache.__featureCountCache) { + if (!cached || Date.now() - cached.ts >= TTL) { void refreshFeatureCounts(); - return gCache.__featureCountCache.map; } - - // First call: must wait - return refreshFeatureCounts(); + return cached?.map ?? new Map(); } -async function refreshFeatureCounts(): Promise> { - try { - const groups = await prisma.gisFeature.groupBy({ - by: ["siruta"], - _count: { id: true }, - }); - const map = new Map(); - for (const g of groups) { - map.set(g.siruta, g._count.id); +function refreshFeatureCounts(): Promise { + if (gCache.__featureCountInFlight) return gCache.__featureCountInFlight; + gCache.__featureCountInFlight = (async () => { + try { + const groups = await prisma.gisFeature.groupBy({ + by: ["siruta"], + _count: { id: true }, + }); + const map = new Map(); + for (const g of groups) { + map.set(g.siruta, g._count.id); + } + gCache.__featureCountCache = { map, ts: Date.now() }; + } catch { + // keep whatever cache we had; next request retries + } finally { + gCache.__featureCountInFlight = null; } - gCache.__featureCountCache = { map, ts: Date.now() }; - return map; - } catch { - return gCache.__featureCountCache?.map ?? new Map(); - } + })(); + return gCache.__featureCountInFlight; } /* ------------------------------------------------------------------ */ @@ -171,10 +176,9 @@ export async function GET() { select: { siruta: true, name: true, county: true, workspacePk: true }, }); - // Feature counts: use in-memory cache (refreshed every 5 min) - // The groupBy query is expensive (~25s without cache) but the data - // changes rarely (only when sync jobs run) - const featureCounts = await getCachedFeatureCounts(); + // Feature counts: in-memory cache, refreshed in the background (never + // awaited — see getCachedFeatureCounts). Cold cache → badge absent. + const featureCounts = getCachedFeatureCounts(); const uats: UatResponse[] = rows.map((r) => ({ siruta: r.siruta,