From 86d917c5e299c9539c44c037c2e5dc2782dd14a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 13:16:00 +0200 Subject: [PATCH] fix(insights): align cold-fallback slope window to the rollup day boundary The rollup read path windows the 7/30/90-day regression on startOfUtcDay(now - N days), while the live-SQL fallback filtered the REGR_* aggregates on the wall-clock NOW() - INTERVAL bound. An account crossing a coverage boundary (warm to cold) therefore saw the boundary day's readings flip in or out of the slope window depending only on whether the rollup tier was populated, returning a cache-dependent slope for the same request. Anchor both cold-fallback paths on date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL 'N days', the SQL twin of startOfUtcDay, so warm and cold return identical boundary-day membership. The avg7/avg30/median windows stay wall-clock since the warm narrow query computes them the same way. --- .../__tests__/summaries-slice.test.ts | 50 +++++++++++++++++++ src/lib/analytics/summaries-slice.ts | 25 +++++++--- src/lib/insights/comprehensive-aggregator.ts | 25 +++++++--- 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts index 1102e2608..2d18dcbd6 100644 --- a/src/lib/analytics/__tests__/summaries-slice.test.ts +++ b/src/lib/analytics/__tests__/summaries-slice.test.ts @@ -652,4 +652,54 @@ describe("computeSummariesSlice", () => { ); }); }); + + /** + * A2-M2 — read-swap boundary-day consistency. The warm + * `computeFromRollups` path windows the regression on UTC-midnight + * (`startOfUtcDay(now − N days)` + `composeWindowedRegression`). The + * cold-fallback `windowed` query must anchor its REGR_* FILTERs on the + * SAME UTC-midnight boundary — `date_trunc('day', NOW() AT TIME ZONE + * 'UTC') − INTERVAL 'N days'` — so a warm→cold coverage transition + * returns identical boundary-day membership for slope7/30/90 instead of + * a cache-dependent answer to the same request. + */ + describe("A2-M2 regression-window boundary alignment", () => { + it("anchors the cold-fallback slope FILTERs on the UTC-midnight day boundary, not the wall-clock instant", async () => { + const queries: string[] = []; + UNSAFE.mockImplementation((sql: string) => { + queries.push(sql); + return Promise.resolve([]); + }); + + // Empty coverage ⇒ cold-fallback path ⇒ the `windowed` query runs. + RAW.mockResolvedValueOnce([]); + await computeSummariesSlice("user-a2m2"); + + const windowedSql = queries.find( + (sql) => sql.includes("AS slope7") && sql.includes("REGR_SLOPE"), + ); + expect(windowedSql).toBeDefined(); + const sql = windowedSql as string; + + // Every regression window (7/30/90 for slope + r²) anchors on the + // day-truncated UTC boundary. Six FILTERs total. + for (const days of ["7 days", "30 days", "90 days"]) { + expect(sql).toContain( + `(date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '${days}') AT TIME ZONE 'UTC'`, + ); + } + const anchored = sql.match( + /date_trunc\('day', NOW\(\) AT TIME ZONE 'UTC'\) - INTERVAL '(?:7|30|90) days'\) AT TIME ZONE 'UTC'/g, + ); + expect(anchored?.length).toBe(6); + + // The regression windows must NOT fall back to the bare wall-clock + // `NOW() - INTERVAL 'N days'` bound the warm path never uses for the + // slope. Isolate the slope columns (everything from the first + // REGR_SLOPE onward) so the avg7/avg30 windows above — which DO stay + // wall-clock by design — don't trip the assertion. + const slopeBlock = sql.slice(sql.indexOf("REGR_SLOPE")); + expect(slopeBlock).not.toMatch(/WHERE m\."measured_at" >= NOW\(\) -/); + }); + }); }); diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 454e3c50d..fe43a1ad2 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -772,41 +772,54 @@ async function computeFromLiveAggregate( ) FILTER ( WHERE m."measured_at" >= NOW() - INTERVAL '90 days' )::double precision AS median, + -- A2-M2 — anchor the regression windows on the UTC-midnight day + -- boundary so this cold-fallback path returns the SAME boundary-day + -- membership the warm computeFromRollups composes via + -- startOfUtcDay(now - N days) + composeWindowedRegression. The + -- expression date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL + -- 'N days' is the SQL twin of startOfUtcDay; the trailing AT TIME + -- ZONE 'UTC' re-materialises the naive truncation as a UTC instant + -- so the measured_at (timestamptz) comparison lands on the same wall + -- the rollup bucketStart sits on. The wall-clock NOW() - INTERVAL + -- bound let the boundary day flip in/out of the slope window across + -- a warm-to-cold coverage transition (cache-dependent slope on the + -- same request); the avg7/30/median windows above stay wall-clock + -- because the warm narrow query computes THOSE the same way. REGR_SLOPE( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= NOW() - INTERVAL '7 days' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') AT TIME ZONE 'UTC' )::double precision AS slope7, REGR_R2( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= NOW() - INTERVAL '7 days' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') AT TIME ZONE 'UTC' )::double precision AS r2_7, REGR_SLOPE( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= NOW() - INTERVAL '30 days' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' )::double precision AS slope30, REGR_R2( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= NOW() - INTERVAL '30 days' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' )::double precision AS r2_30, REGR_SLOPE( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= NOW() - INTERVAL '90 days' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days') AT TIME ZONE 'UTC' )::double precision AS slope90, REGR_R2( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= NOW() - INTERVAL '90 days' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days') AT TIME ZONE 'UTC' )::double precision AS r2_90 FROM ${canonicalMeasurementsFrom(rankUnqualified, "90 days")} GROUP BY m."type" diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts index e00dc87be..2968a8885 100644 --- a/src/lib/insights/comprehensive-aggregator.ts +++ b/src/lib/insights/comprehensive-aggregator.ts @@ -682,41 +682,54 @@ async function buildFromLiveAggregate( WHERE m."measured_at" >= NOW() - INTERVAL '60 days' AND m."measured_at" < NOW() - INTERVAL '30 days' )::double precision AS avg30_last_month, + -- A2-M2 — the regression windows anchor on the UTC-midnight day + -- boundary, NOT the wall-clock instant, so this cold-fallback path + -- returns the SAME boundary-day membership the warm rollup path + -- composes from (startOfUtcDay(now - N days) + + -- composeWindowedRegression). The expression + -- date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL 'N days' + -- is the SQL twin of startOfUtcDay; the trailing AT TIME ZONE 'UTC' + -- re-materialises the naive truncation as a UTC instant so the + -- measured_at (timestamptz) comparison is on the same wall the + -- rollup bucketStart sits on. Without this anchor an account that + -- crosses a coverage boundary (warm to cold) saw the boundary day + -- flip in/out of the slope window, giving a cache-dependent answer + -- to the same request. REGR_SLOPE( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= NOW() - INTERVAL '7 days' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') AT TIME ZONE 'UTC' )::double precision AS slope7, REGR_R2( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= NOW() - INTERVAL '7 days' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') AT TIME ZONE 'UTC' )::double precision AS r2_7, REGR_SLOPE( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= NOW() - INTERVAL '30 days' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' )::double precision AS slope30, REGR_R2( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= NOW() - INTERVAL '30 days' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' )::double precision AS r2_30, REGR_SLOPE( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= NOW() - INTERVAL '90 days' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days') AT TIME ZONE 'UTC' )::double precision AS slope90, REGR_R2( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= NOW() - INTERVAL '90 days' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days') AT TIME ZONE 'UTC' )::double precision AS r2_90 FROM cm m JOIN window_stats ws ON ws."type" = m."type"