fix(uats): never block on the feature-count groupBy — cold cache froze UAT selector

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) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-06-04 18:00:17 +03:00
parent 077ec401fb
commit f7468b23c2
+24 -20
View File
@@ -15,27 +15,29 @@ export const dynamic = "force-dynamic";
const gCache = globalThis as { const gCache = globalThis as {
__featureCountCache?: { map: Map<string, number>; ts: number }; __featureCountCache?: { map: Map<string, number>; ts: number };
__featureCountInFlight?: Promise<void> | null;
}; };
async function getCachedFeatureCounts(): Promise<Map<string, number>> { // 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<string, number> {
const TTL = 5 * 60 * 1000; // 5 minutes const TTL = 5 * 60 * 1000; // 5 minutes
const now = Date.now(); const cached = gCache.__featureCountCache;
if (gCache.__featureCountCache && now - gCache.__featureCountCache.ts < TTL) { if (!cached || Date.now() - cached.ts >= TTL) {
return gCache.__featureCountCache.map;
}
// Run in background if cache exists but expired (return stale, refresh async)
if (gCache.__featureCountCache) {
void refreshFeatureCounts(); void refreshFeatureCounts();
return gCache.__featureCountCache.map; }
return cached?.map ?? new Map();
} }
// First call: must wait function refreshFeatureCounts(): Promise<void> {
return refreshFeatureCounts(); if (gCache.__featureCountInFlight) return gCache.__featureCountInFlight;
} gCache.__featureCountInFlight = (async () => {
async function refreshFeatureCounts(): Promise<Map<string, number>> {
try { try {
const groups = await prisma.gisFeature.groupBy({ const groups = await prisma.gisFeature.groupBy({
by: ["siruta"], by: ["siruta"],
@@ -46,10 +48,13 @@ async function refreshFeatureCounts(): Promise<Map<string, number>> {
map.set(g.siruta, g._count.id); map.set(g.siruta, g._count.id);
} }
gCache.__featureCountCache = { map, ts: Date.now() }; gCache.__featureCountCache = { map, ts: Date.now() };
return map;
} catch { } catch {
return gCache.__featureCountCache?.map ?? new Map(); // keep whatever cache we had; next request retries
} finally {
gCache.__featureCountInFlight = null;
} }
})();
return gCache.__featureCountInFlight;
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -171,10 +176,9 @@ export async function GET() {
select: { siruta: true, name: true, county: true, workspacePk: true }, select: { siruta: true, name: true, county: true, workspacePk: true },
}); });
// Feature counts: use in-memory cache (refreshed every 5 min) // Feature counts: in-memory cache, refreshed in the background (never
// The groupBy query is expensive (~25s without cache) but the data // awaited — see getCachedFeatureCounts). Cold cache → badge absent.
// changes rarely (only when sync jobs run) const featureCounts = getCachedFeatureCounts();
const featureCounts = await getCachedFeatureCounts();
const uats: UatResponse[] = rows.map((r) => ({ const uats: UatResponse[] = rows.map((r) => ({
siruta: r.siruta, siruta: r.siruta,