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
+34 -30
View File
@@ -15,41 +15,46 @@ 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
return refreshFeatureCounts();
} }
async function refreshFeatureCounts(): Promise<Map<string, number>> { function refreshFeatureCounts(): Promise<void> {
try { if (gCache.__featureCountInFlight) return gCache.__featureCountInFlight;
const groups = await prisma.gisFeature.groupBy({ gCache.__featureCountInFlight = (async () => {
by: ["siruta"], try {
_count: { id: true }, const groups = await prisma.gisFeature.groupBy({
}); by: ["siruta"],
const map = new Map<string, number>(); _count: { id: true },
for (const g of groups) { });
map.set(g.siruta, g._count.id); const map = new Map<string, number>();
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; return gCache.__featureCountInFlight;
} catch {
return gCache.__featureCountCache?.map ?? new Map();
}
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -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,