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