perf: cache GisFeature counts in memory (5min TTL, stale-while-revalidate)

Feature count groupBy query is expensive but data changes rarely.
First request waits for query, subsequent ones return cached instantly.
After 5min, stale cache is returned immediately while background
refresh runs. Badge "N local" is back on UAT dropdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-23 13:45:33 +02:00
parent d0c1b5d48e
commit 14a77dd6f7
+49 -18
View File
@@ -8,6 +8,49 @@ import { getSessionCredentials } from "@/modules/parcel-sync/services/session-st
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/* ------------------------------------------------------------------ */
/* Feature count cache (expensive query, cached 5 min) */
/* ------------------------------------------------------------------ */
const gCache = globalThis as {
__featureCountCache?: { map: Map<string, number>; ts: number };
};
async function getCachedFeatureCounts(): Promise<Map<string, number>> {
const TTL = 5 * 60 * 1000; // 5 minutes
const now = Date.now();
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) {
void refreshFeatureCounts();
return gCache.__featureCountCache.map;
}
// First call: must wait
return refreshFeatureCounts();
}
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);
}
gCache.__featureCountCache = { map, ts: Date.now() };
return map;
} catch {
return gCache.__featureCountCache?.map ?? new Map();
}
}
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
@@ -119,37 +162,25 @@ function unwrapArray(data: any): any[] {
/* No eTerra credentials needed — instant response. */
/* ------------------------------------------------------------------ */
export async function GET(req: Request) {
export async function GET() {
try {
const url = new URL(req.url);
const withFeatures = url.searchParams.get("features") === "true";
// CRITICAL: select only needed fields — geometry column has huge polygon data
const rows = await prisma.gisUat.findMany({
orderBy: { name: "asc" },
select: { siruta: true, name: true, county: true, workspacePk: true },
});
// Feature counts are expensive (scans entire GisFeature table)
// Only include when explicitly requested
let featureCounts: Map<string, number> | null = null;
if (withFeatures) {
const groups = await prisma.gisFeature.groupBy({
by: ["siruta"],
_count: { id: true },
});
featureCounts = new Map<string, number>();
for (const g of groups) {
featureCounts.set(g.siruta, g._count.id);
}
}
// 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();
const uats: UatResponse[] = rows.map((r) => ({
siruta: r.siruta,
name: r.name,
county: r.county ?? "",
workspacePk: r.workspacePk ?? 0,
localFeatures: featureCounts?.get(r.siruta) ?? 0,
localFeatures: featureCounts.get(r.siruta) ?? 0,
}));
// Populate in-memory workspace cache for search route