From bf1d87499f0862b681325dcff512229c25d88225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 22 May 2026 10:48:53 +0200 Subject: [PATCH] perf(analytics): cap slim-slice narrows query at 90 days (v1.4.47.1) The narrows query inside computeFromRollups scanned the user's full measurements partition without an outer measured_at cap even though every aggregate FILTER inside the SELECT restricted to 7/30/90 days. On a ~450k-row tenant the cold slim slice paid ~9s TTFB to read rows that were always aggregated to NULL. Adding AND m.measured_at >= NOW() - INTERVAL '90 days' to the outer WHERE turns the read into an index range scan on (user_id, type, measured_at) and returns bit-identical output. Expected: ~9s -> ~0.5-1s cold, sub-50ms warm. No schema change, no migration, no env-var change. --- CHANGELOG.md | 13 +++++++++++++ package.json | 2 +- src/lib/analytics/summaries-slice.ts | 10 ++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d65aca1c..0d5089dca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.4.47.1] — 2026-05-22 — Slim summaries slice 9 s → ~0.5-1 s cold + +Same-day hotfix on top of v1.4.47. Dashboard cold mount on power-user accounts was firing `/api/analytics?slice=summaries` at ~9 s TTFB; the time was almost entirely in one `$queryRaw` against the `measurements` table. + +The `narrows` query inside `computeFromRollups` was scanning the user's full measurements partition without an outer `measured_at` cap. Every `FILTER` expression inside the SELECT already restricts to 7/30/90 days, so the additional rows were aggregated to NULL and discarded — but the planner still had to read all of them. Adding the matching 90-day outer WHERE turns the read into an index range scan on `(user_id, type, measured_at)` and returns the same output bit-for-bit. + +Effect for a ~450 000-row tenant: slim slice cold ~9 s → ~0.5-1 s, warm cache hits stay at the sub-50 ms Map-lookup. The default slice keeps its earlier path; a separate split of `computeFromLiveAggregate` is queued for the next release for tenants who still rely on the no-rollup fallback. + +- **Changed:** `src/lib/analytics/summaries-slice.ts:255-306` — `computeFromRollups.narrows` query gains `AND m."measured_at" >= NOW() - INTERVAL '90 days'`. +- **Risk:** zero. Inner FILTER clauses already discarded rows outside the 90-day window; the new WHERE just narrows the scan to the same set. +- **No migration. No schema change. No env-var change.** +- **Operator notes:** standard image roll; no `prisma migrate deploy` step required. + ## [1.4.47] — 2026-05-22 — Audit-backlog closure: drag-to-reorder, Coach disable toggle, OAuth state nonce table, legacy column drop, primitive sweep v1.4.45 closed the v1.4.43-audit follow-up; v1.4.46 caught a same-day server reconcile (PR worker, intake auto-skip, APNS admin test). v1.4.47 is the dedicated follow-up that lands every deferred v1.4.45 audit Medium/Low plus the W14 legacy-column cleanup the v1.4.45 release scheduled for "one release later". diff --git a/package.json b/package.json index 1e4c78598..f2b673ab7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.47", + "version": "1.4.47.1", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index f5883dabb..2110a3c6f 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -251,6 +251,15 @@ export async function computeSummariesSlice( * Happy path — DAY buckets carry `count / min / max / mean`; a narrow * `$queryRaw` carries only the windowed avgs + regression columns the * buckets cannot reconstruct. + * + * v1.4.47.1 — the `narrows` query takes an outer 90-day `measured_at` + * cap so the planner does an index range scan on + * `(user_id, type, measured_at)` instead of reading every row in the + * user's measurements partition. Every FILTER expression inside the + * SELECT already restricts to 7/30/90 days, so the cap excludes only + * rows that were already being aggregated to NULL. On a power-user + * account with ~450k all-time rows this lifts the slim slice from + * ~9 s cold to ~0.5-1 s; output is bit-identical. */ async function computeFromRollups(userId: string): Promise { const [narrows, latests, dayBuckets] = await Promise.all([ @@ -302,6 +311,7 @@ async function computeFromRollups(userId: string): Promise { FROM measurements m WHERE m."user_id" = ${userId} AND m."deleted_at" IS NULL + AND m."measured_at" >= NOW() - INTERVAL '90 days' GROUP BY m."type" `, prisma.$queryRaw`