mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
This commit is contained in:
1 parent
6026a28fe4
commit
04855b1a67
2 files changed
+224
-13
No files matched your search
@@ -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<string, number>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
+103
-13
@@ -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<DailyStats> {
|
||||
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<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);
|
||||
}
|
||||
|
||||
/** 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<string, number>();
|
||||
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),
|
||||
]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user