perf(analytics): widen live-fallback cap to 425 days to preserve year-ago summaries

QA Specialist-H2 (v1.4.39): the original 90-day cap on the per-type
live-fallback loop in `/api/analytics` silently nulled `summarize().avg30LastYear`
(year-ago tile) on every fall-through path because the window stopped at
day 90 and the year-ago aggregate reads days 365-395. Widened the cap to
425 days (365 + 30 for the year-ago bucket span + 30 buffer) so the
year-over-year comparison tile stays populated on cold mounts. The slim
slice and rollup-backed comprehensive aggregator already operate on
tighter windows so this only affects the legacy live-fallback path the
fast-path coverage gate misses. Adjusted the W-SINCE test that pinned
the 90-day cap and updated the route's `meta.analytics.bp_aggregate.live_since`
annotation rationale.
This commit is contained in:
Marc-André Bombeck committed 2026-05-18 12:55:32 +02:00
1 parent ff4e5faf5f
commit aacfbf5b61
2 files changed
+41 -35

No files matched your search

+19 -13
View File
@@ -13,11 +13,13 @@
*
* This file pins:
*
* 1. **The 90-day floor reaches `prisma.measurement.findMany`.**
* 1. **The 425-day floor reaches `prisma.measurement.findMany`.**
* Every per-type read inside the default slice carries
* `where.measuredAt.gte` ≈ now − 90 days. If a future refactor
* drops the `since` option from the chunked helper this test
* breaks loudly.
* `where.measuredAt.gte` ≈ now − 425 days. QA Specialist-H2
* (v1.4.39) widened the original 90-day cap to 425 days so
* `summarize().avg30LastYear` (year-ago tile) stays populated
* on the live-fallback path. If a future refactor drops the
* `since` option from the chunked helper this test breaks loudly.
*
* 2. **The `meta.analytics.bp_aggregate.live_since` annotate fires.**
* Lets the next perf-verify see how far back the live-fallback
@@ -26,7 +28,7 @@
*
* 3. **The slim slice (`?slice=summaries`) is unaffected.** That
* branch never hit the per-type loop in the first place and
* keeps its own 90-day SQL aggregator.
* keeps its own SQL aggregator.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -193,7 +195,7 @@ beforeEach(async () => {
});
describe("GET /api/analytics — live-fallback `since` cap (W-SINCE)", () => {
it("passes a trailing-90-day `where.measuredAt.gte` to every per-type findMany", async () => {
it("passes a trailing-425-day `where.measuredAt.gte` to every per-type findMany", async () => {
const before = Date.now();
const response = await callGet(
new Request("http://localhost/api/analytics"),
@@ -204,9 +206,11 @@ describe("GET /api/analytics — live-fallback `since` cap (W-SINCE)", () => {
const findMany = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
expect(findMany.mock.calls.length).toBeGreaterThan(0);
const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000;
const expectedMinSince = before - NINETY_DAYS_MS;
const expectedMaxSince = after - NINETY_DAYS_MS;
// QA Specialist-H2 (v1.4.39): 425 days = 365 (year-ago window) +
// 30 (avg30LastYear bucket span) + 30 (cache-aging buffer).
const FOUR_TWENTY_FIVE_DAYS_MS = 425 * 24 * 60 * 60 * 1000;
const expectedMinSince = before - FOUR_TWENTY_FIVE_DAYS_MS;
const expectedMaxSince = after - FOUR_TWENTY_FIVE_DAYS_MS;
// The per-type loop (A2) issues chunked reads with an `orderBy`
// *array* (`(measuredAt asc, id asc)`); the 30-day glucose and
@@ -232,7 +236,7 @@ describe("GET /api/analytics — live-fallback `since` cap (W-SINCE)", () => {
}
});
it("annotates `meta.analytics.bp_aggregate.live_since` with the 90-day cutoff ISO", async () => {
it("annotates `meta.analytics.bp_aggregate.live_since` with the 425-day cutoff ISO", async () => {
const before = Date.now();
await callGet(new Request("http://localhost/api/analytics"));
const after = Date.now();
@@ -256,10 +260,12 @@ describe("GET /api/analytics — live-fallback `since` cap (W-SINCE)", () => {
const bpAggregate = fields.meta.analytics.bp_aggregate;
expect(typeof bpAggregate.live_since).toBe("string");
const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000;
const FOUR_TWENTY_FIVE_DAYS_MS = 425 * 24 * 60 * 60 * 1000;
const liveSinceMs = new Date(bpAggregate.live_since).getTime();
expect(liveSinceMs).toBeGreaterThanOrEqual(before - NINETY_DAYS_MS);
expect(liveSinceMs).toBeLessThanOrEqual(after - NINETY_DAYS_MS);
expect(liveSinceMs).toBeGreaterThanOrEqual(
before - FOUR_TWENTY_FIVE_DAYS_MS,
);
expect(liveSinceMs).toBeLessThanOrEqual(after - FOUR_TWENTY_FIVE_DAYS_MS);
});
it("does not invoke the per-type loop on the slim `?slice=summaries` branch", async () => {
+22 -22
View File
@@ -220,24 +220,24 @@ async function buildAnalyticsResponse(user: AuthedUser) {
// rows each, dominating the cold full-slice critical path (Marc's
// 74.6 s cold mount).
//
// After the cap: the trailing 90 days mirror the comprehensive
// aggregator + summaries-slice windows; the worst case drops to
// roughly the same row count those paths already absorb (~5 k
// estimated on Marc's account). Cumulative HealthKit types
// (ACTIVITY_STEPS, ACTIVE_ENERGY, …) collapse to one-row-per-day
// after the per-day-sum pass downstream so the 90-day window is
// ample for every consumer summarise() feeds.
// After the cap: the trailing 425 days bound the per-type walk
// while preserving `summarize().avg30LastYear` (points 365-395 days
// ago) and `summarize().avg30LastMonth` (30-60 days ago) for the
// dashboard's year-over-year and month-over-month comparison tiles.
// Cumulative HealthKit types (ACTIVITY_STEPS, ACTIVE_ENERGY, …)
// collapse to one-row-per-day after the per-day-sum pass downstream
// so a 425-day window stays bounded (≤ ~25 k rows per type on a
// saturated tenant).
//
// Trade-off: `summarize().avg30LastYear` (points 365-395 days ago)
// returns null on this fall-through path; the dashboard tile's
// year-over-year overlay shows "no prior data" until the rollup
// tier warms. This is acceptable because the live-fallback is
// already a degraded path — users hit it only when the per-type
// coverage gate flips false, which v1.4.38.8 made rare. The slim
// slice (`?slice=summaries`) and the rollup-backed comprehensive
// aggregator already operate on the 90-day window, so the cap
// brings the full slice into line with the slim slice's contract.
const ANALYTICS_LIVE_WINDOW_DAYS = 90;
// QA Specialist-H2 (v1.4.39): the original 90-day cap stripped the
// year-ago window so `summary.avg30LastYear` returned null on every
// fall-through path, making the "vs last year" tile render "no
// prior data" on every cold mount. The 425-day cap (365 + 30 + 30
// buffer) keeps the year-ago window populated; the slim slice
// (`?slice=summaries`) and the rollup-backed comprehensive
// aggregator already operate on tighter windows so this cap only
// affects the legacy live-fallback path the fast-path gate misses.
const ANALYTICS_LIVE_WINDOW_DAYS = 425;
const liveSince = new Date(
Date.now() - ANALYTICS_LIVE_WINDOW_DAYS * 24 * 60 * 60 * 1000,
);
@@ -262,10 +262,10 @@ async function buildAnalyticsResponse(user: AuthedUser) {
types.map((type) =>
fetchMeasurementSeriesChunked(user.id, type, {
includeSleepStage: true,
// v1.4.39 W-SINCE — trailing 90-day floor for the live read.
// Mirrors the comprehensive-aggregator + summaries-slice
// windows so the full slice stops over-reading on the
// fall-through path. See comment block above.
// v1.4.39 W-SINCE — trailing 425-day floor for the live read.
// Wide enough to populate `summarize().avg30LastYear` (the
// year-ago comparison tile) while still bounding the per-type
// row count. See comment block above.
since: liveSince,
}).then((measurements) => {
totalRowsReadForAggregate += measurements.length;
@@ -362,7 +362,7 @@ async function buildAnalyticsResponse(user: AuthedUser) {
//
// v1.4.39 W-SINCE — also surface `live_since` (ISO) so the next
// perf-verify can see how far back the live-fallback read actually
// went. With the 90-day cap in place this should be ~now-90d on
// went. With the 425-day cap in place this should be ~now-425d on
// every full-slice request; an older value would signal that a
// future refactor accidentally widened the window again.
annotate({