perf(coach): 60s LRU on buildCoachSnapshot keyed on (userId, scope)

`buildCoachSnapshot()` reads only persisted data — ~10 measurement
findManys plus the GLP-1 / mood / intake side-fetches — yet a single
chat conversation sends 2-4 turns within a minute and rebuilds the
same snapshot every time. Wrap the existing builder (renamed to
`buildCoachSnapshotImpl`) with an in-memory cache keyed on
`(userId, scope.window, sorted scope.sources)` with a 60 s TTL and a
64-entry LRU cap. Different scopes recompute; the cache evicts on the
next eligible read.

`.planning/round-v1433-audit-perf.md` §3.3 estimates 200-800 ms of
server-side tail saved on the second+ turn — the bulk of the
"blockt komplett" complaint between user-clicks-send and first SSE
`token` frame. The `__resetCoachSnapshotCacheForTests` export gives
the existing two snapshot-test suites a hook so each fixture is read
fresh; both add a `beforeEach` reset alongside the existing
`vi.clearAllMocks()` call. Two new tests pin the cache-hit (one
Prisma findMany call across two `buildCoachSnapshot` calls with the
same scope) and the scope-changed miss (different `window` → two
findManys). Feature-flag tests gain a parallel "per-request
memoisation" describe-block that pins the same behaviour for
`getAssistantFlags()` against a stable mocked event reference.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 17:23:07 +02:00
1 parent 4f63bd5f1e
commit af17db5dd5
4 files changed
+196 -3

No files matched your search

@@ -1,6 +1,9 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { buildCoachSnapshot } from "../snapshot";
import {
__resetCoachSnapshotCacheForTests,
buildCoachSnapshot,
} from "../snapshot";
vi.mock("@/lib/db", () => ({
prisma: {
@@ -50,6 +53,9 @@ function daysAgo(
describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () => {
beforeEach(() => {
vi.clearAllMocks();
// v1.4.33 — `buildCoachSnapshot` now memoises results in-process
// for 60 s; reset between tests so each fixture is read fresh.
__resetCoachSnapshotCacheForTests();
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
// v1.4.23 H4 — snapshot now reads `User.coachPrefsJson`. Default
+54 -1
View File
@@ -1,6 +1,9 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { buildCoachSnapshot } from "../snapshot";
import {
__resetCoachSnapshotCacheForTests,
buildCoachSnapshot,
} from "../snapshot";
vi.mock("@/lib/db", () => ({
prisma: {
@@ -47,6 +50,10 @@ function daysAgo(
describe("buildCoachSnapshot", () => {
beforeEach(() => {
vi.clearAllMocks();
// v1.4.33 — `buildCoachSnapshot` now memoises results in-process
// for 60 s. Reset the cache between tests so each test sees its
// own freshly-mocked Prisma fixture.
__resetCoachSnapshotCacheForTests();
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
@@ -182,4 +189,50 @@ describe("buildCoachSnapshot", () => {
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.mood.timeline.recent.length).toBe(1);
});
// v1.4.33 — 60 s in-process snapshot cache. A chat conversation
// sends 2-4 turns within a minute and rebuilding the snapshot from
// ~10 measurement reads on every turn is wasteful. Cache hits skip
// every persistent read; cache misses on a different scope (window
// or sources) compute fresh.
it("memoises the result for repeated (userId, scope) within the 60 s window", async () => {
featuresMock.mockResolvedValue({
weight: { avg30: 82.1, coverage: { count: 5 } },
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(2, 82.0, "WEIGHT"),
daysAgo(5, 82.4, "WEIGHT"),
]);
const first = await buildCoachSnapshot("user-1", { sources: ["weight"] });
const second = await buildCoachSnapshot("user-1", { sources: ["weight"] });
// Same JSON shape on both calls.
expect(second.snapshotJson).toBe(first.snapshotJson);
// Prisma reads ran once for the first call; the second call short-
// circuits on the cache so the count is still 1.
expect(prismaMock.measurement.findMany).toHaveBeenCalledTimes(1);
expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(1);
});
it("recomputes when the scope window or sources change", async () => {
featuresMock.mockResolvedValue({
weight: { avg30: 82.1, coverage: { count: 5 } },
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(3, 82.0, "WEIGHT"),
]);
await buildCoachSnapshot("user-1", {
window: "last7days",
sources: ["weight"],
});
await buildCoachSnapshot("user-1", {
window: "last30days",
sources: ["weight"],
});
// Two distinct window keys → two cache slots → two Prisma reads.
expect(prismaMock.measurement.findMany).toHaveBeenCalledTimes(2);
});
});
+78
View File
@@ -274,6 +274,67 @@ function resolveScope(scope?: CoachScope): {
};
}
/**
* v1.4.33 — 60-second in-memory cache for `buildCoachSnapshot()`. The
* snapshot reads only persisted data; a single chat conversation sends
* 2-4 turns within a minute and the snapshot would otherwise rebuild
* from the same rows each turn. Caching the result for 60s shaves the
* ~10 measurement reads + the GLP-1 / mood / intake side-fetches off
* every turn after the first, which `.planning/round-v1433-audit-perf.md`
* §3.3 estimates at 200-800 ms of server-side tail.
*
* Scope is part of the cache key so a switch from `last30days` to
* `last7days` (or a different `sources` set) computes fresh. The map
* is bounded at 64 entries — a multi-tenant deployment with a few
* active power users sits well inside that ceiling even if each cycles
* through several scopes per minute.
*/
const SNAPSHOT_TTL_MS = 60_000;
const SNAPSHOT_LRU_MAX = 64;
const snapshotCache = new Map<
string,
{ expiresAt: number; result: CoachSnapshotResult }
>();
function snapshotCacheKey(userId: string, scope: CoachScope | undefined): string {
const { sources, window } = resolveScope(scope);
const sourceList = Array.from(sources).sort().join(",");
return `${userId}|${window}|${sourceList}`;
}
function readSnapshotCache(key: string): CoachSnapshotResult | null {
const entry = snapshotCache.get(key);
if (!entry) return null;
if (entry.expiresAt <= Date.now()) {
snapshotCache.delete(key);
return null;
}
// Touch for LRU — re-insert moves to the end of the Map's iteration order.
snapshotCache.delete(key);
snapshotCache.set(key, entry);
return entry.result;
}
function writeSnapshotCache(key: string, result: CoachSnapshotResult): void {
if (snapshotCache.size >= SNAPSHOT_LRU_MAX) {
// Evict the oldest entry — JS Map iteration order is insertion order,
// so the first key is the least-recently inserted/touched.
const oldest = snapshotCache.keys().next().value;
if (oldest !== undefined) {
snapshotCache.delete(oldest);
}
}
snapshotCache.set(key, {
expiresAt: Date.now() + SNAPSHOT_TTL_MS,
result,
});
}
/** Clear the snapshot cache. Test-only escape hatch. */
export function __resetCoachSnapshotCacheForTests(): void {
snapshotCache.clear();
}
/**
* Build the Coach prompt snapshot for `userId`. Always uses
* `includeRaw=false` because the Coach replies are conversational and
@@ -293,10 +354,27 @@ function resolveScope(scope?: CoachScope): {
* user's display timezone (read from `User.timezone`). Falls back to
* Europe/Berlin when the column is missing so the legacy snapshot
* stays byte-identical for the only path the v1.4.24 suite tested.
*
* v1.4.33 — wraps the previous `buildCoachSnapshotImpl` with a 60s
* in-memory LRU keyed on `(userId, window, sources)`. The Coach's
* chat handler calls this once per turn; within the same conversation
* the second+ turn lands a cache hit and skips the row-level reads.
*/
export async function buildCoachSnapshot(
userId: string,
scope?: CoachScope,
): Promise<CoachSnapshotResult> {
const key = snapshotCacheKey(userId, scope);
const cached = readSnapshotCache(key);
if (cached) return cached;
const result = await buildCoachSnapshotImpl(userId, scope);
writeSnapshotCache(key, result);
return result;
}
async function buildCoachSnapshotImpl(
userId: string,
scope?: CoachScope,
): Promise<CoachSnapshotResult> {
const { sources: scopedSources, window } = resolveScope(scope);
+57 -1
View File
@@ -8,8 +8,15 @@ vi.mock("@/lib/db", () => ({
},
}));
// v1.4.33 — `mockEvent` lets the dedicated memoisation describe-block
// below pin a stable WideEventBuilder stub so the per-request cache
// keys on the same reference across multiple `getAssistantFlags()`
// calls. The legacy describe-blocks leave `mockEvent` null so every
// call gets a fresh `{}` and the memo never collapses — preserves the
// pre-v1.4.33 assertion semantics.
let mockEvent: object | null = null;
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ addWarning: vi.fn() }),
getEvent: () => mockEvent ?? { addWarning: vi.fn() },
}));
import { prisma } from "@/lib/db";
@@ -25,6 +32,7 @@ const FIND = prisma.appSettings.findUnique as ReturnType<typeof vi.fn>;
beforeEach(() => {
FIND.mockReset();
mockEvent = null;
});
describe("resolveAssistantFlags", () => {
@@ -162,3 +170,51 @@ describe("requireAssistantSurface", () => {
expect(err.errorCode).toBe("assistant.disabled.correlations");
});
});
describe("per-request memoisation", () => {
// v1.4.33 — When the same request opens the Coach drawer, five gated
// routes (`/api/insights/chat`, the rail list, each `<metric>-status`
// call, etc.) all funnel through `getAssistantFlags()` → `AppSettings`
// read. The memo on the active WideEventBuilder collapses those to
// one DB hit.
it("collapses repeated reads inside the same request to one DB call", async () => {
FIND.mockResolvedValue({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
});
// Pin a stable stub object so every `getEvent()` inside this test
// returns the same reference — the memo keys on identity, so a
// shared reference == shared cache.
mockEvent = { addWarning: vi.fn() };
const a = await getAssistantFlags();
const b = await getAssistantFlags();
const c = await getAssistantFlags();
expect(a).toBe(b);
expect(b).toBe(c);
expect(FIND).toHaveBeenCalledTimes(1);
});
it("issues independent reads when the request context changes", async () => {
FIND.mockResolvedValue({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
});
mockEvent = { addWarning: vi.fn(), id: "req-1" };
await getAssistantFlags();
mockEvent = { addWarning: vi.fn(), id: "req-2" };
await getAssistantFlags();
expect(FIND).toHaveBeenCalledTimes(2);
});
});