perf(analytics): fast-path gates per-type only

`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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-18 11:06:24 +02:00
1 parent 9cc5ececa7
commit 8a8150d2bf
5 files changed
+50 -20

No files matched your search

+10 -9
View File
@@ -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);
+5 -7
View File
@@ -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;
+5 -3
View File
@@ -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