v1.4.37.1 — event-loop unblock on the read path

Hotfix on top of v1.4.37. The post-deploy verify caught
/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
1.5-3 s perf claim. Root cause: the synchronous
ensureUserRollupsFresh(userId) call at the top of each heavy
read path folds the trailing 90-day DAY window when the rollup
watermark trails the newest measurement. On a power-user account
whose iOS step samples push the watermark every few minutes,
each cache miss synchronously paid the full refresh cost on a
Node 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.

Convert the three call sites (analytics route, comprehensive
aggregator, summaries slice) to fire-and-forget. The downstream
probeRollupCoverage gate falls back to live SQL on partial
coverage so correctness is preserved; the only user-observable
change is one cache window of staleness on the very first
request after a new measurement landed.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 16:37:51 +02:00
1 parent 88028fa082
commit 98e078d359
7 files changed
+161 -22

No files matched your search

+12 -8
View File
@@ -112,14 +112,18 @@ type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["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
+8 -5
View File
@@ -179,11 +179,14 @@ export interface SummariesSlice {
export async function computeSummariesSlice(
userId: string,
): Promise<SummariesSlice> {
// 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
+8 -6
View File
@@ -233,12 +233,14 @@ export async function buildComprehensiveAggregate(
): Promise<ComprehensiveAggregate> {
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