diff --git a/src/lib/day/__tests__/notable-history-cache.test.ts b/src/lib/day/__tests__/notable-history-cache.test.ts new file mode 100644 index 000000000..dd050aea9 --- /dev/null +++ b/src/lib/day/__tests__/notable-history-cache.test.ts @@ -0,0 +1,121 @@ +/** + * The day view's comparison history is read once per record, zone, ladder + * and readable type set, and each day takes its own slice of it. + * + * Paging from one day to the next used to read the same two years again. + * 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"; + +const readLocalDailyStats = vi.fn(); +vi.mock("@/lib/day/daily-stats", () => ({ + readLocalDailyStats: (...a: unknown[]) => readLocalDailyStats(...a), +})); +vi.mock("@/lib/db", () => ({ + prisma: { $queryRaw: vi.fn(async () => []) }, +})); + +import type { MeasurementType } from "@/generated/prisma/enums"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; + +import { + NOTABLE_LOOKBACK_DAYS, + extremesInSeries, + loadNotableRange, +} from "../notable"; + +const TZ = "Europe/Berlin"; +const TODAY = userDayKey(new Date(), TZ); +const SPIKE = shiftDateKey(TODAY, -20); +const FIRST = shiftDateKey(TODAY, -900); + +/** Systolic 120 every day from FIRST to TODAY, 150 on SPIKE. */ +function value(day: string): number { + return day === SPIKE ? 150 : 120; +} + +beforeEach(() => { + __resetAllCachesForTests(); + readLocalDailyStats.mockReset(); + readLocalDailyStats.mockImplementation( + async (args: { types: MeasurementType[]; from: Date; to: Date }) => { + const days = new Map(); + const end = userDayKey(args.to, TZ); + for ( + let day = userDayKey(args.from, TZ); + day < end; + day = shiftDateKey(day, 1) + ) { + if (day >= FIRST && day <= TODAY) days.set(day, value(day)); + } + return new Map( + args.types + .filter((t) => t === "BLOOD_PRESSURE_SYS") + .map((t) => [t, days]), + ); + }, + ); +}); + +function load(day: string, extra: { floor?: Date } = {}) { + return loadNotableRange({ + userId: "user-1", + from: day, + to: day, + tz: TZ, + priorityJson: null, + typeVisible: (t) => t !== "BLOOD_GLUCOSE", + gaps: false, + extremeTypes: ["BLOOD_PRESSURE_SYS"], + firstValueTypes: [], + ...extra, + }); +} + +/** What a live read of the window gives. */ +function expected(day: string) { + const series: Array<[string, number]> = []; + for ( + let d = shiftDateKey(day, -NOTABLE_LOOKBACK_DAYS); + d <= day; + d = shiftDateKey(d, 1) + ) { + if (d >= FIRST) series.push([d, value(d)]); + } + return extremesInSeries("BLOOD_PRESSURE_SYS", series, day); +} + +describe("notable history cache", () => { + it("reads the history once while paging through neighbouring days", async () => { + const spike = await load(SPIKE); + const before = await load(shiftDateKey(SPIKE, -1)); + const after = await load(shiftDateKey(SPIKE, 1)); + + expect(readLocalDailyStats).toHaveBeenCalledTimes(1); + expect(spike).toEqual(expected(SPIKE)); + expect(spike.map((n) => n.kind)).toEqual(["extremeHigh"]); + expect(before).toEqual(expected(shiftDateKey(SPIKE, -1))); + expect(after).toEqual(expected(shiftDateKey(SPIKE, 1))); + }); + + it("never reads a type the modules switch off", async () => { + await load(SPIKE); + const [args] = readLocalDailyStats.mock.calls[0] as [ + { types: MeasurementType[] }, + ]; + expect(args.types).not.toContain("BLOOD_GLUCOSE"); + expect(args.types).toContain("BLOOD_PRESSURE_SYS"); + }); + + it("reads live from a lookback floor inside the window", async () => { + const floor = new Date(Date.now() - 60 * 86_400_000); + await load(SPIKE, { floor }); + await load(SPIKE, { floor }); + + expect(readLocalDailyStats).toHaveBeenCalledTimes(2); + const [args] = readLocalDailyStats.mock.calls[0] as [{ from: Date }]; + expect(args.from).toEqual(floor); + }); +}); diff --git a/src/lib/day/notable.ts b/src/lib/day/notable.ts index 527c8d60d..4f275680a 100644 --- a/src/lib/day/notable.ts +++ b/src/lib/day/notable.ts @@ -25,10 +25,13 @@ * thousands of background samples, which says little as an extreme and * costs a scan of every one of them over two years. */ +import { createHash } from "node:crypto"; + import type { MeasurementType } from "@/generated/prisma/enums"; import type { DayNotable } from "@/lib/day/contract"; import { readLocalDailyStats, type DailyStats } from "@/lib/day/daily-stats"; +import { caches, type ServerCache } from "@/lib/cache/server-cache"; import { prisma } from "@/lib/db"; import { startOfLocalDayKey } from "@/lib/tz/local-day"; import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; @@ -184,6 +187,95 @@ export async function readFirstReadings( return out; } +/** Cached history starts on a block boundary, so neighbouring days share it. */ +const HISTORY_BLOCK_DAYS = 90; +const HISTORY_EPOCH = "1970-01-01"; +/** Measurement writes sweep the cell; the TTL only bounds an idle one. */ +const HISTORY_TTL_MS = 15 * 60_000; + +/** + * 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. + */ +async function readExtremeHistory(args: { + userId: string; + readable: readonly MeasurementType[]; + from: string; + to: string; + tz: string; + priorityJson: unknown; + floor: Date | null; +}): Promise { + const fromInstant = startOfLocalDayKey(args.from, args.tz); + const today = userDayKey(new Date(), args.tz); + const live = (from: Date) => + readLocalDailyStats({ + userId: args.userId, + types: args.readable, + from, + to: startOfLocalDayKey(shiftDateKey(args.to, 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); + + 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 = [ + args.userId, + "notable-history", + args.tz, + ladder, + [...args.readable].sort().join(","), + blockStart, + today, + ].join("|"); + const cache = caches.analytics as ServerCache; + 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); +} + +/** The days of `stats` inside `[from, to]`, a copy. */ +function sliceDays(stats: DailyStats, from: string, to: string): DailyStats { + const out: DailyStats = new Map(); + for (const [type, days] of stats) { + const kept = new Map(); + for (const [day, value] of days) { + if (day >= from && day <= to) kept.set(day, value); + } + out.set(type, kept); + } + return out; +} + /** * The observations for a window of local days `[from, to]`. * @@ -212,20 +304,18 @@ export async function loadNotableRange(args: { (t) => args.typeVisible(t) && (wanted === null || wanted.has(t)), ); const firstTypes = (args.firstValueTypes ?? types).filter(args.typeVisible); - let historyFrom = startOfLocalDayKey( - shiftDateKey(args.from, -NOTABLE_LOOKBACK_DAYS), - args.tz, - ); - if (args.floor && args.floor > historyFrom) historyFrom = args.floor; const [stats, firsts] = await Promise.all([ - readLocalDailyStats({ - userId: args.userId, - types, - from: historyFrom, - to: startOfLocalDayKey(shiftDateKey(args.to, 1), args.tz), - tz: args.tz, - priorityJson: args.priorityJson, - }), + types.length === 0 + ? new Map() + : readExtremeHistory({ + userId: args.userId, + readable: NOTABLE_EXTREME_TYPES.filter(args.typeVisible), + from: shiftDateKey(args.from, -NOTABLE_LOOKBACK_DAYS), + to: args.to, + tz: args.tz, + priorityJson: args.priorityJson, + floor: args.floor ?? null, + }), readFirstReadings(args.userId, firstTypes), ]);