perf(day): cache the notable history in fixed blocks

Each cell ran from a 90-day block boundary to today, so paging back through
days left a nearly full copy of the two years per boundary in an unweighted
bucket, and a cold day long ago read everything from its window to today.
The history is now cached in 90-day blocks that end before today (the block
holding today ends at today and carries it in its key), a window is put
together from the blocks it touches, and the blocks it misses are read in
one query. A cell holds at most one block of days per type.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 09:47:03 +02:00
1 parent 6eb7e508df
commit 5d1d6aa176
2 files changed
+139 -38

No files matched your search

@@ -6,7 +6,7 @@
* The slice has to give exactly what a live read of the window would: the
* extremes depend on where the series starts.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const readLocalDailyStats = vi.fn();
vi.mock("@/lib/day/daily-stats", () => ({
@@ -17,8 +17,8 @@ vi.mock("@/lib/db", () => ({
}));
import type { MeasurementType } from "@/generated/prisma/enums";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { __resetAllCachesForTests, caches } from "@/lib/cache/server-cache";
import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format";
import {
NOTABLE_LOOKBACK_DAYS,
@@ -74,6 +74,11 @@ function load(day: string, extra: { floor?: Date } = {}) {
});
}
/** The block a day falls in, as the cache counts them. */
function blockOf(day: string): number {
return Math.floor(daysBetweenDateKeys("1970-01-01", day) / 90);
}
/** What a live read of the window gives. */
function expected(day: string) {
const series: Array<[string, number]> = [];
@@ -93,7 +98,14 @@ describe("notable history cache", () => {
const before = await load(shiftDateKey(SPIKE, -1));
const after = await load(shiftDateKey(SPIKE, 1));
expect(readLocalDailyStats).toHaveBeenCalledTimes(1);
// One read, plus one for an earlier block when the oldest of the three
// windows starts across a block boundary.
const starts = new Set(
[-1, 0, 1].map((d) =>
blockOf(shiftDateKey(SPIKE, d - NOTABLE_LOOKBACK_DAYS)),
),
);
expect(readLocalDailyStats).toHaveBeenCalledTimes(starts.size);
expect(spike).toEqual(expected(SPIKE));
expect(spike.map((n) => n.kind)).toEqual(["extremeHigh"]);
expect(before).toEqual(expected(shiftDateKey(SPIKE, -1)));
@@ -119,3 +131,47 @@ describe("notable history cache", () => {
expect(args.from).toEqual(floor);
});
});
describe("notable history cache size", () => {
const cached: number[] = [];
beforeEach(() => {
cached.length = 0;
const set = caches.analytics.set.bind(caches.analytics);
vi.spyOn(caches.analytics, "set").mockImplementation((key, value, ttl) => {
if (key.includes("|notable-history|")) {
let days = 0;
for (const series of (
value as Map<string, Map<string, number>>
).values()) {
days = Math.max(days, series.size);
}
cached.push(days);
}
set(key, value, ttl);
});
});
afterEach(() => vi.restoreAllMocks());
it("holds a block of days per cell, and paging back a year adds a block per quarter", async () => {
for (let back = 0; back < 365; back += 1) {
const day = shiftDateKey(TODAY, -back);
expect(await load(day)).toEqual(expected(day));
}
// Two years of history plus the year paged through, in 90-day blocks,
// each cached once: about thirteen cells, never one per day.
const blocks =
blockOf(TODAY) -
blockOf(shiftDateKey(TODAY, -364 - NOTABLE_LOOKBACK_DAYS)) +
1;
expect(cached.length).toBe(blocks);
expect(Math.max(...cached)).toBeLessThanOrEqual(90);
expect(readLocalDailyStats.mock.calls.length).toBeLessThanOrEqual(blocks);
});
it("reads a cold day long ago up to that day's block, not up to today", async () => {
const day = shiftDateKey(TODAY, -700);
expect(await load(day)).toEqual(expected(day));
const [args] = readLocalDailyStats.mock.calls[0] as [{ to: Date }];
expect(userDayKey(args.to, TZ) <= shiftDateKey(day, 91)).toBe(true);
});
});
+79 -34
View File
@@ -187,23 +187,41 @@ export async function readFirstReadings(
return out;
}
/** Cached history starts on a block boundary, so neighbouring days share it. */
/**
* The history is cached in fixed blocks of local days counted from the
* epoch, so neighbouring days share them and a block's size never grows.
*/
const HISTORY_BLOCK_DAYS = 90;
const HISTORY_EPOCH = "1970-01-01";
/** Measurement writes sweep the cell; the TTL only bounds an idle one. */
/** Measurement writes sweep the cells; the TTL only bounds an idle one. */
const HISTORY_TTL_MS = 15 * 60_000;
/** The first day of the block holding `day`. */
function blockStartOf(day: string): string {
const offset = daysBetweenDateKeys(HISTORY_EPOCH, day);
return shiftDateKey(
HISTORY_EPOCH,
Math.floor(offset / HISTORY_BLOCK_DAYS) * HISTORY_BLOCK_DAYS,
);
}
/**
* The daily series behind the extremes, for the local days `[from, to]`.
*
* Every day view compares its day with the two years before it, so paging
* from one day to the next read the same two years again. The history is
* cached per record, zone, source ladder and readable type set, from a block
* boundary at or before `from` up to today, and each caller takes its own
* slice: a day's value does not depend on the window around it, and a slice
* cut at local day keys is exactly the window the live read would see. A
* type the record's modules switch off is never read. A lookback floor
* inside the window, or a window that reaches past today, reads live.
* cached per record, zone, source ladder and readable type set in blocks of
* {@link HISTORY_BLOCK_DAYS} days, and a window is put together from the
* blocks it touches: a day's value does not depend on the window around it,
* so a slice cut at local day keys is exactly what the live read would see.
* A block that ends before today is the same whatever day it is read on; the
* block holding today ends at today and carries today in its key. Each cell
* therefore holds at most one block of days per type, paging back reuses the
* blocks it already has instead of caching a near-full copy of the two years
* per block boundary, and a cold day long ago reads its own two years, not
* everything from there to today. The blocks a window misses are read in one
* query. A type the record's modules switch off is never read. A lookback
* floor inside the window, or a window that reaches past today, reads live.
*/
async function readExtremeHistory(args: {
userId: string;
@@ -216,51 +234,78 @@ async function readExtremeHistory(args: {
}): Promise<DailyStats> {
const fromInstant = startOfLocalDayKey(args.from, args.tz);
const today = userDayKey(new Date(), args.tz);
const live = (from: Date) =>
const read = (from: Date, lastDay: string) =>
readLocalDailyStats({
userId: args.userId,
types: args.readable,
from,
to: startOfLocalDayKey(shiftDateKey(args.to, 1), args.tz),
to: startOfLocalDayKey(shiftDateKey(lastDay, 1), args.tz),
tz: args.tz,
priorityJson: args.priorityJson,
});
if (args.floor && args.floor > fromInstant) return live(args.floor);
if (args.to > today) return live(fromInstant);
if (args.floor && args.floor > fromInstant) return read(args.floor, args.to);
if (args.to > today) return read(fromInstant, args.to);
const offset = daysBetweenDateKeys(HISTORY_EPOCH, args.from);
const blockStart = shiftDateKey(
HISTORY_EPOCH,
Math.floor(offset / HISTORY_BLOCK_DAYS) * HISTORY_BLOCK_DAYS,
);
const ladder = createHash("sha256")
.update(JSON.stringify(args.priorityJson ?? null))
.digest("base64url")
.slice(0, 16);
const key = [
const prefix = [
args.userId,
"notable-history",
args.tz,
ladder,
[...args.readable].sort().join(","),
blockStart,
today,
].join("|");
const cache = caches.analytics as ServerCache<DailyStats>;
const { value } = await cache.wrap(
key,
() =>
readLocalDailyStats({
userId: args.userId,
types: args.readable,
from: startOfLocalDayKey(blockStart, args.tz),
to: startOfLocalDayKey(shiftDateKey(today, 1), args.tz),
tz: args.tz,
priorityJson: args.priorityJson,
}),
HISTORY_TTL_MS,
);
return sliceDays(value, args.from, args.to);
const blocks: Array<{ start: string; end: string; key: string }> = [];
for (
let start = blockStartOf(args.from);
start <= args.to;
start = shiftDateKey(start, HISTORY_BLOCK_DAYS)
) {
const last = shiftDateKey(start, HISTORY_BLOCK_DAYS - 1);
const open = last >= today;
blocks.push({
start,
end: open ? today : last,
key: `${prefix}|${start}|${open ? today : "closed"}`,
});
}
const found = new Map<string, DailyStats>();
const missing = blocks.filter((block) => {
const hit = cache.get(block.key);
if (hit) found.set(block.key, hit);
return !hit;
});
if (missing.length > 0) {
const first = missing[0]!;
const last = missing[missing.length - 1]!;
const stats = await read(
startOfLocalDayKey(first.start, args.tz),
last.end,
);
for (const block of blocks) {
if (block.start < first.start || block.end > last.end) continue;
const cell = sliceDays(stats, block.start, block.end);
cache.set(block.key, cell, HISTORY_TTL_MS);
found.set(block.key, cell);
}
}
const out: DailyStats = new Map();
for (const block of blocks) {
for (const [type, days] of found.get(block.key) ?? new Map()) {
const into = out.get(type) ?? new Map<string, number>();
for (const [day, value] of days) {
if (day >= args.from && day <= args.to) into.set(day, value);
}
out.set(type, into);
}
}
return out;
}
/** The days of `stats` inside `[from, to]`, a copy. */