From ac96e5e975c9653697f405fedbe94bd0515b7e2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 13 Aug 2026 18:52:07 +0200 Subject: [PATCH] Cache the correlations discovery read and keep writes off the hit path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/insights/correlations was the most expensive uncached read in the app: a 180-day window, ~10 parallel series reads, Pearson + FDR in-process, plus two syncAcceptedPatterns DB writes — all paid on every mount. The built body now reads through the analytics bucket's SWR cell (60 s fresh / 1 h stale, keyed userId|correlations|locale), and the pattern-sync writes live inside the builder, so a cache hit performs no write on the read path. Eviction truth comes free: measurement, mood, medication, illness and custom-metric writes already sweep the user's prefix in that bucket, and a pattern dismiss sweeps it through invalidateUserCorrelationPatterns. The route test pins the contract: a second GET inside the fresh window re-runs neither the measurement window read nor the pattern sync, and serves a byte-equal body. --- .../correlations/__tests__/route.test.ts | 24 +++++++ src/app/api/insights/correlations/route.ts | 66 +++++++++++++------ 2 files changed, 71 insertions(+), 19 deletions(-) diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 943931089..2e19d0ea1 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -80,6 +80,8 @@ import { prisma } from "@/lib/db"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { apiError } from "@/lib/api-response"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { syncAcceptedPatterns } from "@/lib/insights/correlation-patterns"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -88,6 +90,9 @@ const SESSION_OK = { beforeEach(() => { vi.resetAllMocks(); + // The route reads through the SWR analytics cache now; a warm cell from a + // prior test would serve a hit and skip the builder every assertion spies on. + __resetAllCachesForTests(); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); // v1.15.20 — default to an allowing analytics-read budget. vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ @@ -157,6 +162,25 @@ describe("GET /api/insights/correlations", () => { expect(body.data.fdrQ).toBeGreaterThan(0); }); + it("serves a cache hit without re-running the discovery reads or the pattern-sync writes", async () => { + // The SWR cell caches the built body for 60 s; the second GET inside that + // window must neither re-read the measurement window nor re-run the two + // `syncAcceptedPatterns` DB writes — writes on the read path were the + // pre-cache defect, and a hit that still wrote would reintroduce it. + // Watched red: with the assertion counts set to 2 (i.e. asserting the + // builder re-ran) this test fails, so the cache path is proven live. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const first = await callGet(makeReq()); + expect(first.status).toBe(200); + const second = await callGet(makeReq()); + expect(second.status).toBe(200); + expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1); + expect(syncAcceptedPatterns).toHaveBeenCalledTimes(2); + const firstBody = (await first.json()) as { data: unknown }; + const secondBody = (await second.json()) as { data: unknown }; + expect(secondBody.data).toEqual(firstBody.data); + }); + it("returns 403 + errorCode when the correlations flag is off", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); ( diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 8c03fdcde..1dd15e271 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -17,6 +17,7 @@ */ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess } from "@/lib/api-response"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { requireAssistantSurface } from "@/lib/feature-flags"; @@ -86,16 +87,43 @@ export const GET = apiHandler(async () => { // Operator can hide the correlation surface entirely. await requireAssistantSurface("correlations"); - const profile = await prisma.user.findUnique({ - where: { id: user.id }, - select: { timezone: true }, - }); - const tz = profile?.timezone ?? "Europe/Berlin"; // Reader's locale for the narrated `interpretation` — the correlation cards // render this string verbatim, so it MUST be localised (cookie / User.locale / // Accept-Language). Without it the never-causal sentence leaked English into a - // non-English UI. + // non-English UI. It is part of the cache key for the same reason. const locale = await resolveServerLocale({ userLocale: user.locale ?? null }); + + // The discovery scan is the most expensive uncached read the app had: + // ~10 parallel window reads over 180 days + Pearson/FDR in-process, paid on + // EVERY mount. Cache the built body in the analytics bucket (60 s fresh / + // 1 h stale — the bucket defaults are exactly the contract this read wants). + // Eviction truth comes free: every measurement / mood / medication / + // illness / custom-metric write sweeps the `${userId}|` prefix in this + // bucket, and a pattern dismiss goes through + // `invalidateUserCorrelationPatterns`, which sweeps it too. The + // `syncAcceptedPatterns` calls are DB writes and live INSIDE the builder, + // so a cache hit performs no write on the read path. + const body = await cachedSwr( + caches.analytics as ServerCache< + Awaited> + >, + `${user.id}|correlations|${locale}`, + () => buildCorrelationsResponse(user.id, locale), + annotate, + ); + + return apiSuccess(body); +}); + +async function buildCorrelationsResponse( + userId: string, + locale: Awaited>, +) { + const profile = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + const tz = profile?.timezone ?? "Europe/Berlin"; const now = new Date(); const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY); @@ -121,12 +149,12 @@ export const GET = apiHandler(async () => { moodWindow, priorityJson, ] = await Promise.all([ - fetchMeasurementWindowSeries(user.id, since, [ + fetchMeasurementWindowSeries(userId, since, [ ...behaviourTypes, ...outcomeTypes, ]), - fetchMoodWindowSeries(user.id, tz, since), - loadUserSourcePriority(user.id), + fetchMoodWindowSeries(userId, tz, since), + loadUserSourcePriority(userId), ]); const { moodDaily, moodCapped } = moodWindow; @@ -141,14 +169,14 @@ export const GET = apiHandler(async () => { environmentSeries, customMetricSeries, ] = await Promise.all([ - fetchComplianceSeries(user.id, tz, since), - fetchSymptomSeries(user.id, tz, since), - fetchLabDraws(user.id, tz, since), + fetchComplianceSeries(userId, tz, since), + fetchSymptomSeries(userId, tz, since), + fetchLabDraws(userId, tz, since), // v1.25 (W-ENV) — environmental-exposure behaviour channels (weather / // daylight). Empty when the module is off / no home set, so the channels // degrade to absent. The module gate is implicit: no rows ⇒ no channels. - fetchEnvironmentSeries(user.id, since), - fetchCustomMetricBehaviourSeries(user.id, tz, since), + fetchEnvironmentSeries(userId, since), + fetchCustomMetricBehaviourSeries(userId, tz, since), ]); const points = (key: string): DailySeriesPoint[] => @@ -219,13 +247,13 @@ export const GET = apiHandler(async () => { }); const [retrospectiveDecisions, recentDecisions] = await Promise.all([ syncAcceptedPatterns({ - userId: user.id, + userId, family: PATTERN_FAMILIES.discoveryRetrospective, accepted: result.discovered.map(toEvidence), computedAt: now, }), syncAcceptedPatterns({ - userId: user.id, + userId, family: PATTERN_FAMILIES.discoveryRecent, accepted: emerging.emerging.map(toEvidence), computedAt: now, @@ -285,10 +313,10 @@ export const GET = apiHandler(async () => { }, }); - return apiSuccess({ + return { ...result, discovered, emerging: emergingWithDecisions, labCorrelations, - }); -}); + }; +}