fix(rollups): step one tier finer when the coarse tier comes back empty

The granularity router read only the coarsest tier the window cleared, and
a coarse bucket that starts before the window is left out. Data that sat
only in that leading partial bucket, such as three months at the start of
a 731-day window, read as no data at all. The router now steps one tier
finer until a tier has buckets, down to DAY.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 22:10:45 +02:00
1 parent bb12d942c0
commit 6769f90f4c
2 files changed
+52 -19

No files matched your search

@@ -213,7 +213,33 @@ describe("readBestGranularityRollups", () => {
it("returns null when the window holds no buckets", async () => {
const result = await readBestGranularityRollups("user", "WEIGHT", 1095);
expect(result).toBeNull();
expect(queryRaw).toHaveBeenCalledTimes(1);
// YEAR, MONTH and WEEK folds, then the DAY tier.
expect(queryRaw).toHaveBeenCalledTimes(3);
expect(findMany).toHaveBeenCalledTimes(1);
});
it("steps one tier finer when the data sits only in the leading partial bucket", async () => {
// A 731-day window on 2026-09-26 starts 2024-09-25. October to December
// 2024 fold into the YEAR bucket 2024-01-01, which starts before the
// window and is left out; the MONTH tier starts inside it.
vi.setSystemTime(new Date("2026-09-26T12:00:00.000Z"));
const readings: Array<[string, number]> = [
["2024-10-05", 80],
["2024-11-05", 81],
["2024-12-05", 82],
];
queryRaw
.mockResolvedValueOnce([folded("2024-01-01T00:00:00.000Z", readings)])
.mockResolvedValueOnce(
readings.map(([day, value]) =>
folded(`${day.slice(0, 7)}-01T00:00:00.000Z`, [[day, value]]),
),
);
const result = await readBestGranularityRollups("user", "WEIGHT", 731);
expect(result?.granularity).toBe("MONTH");
expect(result?.rows.map((r) => r.mean)).toEqual([80, 81, 82]);
expect(sqlOf(queryRaw.mock.calls[0])).toContain("'year'");
expect(sqlOf(queryRaw.mock.calls[1])).toContain("'month'");
});
it("composes a coarse bucket's mean, spread and slope from its days", async () => {
+25 -18
View File
@@ -125,25 +125,32 @@ export async function readBestGranularityRollups(
userPriorityJson !== undefined
? userPriorityJson
: await loadUserSourcePriority(userId);
// Every tier is folded from the same canonical DAY buckets, so a tier
// either has coverage or none has: pick the coarsest floor the window
// clears and read it once.
const floor = GRANULARITY_FLOORS.find((f) => windowDays >= f.minWindowDays);
if (!floor) return null;
const rows = await readGranularity(
userId,
type,
floor.granularity,
since,
// Trailing-window semantics: no upper bound. This router serves the
// "last N days to now" probes (summaries-slice / health-score); the
// requested-window bounding lives on `readTieredRollupSeries`.
null,
priority,
// Every tier is folded from the same canonical DAY buckets, but a coarse
// bucket that starts before `since` is left out (it holds only part of its
// days). Data that sits only in that leading partial bucket, say three
// months at the start of a 731-day window, reads as empty at YEAR: step one
// tier finer until a tier has buckets, down to DAY, which starts inside the
// window by construction.
const floors = GRANULARITY_FLOORS.filter(
(f) => windowDays >= f.minWindowDays,
);
return rows && rows.length > 0
? { granularity: floor.granularity, rows }
: null;
for (const floor of floors) {
const rows = await readGranularity(
userId,
type,
floor.granularity,
since,
// Trailing-window semantics: no upper bound. This router serves the
// "last N days to now" probes (summaries-slice / health-score); the
// requested-window bounding lives on `readTieredRollupSeries`.
null,
priority,
);
if (rows && rows.length > 0) {
return { granularity: floor.granularity, rows };
}
}
return null;
}
/**