mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user