perf(coach): share the heavy per-user reads across the tool-snapshot fan-out

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-22 08:18:13 +02:00
1 parent b6f0ad61cb
commit 600498da12
4 files changed
+125 -28

No files matched your search

@@ -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", () => ({
@@ -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);
});
});
+43 -28
View File
@@ -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
+12
View File
@@ -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