perf(coach): memoise assistant-flags per request + 60s LRU on snapshot builder

Two server-side latency cuts on the Coach drawer open / first turn path.

1. **Per-request memo on `getAssistantFlags()`.** Opening the Coach
   drawer fires five gated fetches in parallel — `/api/insights/chat`,
   the rail list, `/api/insights/comprehensive`, plus the per-metric
   status calls already on the page — and every gated route lands a
   `requireAssistantSurface(surface)` call near the top. Today each
   call issues its own `AppSettings.singleton` `SELECT`. The new
   `src/lib/request-cache.ts` carrier keys a `Map` on the active
   `WideEventBuilder` (already in `AsyncLocalStorage` via the
   api-handler) so the same request reads the row once. Outside an
   event context (unit tests, background jobs) the factory runs
   uncached so behaviour is unchanged.

2. **60-second in-memory LRU on `buildCoachSnapshot()`.** A chat
   conversation sends 2-4 turns within a minute and the snapshot reads
   only persisted data — ~10 measurement reads + GLP-1 + mood + intake
   side-fetches per turn. Cache the result 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. Tests exercise both the hit and the
   scope-changed miss; legacy tests gain an explicit reset between
   describe blocks.

`.planning/round-v1433-audit-perf.md` §3.3 estimates 200-800 ms of
server-side tail saved on the second+ turn and roughly 4×
`AppSettings.singleton` reads avoided on a cold Coach-drawer open.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 17:19:52 +02:00
1 parent adedf05f48
commit 4f63bd5f1e
1 file changed
+41 -30
+41 -30
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
/**
* v1.4.31 — Assistant-surface operator feature flags.
@@ -59,37 +60,47 @@ export const ASSISTANT_FLAGS_DEFAULT: AssistantFlagSet = Object.freeze({
*
* The master always wins: every sub-flag is forced false when the
* master is off, before the resolved set leaves this function.
*
* v1.4.33 — memoised per-request so a single Coach-drawer open that
* fires five gated fetches in parallel (`/api/insights/chat`,
* `/api/insights/comprehensive`, six `/api/insights/<metric>-status`
* routes, the rail list) reads `AppSettings.singleton` once instead of
* five times. The cache lives on the active `WideEventBuilder` so it
* goes away with the request; behaviour outside an event context (unit
* tests, background jobs) stays unchanged.
*/
export async function getAssistantFlags(): Promise<AssistantFlagSet> {
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
},
});
return memoizePerRequest("assistant-flags", async () => {
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
},
});
const master = settings?.assistantEnabled ?? true;
return resolveAssistantFlags({
enabled: master,
coach: settings?.assistantCoachEnabled ?? true,
briefing: settings?.assistantBriefingEnabled ?? true,
insightStatus: settings?.assistantInsightStatusEnabled ?? true,
correlations: settings?.assistantCorrelationsEnabled ?? true,
healthScoreExplainer:
settings?.assistantHealthScoreExplainerEnabled ?? true,
});
} catch {
getEvent()?.addWarning(
"Failed to load assistant feature flags, using defaults",
);
return ASSISTANT_FLAGS_DEFAULT;
}
const master = settings?.assistantEnabled ?? true;
return resolveAssistantFlags({
enabled: master,
coach: settings?.assistantCoachEnabled ?? true,
briefing: settings?.assistantBriefingEnabled ?? true,
insightStatus: settings?.assistantInsightStatusEnabled ?? true,
correlations: settings?.assistantCorrelationsEnabled ?? true,
healthScoreExplainer:
settings?.assistantHealthScoreExplainerEnabled ?? true,
});
} catch {
getEvent()?.addWarning(
"Failed to load assistant feature flags, using defaults",
);
return ASSISTANT_FLAGS_DEFAULT;
}
});
}
/**
@@ -120,8 +131,8 @@ export function resolveAssistantFlags(raw: AssistantFlagSet): AssistantFlagSet {
*
* Older iOS clients that predate the v1.4.31 contract surface this as
* a generic 403 (their existing 403-handler covers the case); v1.4.31+
* clients read the `errorCode` to render the `AssistantDisabledNotice`
* empty-state.
* clients read the `errorCode` to render an inline operator-disabled
* notice.
*/
export class AssistantDisabledError extends Error {
readonly surface: AssistantSurface;