From 600498da128a69b11a92d39bec95543a3bd920b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 22 Jun 2026 08:18:13 +0200 Subject: [PATCH] perf(coach): share the heavy per-user reads across the tool-snapshot fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The F1 coach tools each rebuild a snapshot with a distinct single-source scope, so the 60s snapshot LRU (keyed on the source list) never shares their reads. On a multi-metric turn the model can fan out up to six tools that each fire `extractFeatures` (a user lookup plus a windowed measurement read plus the all-time extremes) and a prefs `findUnique` concurrently — up to twelve heavy reads against the shared Prisma pool at once. Memoise both reads on the request-scoped event cache, keyed on userId (and windowDays for the features read), so the fan-out collapses to one of each within a request while staying correct across requests. Project the feature extractor's measurement read to the six columns its consumers actually use (type, value, measuredAt, sleepStage, source, deviceType) so the 200k-row-class PULSE / glucose windows stop pulling every column. Outputs are unchanged. --- .../coach/__tests__/snapshot-budget.test.ts | 4 ++ src/lib/ai/coach/__tests__/snapshot.test.ts | 66 +++++++++++++++++ src/lib/ai/coach/snapshot.ts | 71 +++++++++++-------- src/lib/insights/features.ts | 12 ++++ 4 files changed, 125 insertions(+), 28 deletions(-) diff --git a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts index 72d0eca8b..85d45b385 100644 --- a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts @@ -63,6 +63,10 @@ vi.mock("@/lib/logging/context", () => ({ }) => { annotateCalls.push({ name: fields.action?.name, meta: fields.meta }); }, + // The snapshot's request-scoped read memoisation (`memoizePerRequest`) reads + // the active event here; no request context in this unit test → null, so the + // memoiser falls through to the factory and behaviour is unchanged. + getEvent: () => null, })); vi.mock("./glp1-snapshot", () => ({ diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index fd36ba353..1e74d4c66 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -7,6 +7,8 @@ import { __resetCoachSnapshotCacheForTests, buildCoachSnapshot, } from "../snapshot"; +import { eventStorage } from "@/lib/logging/context"; +import { WideEventBuilder } from "@/lib/logging/event-builder"; vi.mock("@/lib/db", () => ({ prisma: { @@ -994,3 +996,67 @@ describe("buildCoachSnapshot — reference grounding (W7)", () => { } }); }); + +describe("buildCoachSnapshot request-scoped read sharing (H-1)", () => { + beforeEach(() => { + vi.clearAllMocks(); + __resetCoachSnapshotCacheForTests(); + prismaMock.measurement.findMany.mockResolvedValue([]); + prismaMock.moodEntry.findMany.mockResolvedValue([]); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + prismaMock.medication.findMany.mockResolvedValue([]); + prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null }); + resolveModuleMapMock.mockResolvedValue(allModulesEnabled()); + featuresMock.mockResolvedValue({ + bloodPressure: undefined, + weight: undefined, + pulse: undefined, + mood: undefined, + }); + }); + + it("runs extractFeatures + the prefs read once across distinct-scope tool builds in one request", async () => { + // The F1 coach tools each call buildCoachSnapshot with a DIFFERENT + // single-source scope, so the 60s snapshot LRU (keyed on the source list) + // does NOT share their reads. Memoising the two heavy per-user reads on the + // request-scoped WideEventBuilder cache collapses the fan-out to one of each. + await eventStorage.run(new WideEventBuilder(), async () => { + // Same window (→ same windowDays key), distinct sources (→ distinct LRU + // keys ⇒ two separate snapshot builds, mirroring the real tool fan-out). + await Promise.all([ + buildCoachSnapshot("user-h1", { + sources: ["bp"], + window: "last30days", + }), + buildCoachSnapshot("user-h1", { + sources: ["pulse"], + window: "last30days", + }), + ]); + }); + + expect(featuresMock).toHaveBeenCalledTimes(1); + expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(1); + }); + + it("keeps separate caches per request (no cross-request leakage)", async () => { + // Distinct users so the in-process 60s snapshot LRU (keyed on userId) cannot + // short-circuit the second request — the only thing under test here is that + // the request-scoped feature/prefs cache does NOT survive across requests. + await eventStorage.run(new WideEventBuilder(), async () => { + await buildCoachSnapshot("user-h1a", { + sources: ["bp"], + window: "last30days", + }); + }); + await eventStorage.run(new WideEventBuilder(), async () => { + await buildCoachSnapshot("user-h1b", { + sources: ["bp"], + window: "last30days", + }); + }); + + expect(featuresMock).toHaveBeenCalledTimes(2); + expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index f8698b5fb..a94714540 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -43,6 +43,7 @@ import { } from "@/lib/insights/derived/sleep-rhythm"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { annotate } from "@/lib/logging/context"; +import { memoizePerRequest } from "@/lib/request-cache"; import { buildGlp1SnapshotBlock } from "./glp1-snapshot"; import { buildDerivedSnapshotBlock } from "./derived-snapshot"; import { buildCoachMemoryBlock } from "./memory-snapshot"; @@ -670,31 +671,38 @@ async function buildCoachSnapshotImpl( // single extra round-trip at most. Disabled modules fold into the same // SYSTEM-side exclusion the user's `excludeMetrics` flow already drives. const moduleMapPromise = resolveModuleMap(userId); - const prefsRow = await prisma.user.findUnique({ - where: { id: userId }, - select: { - coachPrefsJson: true, - timezone: true, - locale: true, - // v1.11.5 — needed to collapse a dual-source sleep night to one - // canonical source before reconstructing per-night asleep totals. - sourcePriorityJson: true, - // v1.15 — the cycle snapshot block is gated on the resolved cycle - // toggle (an explicit opt-in/out overrides the gender default). Read - // both columns here so a non-cycle account pays no extra round-trip. - gender: true, - cycleProfile: { select: { cycleTrackingEnabled: true } }, - // v1.16.16 — the glucose block converts canonical mg/dL to the user's - // display unit so the Coach reads the same number every other surface - // shows. Read it on this existing prefs hop (no extra round-trip). - glucoseUnit: true, - // v1.18.6 (W7) — the explicit, user-declared diabetes opt-in. Selects - // the tighter ADA glycemic GOAL band for the glucose reference-grounding - // line only; never inferred from a reading, never a diagnosis. Read on - // this existing prefs hop (no extra round-trip). - hasDiabetes: true, - }, - }); + // v1.20.0 (H-1) — the F1 coach tools each rebuild a single-source snapshot + // with a distinct LRU key, so the 60s snapshot cache does not share this read + // across the fan-out. Memoise it per-request (the select shape is constant, so + // userId is the only key) so up to 6 concurrent tool builds collapse to one + // prefs round-trip instead of starving the shared Prisma pool. + const prefsRow = await memoizePerRequest(`coach-prefs:${userId}`, () => + prisma.user.findUnique({ + where: { id: userId }, + select: { + coachPrefsJson: true, + timezone: true, + locale: true, + // v1.11.5 — needed to collapse a dual-source sleep night to one + // canonical source before reconstructing per-night asleep totals. + sourcePriorityJson: true, + // v1.15 — the cycle snapshot block is gated on the resolved cycle + // toggle (an explicit opt-in/out overrides the gender default). Read + // both columns here so a non-cycle account pays no extra round-trip. + gender: true, + cycleProfile: { select: { cycleTrackingEnabled: true } }, + // v1.16.16 — the glucose block converts canonical mg/dL to the user's + // display unit so the Coach reads the same number every other surface + // shows. Read it on this existing prefs hop (no extra round-trip). + glucoseUnit: true, + // v1.18.6 (W7) — the explicit, user-declared diabetes opt-in. Selects + // the tighter ADA glycemic GOAL band for the glucose reference-grounding + // line only; never inferred from a reading, never a diagnosis. Read on + // this existing prefs hop (no extra round-trip). + hasDiabetes: true, + }, + }), + ); const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson); // Resolve the UI locale for the rolling-profile narrative recall. The // narrative rows are keyed by ("de" | "en"); default to "de" (the app @@ -759,9 +767,16 @@ async function buildCoachSnapshotImpl( // `userId` + the resolved window/sources), so running them // concurrently shaves a round-trip off the cold path. No block reads // `features` before the shared await, so the deferral is safe. - const featuresPromise = extractFeatures(userId, false, { - sinceDays: windowDays, - }); + // v1.20.0 (H-1) — `extractFeatures` is the heaviest read on the cold path + // (user findUnique + a windowed measurement findMany + the all-time extremes). + // The F1 tools rebuild distinct-scope snapshots whose LRU keys differ, so they + // do not share this read; memoise it per-request keyed on the only varying + // input (windowDays) so the fan-out runs it once instead of up to 6× against + // the shared pool. + const featuresPromise = memoizePerRequest( + `coach-features:${userId}:${windowDays}`, + () => extractFeatures(userId, false, { sinceDays: windowDays }), + ); // Trim down to the metrics the Coach narrates. extractFeatures // returns more (sleep, steps, etc.) — the Coach surface keeps the diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 6b60d622e..efe3fe3ba 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -681,6 +681,18 @@ export async function extractFeatures( ? { userId, measuredAt: { gte: sinceCutoff }, deletedAt: null } : { userId, deletedAt: null }, orderBy: { measuredAt: "asc" }, + // Project only the columns every downstream consumer reads (`byType`, + // `summarize`, BP pairing, and `reconstructSleepNights`'s `SleepStageRow`). + // The PULSE / glucose windows are 200k-row-class; pulling every column + // (notes, externalId, …) is pure wasted I/O on the shared Prisma pool. + select: { + type: true, + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, }); // v1.18.11 P1 — when the bulk read is bounded to a recent window, the