From 374c1d3b0692b131f6b3b3b4658909a06c07edf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 16:37:13 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.4.37.1=20=E2=80=94=20event?= =?UTF-8?q?-loop=20unblock=20on=20the=20read=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hotfix on top of v1.4.37. ensureUserRollupsFresh fire-and-forget on /api/analytics, comprehensive aggregator, summaries slice. The synchronous variant was blocking the Node event loop for ~60 s per cache miss on power-user accounts whose iOS step samples kept the 90-day rollup window slightly stale, which starved /api/health, /api/version and concurrent iOS requests. The downstream coverage probe preserves correctness via the live-SQL fallback; the only user-observable change is one cache window of staleness on the very first request after a new measurement lands. --- CHANGELOG.md | 38 ++++++++++++++++++++ package.json | 2 +- src/app/api/analytics/route.ts | 20 ++++++----- src/lib/analytics/summaries-slice.ts | 13 ++++--- src/lib/insights/comprehensive-aggregator.ts | 14 ++++---- 5 files changed, 67 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fbc08029..9b1720bdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## [1.4.37.1] — 2026-05-17 — Event-loop unblock on the read path + +Hotfix on top of v1.4.37. Post-deploy verification surfaced live +`/api/analytics` cold-mount hits of ~62 s and parallel +`/api/health` checks of ~58 s on the live instance — far above the +v1.4.37 perf claim of 1.5–3 s. Root cause: the synchronous +`ensureUserRollupsFresh(userId)` call at the top of each heavy +read path (analytics route, comprehensive aggregator, summaries +slice) folds the trailing 90-day DAY window when the rollup +watermark trails the newest measurement. On an account with +high-frequency Apple Health step ingest the watermark falls behind +every few minutes, so each cache miss synchronously paid the full +refresh cost on a Node.js worker that also serves the rest of the +request fan-out — including `/api/health`, `/api/version`, and +concurrent iOS calls — and the event loop was blocked for the +duration of the recompute. + +### Fixed + +- `ensureUserRollupsFresh` is now fired-and-forgotten on all three + read-path call sites (`src/app/api/analytics/route.ts`, + `src/lib/insights/comprehensive-aggregator.ts`, + `src/lib/analytics/summaries-slice.ts`). The function already + carries its own try/catch envelope, so the `void`-call cannot + reject. Correctness is preserved by the downstream + `probeRollupCoverage` check: when a type is partial the live + fallback fires; the only user-observable change is that the very + first request after a fresh measurement landed may serve data up + to ~60 s old, and the next request returns the up-to-date value + once the background refresh completes. + +### Operator notes + +- No schema change. No env-var change. +- Coolify auto-deploys main on tag push; first webhook may pull + stale `:latest`, redeploy after the docker-publish workflow + completes. + ## [1.4.37] — 2026-05-17 — Final web polish before iOS focus The closing web release before the v1.5 iOS-app sprint. Carries the diff --git a/package.json b/package.json index 8cc9a2987..83d04f3fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.37", + "version": "1.4.37.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/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 03c740849..38c327865 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -112,14 +112,18 @@ type AuthedUser = Awaited>["user"]; * `buildCoachSnapshotImpl` shape. */ async function buildAnalyticsResponse(user: AuthedUser) { - // v1.5.0 — warm the persistent rollup table as a side effect of - // the analytics fan-out. No-op when rollups are already ahead of - // the newest measurement; on first cold-mount after a process - // restart it folds the trailing 90-day window so downstream - // consumers (Coach drawer, weekly report, admin analytics) see a - // warm rollup table on their next read. The route's response - // shape is untouched. - await ensureUserRollupsFresh(user.id); + // v1.4.37.1 hotfix — fire-and-forget the rollup-fresh check. + // Awaiting it on the read path can stall the Node event loop for + // 30–60 s on a power-user account whose iOS step samples keep the + // 90-day DAY window slightly stale, which starves /api/health, + // /api/version, and concurrent iOS calls. The downstream coverage + // probe falls back to live SQL when a type is uncovered, so + // correctness is preserved; the worst the user sees is data from + // up to ~60 s ago on the very first request after a fresh + // measurement lands, and the next request returns the up-to-date + // value once the background refresh completes. `ensureUserRollupsFresh` + // wraps its own try/catch, so the void call cannot reject. + void ensureUserRollupsFresh(user.id); // v1.4.37 W2 — single per-type coverage probe shared by the // bp_in_target / healthScore / correlations branches below. The diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 4a53bf7c8..6ceb016d8 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -179,11 +179,14 @@ export interface SummariesSlice { export async function computeSummariesSlice( userId: string, ): Promise { - // Keep the persistent rollup table current before the bucket read - // fires. On a warm process this is a single watermark query; on a - // cold mount it folds the trailing 90-day window into the DAY - // rollup so the bucket read below has something to compose. - await ensureUserRollupsFresh(userId); + // v1.4.37.1 hotfix — fire-and-forget. See `src/app/api/analytics/route.ts` + // for the full rationale: awaiting this on the read path can stall + // the event loop for 30–60 s on power-user accounts whose iOS step + // samples keep the 90-day window slightly stale. The downstream + // coverage probe falls back to live SQL for uncovered types, so + // correctness is preserved; the read just doesn't block waiting + // for the background refresh. + void ensureUserRollupsFresh(userId); // v1.4.36 QA C1 — per-type coverage probe replaces the legacy global // COUNT. The previous gate returned true as soon as ANY type had at diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts index 8ec4c3c47..a9e6f07ff 100644 --- a/src/lib/insights/comprehensive-aggregator.ts +++ b/src/lib/insights/comprehensive-aggregator.ts @@ -233,12 +233,14 @@ export async function buildComprehensiveAggregate( ): Promise { const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); - // Keep the persistent rollup table current before any read fires. - // After this returns the DAY-granularity buckets for the trailing - // 90 days reflect every measurement that landed before this call - // (the per-write hook is synchronous on the DAY bucket, and - // `ensureUserRollupsFresh` covers cold-mount / process-restart). - await ensureUserRollupsFresh(userId); + // v1.4.37.1 hotfix — fire-and-forget. See `src/app/api/analytics/route.ts` + // for the full rationale: awaiting this on the read path can stall + // the event loop for 30–60 s on power-user accounts whose iOS step + // samples keep the 90-day window slightly stale. The downstream + // coverage probe falls back to live SQL for uncovered types, so + // correctness is preserved; the read just doesn't block waiting + // for the background refresh. + void ensureUserRollupsFresh(userId); // v1.4.36 QA C1 — per-type coverage probe. The legacy global COUNT // returned true as soon as any one type had a DAY bucket, which made