mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
1 parent
ff4e5faf5f
commit
aacfbf5b61
2 files changed
+41
-35
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user