From 22fac8ae904957e307bfc9aa8d0af31b5ab42f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:58:33 +0200 Subject: [PATCH] perf(coach): cap the snapshot measurement read The single multi-type window read had no take bound, so a lastYear / allTime scope over a high-frequency type (PULSE / glucose are 200k-row-class) loaded a year of rows just to fold them into the ~21 daily + ~10 weekly buckets the prompt shows. Read newest-first with a 6000-row cap and restore ascending order; the coarse MONTH/YEAR tail still comes from the rollup tier, so deep history survives. --- src/lib/ai/coach/snapshot.ts | 64 ++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 143b7f19a..1292ed9f7 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -111,6 +111,19 @@ const DAILY_TIMELINE_DAYS = 14; /** Default window when the caller doesn't pass a scope. */ const DEFAULT_WINDOW: CoachScopeWindow = "last30days"; +/** + * v1.18.10 (P-2) — newest-first cap on the single multi-type measurement read + * that feeds the Coach snapshot timelines. The window read can reach 365 days + * (`lastYear` / `allTime`) across high-frequency types (PULSE / glucose are + * 200k-row-class), but the prompt only renders ~21 daily + ~10 weekly buckets + * per metric, so an uncapped read loaded a year of rows to discard almost all + * of them. 6000 keeps the recent-daily + weekly window exact even with several + * dense types active (≈ a year of multi-daily readings on one type, or a + * handful of types at a few readings/day) while bounding the worst case; the + * coarse MONTH/YEAR tail comes from the rollup tier, not this read. + */ +const SNAPSHOT_MEASUREMENT_ROW_CAP = 6000; + /** * v1.17.0 — the glucose clinical panel is a fixed trailing-30-day artifact, * identical across the insights panel, the dashboard snapshot, the doctor @@ -965,24 +978,39 @@ async function buildCoachSnapshotImpl( const measurementRowsPromise = wantedTypes.length > 0 - ? prisma.measurement.findMany({ - where: { - userId, - type: { in: wantedTypes as never[] }, - measuredAt: { gte: cutoff }, - deletedAt: null, - }, - orderBy: { measuredAt: "asc" }, - // v1.7.0 — `glucoseContext` rides along so the glucose block - // can split fasting / postprandial / random / bedtime without - // a second query. NULL on every non-glucose row. - select: { - type: true, - value: true, - measuredAt: true, - glucoseContext: true, - }, - }) + ? prisma.measurement + .findMany({ + where: { + userId, + type: { in: wantedTypes as never[] }, + measuredAt: { gte: cutoff }, + deletedAt: null, + }, + // v1.18.10 (P-2) — read NEWEST-first + cap. PULSE / glucose are + // 200k-row-class types and the window can reach 365 days + // (lastYear / allTime), so an uncapped read pulled the entire + // year of high-frequency rows into memory just to fold them into + // ~21 daily + ~10 weekly buckets the prompt actually shows. The + // newest-first cap keeps the recent-daily timeline exact and only + // sheds the deepest weekly buckets on an extreme-volume account; + // the coarse MONTH/YEAR tail is read separately from the rollup + // tier (`buildCoarseTimelineTail`), so deep history survives. + orderBy: { measuredAt: "desc" }, + take: SNAPSHOT_MEASUREMENT_ROW_CAP, + // v1.7.0 — `glucoseContext` rides along so the glucose block + // can split fasting / postprandial / random / bedtime without + // a second query. NULL on every non-glucose row. + select: { + type: true, + value: true, + measuredAt: true, + glucoseContext: true, + }, + }) + // Downstream bucketers re-sort/group internally, but restore + // ascending order so any order-sensitive consumer sees the same + // shape as before the cap. + .then((rows) => rows.reverse()) : Promise.resolve([]); // v1.11.3 — `extractFeatures` and the shared measurement read are