From 04855b1a67bfef17726a44a701b493fdbf5a408c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 03:36:44 +0200 Subject: [PATCH] perf(day): read the day view's comparison history once while paging Every day view compares its day with the two years before it to find extremes, so stepping from one day to the next read the same two years of readings again. The history is now cached per record, zone, source ladder and readable type set, starting on a 90-day block boundary so neighbouring days share one cell, and each day takes its own slice cut at local day keys, which is exactly the window a live read would see. Measurement writes sweep the cell with the rest of the analytics bucket. A lookback floor inside the window and a window reaching past today still read live, and a type the modules switch off is never read. --- .../__tests__/notable-history-cache.test.ts | 121 ++++++++++++++++++ src/lib/day/notable.ts | 116 +++++++++++++++-- 2 files changed, 224 insertions(+), 13 deletions(-) create mode 100644 src/lib/day/__tests__/notable-history-cache.test.ts 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), ]);