From 14a77dd6f788a3e2e3a6d4c29a79be4f1ecdc494 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Mon, 23 Mar 2026 13:45:33 +0200 Subject: [PATCH] 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) --- src/app/api/eterra/uats/route.ts | 67 +++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/src/app/api/eterra/uats/route.ts b/src/app/api/eterra/uats/route.ts index f9e4f4f..3664663 100644 --- a/src/app/api/eterra/uats/route.ts +++ b/src/app/api/eterra/uats/route.ts @@ -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; ts: number }; +}; + +async function getCachedFeatureCounts(): Promise> { + 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> { + 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() }; + 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 | null = null; - if (withFeatures) { - const groups = await prisma.gisFeature.groupBy({ - by: ["siruta"], - _count: { id: true }, - }); - featureCounts = new Map(); - 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