From 982fe53b510d2d2be1c03583ed8abe98519678b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:41:52 +0200 Subject: [PATCH] fix(measurements): fall back to live SQL when the rollup read throws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daily-series reader only fell back to live SQL on a coverage MISS (zero rollup rows); a transient rollup-table error (statement_timeout, deadlock, connection reset) was unwrapped and 500'd the tile on the single-type route even though live SQL could serve it — and diverged from the batched series route, which already caught. Wrap the primary daily read and the long-range tiered read: treat a throw as a coverage miss and fall through to the live date_trunc aggregate. --- .../daily-series-read-tiered.test.ts | 51 +++++++++++++++++ src/lib/measurements/daily-series-read.ts | 57 +++++++++++++++---- 2 files changed, 96 insertions(+), 12 deletions(-) diff --git a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts index 7a494c151..f8f09d0bd 100644 --- a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts +++ b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts @@ -158,3 +158,54 @@ describe("readDailySeries — long-range tier step-up", () => { expect(mocks.findMany).toHaveBeenCalled(); }); }); + +describe("readDailySeries — rollup read throw falls back to live SQL (F-DB-2)", () => { + it("a rollup-table throw on a normal window degrades to the live date_trunc aggregate, not a 500", async () => { + const to = new Date("2026-06-21T00:00:00.000Z"); + const from = new Date(to.getTime() - 90 * DAY_MS); + + // The primary rollup read throws (statement_timeout / deadlock / reset). + mocks.findMany.mockReset().mockRejectedValue(new Error("statement timeout")); + // Live fallback serves the tile. + mocks.queryRaw.mockResolvedValueOnce([ + { + type: "WEIGHT", + bucket_start: new Date("2026-06-10T00:00:00.000Z"), + avg: 80.5, + cnt: 2, + }, + ]); + + const result = await readDailySeries({ + userId: "u", + type: "WEIGHT", + from, + to, + priorityJson: null, + }); + + // The read did NOT throw — it fell through to live SQL and served a row. + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ type: "WEIGHT", value: 80.5, count: 2 }); + expect(mocks.queryRaw).toHaveBeenCalled(); + }); + + it("a tiered-reader throw on a long window falls through to the daily path", async () => { + const to = new Date("2026-06-21T00:00:00.000Z"); + const from = new Date(to.getTime() - 3650 * DAY_MS); + mocks.readTieredRollupSeries.mockRejectedValueOnce(new Error("deadlock")); + + const result = await readDailySeries({ + userId: "u", + type: "WEIGHT", + from, + to, + priorityJson: null, + }); + + // The daily DAY-rollup read still ran as the fallback (the default findMany + // returns 3 rows), so the throw did not 500 the read. + expect(mocks.findMany).toHaveBeenCalled(); + expect(result).toHaveLength(3); + }); +}); diff --git a/src/lib/measurements/daily-series-read.ts b/src/lib/measurements/daily-series-read.ts index 98cafad59..7461b8dab 100644 --- a/src/lib/measurements/daily-series-read.ts +++ b/src/lib/measurements/daily-series-read.ts @@ -76,17 +76,32 @@ export async function readDailySeries(opts: { (to.getTime() - from.getTime()) / 86_400_000, ); if (windowDaysFull > cap) { - const tiered = await readTieredRollupSeries({ - userId, - type, - // v1.26.0 SEAM-N2 — pass the resolved `[from, to]` bounds so the tier - // reads the REQUESTED window (which may be entirely historic), not a - // trailing "to now" slice. `windowDaysFull` still gates entry above and - // drives the tier width inside the reader. - from, - to, - priorityJson, - }); + // A rollup-table throw here (statement_timeout, deadlock, connection reset) + // must not 500 the whole read — treat it as a coverage miss and fall + // through to the daily path (which itself falls back to live SQL). + let tiered: Awaited> = null; + try { + tiered = await readTieredRollupSeries({ + userId, + type, + // v1.26.0 SEAM-N2 — pass the resolved `[from, to]` bounds so the tier + // reads the REQUESTED window (which may be entirely historic), not a + // trailing "to now" slice. `windowDaysFull` still gates entry above and + // drives the tier width inside the reader. + from, + to, + priorityJson, + }); + } catch (err) { + annotate({ + meta: { + tiered_series_read_threw: true, + tiered_series_read_error: + err instanceof Error ? err.message : String(err), + type, + }, + }); + } if (tiered && tiered.rows.length > 0) { return tiered.rows; } @@ -129,7 +144,25 @@ export async function readDailySeries(opts: { priorityJson, ).slice(0, cap); - const rollupRows = await readRollup(); + // F-DB-2 — the primary rollup read is wrapped: a transient rollup-table error + // (statement_timeout, deadlock, connection reset) must NOT 500 the tile when + // the live table can still serve it. Treat a throw as a coverage miss and fall + // straight through to the live `date_trunc` aggregate — the same posture the + // batched series reader already takes, so the single-type route and the batch + // route no longer diverge on a rollup hiccup. + let rollupRows: Awaited>; + try { + rollupRows = await readRollup(); + } catch (err) { + annotate({ + meta: { + rollup_read_threw: true, + rollup_read_error: err instanceof Error ? err.message : String(err), + type, + }, + }); + return readLiveDaily({ userId, type, from, to, cap, priorityJson }); + } // Coverage-mismatch probe + inline fold (identical to the route). const windowMs = to.getTime() - from.getTime();