fix(measurements): fall back to live SQL when the rollup read throws

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-08 23:41:52 +02:00
1 parent 62c7278275
commit 982fe53b51
2 files changed
+96 -12

No files matched your search

@@ -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);
});
});
+45 -12
View File
@@ -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<ReturnType<typeof readTieredRollupSeries>> = 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<ReturnType<typeof readRollup>>;
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();