From d2bad6943c97a40021f05adcd7637ce631d98b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 12:34:00 +0200 Subject: [PATCH] perf(insights): route daily chart fetches through rollup buckets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Insights trends row fires three parallel GET /api/measurements?type=…&aggregate=daily&limit=5000 requests on mount (BP_SYS / BP_DIA / WEIGHT). The legacy date_trunc path scanned the measurements table each time — ~3 s per call on Marc's account. Add a `source=rollup` opt-in on the listMeasurements schema. When set alongside `aggregate=daily` the route reads from measurement_rollups DAY buckets instead, swapping a full-table scan for a small indexed read against the ~5 k-row rollup table. Cumulative HK metrics get mean * count to reconstruct the daily SUM the chart expects. The chart-data client opts in for every windowDays > 7 fetch so the trends row + every sub-page chart benefit. When the rollup returns zero buckets for the requested window the route falls through to the live aggregate so brand-new accounts still render their first chart correctly. --- docs/api/openapi.yaml | 6 ++ .../__tests__/range-aggregation-route.test.ts | 68 +++++++++++++++ src/app/api/measurements/route.ts | 87 ++++++++++++++++++- src/components/charts/health-chart.tsx | 12 +++ src/lib/validations/measurement.ts | 10 +++ 5 files changed, 182 insertions(+), 1 deletion(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 84daaf2dc..803b4cfd9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2492,6 +2492,12 @@ paths: - weekly - monthly type: string + - in: query + name: source + schema: + enum: + - rollup + type: string responses: "200": content: diff --git a/src/app/api/measurements/__tests__/range-aggregation-route.test.ts b/src/app/api/measurements/__tests__/range-aggregation-route.test.ts index 5783fe231..973b255b9 100644 --- a/src/app/api/measurements/__tests__/range-aggregation-route.test.ts +++ b/src/app/api/measurements/__tests__/range-aggregation-route.test.ts @@ -20,6 +20,9 @@ vi.mock("@/lib/db", () => ({ findMany: vi.fn(), count: vi.fn(), }, + measurementRollup: { + findMany: vi.fn(), + }, $queryRaw: vi.fn(), }, })); @@ -193,6 +196,71 @@ describe("GET /api/measurements — all-time semantics (SD-H1)", () => { expect(months.size).toBe(24); }); + it("source=rollup + aggregate=daily reads measurement_rollups without firing the heavy date_trunc query", async () => { + // v1.4.36 W1 — Insights trends row routes the three daily chart + // fetches through the persistent DAY buckets via `source=rollup`. + // The heavy `date_trunc` $queryRaw must NOT fire on the happy + // path where the rollup has rows for the requested window. + const buckets = Array.from({ length: 30 }, (_, i) => ({ + type: "WEIGHT", + bucketStart: new Date(Date.UTC(2026, 3, 16 + i, 0, 0, 0)), + mean: 81 + i * 0.05, + count: 5, + })); + vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue( + buckets as never, + ); + + const res = await GET( + getRequest( + "type=WEIGHT&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000", + ), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + measurements: Array<{ type: string; value: number; count: number }>; + meta: { aggregate?: string }; + }; + }; + expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1); + expect(prisma.$queryRaw).not.toHaveBeenCalled(); + expect(json.data.measurements.length).toBe(30); + // First bucket reflects the rollup mean — round-trip-stable. + expect(json.data.measurements[0].type).toBe("WEIGHT"); + expect(json.data.measurements[0].value).toBeCloseTo(81, 5); + expect(json.data.measurements[0].count).toBe(5); + expect(json.data.meta.aggregate).toBe("daily"); + }); + + it("falls back to live date_trunc when source=rollup returns zero buckets", async () => { + // Empty rollup ⇒ heavy aggregate runs so brand-new accounts still + // see a correct chart on their first render. + vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue( + [] as never, + ); + const liveBuckets = Array.from({ length: 7 }, (_, i) => ({ + type: "WEIGHT", + bucket_start: new Date(Date.UTC(2026, 4, 8 + i, 0, 0, 0)), + avg: 81 + i * 0.05, + cnt: 2, + })); + vi.mocked(prisma.$queryRaw).mockResolvedValue(liveBuckets as never); + + const res = await GET( + getRequest( + "type=WEIGHT&from=2026-05-08T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000", + ), + ); + expect(res.status).toBe(200); + expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1); + expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); + const json = (await res.json()) as { + data: { measurements: Array }; + }; + expect(json.data.measurements.length).toBe(7); + }); + it("returns a weekly series when the all-time window is under two years", async () => { // Three months past one year → falls in the weekly grain band. const weeks = Array.from({ length: 60 }, (_, i) => ({ diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 0ecccfef8..0266f21e4 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -42,7 +42,7 @@ export const GET = apiHandler(async (request: NextRequest) => { return apiError(parsed.error.issues[0].message, 422); } - const { type, from, to, limit, offset, sortBy, sortDir, aggregate } = + const { type, from, to, limit, offset, sortBy, sortDir, aggregate, source } = parsed.data; const where = { @@ -77,6 +77,91 @@ export const GET = apiHandler(async (request: NextRequest) => { // The route's response is bounded by the `BUCKET_CAP` ceiling per // grain (monthly: 24, weekly: 105, daily: 365) so a multi-decade // account never paints an unbounded series. + // v1.4.36 W1 — rollup-table read for daily aggregates. + // + // The Insights trends row fires three parallel + // `GET /api/measurements?type=…&aggregate=daily&limit=5000` requests + // (BP_SYS / BP_DIA / WEIGHT). The legacy `date_trunc('day', …)` + // path scanned the measurements table each time — a full year window + // on Marc's account hit 3 × ~3 s on the v1.4.35 HAR. Reading from + // the persistent `measurement_rollups` DAY buckets drops each call + // to a small indexed read against the ~5 k-row rollup table. + // + // Gating: + // - `source=rollup` is an explicit client opt-in (`@/lib/validations/measurement` + // constrains the enum to `["rollup"]`). + // - We require `aggregate=daily` because the rollup populator only + // keeps DAY buckets on the synchronous write hook; the WEEK / + // MONTH paths still depend on the async pg-boss recomputes which + // may lag behind a recent write. + // - `type` is mandatory so the response shape mirrors the per-type + // ask the chart code makes. + // - When the rollup window returns zero rows we fall through to + // the live `date_trunc` path so a brand-new account whose + // populator hasn't caught up still sees a correct chart on its + // first render. + if ( + source === "rollup" && + aggregate === "daily" && + type && + from && + to + ) { + const cap = Math.min(limit, BUCKET_CAP.daily); + const rollupRows = await prisma.measurementRollup.findMany({ + where: { + userId: user.id, + type: type as MeasurementType, + granularity: "DAY", + bucketStart: { gte: from, lte: to }, + }, + orderBy: { bucketStart: "asc" }, + take: cap, + select: { + type: true, + bucketStart: true, + mean: true, + count: true, + }, + }); + if (rollupRows.length > 0) { + // Cumulative metrics (steps, active energy, distance, flights, + // daylight) store `mean` per bucket but the chart needs the + // daily SUM. Reconstruct as `mean * count` — algebraically + // equivalent to SUM(value) for a single-source day because + // AVG = SUM / COUNT. + const useSum = + type != null && CUMULATIVE_HK_TYPES.has(type as MeasurementType); + const measurements = rollupRows.map((r) => ({ + type: r.type, + value: useSum ? r.mean * r.count : r.mean, + measuredAt: r.bucketStart.toISOString(), + count: r.count, + })); + annotate({ + action: { name: "measurement.list" }, + meta: { + total: measurements.length, + type, + aggregate: "daily", + source: "rollup", + }, + }); + return apiSuccess({ + measurements, + meta: { + total: measurements.length, + limit: cap, + offset: 0, + aggregate: "daily", + }, + }); + } + // Fall through to the live aggregate path when the rollup is + // empty for the requested window — covers brand-new accounts + + // the race between a recent write hook and the read. + } + if (aggregate && aggregate !== "raw" && from && to) { const grain: AggregateGrain = aggregate; const cap = Math.min(limit, BUCKET_CAP[grain]); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 9e37348b2..a3c2eaf8a 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -585,8 +585,20 @@ export function HealthChart({ // Recharts paint cost ~50× on continuous-monitoring accounts. // Short windows keep raw fetching so hour-by-hour detail // stays visible. + // + // v1.4.36 W1 — also route the daily aggregate through the + // persistent rollup buckets via `source=rollup`. The route + // reads from `measurement_rollups` instead of running a live + // `date_trunc` scan over the raw measurements table; the + // three parallel daily fetches on the Insights trends row + // (BP_SYS / BP_DIA / WEIGHT) drop from ~3 s each to a small + // indexed read against the ~5 k-row rollup table. The server + // falls back to live SQL when the rollup is empty for the + // requested window so brand-new accounts still see a chart + // on their first render. if (fetchWindow.windowDays > 7) { typeParams.set("aggregate", "daily"); + typeParams.set("source", "rollup"); } const res = await fetch(`/api/measurements?${typeParams}`); diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 5625a7048..122f0a4d0 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -262,6 +262,16 @@ export const listMeasurementsSchema = z.object({ // Omitting `aggregate` keeps the raw wire shape (iOS contract); the // chart-data client must opt in explicitly. aggregate: z.enum(["raw", "daily", "weekly", "monthly"]).optional(), + // v1.4.36 W1 — opt-in source switch for daily-aggregate reads. When + // `source=rollup` + `aggregate=daily`, the route reads from the + // persistent `measurement_rollups` DAY buckets instead of running a + // live `date_trunc` GROUP BY scan over the raw `measurements` table. + // The chart-data client opts in for the trends-row strip + every + // sub-page chart so the three parallel daily-aggregate requests stop + // burning a full table scan each. The route falls back to live SQL + // when the rollup bucket set is empty for the requested window so + // brand-new accounts still see correct data on their first chart. + source: z.enum(["rollup"]).optional(), }); export const createBatchMeasurementSchema = z.object({