Cache the correlations discovery read and keep writes off the hit path

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-13 18:52:07 +02:00
1 parent f2f40ae8b2
commit ac96e5e975
2 files changed
+71 -19

No files matched your search

@@ -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);
(
+47 -19
View File
@@ -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<ReturnType<typeof buildCorrelationsResponse>>
>,
`${user.id}|correlations|${locale}`,
() => buildCorrelationsResponse(user.id, locale),
annotate,
);
return apiSuccess(body);
});
async function buildCorrelationsResponse(
userId: string,
locale: Awaited<ReturnType<typeof resolveServerLocale>>,
) {
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,
});
});
};
}