mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user