From 8a8150d2bf11eba7db97ddb3cd137ea5bc030246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 18 May 2026 11:06:24 +0200 Subject: [PATCH] perf(analytics): fast-path gates per-type only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isFullyCovered(coverage) && specificTypes` poisoned every fast- path with a single unrelated uncovered measurement type. v1.4.38.5 verified the discovery query was finding nothing; the slow cold- mount was the AND-stack collapsing the gate. - correlations-fast-path: gates on SYS + PULSE + WEIGHT only. - bp-in-target-fast-path: gates on SYS + DIA only. - health-score-fast-path: gates on WEIGHT only. Coverage-probe semantics unchanged; helpers now consult the per- type entries directly. Power-user cold-mount on /api/analytics estimated 30-75 s → ~3-5 s. --- CHANGELOG.md | 29 +++++++++++++++++++++ package.json | 2 +- src/lib/analytics/bp-in-target-fast-path.ts | 19 +++++++------- src/lib/analytics/correlations-fast-path.ts | 12 ++++----- src/lib/analytics/health-score-fast-path.ts | 8 +++--- 5 files changed, 50 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cf8c4eea..37d5129b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## [1.4.38.8] — 2026-05-18 — Analytics fast-path gates per-type only + +The v1.4.38.5–.7 chain confirmed the rollup-fast-path was bouncing +to the live SQL aggregator even when the helper's own types were +fully covered — every fast-path stacked `isFullyCovered(coverage) +&& specificTypes` and a single unrelated uncovered type (e.g. an +iOS-pushed brand-new ACTIVITY_FLIGHTS reading) flipped the AND +false and stranded the whole helper on live SQL across the full +347 k-row measurement table. + +### Fixed + +- **`correlations-fast-path` gates only on `BLOOD_PRESSURE_SYS + + PULSE + WEIGHT`** — the three types the helper actually reads. + `isFullyCovered(coverage)` AND-term dropped. +- **`bp-in-target-fast-path` gates only on `BLOOD_PRESSURE_SYS + + BLOOD_PRESSURE_DIA`**. Same fix. +- **`health-score-fast-path` gates only on `WEIGHT`**. Same fix. + +Coverage-probe semantics unchanged. Each helper now consults the +per-type entries directly; a brand-new uncovered metric type no +longer poisons unrelated cards. + +### Operator notes + +- No new migration. No env-var change. +- Expected: cold-mount `/api/analytics` on Marc-sized accounts + drops from 30-75 s to ~3-5 s. + ## [1.4.38.7] — 2026-05-18 — Rollup recompute observability + admin trigger The v1.4.38.5 / v1.4.38.6 chain promised a fast-path recovery for diff --git a/package.json b/package.json index 18a978ffe..9e672b089 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.38.7", + "version": "1.4.38.8", "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/bp-in-target-fast-path.ts b/src/lib/analytics/bp-in-target-fast-path.ts index c5cacc29a..1beddab44 100644 --- a/src/lib/analytics/bp-in-target-fast-path.ts +++ b/src/lib/analytics/bp-in-target-fast-path.ts @@ -54,7 +54,6 @@ import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { readRollupBuckets } from "@/lib/measurements/rollups"; import { - isFullyCovered, probeRollupCoverage, type RollupCoverageMap, } from "@/lib/measurements/rollup-coverage"; @@ -149,14 +148,16 @@ export async function computeBpInTargetFastPath(input: { ? "near-utc" : "non-utc-live-fallback"; - // Belt-and-braces: only take the rollup path when EVERY logged type - // is covered AND both BP types are present in the map AND the - // cross-tz guard cleared. A brand-new user without BP at all falls - // into `isFullyCovered === false` (coverage map is empty) and the - // route's downstream code already skips the BP block when - // `getBpTargets` returns null; we still call the live fallback here - // so the helper's contract stays single-shot. - if (userNearUtc && isFullyCovered(coverage) && sysCovered && diaCovered) { + // v1.4.38.8 — gate only on the types the helper actually reads. + // The previous `isFullyCovered(coverage) && sysCovered && diaCovered` + // shape poisoned the BP fast-path with any unrelated brand-new + // measurement type the user had logged (e.g. iOS pushed a new + // ACTIVITY_FLIGHTS reading without a rollup row yet) — even though + // SYS + DIA are fully covered. The cold-mount then fanned out to the + // live aggregator over the full measurement table. Coverage probe is + // already keyed per type, so trusting the per-type entries directly + // is the correct gate. + if (userNearUtc && sysCovered && diaCovered) { return computeFromRollups(userId, targets, now, tzGuard); } return computeFromLive(userId, targets, now, tzGuard); diff --git a/src/lib/analytics/correlations-fast-path.ts b/src/lib/analytics/correlations-fast-path.ts index 605f95479..d5fe75223 100644 --- a/src/lib/analytics/correlations-fast-path.ts +++ b/src/lib/analytics/correlations-fast-path.ts @@ -47,7 +47,6 @@ import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { readRollupBuckets } from "@/lib/measurements/rollups"; import { - isFullyCovered, probeRollupCoverage, type RollupCoverageMap, } from "@/lib/measurements/rollup-coverage"; @@ -140,14 +139,13 @@ export async function computeCorrelationHypothesesFastPath( // local-day buckets and this guard can come back down. const userNearUtc = isNearUtc(userTz, now); - // The three measurement-derived streams ride the rollup-fast-path - // only when EVERY type the user has logged is covered AND each of - // SYS / PULSE / WEIGHT is in the coverage map. Partial coverage - // falls back to the live path so a brand-new metric type doesn't - // make the correlation card vanish. + // v1.4.38.8 — gate only on the three types this helper actually + // reads (SYS / PULSE / WEIGHT). The prior `isFullyCovered &&` AND + // poisoned the correlations fast-path with any unrelated brand-new + // type the user had logged, even when SYS + PULSE + WEIGHT were + // fully covered. Per-type coverage is the correct gate. const measurementsOnRollups = userNearUtc && - isFullyCovered(coverage) && coverage.get("BLOOD_PRESSURE_SYS") === true && coverage.get("PULSE") === true && coverage.get("WEIGHT") === true; diff --git a/src/lib/analytics/health-score-fast-path.ts b/src/lib/analytics/health-score-fast-path.ts index 520ed8cb9..3350bbdf0 100644 --- a/src/lib/analytics/health-score-fast-path.ts +++ b/src/lib/analytics/health-score-fast-path.ts @@ -42,7 +42,6 @@ import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { readRollupBuckets } from "@/lib/measurements/rollups"; import { - isFullyCovered, probeRollupCoverage, type RollupCoverageMap, } from "@/lib/measurements/rollup-coverage"; @@ -146,8 +145,11 @@ export async function computeUserHealthScoreFastPath( const prevUntil = new Date(now.getTime() - 7 * DAY_MS); const coverage = input.coverage ?? (await probeRollupCoverage(userId)); - const weightCovered = - isFullyCovered(coverage) && coverage.get("WEIGHT") === true; + // v1.4.38.8 — gate only on the WEIGHT type this helper reads. The + // prior `isFullyCovered &&` AND poisoned the score fast-path with + // any unrelated brand-new type that lacked rollup coverage. Per- + // type coverage is the correct gate. + const weightCovered = coverage.get("WEIGHT") === true; // Weight series — rollup-fast-path when fully covered, raw findMany // otherwise. The raw read selects only the columns the slope helper