From f7468b23c2934130b3e6e5b1978d9cb316912120 Mon Sep 17 00:00:00 2001 From: Claude VM Date: Thu, 4 Jun 2026 18:00:17 +0300 Subject: [PATCH] =?UTF-8?q?fix(uats):=20never=20block=20on=20the=20feature?= =?UTF-8?q?-count=20groupBy=20=E2=80=94=20cold=20cache=20froze=20UAT=20sel?= =?UTF-8?q?ector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ~30s groupBy over 9.7M GisFeature rows ran synchronously on the first /api/eterra/uats call after every redeploy (in-memory cache), freezing the UAT autocomplete right when users reload post-deploy. Counts only feed the decorative 'N local' badge — return the (possibly empty) cache immediately and refresh in the background, single-flight so concurrent cold requests don't stack 30s queries. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/eterra/uats/route.ts | 64 +++++++++++++++++--------------- 1 file changed, 34 insertions(+), 30 deletions(-) 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,