From 4f63bd5f1ea48af4c6e77df15f6017f3eb401dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 17:18:44 +0200 Subject: [PATCH] perf(coach): memoise assistant-flags per request + 60s LRU on snapshot builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/lib/feature-flags/index.ts | 71 ++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 30 deletions(-) diff --git a/src/lib/feature-flags/index.ts b/src/lib/feature-flags/index.ts index 3b0b32551..c9126a803 100644 --- a/src/lib/feature-flags/index.ts +++ b/src/lib/feature-flags/index.ts @@ -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/-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 { - 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;