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 {
__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 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<Map<string, number>> {
try {
const groups = await prisma.gisFeature.groupBy({
by: ["siruta"],
_count: { id: true },
});
const map = new Map<string, number>();
for (const g of groups) {
map.set(g.siruta, g._count.id);
function refreshFeatureCounts(): Promise<void> {
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<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;
} 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,