Merge branch 'release/v1.45.0' into feat/v145-backup

# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 09:59:37 +02:00
commit 0cefac9d68
94 files changed
+14850 -658

No files matched your search

+6
View File
@@ -28,6 +28,12 @@ playwright/.cache
playwright.config.ts
vitest.config.mts
vitest.integration.config.mts
vitest.mutation.config.mts
# The unit setup imports tests/ (excluded above); the image never runs it and
# the build's type check must not see a half-present test harness.
vitest.setup.ts
stryker.config.mjs
stryker.floors.json
tests
tests/
e2e
+3
View File
@@ -108,6 +108,9 @@ RUN addgroup --system --gid 1001 nodejs && \
# Copy built assets
COPY --from=builder /app/public ./public
# The server reads its message catalogs from here on first use, one locale at
# a time (src/lib/i18n/server-catalogs.ts); they are not bundled.
COPY --from=builder /app/messages ./messages
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Next's standalone output must carry the externalized worker dependencies.
+6
View File
@@ -508,6 +508,11 @@ services:
# support report and Admin > System status say so. On a busy instance,
# raise it with APP_MEMORY_LIMIT in .env; do not lower it.
mem_limit: "${APP_MEMORY_LIMIT:-1536m}"
# On `docker stop` (a redeploy) the app stops taking requests, lets the
# requests in flight finish and gives running jobs up to 30 s to end
# before it exits. Docker's default of 10 s cut that short and left
# jobs marked active until their expiry, holding exclusive queues.
stop_grace_period: 45s
healthcheck:
test:
[
@@ -564,6 +569,7 @@ services:
# max-size: "10m"
# max-file: "3"
# mem_limit: "${WORKER_MEMORY_LIMIT:-1536m}"
# stop_grace_period: 45s
db:
# Pinned to the concrete minor instead of the floating `16-alpine`
+36
View File
@@ -68,6 +68,42 @@ The output is `<label>.json` (every number, plus the commit, Node and Postgres v
Run on a quiet machine; a parallel build or test run moves latency by tens of percent and leaves the statement counts alone. Compare statement counts across machines, latency only across runs on the same machine. A local number is only valid under the production heap ceiling, which the script sets.
## Boot and idle-memory profile
`scripts/bench/boot-profile.ts` profiles a production start the way production runs it: `next start` on the build, every role (web and worker), the production heap ceiling (`--max-old-space-size=792`, the heap Node derives in the 1.5 GB container), against the seeded benchmark database.
```sh
pnpm dlx tsx scripts/bench/boot-profile.ts --idle-seconds 300 --out scripts/bench/results/<label>-boot
```
It records:
- the time to the first answer from `/api/health` and to the worker's `startup` line;
- every `event_loop_stall` the process logs, with the uptime, startup phase, boot step and work in flight it names;
- every `runtime.heap.sampled` line, and every full collection from `--trace-gc` (used heap before, live heap after);
- a CPU profile of the start: the main thread's busy stretches over 50 ms without an idle sample, each with its self time per source module, resolved through the chunk source maps (`profile-analyze.ts`);
- one heap snapshot after the idle wait, taken with `--heapsnapshot-signal` (after a full collection, so it is the live heap), summarised by node type, constructor, largest strings and the largest retained subtrees with a retaining path, and the number of parsed locale catalogs.
The worker runs for the whole idle wait, and its boot passes fold and consolidate the seeded account (old dense readings into their `stats:` rows), roughly halving its row count. Reseed before the next request benchmark, or its numbers describe a different account.
The raw `.cpuprofile` and `.heapsnapshot` go to a temporary directory and are deleted after the summary unless `--keep` is given. Only run it against the benchmark database: a snapshot holds whatever the process decrypted. `--no-snapshot` and `--no-cpu-prof` skip either part. The profiler itself costs memory, so read RSS from this run as an upper bound.
### What the profile found (v1.44 runtime, October 2026)
On the seeded account, idle, with the worker running and no requests (`results/v1.45.0-before-boot.md`):
- **The boot stall is module evaluation.** The longest busy stretch of the main thread was 753 ms, 0.7 s into the start: the instrumentation hook importing the worker, whose five registrars pulled practically all of `src/lib` into one synchronous evaluation (`register-maintenance.ts` alone 280 ms inclusive), followed by the first `/api/health` request compiling its route. The event-loop monitor started after the key and time-zone checks, and a 250 ms tick measures a block by how late the next tick fires, so it reported less than the block or nothing. The beta's 718 ms and 671 ms stalls are this block on a slower machine.
- **The live heap is about 165 MB, not 400.** Full collections brought the heap back to 162 to 172 MB every 15 to 20 s while idle; the used heap between them reached 190 MB. What a heap line shows above the live heap is garbage awaiting the next full collection, plus, on a server with users, the server caches (13 caches of up to 2,000 entries with stale windows of up to an hour). The beta's 400 MB plateau is therefore not a baseline the process cannot shed; it is what the heap grows to under a large limit before a collection, plus caches.
- **What the live heap holds.** Of 172 MB: the query compiler about 50 MB (the engine object 34 MB, its WebAssembly base64 source twice at 4.4 MB, its chunk source twice at 5.6 MB); the locale catalogs about 33 MB (32 parsed catalog objects, eight locales in each of four server bundles, 14 MB, plus the eight-locale chunk source twice at 9.7 MB); module source strings over 50 KB about 44 MB in total (the catalog chunks included); compiled code 19 MB.
### What changed (v1.45.0)
- The event-loop monitor starts first in `register()`, and a stall line names the boot step, startup phase and work in flight (`src/lib/boot/boot-steps.ts`, one `boot.steps` line per boot with each step's duration).
- The worker's registrars are imported one at a time after `boss.start()`, with a turn of the event loop between them.
- The server reads a locale catalog from `messages/<locale>.json` on first use and keeps one parsed copy per process (`src/lib/i18n/server-catalogs.ts`); the seven matchers that index every locale build their index on first use.
Result on the same machine (`results/v1.45.0-boot.md`): the worker graph loads in stretches of at most 246 ms instead of one of 753 ms (the longest application stretch left is the query compiler's start, 336 ms, unchanged); the live heap after five idle minutes went from 162 to 172 MB to 142 to 153 MB; two parsed catalogs (1.6 MB) instead of 32 (14.1 MB), and no catalog chunk sources.
## Statement budgets in the integration suite
`tests/integration/query-budget-hot-routes.test.ts` seeds a small account (four months of the main types with overlapping sources, sleep, glucose, a medication, workouts and mood), and counts the statements of one request per hot route on a cache miss: `/api/auth/me`, the dashboard summary and snapshot, the pulse series over 30, 365 and 3650 days, the day view, insights health status, correlations, and a 500-row batch ingest.
+404
View File
@@ -0,0 +1,404 @@
/**
* Boot and idle-memory profile of the production build.
*
* DATABASE_URL=postgresql://… API_TOKEN_HMAC_KEY=… ENCRYPTION_KEYS=… \
* ENCRYPTION_ACTIVE_KEY_ID=… pnpm dlx tsx scripts/bench/boot-profile.ts \
* [--idle-seconds 300] [--heap-mb 792] [--port 3101] \
* [--no-snapshot] [--no-cpu-prof] [--out scripts/bench/results/<label>-boot]
*
* Starts `next start` with every role (web and worker, as production runs it)
* under the production heap ceiling, against the seeded benchmark database
* (see docs/contributing/benchmarks.md; never a database with real data: a
* heap snapshot holds whatever the process decrypted). It records:
*
* - the time to the first healthy `/api/health` and to `[boot] background
* role ready`;
* - every `event_loop_stall` the process logs, with the task it names;
* - every `runtime.heap.sampled` line;
* - with `--cpu-prof` (default on), the busy stretches of the main thread
* over 100 ms during the boot, each with its self time by source module;
* - with a snapshot (default on), one heap snapshot after the idle wait
* (taken with `--heapsnapshot-signal`, so after a full GC: the live heap),
* summarised by node type, constructor, largest strings and the largest
* retained subtrees with a retaining path.
*
* The raw `.cpuprofile` and `.heapsnapshot` are written to a temporary
* directory and deleted after the summary unless `--keep` is given.
*
* The worker's boot passes fold the seeded account while it idles; reseed
* before running `run.ts` again.
*
* Output: `<out>.json` and `<out>.md`.
*/
import { execSync, spawn } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { cpus, loadavg, tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { longTasks, selfTimeByModule, summariseHeap } from "./profile-analyze";
function arg(name: string, fallback: string): string {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback;
}
const flag = (name: string) => process.argv.includes(`--${name}`);
const IDLE_SECONDS = Number(arg("idle-seconds", "300"));
const HEAP_MB = Number(arg("heap-mb", "792"));
const PORT = Number(arg("port", "3101"));
const OUT = arg("out", join("scripts", "bench", "results", "latest-boot"));
const SNAPSHOT = !flag("no-snapshot");
const CPU_PROF = !flag("no-cpu-prof");
const KEEP = flag("keep");
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function find(obj: unknown, key: string): unknown {
if (!obj || typeof obj !== "object") return undefined;
const rec = obj as Record<string, unknown>;
if (key in rec) return rec[key];
for (const v of Object.values(rec)) {
const hit = find(v, key);
if (hit !== undefined) return hit;
}
return undefined;
}
async function main(): Promise<void> {
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required");
const work = mkdtempSync(join(tmpdir(), "hl-boot-profile-"));
const nodeOptions = [
`--max-old-space-size=${HEAP_MB}`,
...(CPU_PROF ? ["--cpu-prof", `--cpu-prof-dir=${work}`] : []),
...(SNAPSHOT ? ["--heapsnapshot-signal=SIGUSR2"] : []),
].join(" ");
const started = Date.now();
const child = spawn(
process.execPath,
[
// One line per collection; the mark-compacts say how much of the used
// heap was live and how much was garbage waiting for a full GC. Not
// allowed in NODE_OPTIONS, and `next start` serves in this process.
"--trace-gc",
"node_modules/next/dist/bin/next",
"start",
"-p",
String(PORT),
"-H",
"127.0.0.1",
],
{
cwd: process.cwd(),
env: {
...process.env,
NODE_ENV: "production",
PORT: String(PORT),
HOSTNAME: "127.0.0.1",
HEALTHLOG_PROCESS_TYPE: "all",
SESSION_COOKIE_SECURE: "false",
NODE_OPTIONS: nodeOptions,
},
stdio: ["ignore", "pipe", "pipe"],
},
);
// Node writes the snapshot into the process's cwd.
const snapshotDir = process.cwd();
const stalls: Array<Record<string, unknown>> = [];
const heapLines: Array<Record<string, unknown>> = [];
const bootLines: string[] = [];
const markCompacts: Array<{
atMs: number;
beforeMb: number;
afterMb: number;
}> = [];
let readyAtMs: number | null = null;
let snapshotTaken = false;
let buffer = "";
const onLine = (line: string) => {
const gc =
/Mark-Compact(?: \(reduce\))? ([\d.]+) \([\d.]+\) -> ([\d.]+) \(/.exec(
line,
);
if (gc) {
markCompacts.push({
atMs: Date.now() - started,
beforeMb: Number(gc[1]),
afterMb: Number(gc[2]),
});
return;
}
if (line.includes("[boot]")) {
bootLines.push(`${Date.now() - started} ms: ${line.slice(0, 200)}`);
if (line.includes("background role ready"))
readyAtMs = Date.now() - started;
}
if (!line.startsWith("{")) return;
try {
const evt = JSON.parse(line) as Record<string, unknown>;
const task = find(evt, "task_name");
if (task === "startup" && readyAtMs === null) {
readyAtMs = Date.now() - started;
}
if (task === "event_loop_stall") {
// The snapshot itself blocks the process for seconds.
if (snapshotTaken) return;
stalls.push({
atMs: Date.now() - started,
...(find(evt, "result") as object),
});
} else if (task === "runtime.heap.sampled") {
heapLines.push({
atMs: Date.now() - started,
...((find(evt, "meta") as object) ?? {}),
});
} else if (typeof task === "string" && task.startsWith("boot.")) {
bootLines.push(
`${Date.now() - started} ms: ${task} ${JSON.stringify(find(evt, "meta") ?? {})}`.slice(
0,
400,
),
);
}
} catch {
// not a wide event
}
};
child.stdout!.on("data", (d: Buffer) => {
buffer += d.toString();
let nl: number;
while ((nl = buffer.indexOf("\n")) >= 0) {
onLine(buffer.slice(0, nl));
buffer = buffer.slice(nl + 1);
}
});
child.stderr!.on("data", (d: Buffer) => {
for (const line of d.toString().split("\n"))
if (line.includes("[boot]")) onLine(line);
});
let healthyAtMs: number | null = null;
const deadline = Date.now() + 180_000;
while (Date.now() < deadline && healthyAtMs === null) {
if (child.exitCode !== null)
throw new Error(`server exited ${child.exitCode}`);
try {
const res = await fetch(`http://127.0.0.1:${PORT}/api/health`);
if (res.status < 500 || res.status === 503) {
const body = await res.text();
if (res.ok || body.includes("starting"))
healthyAtMs = Date.now() - started;
}
} catch {
// not listening yet
}
await sleep(100);
}
while (readyAtMs === null && Date.now() < deadline) await sleep(200);
console.log(
`health ${healthyAtMs} ms, worker ready ${readyAtMs} ms; idling ${IDLE_SECONDS} s`,
);
await sleep(IDLE_SECONDS * 1000);
let heap: ReturnType<typeof summariseHeap> | null = null;
if (SNAPSHOT) {
const before = new Set(readdirSync(snapshotDir));
snapshotTaken = true;
child.kill("SIGUSR2");
let file = "";
let lastSize = -1;
for (let i = 0; i < 600; i++) {
await sleep(1000);
const f = readdirSync(snapshotDir).find(
(n) => n.endsWith(".heapsnapshot") && !before.has(n),
);
if (!f) continue;
const size = statSync(join(snapshotDir, f)).size;
if (size === lastSize && size > 0) {
file = join(snapshotDir, f);
break;
}
lastSize = size;
}
if (file) {
// A snapshot is finished when the process answers again.
await fetch(`http://127.0.0.1:${PORT}/api/health`).catch(() => undefined);
const moved = join(work, "idle.heapsnapshot");
execSync(`mv ${JSON.stringify(file)} ${JSON.stringify(moved)}`);
console.log("summarising heap snapshot");
heap = summariseHeap(moved, {
"parsed locale catalogs": ["common", "apiErrors", "doctorReport"],
});
}
}
const exited = new Promise((r) => child.once("exit", r));
child.kill("SIGTERM");
await Promise.race([exited, sleep(60_000)]);
if (child.exitCode === null) child.kill("SIGKILL");
let tasks: ReturnType<typeof longTasks> = [];
let bootSelf: Array<[string, number]> = [];
if (CPU_PROF) {
await sleep(1000);
const profiles = readdirSync(work).filter((n) => n.endsWith(".cpuprofile"));
// The largest profile is the server process (`next start` may run a
// small parent).
const main = profiles
.map((n) => join(work, n))
.sort((a, b) => statSync(b).size - statSync(a).size)[0];
if (main) {
const profile = JSON.parse(readFileSync(main, "utf8"));
tasks = longTasks(profile, 50, 0.5).filter((t) => t.startMs < 120_000);
bootSelf = selfTimeByModule(
profile,
0,
(readyAtMs ?? 30_000) + 5_000,
).slice(0, 40);
}
}
if (!KEEP) rmSync(work, { recursive: true, force: true });
else console.log(`raw files kept in ${work}`);
let commit = "unknown";
try {
commit = execSync("git rev-parse --short HEAD").toString().trim();
} catch {
// not a checkout
}
const meta = {
label: OUT.split("/").pop(),
commit,
date: new Date().toISOString(),
node: process.version,
cpus: cpus().length,
loadAverage: loadavg().map((n) => Math.round(n * 10) / 10),
heapMb: HEAP_MB,
idleSeconds: IDLE_SECONDS,
healthyAtMs,
readyAtMs,
stallMaxMs: Math.max(0, ...stalls.map((s) => Number(s.lag_ms ?? 0))),
};
mkdirSync(dirname(OUT), { recursive: true });
// Paths in frame and retainer names are made relative to the checkout,
// before anything shortens them.
const relative = (text: string) => text.split(`${process.cwd()}/`).join("");
if (heap) heap = JSON.parse(relative(JSON.stringify(heap))) as typeof heap;
tasks = JSON.parse(relative(JSON.stringify(tasks))) as typeof tasks;
writeFileSync(
`${OUT}.json`,
relative(
`${JSON.stringify({ meta, bootLines, stalls, heapLines, markCompacts, tasks, bootSelf, heap }, null, 2)}\n`,
),
);
const md: string[] = [
`# Boot profile ${String(meta.label)}`,
"",
`Commit ${commit}, ${meta.date}. Production build, every role, heap ceiling ${HEAP_MB} MB, ${IDLE_SECONDS} s idle. ${meta.cpus} cores, load average ${meta.loadAverage.join(",")} at the end.`,
"",
`- first answer from /api/health: ${healthyAtMs} ms; background role ready: ${readyAtMs} ms`,
`- event-loop stalls logged: ${stalls.length}, max ${meta.stallMaxMs} ms`,
"",
"## Stalls",
"",
...stalls.map((s) => `- ${JSON.stringify(s)}`),
"",
"## Heap samples",
"",
"| at s | used MB | old | large obj | code | external | array buffers | rss |",
"| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
...heapLines.map(
(h) =>
`| ${Math.round(Number(h.atMs) / 1000)} | ${h.heap_used_mb} | ${h.old_space_mb} | ${h.large_object_space_mb} | ${h.code_space_mb} | ${h.external_mb} | ${h.array_buffers_mb} | ${h.rss_mb} |`,
),
"",
"## Full collections (mark-compact)",
"",
`${markCompacts.length} mark-compacts during the run. Used heap before the collection, and what survived it (the live heap):`,
"",
"| at s | before MB | after MB |",
"| ---: | ---: | ---: |",
...markCompacts
.filter(
(m, i, all) =>
i === all.length - 1 || i % Math.ceil(all.length / 15) === 0,
)
.map(
(m) =>
`| ${Math.round(m.atMs / 1000)} | ${m.beforeMb} | ${m.afterMb} |`,
),
"",
"## Main-thread busy stretches over 50 ms without an idle sample, first two minutes",
"",
...tasks.flatMap((t) => [
`### at ${t.startMs} ms, ${t.durationMs} ms`,
"",
"| self time by module | ms |",
"| --- | ---: |",
...t.byModule.slice(0, 10).map(([m, v]) => `| ${m} | ${v} |`),
"",
"| inclusive, application modules | ms |",
"| --- | ---: |",
...t.byEntry.slice(0, 10).map(([m, v]) => `| ${m} | ${v} |`),
"",
]),
"## Self time by module until the worker is ready",
"",
"| module | ms |",
"| --- | ---: |",
...bootSelf.slice(0, 25).map(([m, v]) => `| ${m} | ${v} |`),
"",
];
if (heap) {
md.push(
`## Heap snapshot after ${IDLE_SECONDS} s idle (live heap after a full GC)`,
"",
`${heap.totalMb} MB in ${heap.nodeCount} nodes; module source strings over 50 KB: ${heap.sourceStringsMb} MB.`,
"",
...Object.entries(heap.probes).map(
([k, v]) => `- ${k}: ${v.count} objects, ${v.retainedMb} MB retained`,
),
"",
"| node type | MB |",
"| --- | ---: |",
...heap.byType.map(([k, v]) => `| ${k} | ${v} |`),
"",
"| constructor | MB | count |",
"| --- | ---: | ---: |",
...heap.byConstructor
.slice(0, 20)
.map(([k, v, c]) => `| ${k.replace(/\|/g, "/")} | ${v} | ${c} |`),
"",
"| largest retained subtrees | MB | retaining path |",
"| --- | ---: | --- |",
...heap.topRetainers
.slice(0, 30)
.map(
(r) =>
`| ${r.name.replace(/\|/g, "/")} | ${r.mb} | ${r.path.replace(/\|/g, "/").slice(0, 160)} |`,
),
"",
"| largest strings | MB |",
"| --- | ---: |",
...heap.largestStrings
.slice(0, 15)
.map(([s, t]) => `| ${t.replace(/\|/g, "/").slice(0, 90)} | ${s} |`),
"",
);
}
writeFileSync(`${OUT}.md`, relative(md.join("\n")));
console.log(`wrote ${OUT}.json and ${OUT}.md`);
}
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
+599
View File
@@ -0,0 +1,599 @@
/**
* Readers for the two files `boot-profile.ts` collects: a V8 CPU profile
* (`.cpuprofile`) and a heap snapshot (`.heapsnapshot`). Both are plain
* JSON; these turn them into the few tables a boot investigation needs, so a
* before/after comparison does not depend on opening DevTools by hand.
*
* Frames are attributed to source modules through the module ids Turbopack
* writes into every server chunk (`"[project]/src/lib/x.ts [app-route] …"`):
* a sample at chunk line N belongs to the last module id that starts at or
* before N.
*/
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
// ---------------------------------------------------------------- modules
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const B64_INDEX = new Map([...B64].map((c, i) => [c, i]));
interface ChunkMap {
sources: string[];
/** Per generated line: [column, source index] pairs, ascending. */
lines: Array<Array<[number, number]>>;
}
const chunkMapCache = new Map<string, ChunkMap | null>();
/** Decode a chunk's source map down to (line, column) → source. */
function chunkMap(url: string): ChunkMap | null {
if (chunkMapCache.has(url)) return chunkMapCache.get(url)!;
let path = url;
try {
if (url.startsWith("file://")) path = fileURLToPath(url);
} catch {
// not a file URL
}
let result: ChunkMap | null = null;
if (existsSync(`${path}.map`)) {
type RawMap = {
sources?: string[];
mappings?: string;
sections?: Array<{ offset: { line: number }; map: RawMap }>;
};
const raw = JSON.parse(readFileSync(`${path}.map`, "utf8")) as RawMap;
const sources: string[] = [];
const lines: Array<Array<[number, number]>> = [];
// An index map (`sections`) holds one ordinary map per offset.
const flat: Array<{ line: number; map: RawMap }> = raw.sections
? raw.sections.map((s) => ({ line: s.offset.line, map: s.map }))
: [{ line: 0, map: raw }];
for (const { line: lineOffset, map } of flat) {
const base = sources.length;
sources.push(...(map.sources ?? []));
let src = 0;
let lineNo = lineOffset;
for (const line of (map.mappings ?? "").split(";")) {
const segs: Array<[number, number]> = lines[lineNo] ?? [];
let col = 0;
for (const seg of line.split(",")) {
if (!seg) continue;
const vals: number[] = [];
let shift = 0;
let value = 0;
for (const ch of seg) {
const d = B64_INDEX.get(ch)!;
value += (d & 31) << shift;
if (d & 32) shift += 5;
else {
const neg = value & 1;
value >>= 1;
vals.push(neg ? -value : value);
value = 0;
shift = 0;
}
}
col += vals[0] ?? 0;
if (vals.length > 1) {
src += vals[1]!;
segs.push([col, base + src]);
}
}
lines[lineNo] = segs;
lineNo++;
}
}
for (const segs of lines) segs?.sort((x, y) => x[0] - y[0]);
result = { sources, lines };
}
chunkMapCache.set(url, result);
return result;
}
function packageOf(path: string): string {
const parts = path.split("node_modules/");
const rest = parts[parts.length - 1]!.split("/");
return `npm:${rest[0]!.startsWith("@") ? `${rest[0]}/${rest[1]}` : rest[0]}`;
}
/** The source module a frame at (url, 0-based line, column) belongs to. */
export function moduleForFrame(url: string, line: number, column = 0): string {
if (!url) return "(native)";
if (url.startsWith("node:")) return url;
if (url.includes("/node_modules/") && !url.includes("/.next/")) {
return packageOf(url);
}
const map = chunkMap(url);
const segs = map?.lines[line];
if (map && segs && segs.length > 0) {
let lo = 0;
let hi = segs.length - 1;
let found = segs[0]![1];
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (segs[mid]![0] <= column) {
found = segs[mid]![1];
lo = mid + 1;
} else hi = mid - 1;
}
const source = (map.sources[found] ?? "")
.replace(/^turbopack:\/\/\/\[project\]\//, "")
.replace(/^(\.\.\/)+/, "");
if (source.includes("node_modules/")) return packageOf(source);
return source || url;
}
return url.replace(/^.*\/\.next\//, ".next/");
}
// ------------------------------------------------------------ CPU profile
interface CpuNode {
id: number;
callFrame: {
functionName: string;
url: string;
lineNumber: number;
columnNumber: number;
};
children?: number[];
}
interface CpuProfile {
nodes: CpuNode[];
startTime: number;
endTime: number;
samples: number[];
timeDeltas: number[];
}
export interface LongTask {
startMs: number;
durationMs: number;
/** Self time by source module, descending. */
byModule: Array<[string, number]>;
/** Self time by function, descending. */
byFunction: Array<[string, number]>;
/** Inclusive time of the outermost application frames. */
byEntry: Array<[string, number]>;
}
const IDLE = new Set(["(idle)", "(root)"]);
/**
* Busy stretches of the main thread: consecutive non-idle samples whose gaps
* stay under `gapMs`, longer than `minMs`. Each comes with its self time by
* module and by function.
*/
export function longTasks(
profile: CpuProfile,
minMs = 100,
gapMs = 5,
): LongTask[] {
const byId = new Map<number, CpuNode>();
const parent = new Map<number, number>();
for (const n of profile.nodes) {
byId.set(n.id, n);
for (const c of n.children ?? []) parent.set(c, n.id);
}
const tasks: LongTask[] = [];
let current: {
start: number;
end: number;
samples: Array<[number, number]>;
} | null = null;
const flush = () => {
if (!current) return;
const dur = (current.end - current.start) / 1000;
if (dur >= minMs) {
const mods = new Map<string, number>();
const fns = new Map<string, number>();
const entries = new Map<string, number>();
for (const [id, dt] of current.samples) {
const n = byId.get(id)!;
const cf = n.callFrame;
const mod = moduleForFrame(cf.url, cf.lineNumber, cf.columnNumber);
mods.set(mod, (mods.get(mod) ?? 0) + dt);
const fn = `${cf.functionName || "(anonymous)"} ${mod}`;
fns.set(fn, (fns.get(fn) ?? 0) + dt);
// Inclusive: every distinct module on the stack gets the sample once.
const seen = new Set<string>();
let cur: number | undefined = id;
while (cur !== undefined) {
const c = byId.get(cur)!.callFrame;
if (c.url) {
const m = moduleForFrame(c.url, c.lineNumber, c.columnNumber);
if (m.startsWith("src/") && !seen.has(m)) {
seen.add(m);
entries.set(m, (entries.get(m) ?? 0) + dt);
}
}
cur = parent.get(cur);
}
}
const top = (m: Map<string, number>) =>
[...m.entries()]
.map(([k, v]) => [k, Math.round(v / 100) / 10] as [string, number])
.sort((a, b) => b[1] - a[1])
.slice(0, 15);
tasks.push({
startMs: Math.round((current.start - profile.startTime) / 1000),
durationMs: Math.round(dur),
byModule: top(mods),
byFunction: top(fns),
byEntry: top(entries),
});
}
current = null;
};
let at = profile.startTime;
for (let i = 0; i < profile.samples.length; i++) {
at += profile.timeDeltas[i] ?? 0;
const id = profile.samples[i]!;
const name = byId.get(id)?.callFrame.functionName ?? "";
const dt = profile.timeDeltas[i + 1] ?? 0;
if (IDLE.has(name)) {
if (current && at - current.end > gapMs * 1000) flush();
continue;
}
if (!current) current = { start: at, end: at, samples: [] };
else if (at - current.end > gapMs * 1000) {
flush();
current = { start: at, end: at, samples: [] };
}
current.end = at + dt;
current.samples.push([id, dt]);
}
flush();
return tasks;
}
/** Self time per module over the whole profile, or a window of it. */
export function selfTimeByModule(
profile: CpuProfile,
fromMs = 0,
toMs = Infinity,
): Array<[string, number]> {
const byId = new Map(profile.nodes.map((n) => [n.id, n]));
const mods = new Map<string, number>();
let at = profile.startTime;
for (let i = 0; i < profile.samples.length; i++) {
at += profile.timeDeltas[i] ?? 0;
const rel = (at - profile.startTime) / 1000;
if (rel < fromMs || rel > toMs) continue;
const n = byId.get(profile.samples[i]!)!;
if (IDLE.has(n.callFrame.functionName)) continue;
const mod = moduleForFrame(
n.callFrame.url,
n.callFrame.lineNumber,
n.callFrame.columnNumber,
);
const dt = profile.timeDeltas[i + 1] ?? 0;
mods.set(mod, (mods.get(mod) ?? 0) + dt);
}
return [...mods.entries()]
.map(([k, v]) => [k, Math.round(v / 100) / 10] as [string, number])
.sort((a, b) => b[1] - a[1]);
}
// ---------------------------------------------------------- heap snapshot
interface HeapSnapshot {
snapshot: {
meta: {
node_fields: string[];
node_types: [string[], ...unknown[]];
edge_fields: string[];
edge_types: [string[], ...unknown[]];
};
node_count: number;
};
nodes: number[];
edges: number[];
strings: string[];
}
export interface HeapSummary {
totalMb: number;
nodeCount: number;
byType: Array<[string, number]>;
byConstructor: Array<[string, number, number]>;
largestStrings: Array<[number, string]>;
/** Retained size of the largest dominator subtrees below the GC roots. */
topRetainers: Array<{ mb: number; name: string; path: string }>;
/** Module source strings (whole chunk scripts) over 50 KB. */
sourceStringsMb: number;
/**
* For each probe, the objects that carry every listed property: how many,
* and their summed retained size (the outermost ones only).
*/
probes: Record<string, { count: number; retainedMb: number }>;
}
/**
* Self size by node type and constructor, the largest strings, and retained
* sizes from the dominator tree (Cooper, Harvey, Kennedy), with a retaining
* path for each of the largest subtrees.
*/
export function summariseHeap(
file: string,
probes: Record<string, string[]> = {},
): HeapSummary {
const snap = JSON.parse(readFileSync(file, "utf8")) as HeapSnapshot;
const nf = snap.snapshot.meta.node_fields;
const ef = snap.snapshot.meta.edge_fields;
const nodeTypes = snap.snapshot.meta.node_types[0];
const edgeTypes = snap.snapshot.meta.edge_types[0];
const NF = nf.length;
const EF = ef.length;
const iType = nf.indexOf("type");
const iName = nf.indexOf("name");
const iSelf = nf.indexOf("self_size");
const iEdges = nf.indexOf("edge_count");
const eType = ef.indexOf("type");
const eName = ef.indexOf("name_or_index");
const eTo = ef.indexOf("to_node");
const weak = edgeTypes.indexOf("weak");
const shortcut = edgeTypes.indexOf("shortcut");
const elementT = edgeTypes.indexOf("element");
const hiddenT = edgeTypes.indexOf("hidden");
const N = snap.nodes.length / NF;
const nodes = snap.nodes;
const edges = snap.edges;
const strings = snap.strings;
const firstEdge = new Uint32Array(N + 1);
for (let i = 0, e = 0; i < N; i++) {
firstEdge[i] = e;
e += nodes[i * NF + iEdges]! * EF;
}
firstEdge[N] = edges.length;
const self = (i: number) => nodes[i * NF + iSelf]!;
const typeOf = (i: number) => nodeTypes[nodes[i * NF + iType]!]!;
const nameOf = (i: number) => strings[nodes[i * NF + iName]!]!;
let total = 0;
const byType = new Map<string, number>();
const byCtor = new Map<string, [number, number]>();
const big: Array<[number, string]> = [];
let sourceStrings = 0;
for (let i = 0; i < N; i++) {
const s = self(i);
total += s;
const t = typeOf(i);
byType.set(t, (byType.get(t) ?? 0) + s);
if (t === "object" || t === "closure" || t === "array" || t === "native") {
const k = `${t}:${nameOf(i)}`.slice(0, 80);
const cur = byCtor.get(k) ?? [0, 0];
cur[0] += s;
cur[1] += 1;
byCtor.set(k, cur);
}
if (
t === "string" ||
t === "concatenated string" ||
t === "sliced string"
) {
if (s > 200_000)
big.push([s, nameOf(i).slice(0, 100).replace(/\s+/g, " ")]);
const nm = nameOf(i);
if (
s > 50_000 &&
/__turbopack_context__|"use strict";|module\.exports/.test(
nm.slice(0, 2000),
)
) {
sourceStrings += s;
}
}
}
// Graph for the dominator tree: strong edges only.
const isStrong = (et: number) => et !== weak && et !== shortcut;
// Post-order DFS from node 0.
const order = new Int32Array(N).fill(-1);
const post: number[] = [];
const stackN: number[] = [0];
const stackE: number[] = [firstEdge[0]!];
order[0] = -2;
while (stackN.length) {
const top = stackN.length - 1;
const n = stackN[top]!;
const e = stackE[top]!;
if (e < firstEdge[n + 1]!) {
stackE[top] = e + EF;
const et = edges[e + eType]!;
if (!isStrong(et)) continue;
const to = edges[e + eTo]! / NF;
if (order[to] === -1) {
order[to] = -2;
stackN.push(to);
stackE.push(firstEdge[to]!);
}
} else {
order[n] = post.length;
post.push(n);
stackN.pop();
stackE.pop();
}
}
const R = post.length;
// Predecessors in post-order numbering (CSR).
const predCount = new Uint32Array(R + 1);
for (let n = 0; n < N; n++) {
if (order[n]! < 0) continue;
for (let e = firstEdge[n]!; e < firstEdge[n + 1]!; e += EF) {
if (!isStrong(edges[e + eType]!)) continue;
const to = edges[e + eTo]! / NF;
if (order[to]! >= 0) predCount[order[to]! + 1]! += 1;
}
}
for (let i = 0; i < R; i++) predCount[i + 1]! += predCount[i]!;
const preds = new Uint32Array(predCount[R]!);
const fill = predCount.slice(0, R);
for (let n = 0; n < N; n++) {
if (order[n]! < 0) continue;
for (let e = firstEdge[n]!; e < firstEdge[n + 1]!; e += EF) {
if (!isStrong(edges[e + eType]!)) continue;
const to = edges[e + eTo]! / NF;
if (order[to]! >= 0) preds[fill[order[to]!]!++] = order[n]!;
}
}
const rootPo = order[0]!;
const idom = new Int32Array(R).fill(-1);
idom[rootPo] = rootPo;
let changed = true;
while (changed) {
changed = false;
for (let b = R - 1; b >= 0; b--) {
if (b === rootPo) continue;
let ni = -1;
for (let p = predCount[b]!; p < predCount[b + 1]!; p++) {
let q = preds[p]!;
if (idom[q] === -1) continue;
if (ni === -1) {
ni = q;
continue;
}
let a = ni;
while (a !== q) {
while (a < q) a = idom[a]!;
while (q < a) q = idom[q]!;
}
ni = a;
}
if (ni !== -1 && idom[b] !== ni) {
idom[b] = ni;
changed = true;
}
}
}
const retained = new Float64Array(R);
for (let b = 0; b < R; b++) retained[b] += self(post[b]!);
for (let b = 0; b < R; b++) {
if (b !== rootPo && idom[b]! >= 0) retained[idom[b]!] += retained[b]!;
}
// A retaining path: the first strong, named predecessor edge, walked up.
const retainerEdge = new Map<number, [number, string]>();
const wanted = new Set<number>();
const candidates: number[] = [];
for (let b = 0; b < R; b++) {
const n = post[b]!;
const t = typeOf(n);
if (b === rootPo || t === "synthetic" || t === "hidden") continue;
// Skip nodes whose immediate dominator is itself a big application
// object: report the outermost holders only.
if (retained[b]! < 2_000_000) continue;
candidates.push(b);
}
candidates.sort((a, b) => retained[b]! - retained[a]!);
const picked: number[] = [];
for (const b of candidates) {
// keep the outermost: skip if an ancestor in the dominator tree is
// already picked and within 1.5× (it would be a duplicate line)
let a = idom[b]!;
let dup = false;
while (a !== rootPo && a >= 0) {
if (picked.includes(a) && retained[a]! < retained[b]! * 1.5) {
dup = true;
break;
}
a = idom[a]!;
}
if (!dup) picked.push(b);
if (picked.length >= 40) break;
}
for (const b of picked) wanted.add(post[b]!);
// Build one retainer per node in a BFS from the root (shortest path).
const dist = new Int32Array(N).fill(-1);
const queue = new Int32Array(N);
let qh = 0;
let qt = 0;
queue[qt++] = 0;
dist[0] = 0;
while (qh < qt) {
const n = queue[qh++]!;
for (let e = firstEdge[n]!; e < firstEdge[n + 1]!; e += EF) {
const et = edges[e + eType]!;
if (!isStrong(et)) continue;
const to = edges[e + eTo]! / NF;
if (dist[to] !== -1) continue;
dist[to] = dist[n]! + 1;
const label =
et === elementT || et === hiddenT
? `[${edges[e + eName]}]`
: String(strings[edges[e + eName]!] ?? "");
retainerEdge.set(to, [n, label]);
queue[qt++] = to;
}
}
const pathOf = (n: number) => {
const parts: string[] = [];
let cur = n;
for (let depth = 0; depth < 12 && cur !== 0; depth++) {
const r = retainerEdge.get(cur);
if (!r) break;
parts.unshift(`${r[1]}`);
cur = r[0];
const t = typeOf(cur);
if (t !== "hidden" && t !== "synthetic" && depth > 0) {
parts.unshift(`${nameOf(cur).slice(0, 40)}`);
if (depth > 4) break;
}
}
return parts.join(" > ");
};
const mb = (n: number) => Math.round((n / 1048576) * 10) / 10;
const probeResults: HeapSummary["probes"] = {};
const propertyT = edgeTypes.indexOf("property");
for (const [label, props] of Object.entries(probes)) {
const hits = new Set<number>();
for (let n = 0; n < N; n++) {
if (typeOf(n) !== "object" || order[n]! < 0) continue;
const seen = new Set<string>();
for (let e = firstEdge[n]!; e < firstEdge[n + 1]!; e += EF) {
if (edges[e + eType] !== propertyT) continue;
seen.add(strings[edges[e + eName]!]!);
}
if (props.every((p) => seen.has(p))) hits.add(order[n]!);
}
let retainedSum = 0;
for (const b of hits) {
// Count a hit only when no dominator above it is a hit too.
let a = idom[b]!;
let nested = false;
while (a !== rootPo && a >= 0) {
if (hits.has(a)) {
nested = true;
break;
}
a = idom[a]!;
}
if (!nested) retainedSum += retained[b]!;
}
probeResults[label] = { count: hits.size, retainedMb: mb(retainedSum) };
}
return {
probes: probeResults,
totalMb: mb(total),
nodeCount: N,
byType: [...byType.entries()]
.map(([k, v]) => [k, mb(v)] as [string, number])
.sort((a, b) => b[1] - a[1]),
byConstructor: [...byCtor.entries()]
.map(([k, [s, c]]) => [k, mb(s), c] as [string, number, number])
.sort((a, b) => b[1] - a[1])
.slice(0, 30),
largestStrings: big
.sort((a, b) => b[0] - a[0])
.slice(0, 25)
.map(([s, t]) => [mb(s), t]),
topRetainers: picked.map((b) => ({
mb: mb(retained[b]!),
name: `${typeOf(post[b]!)}:${nameOf(post[b]!).slice(0, 60).replace(/\s+/g, " ")}`,
path: pathOf(post[b]!),
})),
sourceStringsMb: mb(sourceStrings),
};
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,221 @@
# Boot profile v1.45.0-before-boot
Commit 0f6d668df, 2026-10-22T04:36:55.826Z. Production build, every role, heap ceiling 792 MB, 300 s idle. 14 cores, load average 9.3,9.4,11.2 at the end.
- first answer from /api/health: 1590 ms; background role ready: 5432 ms
- event-loop stalls logged: 0, max 0 ms
## Stalls
## Heap samples
| at s | used MB | old | large obj | code | external | array buffers | rss |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 6 | 184 | 105 | 68 | 4 | 11 | 0 | 267 |
| 308 | 164 | 99 | 58 | 5 | 11 | 0 | 623 |
## Full collections (mark-compact)
47 mark-compacts during the run. Used heap before the collection, and what survived it (the live heap):
| at s | before MB | after MB |
| ---: | ---: | ---: |
| 0 | 21.4 | 17.4 |
| 9 | 189.6 | 172.9 |
| 154 | 193.8 | 171.6 |
| 170 | 187.3 | 161.7 |
| 188 | 181.6 | 163.6 |
| 205 | 182.3 | 163.6 |
| 222 | 190.6 | 165 |
| 239 | 182.9 | 163.4 |
| 258 | 190.5 | 163.7 |
| 274 | 187.7 | 164.2 |
| 290 | 193.9 | 164.2 |
| 306 | 175.3 | 163.9 |
| 309 | 174 | 163.7 |
## Main-thread busy stretches over 50 ms without an idle sample, first two minutes
### at 5 ms, 279 ms
| self time by module | ms |
| --- | ---: |
| (native) | 122.3 |
| npm:next | 57.1 |
| node:internal/modules/cjs/loader | 41.7 |
| node:internal/bootstrap/realm | 24.7 |
| node:fs | 5.3 |
| node:internal/deps/undici/undici | 5.3 |
| node:internal/modules/package_json_reader | 4.5 |
| node:path | 4 |
| node:internal/streams/destroy | 1.5 |
| node:internal/deps/cjs-module-lexer/lexer | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
### at 339 ms, 324 ms
| self time by module | ms |
| --- | ---: |
| (native) | 87.6 |
| node:internal/modules/cjs/loader | 54.2 |
| npm:@prisma/client | 38 |
| wasm://wasm/00d1cb12 | 36 |
| node:internal/modules/esm/utils | 26.7 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 24.9 |
| npm:next | 22.1 |
| npm:zod | 6 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 3.1 |
| npm:@prisma/client-runtime-utils | 3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/instrumentation.ts | 80.6 |
| src/lib/db-session.ts | 60.7 |
| src/lib/notifications/types.ts | 13.2 |
| src/lib/boot/readiness-summary.ts | 13.2 |
| src/lib/ai/local-host-allowlist.ts | 13.2 |
| src/generated/prisma/internal/class.ts | 7.3 |
| src/lib/logging/context.ts | 6 |
| src/lib/crypto.ts | 6 |
| src/lib/boot/key-check.ts | 6 |
| src/lib/validations/measurement.ts | 3 |
### at 698 ms, 753 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 178.2 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 177.4 |
| (native) | 83.8 |
| npm:zod | 39.5 |
| npm:next | 34.6 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 33.9 |
| npm:mmdb-lib | 26.1 |
| node:fs | 23.5 |
| npm:yaml | 22.4 |
| src/lib/activity/goal-bounds.ts | 18.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/instrumentation.ts | 410 |
| src/lib/jobs/reminder/register-maintenance.ts | 279.5 |
| src/lib/sharing/acting-session.ts | 138.5 |
| src/lib/jobs/offhost-backup.ts | 137.1 |
| src/lib/integrations/oauth-refresh.ts | 136.3 |
| src/lib/jobs/morning-digest-refresh-shared.ts | 136.3 |
| src/lib/export/environment-backup.ts | 134.1 |
| src/lib/export/full-backup-payload.ts | 134.1 |
| src/app/api/health/route.ts | 127.5 |
| src/lib/insights/snapshot-hash.ts | 124.9 |
## Self time by module until the worker is ready
| module | ms |
| --- | ---: |
| (native) | 524.6 |
| node:internal/modules/cjs/loader | 274 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 202.3 |
| npm:next | 136.8 |
| wasm://wasm/00d1cb12 | 108.1 |
| npm:@prisma/client | 76.1 |
| npm:pg | 56.5 |
| npm:zod | 45.5 |
| node:internal/modules/esm/utils | 41.4 |
| npm:luxon | 38.5 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 35.3 |
| node:fs | 31.7 |
| node:internal/bootstrap/realm | 28.9 |
| npm:pg-protocol | 27.7 |
| npm:mmdb-lib | 26.1 |
| npm:yaml | 22.4 |
| npm:pg-boss | 19.3 |
| src/lib/activity/goal-bounds.ts | 18.5 |
| node:internal/process/task_queues | 17.7 |
| node:internal/streams/readable | 15.5 |
| node:internal/async_hooks | 15.1 |
| npm:cron-parser | 14.6 |
| node:internal/timers | 12.8 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 12.4 |
| node:buffer | 11.6 |
## Heap snapshot after 300 s idle (live heap after a full GC)
172.3 MB in 1412114 nodes; module source strings over 50 KB: 44.2 MB.
- parsed locale catalogs: 32 objects, 14.1 MB retained
| node type | MB |
| --- | ---: |
| string | 79.1 |
| array | 27.6 |
| code | 18.6 |
| object | 16.7 |
| hidden | 9.3 |
| native | 8 |
| object shape | 5.8 |
| closure | 5 |
| concatenated string | 1.3 |
| number | 0.4 |
| regexp | 0.2 |
| sliced string | 0.2 |
| synthetic | 0 |
| symbol | 0 |
| bigint | 0 |
| constructor | MB | count |
| --- | ---: | ---: |
| array:(object elements) | 20.6 | 24726 |
| object:Object | 11.9 | 300844 |
| native:system / JSArrayBufferData | 7.5 | 169 |
| array:(object properties) | 4.4 | 6540 |
| array: | 2.7 | 9578 |
| object:system / Context | 1.6 | 28887 |
| closure: | 1.5 | 27081 |
| object:Date1 | 1 | 10632 |
| closure:native_bind | 0.9 | 20134 |
| object:Array | 0.6 | 19550 |
| object:a | 0.4 | 3880 |
| native:Node / histogram | 0.4 | 1 |
| closure:get | 0.3 | 4717 |
| object:Set | 0.2 | 5102 |
| closure:set | 0.2 | 3414 |
| object:d | 0.2 | 3874 |
| object:Module | 0.1 | 1907 |
| object:Promise | 0.1 | 1590 |
| closure:n | 0.1 | 1400 |
| closure:i | 0.1 | 1390 |
| largest retained subtrees | MB | retaining path |
| --- | ---: | --- |
| object:yr | 33.9 | [1] > [10] > 15 > extension > global > prisma > [1] > _engine |
| object:Object | 10 | tX > onClose > onClose > context > system / Context > previous > system / Context > e > Context > __proto__ > c |
| code:.next/server/chu | 9.8 | system / Context > timerListMap > Object > [120000] > TimersList > _idleNext > Timeout > _onTimeout > > shared > script |
| code:.next/server/chu | 9.7 | Module > _cache > Object > .next/server/chunks/_09yxiz_._.js > Module > exports > Array > [1] > > shared > script |
| object:system / Context | 7.4 | Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [25] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4] > context |
| code:.next/server/chu | 5.6 | global > prisma > [1] > t > _engineConfig > Object > compilerWasm > Object > getRuntime > getRuntime > shared > script |
| code:.next/server/chu | 5.6 | Module > _cache > Object > .next/server/chunks/_0hfea7u._.js > Module > exports > Array > [1] > > shared > script |
| object:Generator | 4.4 | [6] > principal_realm > Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [26] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4 |
| string:AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/ | 4.4 | [1] > [21] > 59867 |
| code:.next/server/chu | 3.7 | [1] > [10] > 15 > extension > global > __healthlog_pgboss__ > PgBoss > _events > Object > error > > shared > script |
| object:Client | 3.6 | Object > close > > context > system / Context > self > Connection > _events > Object > parseComplete > bound_this |
| largest strings | MB |
| --- | ---: |
| module.exports=[425781,(e,a,i)=>{a.exports=JSON.parse('{"common":{"listCapped":"Hier stehe | 9.7 |
| module.exports=[157893,(e,a,i)=>{a.exports=JSON.parse('{"common":{"listCapped":"Hier stehe | 9.7 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| module.exports=[234494,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=> | 5.6 |
| const wasm = "AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGAB | 4.4 |
| AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGABfwF/YAZ/f39/f3 | 4.4 |
| module.exports=[799092,(e,t,n)=>{"use strict";Object.defineProperty(n,"__esModule",{value: | 3.7 |
| lliADOAUngIEAACgKwAgBgAAoSsAIAcAAKErACAIAACiKwAgCQAAoysAIAoAAKQrACALAAClKwAgDAAApisAIA0AAK | 1.9 |
| (()=>{var e,t,r={"./dist/build/webpack/alias/react-dom-server.js"(e,t,r){"use strict";var | 1.3 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[834886,(e,t,n)=>{"use strict";Object.defineProperty(n,"__esModule",{value: | 1.1 |
| // v1.4.34 web-freeze. Schema is locked additive-only until iOS launch // + v1.5.0 marker. | 1.1 |
| module.exports=[971743,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1.1 |
| module.exports=[835717,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1 |
| module.exports=[594108,806176,674750,98746,t=>{"use strict";var i;let r;function n(t){retu | 0.7 |
+254
View File
@@ -0,0 +1,254 @@
{
"meta": {
"label": "v1.45.0-before",
"commit": "1915f9152",
"date": "2026-10-22T05:51:47.626Z",
"node": "v22.23.2",
"postgres": "16.15",
"cpus": 14,
"loadAverage": [
29.9,
21.9,
18.9
],
"measurements": 1265561,
"iterations": 20,
"warmup": 3,
"heapMb": 792,
"idleStatementsPerSecond": 0,
"batch5k": {
"totalMs": 1730,
"totalStatements": 534
}
},
"results": [
{
"name": "/api/auth/me",
"mode": "cold",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 9.2,
"p95Ms": 11.6,
"maxMs": 13.3,
"statementsMedian": 19,
"statementsMax": 19,
"failures": 0
},
{
"name": "/api/auth/me",
"mode": "warm",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 8.6,
"p95Ms": 10.5,
"maxMs": 13,
"statementsMedian": 19,
"statementsMax": 19,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "cold",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 170.2,
"p95Ms": 220.5,
"maxMs": 228.3,
"statementsMedian": 31,
"statementsMax": 34,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "warm",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 6,
"p95Ms": 6.7,
"maxMs": 6.8,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "cold",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 1481.8,
"p95Ms": 1526.2,
"maxMs": 1530,
"statementsMedian": 158,
"statementsMax": 161,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "warm",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 7,
"p95Ms": 7.9,
"maxMs": 10.2,
"statementsMedian": 13,
"statementsMax": 13,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 24.4,
"p95Ms": 30.8,
"maxMs": 31.1,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 20.5,
"p95Ms": 29.4,
"maxMs": 30.2,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 184.1,
"p95Ms": 192.6,
"maxMs": 201.1,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 178.1,
"p95Ms": 186.5,
"maxMs": 187.6,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 1627,
"p95Ms": 1664.5,
"maxMs": 1675.7,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 2090.9,
"p95Ms": 2435.4,
"maxMs": 2529.7,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "day view",
"mode": "cold",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 76.7,
"p95Ms": 93.6,
"maxMs": 116.2,
"statementsMedian": 49,
"statementsMax": 49,
"failures": 0
},
{
"name": "day view",
"mode": "warm",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 62.8,
"p95Ms": 72,
"maxMs": 73.5,
"statementsMedian": 48,
"statementsMax": 48,
"failures": 0
},
{
"name": "insights health status",
"mode": "cold",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 244.7,
"p95Ms": 401.2,
"maxMs": 404.1,
"statementsMedian": 68,
"statementsMax": 69,
"failures": 0
},
{
"name": "insights health status",
"mode": "warm",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 238.9,
"p95Ms": 399.1,
"maxMs": 476.6,
"statementsMedian": 68,
"statementsMax": 68,
"failures": 0
},
{
"name": "insights correlations",
"mode": "cold",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 105.6,
"p95Ms": 326.9,
"maxMs": 349.7,
"statementsMedian": 49,
"statementsMax": 52,
"failures": 0
},
{
"name": "insights correlations",
"mode": "warm",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 11.3,
"p95Ms": 16.6,
"maxMs": 20.3,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "batch ingest 5k (per 500)",
"mode": "write",
"request": "POST /api/measurements/batch ×10",
"n": 10,
"p50Ms": 166.1,
"p95Ms": 220.3,
"maxMs": 220.3,
"statementsMedian": 53,
"statementsMax": 56,
"failures": 0
}
]
}
+25
View File
@@ -0,0 +1,25 @@
# Benchmark v1.45.0-before
Commit 1915f9152 (the v1.44 runtime this branch starts from), 2026-10-22T05:51:47.626Z. 1265561 measurement rows on the bench account, 20 measured requests per row after 3 warm-up requests, one at a time. Server: production build, web process, heap ceiling 792 MB. Postgres 16.15, 14 cores, load average 29.9,21.9,18.9 at the end. Idle background: 0 statements/s.
| Request | Mode | n | p50 ms | p95 ms | max ms | statements (median) | statements (max) |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| /api/auth/me | cold | 20 | 9.2 | 11.6 | 13.3 | 19 | 19 |
| /api/auth/me | warm | 20 | 8.6 | 10.5 | 13 | 19 | 19 |
| dashboard summary | cold | 20 | 170.2 | 220.5 | 228.3 | 31 | 34 |
| dashboard summary | warm | 20 | 6 | 6.7 | 6.8 | 7 | 7 |
| dashboard snapshot | cold | 20 | 1481.8 | 1526.2 | 1530 | 158 | 161 |
| dashboard snapshot | warm | 20 | 7 | 7.9 | 10.2 | 13 | 13 |
| series pulse 30 d | cold | 20 | 24.4 | 30.8 | 31.1 | 8 | 8 |
| series pulse 30 d | warm | 20 | 20.5 | 29.4 | 30.2 | 8 | 9 |
| series pulse 365 d | cold | 20 | 184.1 | 192.6 | 201.1 | 8 | 8 |
| series pulse 365 d | warm | 20 | 178.1 | 186.5 | 187.6 | 8 | 8 |
| series pulse 3650 d | cold | 20 | 1627 | 1664.5 | 1675.7 | 8 | 9 |
| series pulse 3650 d | warm | 20 | 2090.9 | 2435.4 | 2529.7 | 8 | 9 |
| day view | cold | 20 | 76.7 | 93.6 | 116.2 | 49 | 49 |
| day view | warm | 20 | 62.8 | 72 | 73.5 | 48 | 48 |
| insights health status | cold | 20 | 244.7 | 401.2 | 404.1 | 68 | 69 |
| insights health status | warm | 20 | 238.9 | 399.1 | 476.6 | 68 | 68 |
| insights correlations | cold | 20 | 105.6 | 326.9 | 349.7 | 49 | 52 |
| insights correlations | warm | 20 | 11.3 | 16.6 | 20.3 | 8 | 8 |
| batch ingest 5k (per 500) | write | 10 | 166.1 | 220.3 | 220.3 | 53 | 56 |
File diff suppressed because it is too large. Load diff
+298
View File
@@ -0,0 +1,298 @@
# Boot profile v1.45.0-boot-after
Commit a859adb59, 2026-10-22T04:49:50.419Z. Production build, every role, heap ceiling 792 MB, 300 s idle. 14 cores, load average 8.6,9.4,10.7 at the end.
- first answer from /api/health: 850 ms; background role ready: 3038 ms
- event-loop stalls logged: 0, max 0 ms
## Stalls
## Heap samples
| at s | used MB | old | large obj | code | external | array buffers | rss |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 6 | 166 | 91 | 53 | 3 | 12 | 1 | 430 |
| 306 | 153 | 90 | 53 | 4 | 11 | 0 | 599 |
## Full collections (mark-compact)
10 mark-compacts during the run. Used heap before the collection, and what survived it (the live heap):
| at s | before MB | after MB |
| ---: | ---: | ---: |
| 0 | 20.2 | 17.1 |
| 0 | 55.3 | 52.6 |
| 0 | 83.2 | 81.1 |
| 1 | 148.4 | 132.7 |
| 16 | 159.6 | 153.2 |
| 17 | 153.4 | 151 |
| 62 | 167.5 | 157.5 |
| 246 | 183.4 | 152.6 |
| 303 | 165.6 | 151.8 |
| 303 | 151.8 | 151.8 |
## Main-thread busy stretches over 50 ms without an idle sample, first two minutes
### at 6 ms, 175 ms
| self time by module | ms |
| --- | ---: |
| npm:next | 47.2 |
| node:internal/modules/cjs/loader | 34.8 |
| (native) | 34.5 |
| node:internal/bootstrap/realm | 25.7 |
| node:internal/deps/undici/undici | 8.8 |
| node:path | 6.3 |
| node:internal/modules/package_json_reader | 3 |
| node:internal/net | 3 |
| node:internal/modules/esm/resolve | 2.5 |
| node:fs | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
### at 203 ms, 241 ms
| self time by module | ms |
| --- | ---: |
| (native) | 52.7 |
| node:internal/modules/cjs/loader | 40.1 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 31.7 |
| wasm://wasm/00d1cb12 | 28.7 |
| npm:@prisma/client | 28 |
| npm:next | 16.3 |
| node:internal/modules/esm/utils | 12 |
| node:fs | 4.5 |
| npm:zod | 3 |
| node:internal/modules/helpers | 3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/instrumentation.ts | 80.8 |
| src/lib/db-session.ts | 49.5 |
| src/lib/boot/startup-state.ts | 25.6 |
| src/lib/boot/boot-steps.ts | 25.6 |
| src/lib/logging/background.ts | 25.6 |
| src/lib/process-type.ts | 25.5 |
| src/lib/logging/context.ts | 13.6 |
| src/lib/validations/measurement.ts | 12.1 |
| src/lib/logging/redact.ts | 10.5 |
| src/lib/validations/entry-instant.ts | 4.6 |
### at 501 ms, 178 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 53.9 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 37.5 |
| npm:next | 18 |
| npm:zod | 12 |
| node:internal/modules/esm/utils | 10.5 |
| (native) | 8.7 |
| node:fs | 6 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 4.5 |
| npm:undici | 4.5 |
| node:internal/url | 1.8 |
| inclusive, application modules | ms |
| --- | ---: |
| src/app/api/health/route.ts | 61.4 |
| src/lib/api-errors.ts | 27 |
| src/lib/jobs/job-failures.ts | 14.9 |
| src/lib/jobs/offhost-backup.ts | 14.9 |
| src/lib/managed-profiles/handover-access.ts | 14.9 |
| src/lib/sharing/acting-session.ts | 14.9 |
| src/lib/db-session.ts | 13.4 |
| src/lib/export/environment-backup.ts | 13.4 |
| src/lib/export/full-backup-payload.ts | 13.4 |
| src/lib/logging/background.ts | 10.5 |
### at 833 ms, 184 ms
| self time by module | ms |
| --- | ---: |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 38.9 |
| node:internal/modules/cjs/loader | 28.6 |
| (native) | 22.8 |
| src/lib/i18n/server-catalogs.ts | 15.1 |
| npm:yaml | 15 |
| node:fs | 12.1 |
| npm:zod | 9 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 5.9 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 4.5 |
| npm:semver | 3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 129.1 |
| src/lib/jobs/reminder/google-health-sync.ts | 88.5 |
| src/lib/whoop/sync.ts | 79.5 |
| src/lib/whoop/sync-body.ts | 63 |
| src/lib/insights/bucket-series.ts | 60 |
| src/lib/whoop/oauth-descriptor.ts | 60 |
| src/lib/whoop/sync-core.ts | 60 |
| src/instrumentation.ts | 43.4 |
| src/lib/import/unzip-export-xml.ts | 31.4 |
| src/lib/jobs/integration-status-null-probe.ts | 31.4 |
### at 1099 ms, 54 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 23.9 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 10.6 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 5.8 |
| src/lib/daily/today-overview.ts | 4.5 |
| src/lib/jobs/reminder-worker.ts | 3 |
| node:fs | 3 |
| npm:pg-boss | 1.5 |
| npm:zod | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 52.3 |
| src/lib/jobs/reminder/insights-handlers.ts | 22.3 |
| src/lib/ai/prompts/arrival-reaction.ts | 12 |
| src/lib/daily/score-steady.ts | 10.5 |
| src/lib/dashboard/bands.ts | 6 |
| src/lib/jobs/data-arrival.ts | 4.5 |
| src/lib/dashboard/snapshot-read.ts | 4.5 |
| src/lib/daily/today-overview.ts | 4.5 |
| src/lib/daily/priority-item-key.ts | 4.5 |
| src/lib/ai/prompts/status-batch.ts | 3 |
### at 1309 ms, 51 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 28 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 6 |
| node:fs | 4.5 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 3 |
| turbopack:///[turbopack]/nodejs/runtime/runtime-base.ts | 1.5 |
| src/lib/jobs/failure-condition.ts | 1.5 |
| src/lib/labs/parse-reference-range.ts | 1.5 |
| npm:zod | 1.5 |
| node:internal/linkedlist | 1.5 |
| (native) | 0.9 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 48.2 |
| src/lib/jobs/reminder/ops-handlers.ts | 14.1 |
| src/lib/medications/ingredients/label-extract.ts | 3 |
| src/lib/export/restore-backup.ts | 2.1 |
| src/lib/jobs/failure-condition.ts | 1.5 |
| src/lib/documents/ai-runs/summary-run.ts | 1.5 |
| src/lib/documents/auto-stage-labs.ts | 1.5 |
| src/lib/documents/ai-runs/store.ts | 1.5 |
| src/lib/labs/parse-reference-range.ts | 1.5 |
| src/lib/labs/ocr-upload.ts | 1.5 |
## Self time by module until the worker is ready
| module | ms |
| --- | ---: |
| (native) | 230.8 |
| node:internal/modules/cjs/loader | 223.6 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 124.7 |
| npm:next | 98.8 |
| wasm://wasm/00d1cb12 | 62.6 |
| npm:@prisma/client | 56.2 |
| node:fs | 34.6 |
| node:internal/bootstrap/realm | 28.7 |
| npm:pg | 28.3 |
| npm:zod | 27 |
| npm:pg-boss | 24.6 |
| node:internal/modules/esm/utils | 22.5 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 20.6 |
| npm:luxon | 17 |
| src/lib/i18n/server-catalogs.ts | 15.1 |
| npm:yaml | 15 |
| npm:pg-pool | 13 |
| node:internal/deps/undici/undici | 10.4 |
| node:path | 7.8 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 7.5 |
| node:internal/async_hooks | 7.5 |
| node:internal/process/task_queues | 7.3 |
| node:internal/modules/package_json_reader | 6 |
| npm:undici | 6 |
| npm:pg-protocol | 6 |
## Heap snapshot after 300 s idle (live heap after a full GC)
160.1 MB in 1275329 nodes; module source strings over 50 KB: 39.8 MB.
- parsed locale catalogs: 8 objects, 7 MB retained
| node type | MB |
| --- | ---: |
| string | 68.7 |
| array | 26.4 |
| code | 21.1 |
| object | 14.1 |
| hidden | 8.9 |
| native | 8 |
| object shape | 5.9 |
| closure | 5.2 |
| concatenated string | 1.3 |
| regexp | 0.2 |
| sliced string | 0.2 |
| number | 0.1 |
| synthetic | 0 |
| symbol | 0 |
| bigint | 0 |
| constructor | MB | count |
| --- | ---: | ---: |
| array:(object elements) | 20.4 | 23797 |
| object:Object | 10.2 | 274231 |
| native:system / JSArrayBufferData | 7.6 | 171 |
| array:(object properties) | 3.6 | 6512 |
| array: | 2.4 | 10502 |
| closure: | 1.6 | 29042 |
| object:system / Context | 1.6 | 28873 |
| closure:native_bind | 1 | 20782 |
| object:Array | 0.6 | 19679 |
| object:a | 0.4 | 4438 |
| native:Node / histogram | 0.4 | 1 |
| closure:get | 0.3 | 4736 |
| object:d | 0.3 | 4425 |
| object:Set | 0.2 | 5673 |
| closure:set | 0.2 | 3432 |
| object:Module | 0.2 | 1958 |
| closure:i | 0.1 | 1581 |
| closure:n | 0.1 | 1572 |
| object:Promise | 0.1 | 1277 |
| object:n | 0.1 | 1881 |
| largest retained subtrees | MB | retaining path |
| --- | ---: | --- |
| object:t | 33.9 | [1] > [10] > 60 > extension > global > prisma > [1] |
| object:global | 7.9 | [1] > [10] > 60 > extension |
| object:system / Context | 7.4 | Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [25] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4] > context |
| object:Module | 5.6 | [1] > [10] > 97 > process > nextTick > patchedNextTick > context > system / Context > require > require > cache > .n |
| code:.next/server/chu | 5.6 | require > cache > Object > .next/server/chunks/_1kwfnjg._.js > Module > exports > Array > [1] > > shared > script |
| code:.next/server/chu | 5.6 | global > prisma > [1] > t > _engineConfig > Object > compilerWasm > Object > getRuntime > getRuntime > shared > script |
| object:Generator | 4.4 | [6] > principal_realm > Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [26] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4 |
| string:AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/ | 4.4 | [1] > [21] > 62203 |
| object:Object | 3.3 | system / Context > require > require > cache > Object > .next/server/chunks/[turbopack]_runtime.js > Module > export |
| largest strings | MB |
| --- | ---: |
| module.exports=[234494,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=> | 5.6 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| const wasm = "AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGAB | 4.4 |
| AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGABfwF/YAZ/f39/f3 | 4.4 |
| lliADOAUngIEAACgKwAgBgAAoSsAIAcAAKErACAIAACiKwAgCQAAoysAIAoAAKQrACALAAClKwAgDAAApisAIA0AAK | 1.9 |
| (()=>{var e,t,r={"./dist/build/webpack/alias/react-dom-server.js"(e,t,r){"use strict";var | 1.3 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[799092,(e,t,n)=>{"use strict";Object.defineProperty(n,"__esModule",{value: | 1.1 |
| // v1.4.34 web-freeze. Schema is locked additive-only until iOS launch // + v1.5.0 marker. | 1.1 |
| module.exports=[971743,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1.1 |
| module.exports=[835717,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1 |
| module.exports=[767189,e=>e.a(async(t,a)=>{try{var n=e.i(64105),r=e.i(563616),i=t([n]);[n] | 0.8 |
| module.exports=[594108,806176,674750,98746,t=>{"use strict";var i;let r;function n(t){retu | 0.7 |
File diff suppressed because it is too large. Load diff
+804
View File
@@ -0,0 +1,804 @@
# Boot profile v1.45.0-boot-final
Commit a57fa8335, 2026-10-22T06:58:52.591Z. Production build, every role, heap ceiling 792 MB, 300 s idle. 14 cores, load average 5.6,6.4,11.1 at the end.
- first answer from /api/health: 1325 ms; background role ready: 3217 ms
- event-loop stalls logged: 0, max 0 ms
## Stalls
## Heap samples
| at s | used MB | old | large obj | code | external | array buffers | rss |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 6 | 225 | 158 | 54 | 4 | 11 | 0 | 500 |
| 306 | 158 | 96 | 53 | 5 | 11 | 0 | 788 |
## Full collections (mark-compact)
98 mark-compacts during the run. Used heap before the collection, and what survived it (the live heap):
| at s | before MB | after MB |
| ---: | ---: | ---: |
| 0 | 21.5 | 17.5 |
| 8 | 203.8 | 146.5 |
| 24 | 236.5 | 142.5 |
| 41 | 223.8 | 144.9 |
| 60 | 225.8 | 147.5 |
| 85 | 224.4 | 147.7 |
| 124 | 225.4 | 151.2 |
| 167 | 176.7 | 151 |
| 209 | 230.1 | 150.4 |
| 242 | 197.9 | 151.5 |
| 273 | 224.8 | 152.2 |
| 302 | 177.9 | 152.5 |
| 312 | 170.5 | 152.9 |
| 327 | 170.6 | 152 |
| 338 | 169.7 | 151.8 |
## Main-thread busy stretches over 50 ms without an idle sample, first two minutes
### at 5 ms, 396 ms
| self time by module | ms |
| --- | ---: |
| (native) | 230.2 |
| node:internal/modules/cjs/loader | 57.8 |
| npm:next | 52.3 |
| node:internal/bootstrap/realm | 29.3 |
| node:internal/deps/undici/undici | 8.9 |
| node:internal/modules/esm/loader | 4.5 |
| node:fs | 3 |
| node:internal/net | 2.5 |
| node:internal/bootstrap/switches/is_main_thread | 2 |
| node:os | 1.8 |
| inclusive, application modules | ms |
| --- | ---: |
### at 472 ms, 336 ms
| self time by module | ms |
| --- | ---: |
| (native) | 121.3 |
| wasm://wasm/00d1cb12 | 43 |
| node:internal/modules/cjs/loader | 40.5 |
| npm:next | 31.7 |
| npm:@prisma/client | 23.8 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 23.4 |
| node:internal/modules/esm/utils | 17.3 |
| npm:zod | 5.9 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 3 |
| node:vm | 1.7 |
| inclusive, application modules | ms |
| --- | ---: |
| src/instrumentation.ts | 95.8 |
| src/lib/db-session.ts | 47.3 |
| src/lib/process-type.ts | 27.1 |
| src/lib/boot/startup-state.ts | 25.5 |
| src/lib/boot/boot-steps.ts | 25.5 |
| src/lib/logging/background.ts | 25.5 |
| src/lib/logging/redact.ts | 13 |
| src/lib/logging/context.ts | 11 |
| src/lib/validations/measurement.ts | 10 |
| src/generated/prisma/internal/class.ts | 4.7 |
### at 811 ms, 225 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 52.3 |
| (native) | 48.2 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 37 |
| npm:next | 22.2 |
| npm:zod | 12.5 |
| node:internal/modules/esm/utils | 8.7 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 5.6 |
| node:fs | 4.5 |
| node:internal/async_hooks | 3 |
| node:internal/modules/esm/translators | 2.6 |
| inclusive, application modules | ms |
| --- | ---: |
| src/app/api/health/route.ts | 62 |
| src/lib/api-errors.ts | 25.9 |
| src/lib/auth/secure-cookie.ts | 14.9 |
| src/lib/logging/background.ts | 13.4 |
| src/lib/sharing/acting-session.ts | 12.8 |
| src/lib/validations/measurement.ts | 11.9 |
| src/lib/db-session.ts | 11.3 |
| src/lib/export/environment-backup.ts | 11.3 |
| src/lib/export/full-backup-payload.ts | 11.3 |
| src/lib/jobs/job-failures.ts | 11.3 |
### at 1063 ms, 246 ms
| self time by module | ms |
| --- | ---: |
| (native) | 64.7 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 47.2 |
| node:internal/modules/cjs/loader | 31.6 |
| npm:luxon | 18 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 15.3 |
| npm:yaml | 15 |
| npm:zod | 10.2 |
| src/lib/i18n/server-catalogs.ts | 8.2 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 4.5 |
| npm:cron-parser | 4 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 137.6 |
| src/lib/jobs/reminder/google-health-sync.ts | 78 |
| src/lib/whoop/sync.ts | 65.9 |
| src/instrumentation.ts | 53.8 |
| src/lib/insights/bucket-series.ts | 47.1 |
| src/lib/whoop/oauth-descriptor.ts | 47.1 |
| src/lib/whoop/sync-core.ts | 47.1 |
| src/lib/whoop/sync-body.ts | 47.1 |
| src/lib/import/unzip-export-xml.ts | 35.3 |
| src/lib/jobs/integration-status-null-probe.ts | 35.3 |
### at 1363 ms, 68 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 29.2 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 6.7 |
| (native) | 5.9 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 4.5 |
| src/lib/daily/today-overview.ts | 4.1 |
| node:fs | 3 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 3 |
| node:internal/streams/writable | 1.5 |
| src/lib/jobs/reminder/insights-handlers.ts | 1.5 |
| npm:next | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 62.5 |
| src/lib/jobs/reminder/insights-handlers.ts | 24.4 |
| src/lib/ai/prompts/arrival-reaction.ts | 12.8 |
| src/lib/daily/score-steady.ts | 11.3 |
| src/lib/dashboard/bands.ts | 7.1 |
| src/lib/dashboard/snapshot-read.ts | 5.8 |
| src/lib/jobs/data-arrival.ts | 4.5 |
| src/lib/daily/today-overview.ts | 4.1 |
| src/lib/daily/priority-item-key.ts | 4.1 |
| src/lib/ai/prompts/status-batch.ts | 3 |
### at 1545 ms, 57 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 25.3 |
| (native) | 12.9 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 7.2 |
| node:fs | 4.2 |
| node:buffer | 1.5 |
| npm:zod | 1.5 |
| src/lib/jobs/pr-detection.ts | 1.5 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 1.5 |
| src/lib/jobs/measurement-maintenance.ts | 1.3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 55.3 |
| src/lib/jobs/reminder/ops-handlers.ts | 13 |
| src/lib/export/restore-backup.ts | 3 |
| src/lib/medications/ingredients/label-extract.ts | 3 |
| src/lib/environment/service.ts | 1.5 |
| src/lib/export/backup-blob.ts | 1.5 |
| src/lib/jobs/pr-detection.ts | 1.5 |
| src/lib/jobs/document-content-index-backfill.ts | 1.5 |
| src/lib/labs/ocr-extract.ts | 1.5 |
| src/lib/jobs/reminder/backup-handlers.ts | 1.5 |
### at 1820 ms, 114 ms
| self time by module | ms |
| --- | ---: |
| src/lib/tz/zone-offset.ts | 36 |
| npm:@prisma/client | 18.2 |
| src/lib/validations/source-priority.ts | 18.1 |
| (native) | 13.1 |
| src/lib/personal-records/pr-detection-worker.ts | 7.2 |
| node:buffer | 4.1 |
| src/lib/analytics/source-priority.ts | 3.7 |
| npm:zod | 2.3 |
| npm:pg | 2 |
| src/lib/tz/format.ts | 1.9 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 73.3 |
| src/lib/tz/format.ts | 40.7 |
| src/lib/measurements/consolidation-tz.ts | 40.7 |
| src/lib/tz/zone-offset.ts | 38.8 |
| src/lib/measurements/consolidation-base.ts | 38.1 |
| src/lib/analytics/source-priority.ts | 28.1 |
| src/lib/validations/source-priority.ts | 20.4 |
| src/lib/tz/intl-cache.ts | 1.5 |
### at 2407 ms, 50 ms
| self time by module | ms |
| --- | ---: |
| (native) | 18.9 |
| src/lib/validations/source-priority.ts | 14.1 |
| src/lib/analytics/source-priority.ts | 3.7 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| npm:next | 1.8 |
| npm:%40prisma | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| node:internal/async_local_storage/async_hooks | 1.5 |
| npm:pg | 1.5 |
| npm:@prisma/client | 1.3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 26.6 |
| src/lib/analytics/source-priority.ts | 19.3 |
| src/lib/validations/source-priority.ts | 14.1 |
| src/lib/tz/format.ts | 3 |
| src/lib/measurements/consolidation-tz.ts | 3 |
| src/lib/measurements/consolidation-base.ts | 2.8 |
| src/lib/tz/zone-offset.ts | 1.5 |
### at 18192 ms, 59 ms
| self time by module | ms |
| --- | ---: |
| (native) | 15.5 |
| npm:@prisma/client | 13.9 |
| src/lib/validations/source-priority.ts | 11.8 |
| npm:pg | 3.6 |
| node:events | 3 |
| node:buffer | 2.5 |
| src/lib/measurements/consolidation-base.ts | 1.5 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| node:internal/async_hooks | 1.5 |
| npm:pg-types | 1.4 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 18.9 |
| src/lib/analytics/source-priority.ts | 12.9 |
| src/lib/validations/source-priority.ts | 11.8 |
| src/lib/measurements/consolidation-base.ts | 4.5 |
| src/lib/tz/zone-offset.ts | 2.6 |
| src/lib/tz/format.ts | 2.6 |
| src/lib/measurements/consolidation-tz.ts | 2.6 |
### at 19914 ms, 65 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 18.1 |
| (native) | 12.3 |
| npm:@prisma/client | 11.6 |
| npm:next | 5.9 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| src/lib/tz/format.ts | 3 |
| npm:pg | 2.6 |
| npm:pg-protocol | 2.5 |
| node:internal/async_hooks | 1.5 |
| src/lib/analytics/source-priority.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 29.8 |
| src/lib/analytics/source-priority.ts | 22.6 |
| src/lib/validations/source-priority.ts | 18.1 |
| src/lib/tz/format.ts | 5.8 |
| src/lib/measurements/consolidation-tz.ts | 5.8 |
| src/lib/tz/zone-offset.ts | 2.8 |
| src/lib/measurements/consolidation-base.ts | 2.8 |
### at 20296 ms, 55 ms
| self time by module | ms |
| --- | ---: |
| (native) | 22.4 |
| npm:@prisma/client | 14.8 |
| node:buffer | 3 |
| npm:zod | 3 |
| src/lib/measurements/consolidation-base.ts | 3 |
| src/lib/tz/format.ts | 1.5 |
| node:internal/stream_base_commons | 1.5 |
| src/lib/analytics/source-priority.ts | 1.5 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| npm:pg-protocol | 1.3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 10.5 |
| src/lib/analytics/source-priority.ts | 6 |
| src/lib/validations/source-priority.ts | 3 |
| src/lib/measurements/consolidation-base.ts | 3 |
| src/lib/tz/format.ts | 1.5 |
| src/lib/measurements/consolidation-tz.ts | 1.5 |
### at 30289 ms, 69 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 18.3 |
| (native) | 12.9 |
| npm:@prisma/client | 8.8 |
| node:buffer | 6.8 |
| src/lib/personal-records/pr-detection-worker.ts | 4.5 |
| src/lib/tz/format.ts | 4 |
| npm:pg-protocol | 3 |
| npm:zod | 3 |
| node:timers | 1.5 |
| node:internal/async_hooks | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 33 |
| src/lib/analytics/source-priority.ts | 24.3 |
| src/lib/validations/source-priority.ts | 21.3 |
| src/lib/tz/format.ts | 5.6 |
| src/lib/measurements/consolidation-tz.ts | 5.6 |
| src/lib/measurements/consolidation-base.ts | 2.6 |
| src/lib/tz/zone-offset.ts | 1.5 |
### at 31004 ms, 68 ms
| self time by module | ms |
| --- | ---: |
| (native) | 17.8 |
| src/lib/validations/source-priority.ts | 14.9 |
| npm:@prisma/client | 7.6 |
| npm:next | 4.5 |
| node:internal/async_hooks | 4.5 |
| npm:pg-protocol | 4.1 |
| npm:pg | 3 |
| src/lib/tz/zone-offset.ts | 3 |
| node:buffer | 2.7 |
| src/lib/analytics/source-priority.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 26.4 |
| src/lib/analytics/source-priority.ts | 19 |
| src/lib/validations/source-priority.ts | 16.2 |
| src/lib/measurements/consolidation-base.ts | 6.1 |
| src/lib/tz/format.ts | 5.8 |
| src/lib/measurements/consolidation-tz.ts | 5.8 |
| src/lib/tz/zone-offset.ts | 4.5 |
### at 35350 ms, 65 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 17 |
| npm:@prisma/client | 11.8 |
| (native) | 8.6 |
| node:buffer | 6.5 |
| src/lib/tz/format.ts | 4.5 |
| npm:pg-protocol | 3 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| npm:%40prisma | 1.6 |
| npm:pg | 1.5 |
| src/lib/analytics/source-priority.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 26 |
| src/lib/analytics/source-priority.ts | 20 |
| src/lib/validations/source-priority.ts | 17 |
| src/lib/tz/format.ts | 4.5 |
| src/lib/measurements/consolidation-tz.ts | 4.5 |
| src/lib/measurements/consolidation-base.ts | 3 |
### at 36452 ms, 57 ms
| self time by module | ms |
| --- | ---: |
| (native) | 20 |
| src/lib/validations/source-priority.ts | 16 |
| npm:@prisma/client | 7.3 |
| node:internal/per_context/primordials | 2.8 |
| npm:%40prisma | 1.5 |
| npm:next | 1.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
| node:internal/async_hooks | 1.5 |
| node:internal/streams/readable | 1.5 |
| npm:pg-protocol | 1.2 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 21.2 |
| src/lib/validations/source-priority.ts | 16 |
| src/lib/analytics/source-priority.ts | 16 |
| src/lib/tz/format.ts | 2.5 |
| src/lib/measurements/consolidation-tz.ts | 2.5 |
| src/lib/measurements/consolidation-base.ts | 2.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
### at 38744 ms, 56 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 13.3 |
| (native) | 9.9 |
| npm:@prisma/client | 9.2 |
| node:buffer | 4.7 |
| npm:pg-protocol | 4.3 |
| node:events | 1.9 |
| npm:pg | 1.6 |
| npm:%40prisma | 1.5 |
| npm:next | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 21.8 |
| src/lib/analytics/source-priority.ts | 16 |
| src/lib/validations/source-priority.ts | 13.3 |
| src/lib/measurements/consolidation-base.ts | 4.3 |
| src/lib/tz/format.ts | 3 |
| src/lib/measurements/consolidation-tz.ts | 3 |
| src/lib/tz/zone-offset.ts | 1.4 |
### at 42458 ms, 50 ms
| self time by module | ms |
| --- | ---: |
| npm:@prisma/client | 13.5 |
| src/lib/validations/source-priority.ts | 10.1 |
| (native) | 7.6 |
| npm:pg-protocol | 3.2 |
| node:events | 3 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| src/lib/tz/format.ts | 3 |
| npm:pg | 2.8 |
| node:internal/async_hooks | 1.5 |
| npm:%40prisma | 1.4 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 17.3 |
| src/lib/analytics/source-priority.ts | 12.7 |
| src/lib/validations/source-priority.ts | 10.1 |
| src/lib/tz/format.ts | 3 |
| src/lib/measurements/consolidation-tz.ts | 3 |
| src/lib/measurements/consolidation-base.ts | 1.5 |
| src/lib/jobs/job-lock.ts | 1.3 |
| src/lib/jobs/long-pass.ts | 1.3 |
| src/lib/jobs/run-job.ts | 1.3 |
| src/lib/jobs/job-observer.ts | 1.3 |
### at 45451 ms, 63 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 15.8 |
| npm:@prisma/client | 10.5 |
| (native) | 8.4 |
| src/lib/tz/format.ts | 5.3 |
| node:internal/async_local_storage/async_hooks | 4.4 |
| node:buffer | 3 |
| npm:pg | 3 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| npm:zod | 3 |
| npm:%40prisma | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 31.7 |
| src/lib/analytics/source-priority.ts | 21.8 |
| src/lib/validations/source-priority.ts | 18.8 |
| src/lib/measurements/consolidation-base.ts | 5.3 |
| src/lib/tz/format.ts | 5.3 |
| src/lib/measurements/consolidation-tz.ts | 5.3 |
### at 56854 ms, 66 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 15 |
| npm:@prisma/client | 13.7 |
| (native) | 6.6 |
| node:buffer | 6.6 |
| npm:next | 6 |
| npm:pg | 4.5 |
| src/lib/personal-records/pr-detection-worker.ts | 4.1 |
| src/lib/tz/format.ts | 3.1 |
| node:internal/process/task_queues | 1.5 |
| node:internal/async_hooks | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 25.3 |
| src/lib/analytics/source-priority.ts | 18.1 |
| src/lib/validations/source-priority.ts | 16.5 |
| src/lib/tz/format.ts | 3.1 |
| src/lib/measurements/consolidation-tz.ts | 3.1 |
| src/lib/measurements/consolidation-base.ts | 3.1 |
### at 56923 ms, 52 ms
| self time by module | ms |
| --- | ---: |
| (native) | 20 |
| npm:@prisma/client | 10.1 |
| node:buffer | 3.7 |
| npm:pg | 3 |
| src/lib/tz/format.ts | 2.6 |
| npm:pg-protocol | 2.6 |
| src/lib/analytics/source-priority.ts | 2.6 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
| npm:pg-types | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 9.3 |
| src/lib/analytics/source-priority.ts | 5.3 |
| src/lib/tz/format.ts | 4.1 |
| src/lib/measurements/consolidation-tz.ts | 4.1 |
| src/lib/measurements/consolidation-base.ts | 2.6 |
| src/lib/tz/zone-offset.ts | 1.5 |
| src/lib/validations/source-priority.ts | 1.1 |
### at 59508 ms, 56 ms
| self time by module | ms |
| --- | ---: |
| npm:@prisma/client | 15.7 |
| src/lib/validations/source-priority.ts | 9.7 |
| node:buffer | 7 |
| (native) | 6.1 |
| npm:pg-protocol | 4.5 |
| npm:next | 4.1 |
| src/lib/tz/format.ts | 3 |
| src/lib/analytics/source-priority.ts | 3 |
| node:internal/async_hooks | 1.5 |
| src/lib/measurements/consolidation-base.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 21.7 |
| src/lib/analytics/source-priority.ts | 15.8 |
| src/lib/validations/source-priority.ts | 9.7 |
| src/lib/tz/format.ts | 5.9 |
| src/lib/measurements/consolidation-tz.ts | 5.9 |
| src/lib/measurements/consolidation-base.ts | 5.9 |
| src/lib/tz/zone-offset.ts | 2.9 |
### at 61315 ms, 62 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 20.1 |
| npm:@prisma/client | 14.1 |
| (native) | 8.2 |
| npm:pg | 4.5 |
| node:buffer | 3.1 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| src/lib/measurements/consolidation-base.ts | 3 |
| npm:pg-protocol | 1.5 |
| npm:next | 1.5 |
| src/lib/analytics/source-priority.ts | 1.4 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 30.3 |
| src/lib/analytics/source-priority.ts | 22.8 |
| src/lib/validations/source-priority.ts | 21.4 |
| src/lib/measurements/consolidation-base.ts | 4.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| src/lib/measurements/consolidation-tz.ts | 1.5 |
### at 72986 ms, 57 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 19.1 |
| npm:@prisma/client | 10.2 |
| (native) | 6.6 |
| npm:pg-protocol | 5.5 |
| npm:next | 3 |
| src/lib/analytics/source-priority.ts | 3 |
| npm:pg | 2.5 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| node:internal/async_local_storage/async_hooks | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 29.6 |
| src/lib/analytics/source-priority.ts | 23.5 |
| src/lib/validations/source-priority.ts | 19.1 |
| src/lib/measurements/consolidation-base.ts | 4.6 |
| src/lib/tz/format.ts | 4.5 |
| src/lib/measurements/consolidation-tz.ts | 4.5 |
| src/lib/tz/zone-offset.ts | 3 |
### at 73523 ms, 51 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 12 |
| npm:next | 8.9 |
| npm:@prisma/client | 6 |
| (native) | 4.5 |
| src/lib/analytics/source-priority.ts | 4.2 |
| node:buffer | 4 |
| npm:pg | 2.1 |
| npm:pg-protocol | 1.5 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 27.8 |
| src/lib/analytics/source-priority.ts | 17.7 |
| src/lib/validations/source-priority.ts | 13.5 |
| src/lib/measurements/consolidation-base.ts | 8.6 |
| src/lib/tz/format.ts | 7.1 |
| src/lib/measurements/consolidation-tz.ts | 7.1 |
| src/lib/tz/zone-offset.ts | 6 |
### at 75393 ms, 100 ms
| self time by module | ms |
| --- | ---: |
| (native) | 32.6 |
| npm:@prisma/client | 17.2 |
| src/lib/validations/source-priority.ts | 14.6 |
| npm:next | 7.5 |
| src/lib/personal-records/pr-detection-worker.ts | 6.1 |
| src/lib/tz/zone-offset.ts | 5.6 |
| npm:pg-protocol | 4.1 |
| npm:pg | 2.5 |
| src/lib/tz/format.ts | 2.3 |
| node:internal/async_local_storage/async_hooks | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 34.6 |
| src/lib/analytics/source-priority.ts | 24.3 |
| src/lib/validations/source-priority.ts | 16.1 |
| src/lib/tz/format.ts | 10.9 |
| src/lib/measurements/consolidation-tz.ts | 10.9 |
| src/lib/tz/zone-offset.ts | 8.6 |
| src/lib/measurements/consolidation-base.ts | 5.7 |
### at 78085 ms, 54 ms
| self time by module | ms |
| --- | ---: |
| src/lib/tz/zone-offset.ts | 26.6 |
| src/lib/validations/source-priority.ts | 13.5 |
| npm:@prisma/client | 4.2 |
| (native) | 4.1 |
| node:internal/async_local_storage/async_hooks | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| src/lib/tz/intl-cache.ts | 1.5 |
| npm:pg | 1.1 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 44.7 |
| src/lib/tz/format.ts | 29.6 |
| src/lib/measurements/consolidation-tz.ts | 29.6 |
| src/lib/measurements/consolidation-base.ts | 29.6 |
| src/lib/tz/zone-offset.ts | 28.1 |
| src/lib/analytics/source-priority.ts | 15 |
| src/lib/validations/source-priority.ts | 13.5 |
| src/lib/tz/intl-cache.ts | 1.5 |
## Self time by module until the worker is ready
| module | ms |
| --- | ---: |
| (native) | 853 |
| npm:@prisma/client | 295.9 |
| node:internal/modules/cjs/loader | 248.7 |
| src/lib/validations/source-priority.ts | 210.3 |
| npm:next | 188.4 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 121.5 |
| src/lib/tz/zone-offset.ts | 83.2 |
| wasm://wasm/00d1cb12 | 81.4 |
| npm:pg | 78.5 |
| node:buffer | 59.1 |
| src/lib/tz/format.ts | 55 |
| npm:zod | 50.7 |
| src/lib/personal-records/pr-detection-worker.ts | 48 |
| npm:pg-protocol | 37 |
| node:internal/bootstrap/realm | 32 |
| src/lib/analytics/source-priority.ts | 31.7 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 27.7 |
| node:internal/modules/esm/utils | 26 |
| npm:luxon | 22.1 |
| node:internal/async_hooks | 21.7 |
| node:internal/async_local_storage/async_hooks | 21.2 |
| node:fs | 18.8 |
| npm:pg-boss | 18.3 |
| npm:pg-pool | 18.1 |
| npm:yaml | 15 |
## Heap snapshot after 300 s idle (live heap after a full GC)
159.5 MB in 1302659 nodes; module source strings over 50 KB: 39.8 MB.
- parsed locale catalogs: 2 objects, 1.6 MB retained
| node type | MB |
| --- | ---: |
| string | 67.5 |
| array | 26.6 |
| code | 19.2 |
| object | 15.9 |
| hidden | 9.2 |
| native | 8 |
| object shape | 5.9 |
| closure | 5.2 |
| concatenated string | 1.3 |
| number | 0.5 |
| sliced string | 0.2 |
| regexp | 0.2 |
| synthetic | 0 |
| symbol | 0 |
| bigint | 0 |
| constructor | MB | count |
| --- | ---: | ---: |
| array:(object elements) | 20.8 | 25651 |
| object:Object | 11.4 | 293893 |
| native:system / JSArrayBufferData | 7.5 | 170 |
| array:(object properties) | 3.3 | 6517 |
| array: | 2.5 | 10169 |
| closure: | 1.6 | 29121 |
| object:system / Context | 1.6 | 28886 |
| closure:native_bind | 1 | 20804 |
| object:Array | 0.6 | 20938 |
| object:Date1 | 0.5 | 5332 |
| object:a | 0.4 | 4438 |
| native:Node / histogram | 0.4 | 1 |
| object:d | 0.3 | 4425 |
| closure:get | 0.3 | 4732 |
| object:Set | 0.2 | 5670 |
| object:Module | 0.2 | 1963 |
| closure:set | 0.2 | 3428 |
| closure:n | 0.1 | 1572 |
| closure:i | 0.1 | 1578 |
| object:Promise | 0.1 | 1341 |
| largest retained subtrees | MB | retaining path |
| --- | ---: | --- |
| object:yr | 34.6 | [1] > [10] > 22 > extension > global > prisma > [1] > _engine |
| object:system / Context | 7.4 | Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [25] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4] > context |
| object:Promise | 5.9 | Object > executor > e > #r > gr > transactions > Map > table > > 6 > closing |
| object:Module | 5.6 | [1] > [10] > 99 > process > getBuiltinModule > getBuiltinModule > context > system / Context > $Module > Module > _cache > |
| code:.next/server/chu | 5.6 | Module > _cache > Object > .next/server/chunks/_1kwfnjg._.js > Module > exports > Array > [1] > > shared > script |
| code:.next/server/chu | 5.6 | global > prisma > [1] > t > _engineConfig > Object > compilerWasm > Object > getRuntime > getRuntime > shared > script |
| object:Generator | 4.4 | [6] > principal_realm > Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [26] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4 |
| string:AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/ | 4.4 | [1] > [21] > 60025 |
| object:Object | 3.4 | system / Context > $Module > Module > _cache > Object > .next/server/chunks/[turbopack]_runtime.js > Module > export |
| object:global | 2.6 | [1] > [10] > 22 > extension |
| largest strings | MB |
| --- | ---: |
| module.exports=[234494,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=> | 5.6 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| const wasm = "AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGAB | 4.4 |
| AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGABfwF/YAZ/f39/f3 | 4.4 |
| lliADOAUngIEAACgKwAgBgAAoSsAIAcAAKErACAIAACiKwAgCQAAoysAIAoAAKQrACALAAClKwAgDAAApisAIA0AAK | 1.9 |
| (()=>{var e,t,r={"./dist/build/webpack/alias/react-dom-server.js"(e,t,r){"use strict";var | 1.3 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[799092,(e,t,n)=>{"use strict";Object.defineProperty(n,"__esModule",{value: | 1.1 |
| // v1.4.34 web-freeze. Schema is locked additive-only until iOS launch // + v1.5.0 marker. | 1.1 |
| module.exports=[971743,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1.1 |
| module.exports=[835717,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1 |
| module.exports=[767189,e=>e.a(async(t,a)=>{try{var n=e.i(64105),r=e.i(563616),i=t([n]);[n] | 0.8 |
| module.exports=[594108,806176,674750,98746,t=>{"use strict";var i;let r;function n(t){retu | 0.7 |
+28
View File
@@ -0,0 +1,28 @@
# v1.45.0: boot and idle memory
`scripts/bench/boot-profile.ts`, production build, every role, heap ceiling
792 MB, against the seeded account, 300 s idle. `v1.45.0-before-boot` is the
v1.44 runtime, `v1.45.0-boot-after` the branch after the boot changes and
before the lazy match indexes, `v1.45.0-boot-final` the final tree.
| | before | final |
| --- | ---: | ---: |
| longest main-thread busy stretch, application code | 753 ms (worker graph, one block) | 336 ms (query compiler start, 324 ms before as well) |
| longest stretch loading the worker graph | 753 ms | 246 ms (one registrar) |
| live heap after full collections, idle | 162 to 172 MB | 142 to 153 MB |
| heap snapshot after 300 s idle | 172.3 MB | 159.5 MB |
| parsed locale catalogs in the heap | 32 objects, 14.1 MB | 2 objects, 1.6 MB |
| catalog chunk sources in the heap | 2 x 9.7 MB | none |
| module source strings over 50 KB | 44.2 MB | 39.8 MB |
| event-loop stalls logged (500 ms threshold) | 0 (monitor started late) | 0 (monitor first) |
Time to the first health answer and to a ready worker moved with the host's
load (1.6 s / 5.4 s before at load 9, 1.3 s / 3.2 s after at load 6) and is
not claimed as a result.
The rest of the 150 MB is the runtime itself: the query compiler (about
50 MB: its engine object, its WebAssembly source and chunk, twice), compiled
code (19 MB) and the remaining module sources. The used heap between full
collections stayed under 240 MB in both runs; the beta's 400 MB plateau is
garbage awaiting a collection under a larger limit plus the server caches of
real users, not a baseline the process cannot shed.
+22
View File
@@ -0,0 +1,22 @@
# v1.45.0: request fan-out
Same runs as `v1.45.0-read-path.md`.
| request | statements before | statements after | p50 ms before / after |
| --- | ---: | ---: | ---: |
| /api/auth/me | 19 | 14 | 9 / 8 |
| day view, cold | 49 | 48 | 77 / 68 |
- `/api/auth/me` read the AppSettings singleton six times (MFA policy,
Assistant switches, module availability, provider presence, AI usage
bounds, reasoning controls); one memoised read per request now serves all
six (`src/lib/settings/operator-row.ts`). On the small budget fixture it
went from 18 to 13 statements. The remaining reads are the session, the
grant, cycle profile, onboarding, consent, coach usage and five user reads
with different column sets.
- The day view's record sections run six at a time instead of about
twenty-five at once, so one day never holds more than a third of the pool.
The statement count is the same; what changes is the peak of connections.
- Keyset pagination for `/api/measurements` was not done: a cursor needs a
new request parameter and a response field, a contract change for the
native client.
+254
View File
@@ -0,0 +1,254 @@
{
"meta": {
"label": "v1.45.0-final",
"commit": "a57fa8335",
"date": "2026-10-22T06:52:09.539Z",
"node": "v22.23.2",
"postgres": "16.15",
"cpus": 14,
"loadAverage": [
6.8,
8.9,
14.5
],
"measurements": 1265561,
"iterations": 20,
"warmup": 3,
"heapMb": 792,
"idleStatementsPerSecond": 0,
"batch5k": {
"totalMs": 4448,
"totalStatements": 504
}
},
"results": [
{
"name": "/api/auth/me",
"mode": "cold",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 8.3,
"p95Ms": 27.2,
"maxMs": 221,
"statementsMedian": 14,
"statementsMax": 14,
"failures": 0
},
{
"name": "/api/auth/me",
"mode": "warm",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 7.9,
"p95Ms": 10.1,
"maxMs": 17.1,
"statementsMedian": 14,
"statementsMax": 17,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "cold",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 86.6,
"p95Ms": 100.8,
"maxMs": 126.8,
"statementsMedian": 30,
"statementsMax": 31,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "warm",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 9.2,
"p95Ms": 17.1,
"maxMs": 18.4,
"statementsMedian": 6,
"statementsMax": 6,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "cold",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 545.3,
"p95Ms": 592.5,
"maxMs": 606.3,
"statementsMedian": 153,
"statementsMax": 156,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "warm",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 8.1,
"p95Ms": 17.9,
"maxMs": 21.9,
"statementsMedian": 10,
"statementsMax": 10,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 18.7,
"p95Ms": 27.2,
"maxMs": 36.1,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 15.9,
"p95Ms": 21.8,
"maxMs": 26,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 40.7,
"p95Ms": 55.6,
"maxMs": 56.2,
"statementsMedian": 7,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 38.3,
"p95Ms": 59.6,
"maxMs": 88.6,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 334.9,
"p95Ms": 348.5,
"maxMs": 351.2,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 328.1,
"p95Ms": 346,
"maxMs": 348.9,
"statementsMedian": 7,
"statementsMax": 8,
"failures": 0
},
{
"name": "day view",
"mode": "cold",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 68.4,
"p95Ms": 102.6,
"maxMs": 104.7,
"statementsMedian": 48,
"statementsMax": 49,
"failures": 0
},
{
"name": "day view",
"mode": "warm",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 46.4,
"p95Ms": 66.1,
"maxMs": 72.6,
"statementsMedian": 47,
"statementsMax": 47,
"failures": 0
},
{
"name": "insights health status",
"mode": "cold",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 156,
"p95Ms": 190,
"maxMs": 197.7,
"statementsMedian": 67,
"statementsMax": 67,
"failures": 0
},
{
"name": "insights health status",
"mode": "warm",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 173.6,
"p95Ms": 1163.4,
"maxMs": 1419.4,
"statementsMedian": 67,
"statementsMax": 67,
"failures": 0
},
{
"name": "insights correlations",
"mode": "cold",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 106.1,
"p95Ms": 229.1,
"maxMs": 383.3,
"statementsMedian": 48,
"statementsMax": 49,
"failures": 0
},
{
"name": "insights correlations",
"mode": "warm",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 18.7,
"p95Ms": 626.6,
"maxMs": 1136.5,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "batch ingest 5k (per 500)",
"mode": "write",
"request": "POST /api/measurements/batch ×10",
"n": 10,
"p50Ms": 185.7,
"p95Ms": 1269.2,
"maxMs": 1269.2,
"statementsMedian": 50,
"statementsMax": 53,
"failures": 0
}
]
}
+25
View File
@@ -0,0 +1,25 @@
# Benchmark v1.45.0-final
Commit a57fa8335 with the working tree of the final commits, 2026-10-22T06:52:09.539Z. 1265561 measurement rows on the bench account, 20 measured requests per row after 3 warm-up requests, one at a time. Server: production build, web process, heap ceiling 792 MB. Postgres 16.15, 14 cores, load average 6.8,8.9,14.5 at the end. Idle background: 0 statements/s.
| Request | Mode | n | p50 ms | p95 ms | max ms | statements (median) | statements (max) |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| /api/auth/me | cold | 20 | 8.3 | 27.2 | 221 | 14 | 14 |
| /api/auth/me | warm | 20 | 7.9 | 10.1 | 17.1 | 14 | 17 |
| dashboard summary | cold | 20 | 86.6 | 100.8 | 126.8 | 30 | 31 |
| dashboard summary | warm | 20 | 9.2 | 17.1 | 18.4 | 6 | 6 |
| dashboard snapshot | cold | 20 | 545.3 | 592.5 | 606.3 | 153 | 156 |
| dashboard snapshot | warm | 20 | 8.1 | 17.9 | 21.9 | 10 | 10 |
| series pulse 30 d | cold | 20 | 18.7 | 27.2 | 36.1 | 8 | 8 |
| series pulse 30 d | warm | 20 | 15.9 | 21.8 | 26 | 8 | 8 |
| series pulse 365 d | cold | 20 | 40.7 | 55.6 | 56.2 | 7 | 8 |
| series pulse 365 d | warm | 20 | 38.3 | 59.6 | 88.6 | 7 | 7 |
| series pulse 3650 d | cold | 20 | 334.9 | 348.5 | 351.2 | 7 | 7 |
| series pulse 3650 d | warm | 20 | 328.1 | 346 | 348.9 | 7 | 8 |
| day view | cold | 20 | 68.4 | 102.6 | 104.7 | 48 | 49 |
| day view | warm | 20 | 46.4 | 66.1 | 72.6 | 47 | 47 |
| insights health status | cold | 20 | 156 | 190 | 197.7 | 67 | 67 |
| insights health status | warm | 20 | 173.6 | 1163.4 | 1419.4 | 67 | 67 |
| insights correlations | cold | 20 | 106.1 | 229.1 | 383.3 | 48 | 49 |
| insights correlations | warm | 20 | 18.7 | 626.6 | 1136.5 | 7 | 7 |
| batch ingest 5k (per 500) | write | 10 | 185.7 | 1269.2 | 1269.2 | 50 | 53 |
@@ -0,0 +1,45 @@
# v1.45.0: read path
Request benchmark on the seeded account (1,265,561 rows), production build,
web process, heap ceiling 792 MB, 20 requests per row, one at a time.
`v1.45.0-before` is the v1.44 runtime (1915f9152), `v1.45.0-final` this
branch; both on the same machine within the hour, load average 7 to 30
(other work on the host), so latency compares within a factor of noise and
statements exactly. Cold means every request follows a one-row manual sync.
| request | before p50 / p95 ms | after p50 / p95 ms | statements before / after |
| --- | ---: | ---: | ---: |
| dashboard snapshot, cold | 1482 / 1526 | 545 / 593 | 158 / 153 |
| dashboard summary, cold | 170 / 221 | 87 / 101 | 31 / 30 |
| series pulse 3650 d, cold | 1627 / 1665 | 335 / 349 | 8 / 7 |
| series pulse 3650 d, warm | 2091 / 2435 | 328 / 346 | 8 / 7 |
| series pulse 365 d, cold | 184 / 193 | 41 / 56 | 8 / 7 |
| series pulse 30 d, cold | 24 / 31 | 19 / 27 | 8 / 8 |
| insights health status, cold | 245 / 401 | 156 / 190 | 68 / 67 |
What moved them:
- **JIT off for app sessions.** Postgres compiled every fold whose estimated
cost passed `jit_above_cost`; on the ten-year pulse pick that was 100 to
140 ms of a 220 ms statement (`EXPLAIN ANALYZE`: 395 ms with JIT, 220 ms
without).
- **The canonical-source pick groups first.** `DISTINCT ON` now sorts the
distinct `(type, day, source)` triples instead of every raw reading (the
ten-year pulse pick sorted 260,000 rows and spilled to disk); 220 ms to
175 ms on its own. Parity: `tests/integration/canonical-source-pick-parity.test.ts`
runs the old statement beside the new one over five reader shapes and two
ladders on a fixture with overlapping and off-ladder sources, tombstones,
empty days and readings around midnight; identical rows.
- **The dense series reads its rows once.** Buckets and strip figures come
from one statement over a materialised window instead of two statements
that each picked the canonical source and scanned the readings. Parity:
`tests/integration/dense-series-read-parity.test.ts`, the two old
statements as reference, pulse (two ranked sources), glucose and oxygen
saturation, day and hour grains, UTC, Europe/Berlin, Asia/Kolkata and
America/New_York; identical buckets and figures (relative 1e-9).
- **Closed blocks survive a sync.** The slice's pre-fold remainder (every
reading older than five years, the snapshot's largest statement at 357 ms)
and the day view's past history blocks are cached under a key that names
the instant their readings end before; a sync of newer readings no longer
drops them (`src/lib/cache/closed-block.ts`). The values are the same
statement's; only how long they are kept changed.
@@ -0,0 +1,28 @@
# v1.45.0: workout duplicate window
A correctness change, not a speed one; the request benchmark does not read
workouts, so there is no latency row for it.
Before: two copies of one session were collapsed only when both starts fell
in the same fixed five-minute clock slot (`floor(start / 5 min)`). A pair 30 s
apart that straddled a slot boundary stayed two workouts in the list, the
batch write, the calendar, strain and the Coach.
After: per user and sport, sorted by start, a workout joins the open cluster
while it starts less than five minutes after the cluster's first start.
Proof (`src/lib/measurements/__tests__/pick-canonical-workout-rows.property.test.ts`,
against the previous picker kept as `pick-canonical-workout-rows.legacy.ts`):
| property | inputs |
| --- | --- |
| same rows as the fixed slots wherever no pair within five minutes straddles a slot boundary, three ladders | 4,000 generated inputs, over 1,000 comparable |
| same strain impulses as before on those inputs | 2,000 generated inputs, over 500 comparable |
| two sources' copies collapse at any offset under five minutes (the old picker missed a share) | 3,000 random offsets |
| five minutes or more apart never collapse | 2,000 |
| a uniform shift of every start keeps the picked set | 1,500 |
| a row of workouts four minutes apart does not chain | fixed case |
One existing case changed its expectation: three runs at 08:04, 08:08 and
08:12 used to stay three because they sat in three slots; 08:08 is now the
08:04 run from a lower-ranked source (four minutes apart), and 08:12 stays.
@@ -0,0 +1,26 @@
# v1.45.0: write path
Same runs as `v1.45.0-read-path.md`.
| request | before p50 ms | after p50 ms | statements before / after |
| --- | ---: | ---: | ---: |
| batch ingest, 500 fresh rows per request | 166 | 186 | 53 / 50 |
Fresh rows were already set-based (v1.44); the benchmark's ingest is all
fresh rows, so it shows no gain, as expected. The changes are for re-posts:
- **Unchanged `stats:` re-posts** write nothing and start nothing. One read
over the batch's `stats:` ids replaces a reconciler transaction (about ten
round trips) per row, and an all-unchanged batch no longer refolds, enqueues
rollups or PR detection, or marks caches stale. The per-entry status stays
`updated` and the summary's `updated` count is unchanged.
`tests/integration/routes/measurements-batch-unchanged-stats.test.ts`: four
unchanged re-posts take at most 12 statements in total (before: about 40)
with zero rollup, invalidation and PR calls; a changed value, a changed
device or a tombstoned row still goes through the reconciler.
- **Apple Health re-import** skips the UPDATE of a row stored with exactly the
values it would write; one read per chunk instead of one UPDATE per row.
`tests/integration/apple-health-reimport-unchanged.test.ts`: a re-import of
20 stored rows reports the same per-type counts and sends no UPDATE.
- **Narrower invalidation.** The batch route tells the cache how far back it
wrote (`touchedFrom`), so closed blocks of older readings stay.
@@ -1,14 +1,13 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 125,
"total": 148250,
"count": 124,
"total": 147278,
"entries": {
"app/api/dashboard/summary/route.ts": 1032,
"app/api/documents/inbound/route.ts": 811,
"app/api/insights/generate/route.ts": 1209,
"app/api/measurements/batch/route.ts": 1576,
"app/api/measurements/batch/route.ts": 1569,
"app/api/measurements/route.ts": 938,
"app/api/measurements/series/route.ts": 879,
"app/api/medications/[id]/route.ts": 1281,
"app/api/medications/intake/bulk/route.ts": 1044,
"app/api/workouts/batch/route.ts": 971,
@@ -52,11 +51,11 @@
"lib/ai/coach/types.ts": 1011,
"lib/ai/codex-client.ts": 1094,
"lib/ai/prompts/insight-generator.ts": 1121,
"lib/ai/provider.ts": 1644,
"lib/ai/provider.ts": 1632,
"lib/ai/safety/outbound-screen.ts": 1463,
"lib/analytics/score/reader.ts": 998,
"lib/analytics/sleep-night.ts": 1345,
"lib/analytics/summaries-slice.ts": 1314,
"lib/analytics/summaries-slice.ts": 1248,
"lib/api-handler.ts": 1978,
"lib/auth/session.ts": 811,
"lib/cache/server-cache.ts": 851,
@@ -107,10 +106,10 @@
"lib/jobs/reminder/register-status.ts": 878,
"lib/managed-profiles/handover.ts": 986,
"lib/mcp/record-search.ts": 1467,
"lib/mcp/rich-reads.ts": 1468,
"lib/mcp/rich-reads.ts": 1467,
"lib/mcp/tools.ts": 2654,
"lib/measurements/dense-intraday-retention.ts": 1057,
"lib/measurements/import-apple-health-export.ts": 1729,
"lib/measurements/import-apple-health-export.ts": 1722,
"lib/medications/scheduling/cadence.ts": 978,
"lib/medications/scheduling/recurrence.ts": 1108,
"lib/medications/scheduling/slot-upsert.ts": 926,
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 410,
"total": 148267,
"total": 148056,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -38,10 +38,10 @@
"app/api/mcp/oauth/token/route.ts :: POST > withBackgroundEvent(arg)": 248,
"app/api/measurement-reminders/[id]/route.ts :: apiHandler(arg) #2": 240,
"app/api/measurements/[id]/route.ts :: apiHandler(arg) #2": 203,
"app/api/measurements/batch/route.ts :: postBatch": 1277,
"app/api/measurements/batch/route.ts :: postBatch": 1262,
"app/api/measurements/route.ts :: apiHandler(arg)": 286,
"app/api/measurements/route.ts :: postMeasurement": 377,
"app/api/measurements/series/route.ts :: apiHandler(arg)": 670,
"app/api/measurements/series/route.ts :: apiHandler(arg)": 549,
"app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 256,
"app/api/medications/[id]/intake/[eventId]/route.ts :: apiHandler(arg)": 390,
"app/api/medications/[id]/intake/route.ts :: postIntake": 607,
@@ -299,7 +299,7 @@
"lib/analytics/correlations-fast-path.ts :: computeCorrelationHypothesesFastPath": 256,
"lib/analytics/intraday-pulse-io.ts :: loadIntradayPulse": 206,
"lib/analytics/score/reader.ts :: computeUserHealthScore": 324,
"lib/analytics/summaries-slice.ts :: computeFromRollups": 441,
"lib/analytics/summaries-slice.ts :: computeFromRollups": 377,
"lib/api-handler.ts :: apiHandler": 207,
"lib/api-handler.ts :: apiHandler > wrapped": 201,
"lib/auth/profile-update.ts :: applyProfileUpdate": 353,
@@ -389,7 +389,7 @@
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention > walkSource > writeDay > foldOnce": 212,
"lib/measurements/drain-per-sample-cumulative.ts :: drainPerSampleCumulative": 344,
"lib/measurements/drain-per-sample-cumulative.ts :: drainPerSampleCumulative > writeDay": 205,
"lib/measurements/import-apple-health-export.ts :: streamParseExportXml": 1256,
"lib/measurements/import-apple-health-export.ts :: streamParseExportXml": 1245,
"lib/measurements/import-apple-health-export.ts :: streamParseExportXml > (anonymous) #2": 323,
"lib/medications/efficacy/build-efficacy.ts :: buildMedicationEfficacy": 268,
"lib/medications/intake-slot-dedup.ts :: dedupeUserIntakeSlots": 321,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2959,
"total": 10525,
"total": 10524,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -1885,7 +1885,7 @@
"lib/analytics/sleep-night.ts": 3,
"lib/analytics/source-priority.ts": 2,
"lib/analytics/source-rank-sql.ts": 3,
"lib/analytics/summaries-slice.ts": 37,
"lib/analytics/summaries-slice.ts": 36,
"lib/analytics/trend-delta.ts": 1,
"lib/analytics/trends.ts": 6,
"lib/analytics/value-bands.ts": 1,
@@ -79,6 +79,8 @@ const WRITE_SITES: Record<string, string> = {
"a primitive; every caller runs the tail over the verdicts' dirty identities",
"lib/measurements/import-apple-health-export.ts":
"the import worker refolds the import's span and re-warms every imported type",
"lib/measurements/import-unchanged-rows.ts":
"the Apple export import's natural-key rescue, inside the import; its worker runs both legs once over the whole import",
"lib/import/health-connect/import.ts":
"the Health Connect import worker refolds the import's span and re-warms every imported type",
"lib/export/measurements-restore.ts":
@@ -93,7 +93,6 @@ const HELPER: Entry = { helper: true };
/** Files with a database mean over values. */
const SQL_READERS: Record<string, Entry> = {
"app/api/measurements/series/route.ts": HELPER,
"lib/ai/coach/tools/availability.ts": HELPER,
"lib/analytics/summaries-slice.ts": HELPER,
"lib/doctor-report/dense-buckets.ts": HELPER,
@@ -102,6 +101,8 @@ const SQL_READERS: Record<string, Entry> = {
// The dry run's hour means, weighed by both methods through the helper.
"lib/jobs/pulse-day-mean.ts": HELPER,
"lib/measurements/daily-series-read.ts": HELPER,
// The series route's long-window dense read.
"lib/measurements/dense-series-read.ts": HELPER,
"lib/rollups/measurement-rollups.ts": HELPER,
"lib/rollups/tiered-context.ts": HELPER,
"lib/insights/correlation-channel-series.ts": {
@@ -576,15 +576,21 @@ describe("a batch of duplicates only (v1.42)", () => {
});
describe("cache invalidation by origin (v1.42)", () => {
// Every call also says how far back the batch reached (`touchedFrom`), so
// closed blocks of older readings stay cached.
it.each([
["foreground", { evict: true }],
["manual", { evict: true }],
["background", undefined],
["push", undefined],
[undefined, undefined],
["background", {}],
["push", {}],
[undefined, {}],
])("syncTrigger %s → %o", async (trigger, expected) => {
await post([weight(`uuid-${trigger}`, new Date(NOW - DAY))], trigger);
expect(invalidateUserMeasurements).toHaveBeenCalledWith(USER, expected);
const at = new Date(NOW - DAY);
await post([weight(`uuid-${trigger}`, at)], trigger);
expect(invalidateUserMeasurements).toHaveBeenCalledWith(USER, {
...expected,
touchedFrom: at,
});
});
it("evicts for a background batch that wrote a hand-entered reading", async () => {
@@ -599,6 +605,7 @@ describe("cache invalidation by origin (v1.42)", () => {
);
expect(invalidateUserMeasurements).toHaveBeenCalledWith(USER, {
evict: true,
touchedFrom: new Date(NOW - DAY),
});
});
});
+78 -85
View File
@@ -66,6 +66,14 @@ import {
oppositeMergeSource,
type MergeCandidate,
} from "@/lib/measurements/cross-source-merge";
import {
externalKeyOf,
onceInBatch,
readStoredSampleIdentities,
readStoredStatsRows,
sampleIsStoredDuplicate,
statsWriteUnchanged,
} from "@/lib/measurements/batch-stored-rows";
import {
lockMeasurementUser,
reconcileExternalMeasurement,
@@ -643,6 +651,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
let insertedCount = 0;
let updatedCount = 0;
/**
* `stats:` re-posts reported `updated` with nothing to write (see
* `isUnchangedStatsRow`). Counted in the response's `updated` like before,
* and in nothing that starts work.
*/
let unchangedStatsCount = 0;
let duplicateCount = 0;
let failedCount = 0;
// v1.44 — subset of `insertedCount` written by the set-based insert.
@@ -769,82 +783,52 @@ async function postBatch(request: NextRequest): Promise<Response> {
// before this one is reached, and then the reconciler has to decide.
// `stats:` rows are never prefiltered (overwrite semantics), and neither
// is a value the reconciler's own plausibility gate would refuse.
const externalKeyCounts = new Map<string, number>();
const naturalKeyCounts = new Map<string, number>();
const naturalKeyOf = (
type: unknown,
source: unknown,
measuredAt: Date,
sleepStage: unknown,
) => `${type}::${source}::${measuredAt.getTime()}::${sleepStage ?? "null"}`;
for (const p of prepared) {
const externalKey = `${p.row.type}::${p.row.source}::${p.row.externalId}`;
externalKeyCounts.set(
externalKey,
(externalKeyCounts.get(externalKey) ?? 0) + 1,
);
const naturalKey = naturalKeyOf(
p.row.type,
p.row.source,
p.row.measuredAt as Date,
p.row.sleepStage,
);
naturalKeyCounts.set(
naturalKey,
(naturalKeyCounts.get(naturalKey) ?? 0) + 1,
);
}
const sampleExternalIds = [
const onceInThisBatch = onceInBatch(prepared.map((p) => p.row));
const existingByExternalKey = await readStoredSampleIdentities(
prisma,
user.id,
[
...new Set(
prepared
.map((p) => p.row.externalId as string)
.filter((externalId) => !isStatsExternalId(externalId)),
),
],
);
const isExactStoredDuplicate = (p: Prepared): boolean =>
sampleIsStoredDuplicate(
existingByExternalKey.get(
externalKeyOf(p.row.type, p.row.source, p.row.externalId),
),
p.row,
) &&
onceInThisBatch(p.row) &&
classifyRangeRejection(
p.row.type as MeasurementType,
p.row.value as number,
) === null;
// An unchanged `stats:` re-post is reported `updated` without a write
// (see `batch-stored-rows.ts`), under the same once-per-batch key rule.
const storedStats = await readStoredStatsRows(prisma, user.id, [
...new Set(
prepared
.map((p) => p.row.externalId as string)
.filter((externalId) => !isStatsExternalId(externalId)),
.filter((externalId) => isStatsExternalId(externalId)),
),
];
const existingByExternalKey = new Map<
string,
{ measuredAt: Date; sleepStage: unknown }
>();
if (sampleExternalIds.length > 0) {
const existing = await prisma.measurement.findMany({
where: { userId: user.id, externalId: { in: sampleExternalIds } },
select: {
type: true,
source: true,
externalId: true,
measuredAt: true,
sleepStage: true,
},
});
for (const row of existing) {
existingByExternalKey.set(
`${row.type}::${row.source}::${row.externalId}`,
{ measuredAt: row.measuredAt, sleepStage: row.sleepStage },
);
}
}
function isExactStoredDuplicate(p: Prepared): boolean {
const externalKey = `${p.row.type}::${p.row.source}::${p.row.externalId}`;
const stored = existingByExternalKey.get(externalKey);
if (!stored) return false;
const measuredAt = p.row.measuredAt as Date;
const naturalKey = naturalKeyOf(
p.row.type,
p.row.source,
measuredAt,
p.row.sleepStage,
]);
const isUnchangedStatsRow = (p: Prepared): boolean =>
onceInThisBatch(p.row) &&
classifyRangeRejection(
p.row.type as MeasurementType,
p.row.value as number,
) === null &&
statsWriteUnchanged(
storedStats.get(
externalKeyOf(p.row.type, p.row.source, p.row.externalId),
),
desiredFor(p),
);
return (
stored.measuredAt.getTime() === measuredAt.getTime() &&
(stored.sleepStage ?? null) === (p.row.sleepStage ?? null) &&
externalKeyCounts.get(externalKey) === 1 &&
naturalKeyCounts.get(naturalKey) === 1 &&
classifyRangeRejection(
p.row.type as MeasurementType,
p.row.value as number,
) === null
);
}
// v1.42 — the `folded_window` guard (`folded-window.ts`). A raw Apple
// Health sample of a dense or mean type, old enough to have been folded,
@@ -1063,6 +1047,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
continue;
}
if (statsRow && isUnchangedStatsRow(p)) {
results[p.index] = { index: p.index, status: "updated" };
unchangedStatsCount++;
pushMergeCandidate(p);
continue;
}
if (!statsRow && hasSameReadingSibling(p)) {
results[p.index] = {
index: p.index,
@@ -1093,16 +1084,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
continue;
}
const naturalKey = naturalKeyOf(
p.row.type,
p.row.source,
p.row.measuredAt as Date,
p.row.sleepStage,
);
if (
externalKeyCounts.get(key) === 1 &&
naturalKeyCounts.get(naturalKey) === 1
) {
if (onceInThisBatch(p.row)) {
pendingInserts.push(p);
pushMergeCandidate(p);
continue;
@@ -1311,7 +1293,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
details: {
processed: entries.length,
inserted: insertedCount,
updated: updatedCount,
updated: updatedCount + unchangedStatsCount,
duplicates: duplicateCount,
skipped: skipped.length,
failed: failedCount,
@@ -1449,7 +1431,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
meta: {
processed: entries.length,
inserted: insertedCount,
updated: updatedCount,
updated: updatedCount + unchangedStatsCount,
stats_unchanged: unchangedStatsCount,
duplicates: duplicateCount,
skipped: skipped.length,
// v1.32.1 (issue #585) — per-reason skip breakdown; see the
@@ -1497,7 +1480,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
syncTrigger === "foreground" ||
syncTrigger === "manual" ||
wroteManualRow;
invalidateUserMeasurements(user.id, evict ? { evict: true } : undefined);
// Every row this batch created, changed or retired is in
// `writtenIdentities`, so its earliest `measuredAt` bounds what changed:
// closed blocks of older readings (the pre-fold remainder, the day
// view's past history blocks) stay cached across the sync.
const touchedFrom = new Date(
Math.min(...writtenIdentities.map((w) => w.measuredAt.getTime())),
);
invalidateUserMeasurements(user.id, {
...(evict ? { evict: true } : {}),
...(Number.isFinite(touchedFrom.getTime()) ? { touchedFrom } : {}),
});
// v1.37.19 — shared post-mutation tail for every distinct
// (type, day) the batch touched. Updates participate as well as
@@ -1563,7 +1556,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
const response = apiSuccess({
processed: entries.length,
inserted: insertedCount + hourInsertedCount,
updated: updatedCount + hourUpdatedCount,
updated: updatedCount + unchangedStatsCount + hourUpdatedCount,
duplicates: duplicateCount,
failed: failedCount,
skipped,
@@ -21,6 +21,51 @@ vi.mock("@/lib/db", () => ({
},
}));
// The route's dense read is one statement (`dense-series-read.ts`, held to
// the two statements it replaced by an integration parity test). Here it
// issues those two statements again, so the route's shaping of buckets and
// figures is pinned against the same queued results as before.
vi.mock("@/lib/measurements/dense-series-read", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/lib/measurements/dense-series-read")
>();
const { usesHourlyMeanDay } =
await import("@/lib/measurements/day-statistic");
return {
...actual,
readDenseSeries: async (args: {
userId: string;
type: Parameters<typeof usesHourlyMeanDay>[0];
grain: "day" | "hour";
userTz: string;
since: Date;
seriesRows: string;
}) => {
const { prisma } = await import("@/lib/db");
const hourWeighted = args.grain === "day" && usesHourlyMeanDay(args.type);
const bucketRows = await prisma.$queryRawUnsafe(
actual.denseBucketsSql(
hourWeighted,
`${args.seriesRows} WHERE m."measured_at" >= $4`,
),
args.userId,
args.grain,
args.userTz,
args.since,
);
const [rawAgg] = (await prisma.$queryRawUnsafe(
`WITH src AS (SELECT m.* FROM ${args.seriesRows} WHERE m."measured_at" >= $4) ${actual.denseFiguresSql(usesHourlyMeanDay(args.type))}`,
args.userId,
args.grain,
args.userTz,
args.since,
)) as unknown[];
return { bucketRows, rawAgg };
},
};
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
// v1.11.4 — the sleep branch resolves the user's tz to bucket per-night;
+7 -134
View File
@@ -14,6 +14,7 @@
* window, judged by that direction) and `referenceLines` (the person's own
* target lines, else the clinical ones, in the unit of the points).
*/
import { readDenseSeries } from "@/lib/measurements/dense-series-read";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
@@ -33,14 +34,7 @@ import { readSleepNights } from "@/lib/measurements/list/sleep-nights";
import { VALUE_RANGES } from "@/lib/validations/measurement";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
import {
dayWeightedRows,
hourWeightSql,
readingsMean,
weightedHourMeanSql,
windowMeanSql,
zoneDayFrame,
} from "@/lib/measurements/day-mean";
import { readingsMean } from "@/lib/measurements/day-mean";
import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
@@ -461,135 +455,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
// weighted mean of its local hours' means, so a workout hour with hundreds
// of times the readings does not outweigh the rest of the day (see
// `day-statistic.ts`). The hour grain is already one mean per hour.
const hourWeighted = grain === "day" && usesHourlyMeanDay(type);
const bucketRows = await prisma.$queryRawUnsafe<
Array<{
bucket_start: Date;
mean: number;
min_value: number;
max_value: number;
}>
>(
hourWeighted
? `
WITH localized AS (
SELECT
m."value",
m."value_min",
m."value_max",
date_trunc(
'hour',
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $3
) AS local_hour
FROM ${seriesRows}
WHERE m."measured_at" >= $4
),
hourly AS (
SELECT
date_trunc($2, local_hour) AS local_day,
AVG("value") AS hour_mean,
COUNT(*) AS n,
MIN(COALESCE("value_min", "value")) AS min_value,
MAX(COALESCE("value_max", "value")) AS max_value
FROM localized
GROUP BY local_hour
)
SELECT
local_day AT TIME ZONE $3 AS bucket_start,
${weightedHourMeanSql("hour_mean", hourWeightSql("n"))}::double precision AS mean,
MIN(min_value)::double precision AS min_value,
MAX(max_value)::double precision AS max_value
FROM hourly
GROUP BY local_day
ORDER BY bucket_start ASC
`
: `
WITH localized AS (
SELECT
m."value",
m."value_min",
m."value_max",
date_trunc(
$2,
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $3
) AS local_day
FROM ${seriesRows}
WHERE m."measured_at" >= $4
)
SELECT
local_day AT TIME ZONE $3 AS bucket_start,
AVG("value")::double precision AS mean,
MIN(COALESCE("value_min", "value"))::double precision AS min_value,
MAX(COALESCE("value_max", "value"))::double precision AS max_value
FROM localized
GROUP BY local_day
ORDER BY bucket_start ASC
`,
user.id,
const { bucketRows, rawAgg } = await readDenseSeries({
userId: user.id,
type,
grain,
userTz,
since,
);
// Stats stay aggregated over the RAW rows (not the day buckets) so the
// strip's mean/min/max/stdDev/count are the same figures the raw path
// computed: `AVG`/`MIN`/`MAX` over `value`, `STDDEV_POP` matching the
// JS population-variance helper, exact row count. Aggregate-only —
// no row transfer. For an hourly-mean type (pulse) the mean is the mean
// of the local days' values, each day the mean of its local hours'
// means, so it is the mean of the day points the chart draws; count,
// min, max and the spread stay over every reading.
const [rawAgg] = usesHourlyMeanDay(type)
? await prisma.$queryRawUnsafe<
Array<{
n: number;
mean: number | null;
min: number | null;
max: number | null;
sd: number | null;
}>
>(
`
WITH src AS (
SELECT m.*
FROM ${seriesRows}
WHERE m."measured_at" >= $2
)
SELECT
COUNT(*)::int AS n,
${windowMeanSql({ typeColumn: 'm."type"', value: 'm."value"', weight: "m.day_weight" })}::double precision
AS mean,
MIN(m."value")::double precision AS min,
MAX(m."value")::double precision AS max,
STDDEV_POP(m."value")::double precision AS sd
FROM ${dayWeightedRows("src", zoneDayFrame("$3"))} m
GROUP BY m."type"
`,
user.id,
since,
userTz,
)
: await prisma.$queryRawUnsafe<
Array<{
n: number;
mean: number | null;
min: number | null;
max: number | null;
sd: number | null;
}>
>(
`
SELECT
COUNT(*)::int AS n,
AVG(m."value")::double precision AS mean,
MIN(m."value")::double precision AS min,
MAX(m."value")::double precision AS max,
STDDEV_POP(m."value")::double precision AS sd
FROM ${seriesRows}
WHERE m."measured_at" >= $2
`,
user.id,
since,
);
seriesRows,
});
const round2 = (v: number) => Math.round(v * 100) / 100;
const dayId = (d: Date) =>
grain === "day"
+9 -7
View File
@@ -25,7 +25,9 @@ import localFont from "next/font/local";
// `next/font/local` is evaluated at build time from the literal arguments of
// each call, so every option below has to be written out in place.
export const interCyrillicExt = localFont({
// The bindings are never read: importing this module is what attaches the
// faces, so each name carries the leading underscore of an intended unused.
const _interCyrillicExt = localFont({
src: [
{
path: "./inter/inter-cyrillic-ext.woff2",
@@ -61,7 +63,7 @@ export const interCyrillicExt = localFont({
],
});
export const interCyrillic = localFont({
const _interCyrillic = localFont({
src: [
{ path: "./inter/inter-cyrillic.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-cyrillic.woff2", weight: "500", style: "normal" },
@@ -80,7 +82,7 @@ export const interCyrillic = localFont({
],
});
export const interGreekExt = localFont({
const _interGreekExt = localFont({
src: [
{ path: "./inter/inter-greek-ext.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-greek-ext.woff2", weight: "500", style: "normal" },
@@ -96,7 +98,7 @@ export const interGreekExt = localFont({
],
});
export const interGreek = localFont({
const _interGreek = localFont({
src: [
{ path: "./inter/inter-greek.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-greek.woff2", weight: "500", style: "normal" },
@@ -116,7 +118,7 @@ export const interGreek = localFont({
],
});
export const interVietnamese = localFont({
const _interVietnamese = localFont({
src: [
{ path: "./inter/inter-vietnamese.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-vietnamese.woff2", weight: "500", style: "normal" },
@@ -136,7 +138,7 @@ export const interVietnamese = localFont({
],
});
export const interLatinExt = localFont({
const _interLatinExt = localFont({
src: [
{ path: "./inter/inter-latin-ext.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-latin-ext.woff2", weight: "500", style: "normal" },
@@ -156,7 +158,7 @@ export const interLatinExt = localFont({
],
});
export const interLatin = localFont({
const _interLatin = localFont({
src: [
{ path: "./inter/inter-latin.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-latin.woff2", weight: "500", style: "normal" },
+23 -9
View File
@@ -3,6 +3,18 @@ import type { Instrumentation } from "next";
export async function register() {
// Only start the worker on the Node.js server runtime (not Edge, not build)
if (process.env.NEXT_RUNTIME === "nodejs") {
// The event-loop monitor comes first, so the loading of the database
// client and of the worker graph is inside its view, and every stall it
// reports names the boot step that was running (`boot-steps.ts`).
const [{ startEventLoopLagMonitor }, { beginBootStep }] = await Promise.all(
[
import("@/lib/observability/event-loop-lag"),
import("@/lib/boot/boot-steps"),
],
);
startEventLoopLagMonitor();
beginBootStep("readiness_summary");
// First-boot readiness summary: a human-readable block on the core secrets
// (with the exact fix on a bad one) before any fail-closed loader throws,
// plus a one-glance status of the optional channels. Runs for every process
@@ -18,6 +30,7 @@ export async function register() {
// Does the configured encryption key open this database? A process whose
// key does not refuses every API request with one clear 503 and starts
// neither the producer nor the worker (a wrong key must never write).
beginBootStep("key_check");
try {
const [{ runBootKeyCheck }, { prisma }] = await Promise.all([
import("@/lib/boot/key-check"),
@@ -29,6 +42,7 @@ export async function register() {
}
// Every connection is pinned to UTC; say clearly if one is not.
beginBootStep("time_zone_check");
try {
const [{ runBootTimeZoneCheck }, { prisma }] = await Promise.all([
import("@/lib/boot/time-zone-check"),
@@ -41,6 +55,7 @@ export async function register() {
// Every pulse reader follows the operator's pulse day-mean method; mirror
// it in this process and keep the mirror current.
beginBootStep("pulse_method_sync");
try {
const { startPulseDayMeanMethodSync } =
await import("@/lib/measurements/pulse-day-mean-method");
@@ -49,15 +64,10 @@ export async function register() {
// Without the mirror the process keeps the default method.
}
// Every Node process watches its own event loop — web-only
// containers included; a stall report from the process serving
// requests is the whole point.
const { startEventLoopLagMonitor } =
await import("@/lib/observability/event-loop-lag");
startEventLoopLagMonitor();
// The same for its heap: a trend line, and one report naming the work in
// flight before the heap runs out (a heap-limit crash logs nothing else).
// Every Node process watches its heap as it watches its event loop: a
// trend line, and one report naming the work in flight before the heap
// runs out (a heap-limit crash logs nothing else).
beginBootStep("monitors");
try {
const [{ startHttpInFlightTracking }, { startHeapMonitor }] =
await Promise.all([
@@ -116,6 +126,7 @@ export async function register() {
// process owns no schedules, supervision, or consumers.
void (async () => {
try {
beginBootStep("producer_start");
const { startGlobalBossProducer } =
await import("@/lib/jobs/boss-instance");
const boss = await startGlobalBossProducer(process.env.DATABASE_URL);
@@ -125,6 +136,8 @@ export async function register() {
registerShutdownHandlers(boss);
}
ready(boss !== null);
const { reportBootSteps } = await import("@/lib/boot/boot-steps");
await reportBootSteps();
} catch (err) {
ready(false);
// Queue-backed garnishes degrade independently; never make the web
@@ -147,6 +160,7 @@ export async function register() {
startupEvt.setBackground({ task_name: "startup" });
try {
beginBootStep("worker_import");
const { startReminderWorker } =
await import("@/lib/jobs/reminder-worker");
await startReminderWorker();
+16 -2
View File
@@ -21,8 +21,10 @@ describe("per-process database connection budget", () => {
const originalConnectionLimit = process.env.DB_CONNECTION_LIMIT;
const originalPoolTimeout = process.env.DB_POOL_TIMEOUT;
const originalLegacyPoolMax = process.env.DATABASE_POOL_MAX;
const originalProcessType = process.env.HEALTHLOG_PROCESS_TYPE;
beforeEach(() => {
process.env.HEALTHLOG_PROCESS_TYPE = "web";
process.env.DATABASE_URL =
"postgresql://healthlog:test@db:5432/healthlog?connection_limit=20&pool_timeout=20";
delete process.env.DB_CONNECTION_LIMIT;
@@ -39,6 +41,7 @@ describe("per-process database connection budget", () => {
restore("DB_CONNECTION_LIMIT", originalConnectionLimit);
restore("DB_POOL_TIMEOUT", originalPoolTimeout);
restore("DATABASE_POOL_MAX", originalLegacyPoolMax);
restore("HEALTHLOG_PROCESS_TYPE", originalProcessType);
});
it("splits the URL connection_limit across Prisma and pg-boss", () => {
@@ -48,6 +51,17 @@ describe("per-process database connection budget", () => {
expect(getPrismaPoolMax() + getPgBossPoolMax()).toBe(getConnectionBudget());
});
it("gives a process that runs the worker four pg-boss connections", () => {
for (const role of ["all", "worker"]) {
process.env.HEALTHLOG_PROCESS_TYPE = role;
expect(getPgBossPoolMax()).toBe(4);
expect(getPrismaPoolMax()).toBe(16);
}
process.env.DB_CONNECTION_LIMIT = "3";
expect(getPgBossPoolMax()).toBe(2);
expect(getPrismaPoolMax()).toBe(1);
});
it("uses the existing pool_timeout seconds for both pool constructors", () => {
expect(getPoolConnectionTimeoutMs()).toBe(20_000);
});
@@ -138,14 +152,14 @@ describe("buildSessionOptions", () => {
it("keeps work_mem when the timeout is disabled", () => {
process.env.DATABASE_STATEMENT_TIMEOUT_MS = "0";
delete process.env.DATABASE_WORK_MEM;
expect(buildSessionOptions()).toBe("-c work_mem=16MB -c TimeZone=UTC");
expect(buildSessionOptions()).toBe("-c work_mem=16MB -c jit=off -c TimeZone=UTC");
});
it("sets the default work_mem beside the timeouts", () => {
delete process.env.DATABASE_STATEMENT_TIMEOUT_MS;
delete process.env.DATABASE_WORK_MEM;
expect(buildSessionOptions()).toBe(
"-c statement_timeout=60000 -c idle_in_transaction_session_timeout=60000 -c work_mem=16MB -c TimeZone=UTC",
"-c statement_timeout=60000 -c idle_in_transaction_session_timeout=60000 -c work_mem=16MB -c jit=off -c TimeZone=UTC",
);
});
+2 -8
View File
@@ -17,6 +17,7 @@
* (`PUT /api/auth/me/ai-usage`); a managed profile has no choice of its own
* and follows the instance default.
*/
import { readOperatorRow } from "@/lib/settings/operator-row";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
@@ -83,14 +84,7 @@ export function aiUsageSettingsFromRow(
export function loadAiUsageSettings(): Promise<AiUsageSettings> {
const read = async (): Promise<AiUsageSettings> => {
try {
const row = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
aiUsageMaxLevel: true,
aiUsageDefaultLevel: true,
aiSyncRegenGapHours: true,
},
});
const row = await readOperatorRow();
return aiUsageSettingsFromRow(row);
} catch {
getEvent()?.addWarning(
+13 -3
View File
@@ -388,7 +388,17 @@ function buildDerivedHealthTokens(): ReadonlySet<string> {
return tokens;
}
const DERIVED_HEALTH_TOKENS = buildDerivedHealthTokens();
let memoDerivedHealthTokensCache:
ReturnType<typeof memoDerivedHealthTokensBuild> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function memoDerivedHealthTokens(): ReturnType<
typeof memoDerivedHealthTokensBuild
> {
return (memoDerivedHealthTokensCache ??= memoDerivedHealthTokensBuild());
}
function memoDerivedHealthTokensBuild() {
return buildDerivedHealthTokens();
}
/**
* Test seam: the guard suite asserts the derived set is non-empty and that
@@ -396,13 +406,13 @@ const DERIVED_HEALTH_TOKENS = buildDerivedHealthTokens();
* metric labels fails the suite rather than quietly narrowing the allow-list.
*/
export function derivedHealthTokens(): ReadonlySet<string> {
return DERIVED_HEALTH_TOKENS;
return memoDerivedHealthTokens();
}
/** True when any whole word of the message is a health noun from the bundles. */
function matchesDerivedHealthToken(message: string): boolean {
for (const word of healthWordTokens(message)) {
if (DERIVED_HEALTH_TOKENS.has(word)) return true;
if (memoDerivedHealthTokens().has(word)) return true;
}
return false;
}
+2 -14
View File
@@ -1,4 +1,5 @@
import type { AIProvider, CompletionResult } from "./types";
import { readOperatorRow } from "@/lib/settings/operator-row";
import { bindResponseTimeout } from "./effective-timeout";
import { bindReasoningEffort } from "./reasoning-effort";
import { prisma } from "@/lib/db";
@@ -1145,16 +1146,6 @@ const PRESENCE_USER_SELECT = {
aiResponseTimeoutSeconds: true,
} as const;
const PRESENCE_SETTINGS_SELECT = {
adminAiKeyEncrypted: true,
adminAiModel: true,
adminAiBaseUrl: true,
adminCodexConnectionStatus: true,
adminCodexAccessTokenEncrypted: true,
adminCodexRefreshTokenEncrypted: true,
adminCodexAccountIdEncrypted: true,
} as const;
const NO_PRESENCE: ProviderPresence = Object.freeze({
entries: [],
localOcrEnabled: false,
@@ -1217,10 +1208,7 @@ export function probeProviderChain(
where: { id: recordId },
select: PRESENCE_USER_SELECT,
}),
prisma.appSettings.findUnique({
where: { id: "singleton" },
select: PRESENCE_SETTINGS_SELECT,
}),
readOperatorRow(),
]);
if (!row) return NO_PRESENCE;
const policy = providerCredentialPolicy(authority, row.managedProfileAt);
+2 -4
View File
@@ -5,6 +5,7 @@
* Server-only. The decision itself is the pure `resolveReasoning` in
* `./resolve.ts`; this file only gathers its inputs.
*/
import { readOperatorRow } from "@/lib/settings/operator-row";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
@@ -70,10 +71,7 @@ function loadMemoisedControls(): Promise<ReasoningAdminControls> {
"ai-reasoning-controls",
async () => {
try {
const row = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { aiReasoningEnabled: true, aiReasoningMaxEffort: true },
});
const row = await readOperatorRow();
return {
enabled: row?.aiReasoningEnabled ?? true,
maxEffort: parseReasoningMaxEffort(row?.aiReasoningMaxEffort),
+124
View File
@@ -0,0 +1,124 @@
/**
* The pre-fold remainder of the dashboard slice: per type, the all-time
* figures of every reading older than the rollup fold window.
*
* The slice splices it into its per-type figures, so "all-time" means all time
* on the rollup path too (empty for any account younger than the
* window). It keeps one source per (type, day) the way the rollup half
* and the live path do: without it, a vital recorded by two devices
* counted both of them on every pre-fold day. Each source is folded per
* day first (a hash aggregate, a few rows per day however dense the
* stream), each day then keeps its ladder-canonical source, so the
* sort never sees more than one row per source per day.
* An hourly-mean type folds each source's day as the mean of its
* hours' means (`wsum` / `wdays`, see `day-mean.ts`) and the days into
* the remainder once each, the statistic the DAY rollup stores.
*
* It is a closed block: it aggregates only readings measured before
* `foldSplit`, the start of a UTC day five years back, so a sync of newer
* readings cannot change it. On a long history it was the slice's largest
* statement (every reading older than five years, folded per source and day),
* and the slice is rebuilt after every sync. It is cached under a closed-block
* key (`closedBlockKeyPart`): a measurement write that reaches back before
* `foldSplit`, and every write that does not say how far back it reached,
* drops it; the split moves on at the next UTC day, which names a new key.
* The TTL bounds an entry that a write in another process (a split worker)
* cannot reach.
*/
import { createHash } from "node:crypto";
import { Prisma } from "@/generated/prisma/client";
import { closedBlockKeyPart } from "@/lib/cache/closed-block";
import { caches, type ServerCache } from "@/lib/cache/server-cache";
import { prisma } from "@/lib/db";
import { dayWeightedRowsSql, foldMeanSql } from "@/lib/measurements/day-mean";
export type PreFoldRemainderRow = {
type: string;
count: number;
days: number;
min: number;
max: number;
mean: number;
};
/** A closed block's lifetime when no write reaches it. */
const PRE_FOLD_REMAINDER_TTL_MS = 15 * 60_000;
/** The remainder as a live read, uncached. */
export function readPreFoldRemainderLive(
userId: string,
foldSplit: Date,
rankPerSource: string,
): Promise<PreFoldRemainderRow[]> {
return prisma.$queryRaw<PreFoldRemainderRow[]>`
WITH src AS (
SELECT *
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."measured_at" < ${foldSplit}
),
per_source AS (
SELECT
m."type",
m."source",
date_trunc('day', m."measured_at") AS day,
COUNT(*)::int AS cnt,
SUM(m."value")::double precision AS total,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value,
SUM(m."value" * m.day_weight)::double precision AS wsum,
SUM(m.day_weight)::double precision AS wdays
FROM ${dayWeightedRowsSql("src", null, { bySource: true })} m
GROUP BY m."type", m."source", 3
),
canon AS (
SELECT DISTINCT ON (p."type", p.day)
p."type", p.cnt, p.total, p.min_value, p.max_value, p.wsum, p.wdays
FROM per_source p
ORDER BY p."type", p.day, (${Prisma.raw(rankPerSource)}), p."source"::text
)
SELECT
c."type"::text AS type,
SUM(c.cnt)::int AS count,
COUNT(*)::int AS days,
MIN(c.min_value)::double precision AS min,
MAX(c.max_value)::double precision AS max,
${Prisma.raw(
foldMeanSql({
typeColumn: 'c."type"',
total: "c.total",
count: "c.cnt",
weightedSum: "c.wsum",
weightSum: "c.wdays",
}),
)}::double precision AS mean
FROM canon c
GROUP BY c."type"
`;
}
/**
* The remainder through its closed-block cache cell. `rankPerSource` is the
* ladder CASE over `p."type"` / `p."source"`, built from `priorityJson`,
* which names the cell.
*/
export async function readPreFoldRemainder(
userId: string,
foldSplit: Date,
priorityJson: unknown,
rankPerSource: string,
): Promise<PreFoldRemainderRow[]> {
const ladder = createHash("sha256")
.update(JSON.stringify(priorityJson ?? null))
.digest("base64url")
.slice(0, 16);
const cache = caches.analytics as ServerCache<PreFoldRemainderRow[]>;
const { value } = await cache.wrap(
`${userId}|pre-fold-remainder|${closedBlockKeyPart(foldSplit)}|${ladder}`,
() => readPreFoldRemainderLive(userId, foldSplit, rankPerSource),
PRE_FOLD_REMAINDER_TTL_MS,
);
return value;
}
+24 -10
View File
@@ -127,6 +127,16 @@ function assertInterval(value: string): string {
* is whitelisted and, when given, scopes both the inner pick and the outer
* filter to a trailing window. `onlyType`, when given, scopes both halves to
* that one metric.
*
* The pick groups before it sorts. Its order (rank, then source name) depends
* only on a row's `(type, source)`, so every reading of one source on one day
* sorts alike, and the distinct `(type, day, source)` triples carry the whole
* decision. A hash aggregate reduces the window's rows to those triples (a
* few thousand) and `DISTINCT ON` sorts only them; sorting every raw row by
* a CASE expression no index can supply spilled to disk on a large account.
* The picked source per day is the same by construction;
* `tests/integration/canonical-source-pick-parity.test.ts` holds the old
* statement as its reference.
*/
export function canonicalMeasurementsCte(
rankUnqualified: string,
@@ -152,16 +162,20 @@ export function canonicalMeasurementsCte(
SELECT mm.*
FROM measurements mm
JOIN (
SELECT DISTINCT ON ("type", date_trunc('day', "measured_at"))
"type" AS t,
date_trunc('day', "measured_at") AS d,
"source" AS canon
FROM measurements
WHERE "user_id" = $1
AND "deleted_at" IS NULL
${sinceInner}
${typeInner}
ORDER BY "type", date_trunc('day', "measured_at"), (${rankUnqualified}), "source"::text
SELECT DISTINCT ON ("type", d)
"type" AS t,
d,
"source" AS canon
FROM (
SELECT "type", date_trunc('day', "measured_at") AS d, "source"
FROM measurements
WHERE "user_id" = $1
AND "deleted_at" IS NULL
${sinceInner}
${typeInner}
GROUP BY "type", date_trunc('day', "measured_at"), "source"
) g
ORDER BY "type", d, (${rankUnqualified}), "source"::text
) c
ON c.t = mm."type"
AND c.d = date_trunc('day', mm."measured_at")
+4 -70
View File
@@ -57,7 +57,6 @@
import pLimit from "p-limit";
import type { MeasurementType } from "@/generated/prisma/client";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
@@ -69,8 +68,6 @@ import {
SESSION_DAY_FRAME,
dayValue,
dayWeightedRows,
dayWeightedRowsSql,
foldMeanSql,
isHourlyMeanTypeSql,
windowMeanSql,
} from "@/lib/measurements/day-mean";
@@ -78,6 +75,7 @@ import { wireSummary, type DataSummary } from "@/lib/analytics/trends";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { annotate } from "@/lib/logging/context";
import { caches, type ServerCache } from "@/lib/cache/server-cache";
import { readPreFoldRemainder } from "@/lib/analytics/pre-fold-remainder";
import {
ensureUserRollupsFresh,
readRollupBuckets,
@@ -704,73 +702,9 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
sumYy: true,
},
}),
// v1.37.19 — all-time remainder OLDER than the fold window,
// spliced into the per-type figures below so "all-time" means all time
// on the rollup path too (empty for any account younger than the
// window). It keeps one source per (type, day) the way the rollup half
// and the live path do: without it, a vital recorded by two devices
// counted both of them on every pre-fold day. Each source is folded per
// day first (a hash aggregate, a few rows per day however dense the
// stream), each day then keeps its ladder-canonical source, so the
// sort never sees more than one row per source per day.
// An hourly-mean type folds each source's day as the mean of its
// hours' means (`wsum` / `wdays`, see `day-mean.ts`) and the days into
// the remainder once each, the statistic the DAY rollup stores.
prisma.$queryRaw<
Array<{
type: string;
count: number;
days: number;
min: number;
max: number;
mean: number;
}>
>`
WITH src AS (
SELECT *
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."measured_at" < ${foldSplit}
),
per_source AS (
SELECT
m."type",
m."source",
date_trunc('day', m."measured_at") AS day,
COUNT(*)::int AS cnt,
SUM(m."value")::double precision AS total,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value,
SUM(m."value" * m.day_weight)::double precision AS wsum,
SUM(m.day_weight)::double precision AS wdays
FROM ${dayWeightedRowsSql("src", null, { bySource: true })} m
GROUP BY m."type", m."source", 3
),
canon AS (
SELECT DISTINCT ON (p."type", p.day)
p."type", p.cnt, p.total, p.min_value, p.max_value, p.wsum, p.wdays
FROM per_source p
ORDER BY p."type", p.day, (${Prisma.raw(rankPerSource)}), p."source"::text
)
SELECT
c."type"::text AS type,
SUM(c.cnt)::int AS count,
COUNT(*)::int AS days,
MIN(c.min_value)::double precision AS min,
MAX(c.max_value)::double precision AS max,
${Prisma.raw(
foldMeanSql({
typeColumn: 'c."type"',
total: "c.total",
count: "c.cnt",
weightedSum: "c.wsum",
weightSum: "c.wdays",
}),
)}::double precision AS mean
FROM canon c
GROUP BY c."type"
`,
// The all-time remainder older than the fold window (a closed block,
// see `pre-fold-remainder.ts`).
readPreFoldRemainder(userId, foldSplit, priorityJson, rankPerSource),
]);
const preFoldByType = new Map<
+2 -4
View File
@@ -16,6 +16,7 @@
* out; the operator/CLI escape hatch (`scripts/disable-mfa.ts`, toggling the
* policy off) is the recovery path of last resort.
*/
import { readOperatorRow } from "@/lib/settings/operator-row";
import { cookies } from "next/headers";
import { prisma } from "@/lib/db";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
@@ -44,10 +45,7 @@ export async function resolveMfaEnrollmentRequired(
// Already has a confirmed TOTP secret → enrolled, nothing required.
if (user.totpConfirmedAt) return false;
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { mfaRequired: true },
});
const settings = await readOperatorRow();
const policyApplies = Boolean(settings?.mfaRequired) || user.mfaEnforced;
if (!policyApplies) return false;
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import {
beginBootStep,
bootSteps,
currentBootStep,
endBootSteps,
} from "../boot-steps";
describe("boot steps", () => {
it("times each step until the next begins, and names the running one", () => {
beginBootStep("a", 100);
expect(currentBootStep()).toBe("a");
beginBootStep("b", 130);
expect(currentBootStep()).toBe("b");
const steps = endBootSteps(200);
expect(currentBootStep()).toBeNull();
expect(steps.slice(-2)).toEqual([
{ name: "a", startedAtMs: 100, durationMs: 30 },
{ name: "b", startedAtMs: 130, durationMs: 70 },
]);
expect(bootSteps().slice(-2)).toEqual(steps.slice(-2));
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* Named boot steps with their timings, and the step running right now.
*
* A stall at boot used to be one number: "the loop was blocked 718 ms". The
* event-loop monitor now names the step that was running when the late tick
* fired (`currentBootStep`), and the boot writes one `boot.steps` line with
* how long each step took, so a slow boot says where its time went.
*
* Kept on `globalThis`: the steps are marked from the instrumentation bundle
* and from the worker modules it loads, which are separate module copies in
* a production build.
*/
import { withBackgroundEvent } from "@/lib/logging/background";
import { annotate } from "@/lib/logging/context";
export interface BootStep {
name: string;
/** Milliseconds since the process started (`performance.timeOrigin`). */
startedAtMs: number;
durationMs: number | null;
}
const KEY = Symbol.for("healthlog.boot.steps");
interface State {
steps: BootStep[];
current: BootStep | null;
}
function state(): State {
const slot = globalThis as Record<symbol, unknown>;
let current = slot[KEY] as State | undefined;
if (!current) {
current = { steps: [], current: null };
slot[KEY] = current;
}
return current;
}
/**
* Begin `name`, ending the step before it. Steps run one after another; a
* step that starts work in the background ends when the next one begins.
*/
export function beginBootStep(
name: string,
now: number = performance.now(),
): void {
const s = state();
if (s.current && s.current.durationMs === null) {
s.current.durationMs = Math.round(now - s.current.startedAtMs);
}
const step: BootStep = {
name,
startedAtMs: Math.round(now),
durationMs: null,
};
s.steps.push(step);
s.current = step;
}
/** End the running step without starting another. */
export function endBootSteps(now: number = performance.now()): BootStep[] {
const s = state();
if (s.current && s.current.durationMs === null) {
s.current.durationMs = Math.round(now - s.current.startedAtMs);
}
s.current = null;
return [...s.steps];
}
/** The step running now, or null once the boot is through. */
export function currentBootStep(): string | null {
return state().current?.name ?? null;
}
/** Every step so far, for the boot line. */
export function bootSteps(): BootStep[] {
return [...state().steps];
}
/**
* End the boot and write one `boot.steps` line with every step and its
* duration, so a slow start says where its time went. Never throws.
*/
export async function reportBootSteps(
meta: Record<string, string | number> = {},
): Promise<void> {
const steps = endBootSteps();
try {
await withBackgroundEvent("boot.steps", async () => {
annotate({
meta: {
...meta,
boot_steps: steps
.map((s) => `${s.name}=${s.durationMs ?? 0}`)
.join(","),
boot_total_ms: Math.round(performance.now()),
},
});
});
} catch {
// A diagnostic must never break boot.
}
}
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it } from "vitest";
import { closedBlockKeyPart, closedBlockUntouched } from "../closed-block";
import { invalidateUserMeasurements } from "../invalidate";
import { caches, type ServerCache } from "../server-cache";
const analytics = caches.analytics as ServerCache<{ v: number }>;
const USER = "u-closed";
const SPLIT = Date.UTC(2021, 9, 22);
const closedKey = `${USER}|pre-fold-remainder|${closedBlockKeyPart(SPLIT)}|x`;
const openKey = `${USER}|dashboard-snapshot|en`;
describe("closed analytics blocks", () => {
beforeEach(() => {
analytics.deleteByPrefix(`${USER}|`);
analytics.set(closedKey, { v: 1 });
analytics.set(openKey, { v: 2 });
});
it("reads the instant a closed block ends before", () => {
expect(closedBlockUntouched(closedKey, new Date(SPLIT))).toBe(true);
expect(closedBlockUntouched(closedKey, new Date(SPLIT - 1))).toBe(false);
expect(closedBlockUntouched(openKey, new Date(SPLIT))).toBe(false);
});
it("survives a write of newer readings, evicting or marking stale", () => {
for (const evict of [false, true]) {
invalidateUserMeasurements(USER, {
evict,
touchedFrom: new Date(SPLIT + 86_400_000),
});
expect(analytics.get(closedKey)).toEqual({ v: 1 });
expect(analytics.get(openKey)).toBeNull();
analytics.set(openKey, { v: 2 });
}
});
it("goes with a write that reaches before it, or does not say", () => {
invalidateUserMeasurements(USER, { touchedFrom: new Date(SPLIT - 1) });
expect(analytics.get(closedKey)).toBeNull();
analytics.set(closedKey, { v: 1 });
invalidateUserMeasurements(USER);
expect(analytics.get(closedKey)).toBeNull();
});
});
+23
View File
@@ -0,0 +1,23 @@
/**
* The key part that marks an analytics entry as a closed block: a value that
* depends only on readings measured before `before`. Such a block is the
* same however many syncs land after it, as long as none of them touches a
* reading that old, so a measurement write that says how far back it
* reached (`touchedFrom`) leaves it in place. Every other invalidation, and
* every write that does not say, drops it as before.
*/
export function closedBlockKeyPart(before: Date | number): string {
const ms = typeof before === "number" ? before : before.getTime();
return `before:${ms}`;
}
const CLOSED_BLOCK_RE = /\|before:(\d+)(?:\||$)/;
/**
* Whether `key` is a closed block that a write reaching back to
* `touchedFrom` cannot have changed. Exported for its test.
*/
export function closedBlockUntouched(key: string, touchedFrom: Date): boolean {
const match = CLOSED_BLOCK_RE.exec(key);
return match !== null && Number(match[1]) <= touchedFrom.getTime();
}
+16 -3
View File
@@ -20,6 +20,7 @@
import { forgetCoverageProbe } from "@/lib/rollups/coverage-inflight";
import { closedBlockUntouched } from "./closed-block";
import { caches } from "./server-cache";
/**
@@ -74,16 +75,28 @@ export function invalidateUserDashboardSnapshot(userId: string): void {
*/
export function invalidateUserMeasurements(
userId: string,
opts?: { evict?: boolean },
opts?: {
evict?: boolean;
/**
* The earliest `measuredAt` the write created, changed or removed. With
* it, closed blocks of older readings (`closedBlockKeyPart`) stay.
* Leave it out unless the caller knows every row it touched.
*/
touchedFrom?: Date;
},
): void {
// A coverage probe already running read the set before this write; the
// next reader must not join it.
forgetCoverageProbe(userId);
const touchedFrom = opts?.touchedFrom;
const spare = touchedFrom
? (key: string) => closedBlockUntouched(key, touchedFrom)
: undefined;
// The `${userId}|` prefix covers the slim / thick analytics cells, the
// iOS summary cell, AND the v1.7.0 dashboard snapshot
// (`${userId}|dashboard-snapshot`) in one pass.
if (opts?.evict) {
caches.analytics.deleteByPrefix(`${userId}|`);
caches.analytics.deleteByPrefix(`${userId}|`, spare);
} else {
// v1.12.7 — mark stale rather than hard-evict. Measurement writes are
// the highest-frequency dirty signal (every iOS Apple-Health sync
@@ -94,7 +107,7 @@ export function invalidateUserMeasurements(
// summary cells read via plain `cached` are unaffected: a marked-stale
// entry has `expiresAt === now`, so their next read is a clean miss and
// rebuilds fresh — identical to the old evict for those keys.
caches.analytics.markStaleByPrefix(`${userId}|`);
caches.analytics.markStaleByPrefix(`${userId}|`, spare);
}
caches.achievements.deleteByPrefix(userId);
caches.workouts.deleteByPrefix(`${userId}|`);
+11 -11
View File
@@ -88,15 +88,15 @@ export interface ServerCacheStats {
* fence — invalidation flips `invalidated` and detaches the slot from
* `pending`, so (a) the builder's `.then` skips the `set()` and (b) the
* next read starts a fresh build instead of joining the stale one.
* Callers already awaiting the detached promise still receive its value
* (equivalent to having read a moment before the write); it just never
* enters the cache.
* Callers already awaiting the detached promise still get its value (as
* if read just before the write); it never enters the cache.
*/
interface PendingBuild<T> {
promise: Promise<T>;
invalidated: boolean;
}
type KeyFilter = (key: string) => boolean; // closed blocks, `invalidate.ts`
export class ServerCache<T> {
private readonly map = new Map<
string,
@@ -124,9 +124,9 @@ export class ServerCache<T> {
}
/** Fence + detach every in-flight builder under `prefix`. */
private detachPendingByPrefix(prefix: string): void {
private detachPendingByPrefix(prefix: string, spare?: KeyFilter): void {
for (const [key, slot] of this.pending) {
if (key.startsWith(prefix)) {
if (key.startsWith(prefix) && !spare?.(key)) {
slot.invalidated = true;
this.pending.delete(key);
}
@@ -283,17 +283,17 @@ export class ServerCache<T> {
* so the next read is a clean miss — identical to the old evict
* behaviour).
*/
markStaleByPrefix(prefix: string): number {
markStaleByPrefix(prefix: string, spare?: KeyFilter): number {
// v1.16.9 — fence in-flight builders too: a refresh that started
// before the write would otherwise complete after this mark and
// re-cache the pre-write body as FRESH for the full TTL. Detaching
// keeps the SWR contract — the marked entry still serves stale, and
// the next read kicks off a fresh (post-write) recompute.
this.detachPendingByPrefix(prefix);
this.detachPendingByPrefix(prefix, spare);
let marked = 0;
const now = Date.now();
for (const [key, entry] of this.map) {
if (key.startsWith(prefix)) {
if (key.startsWith(prefix) && !spare?.(key)) {
entry.expiresAt = now;
marked += 1;
}
@@ -320,12 +320,12 @@ export class ServerCache<T> {
* entire user-bucket from a cache without having to enumerate every
* possible cache-key variant the user might have warmed.
*/
deleteByPrefix(prefix: string): number {
deleteByPrefix(prefix: string, spare?: KeyFilter): number {
// v1.16.9 — fence in-flight builders under the prefix (see `delete`).
this.detachPendingByPrefix(prefix);
this.detachPendingByPrefix(prefix, spare);
let removed = 0;
for (const key of this.map.keys()) {
if (key.startsWith(prefix)) {
if (key.startsWith(prefix) && !spare?.(key)) {
const entry = this.map.get(key);
this.map.delete(key);
this.totalWeight -= entry?.weight ?? 0;
+18 -5
View File
@@ -14,6 +14,8 @@
* once in `sections`. Nothing here writes, and nothing here talks to a model;
* `model-projection.ts` is the one way a day leaves for one.
*/
import pLimit from "p-limit";
import type { MeasurementType } from "@/generated/prisma/enums";
import { readDayMovement } from "@/lib/dawarich/movement";
@@ -35,6 +37,9 @@ import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { localDayWindow } from "@/lib/tz/local-day";
import { resolveUserTimezone } from "@/lib/tz/resolver";
/** Record sections read at once for one day (see `loadDay`). */
const DAY_SECTION_CONCURRENCY = 6;
const NO_READINGS: DayReadings = {
rowsByType: new Map(),
night: null,
@@ -99,6 +104,7 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
? await loadUserSourcePriority(recordId)
: null;
const sectionLimit = pLimit(DAY_SECTION_CONCURRENCY);
const recordSections = [...access.readable].filter(
(section) => RECORD_SECTION_READERS[section] !== undefined,
);
@@ -115,13 +121,20 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
priority,
)
: Promise.resolve(NO_READINGS),
// About twenty-five section readers, each a query or two, beside the
// readings, scores and movement: unbounded they took more connections
// than the pool holds, and a neighbouring request waited for a slot.
// Six at a time keeps the day under a third of the pool; the parts come
// back in section order as before.
Promise.all(
recordSections.map((section) =>
(
RECORD_SECTION_READERS[section] as (
frame: DayFrame,
) => Promise<SectionPart>
)(frame),
sectionLimit(() =>
(
RECORD_SECTION_READERS[section] as (
frame: DayFrame,
) => Promise<SectionPart>
)(frame),
),
),
),
scoresReadable
+8 -1
View File
@@ -31,6 +31,7 @@ import type { MeasurementType } from "@/generated/prisma/enums";
import type { DayNotable } from "@/lib/day/contract";
import { readLocalDailyStats, type DailyStats } from "@/lib/day/daily-stats";
import { closedBlockKeyPart } from "@/lib/cache/closed-block";
import { caches, type ServerCache } from "@/lib/cache/server-cache";
import { prisma } from "@/lib/db";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
@@ -267,10 +268,16 @@ async function readExtremeHistory(args: {
) {
const last = shiftDateKey(start, HISTORY_BLOCK_DAYS - 1);
const open = last >= today;
// A closed block names the instant its readings end before, with a day
// of margin for a night that runs past midnight, so a sync that only
// touched newer readings leaves it cached (`closedBlockKeyPart`).
const closed = closedBlockKeyPart(
startOfLocalDayKey(shiftDateKey(last, 2), args.tz),
);
blocks.push({
start,
end: open ? today : last,
key: `${prefix}|${start}|${open ? today : "closed"}`,
key: `${prefix}|${start}|${open ? today : closed}`,
});
}
+28 -5
View File
@@ -6,6 +6,7 @@ import {
sessionOptionsDisabled,
TIME_ZONE_OPTION,
} from "@/lib/db-session";
import { shouldRunWorker } from "@/lib/process-type";
export {
pinTimeZoneOnConnect,
@@ -21,7 +22,17 @@ const globalForPrisma = globalThis as unknown as {
const DEFAULT_CONNECTION_BUDGET = 20;
const DEFAULT_POOL_TIMEOUT_SECONDS = 20;
const PG_BOSS_CONNECTIONS = 2;
/**
* pg-boss connections. A web-only process only sends jobs, and two sockets
* carry that. A process that runs the worker polls: about a hundred bindings
* (two dozen of them every two seconds, several with two to five slots), the
* cron timekeeper, supervision, and every job's own start and completion
* writes, plus the web sends when web and worker share the process. On two
* sockets those queued behind each other; four keep a fetch from waiting on
* a completion. The connections come out of the same per-process budget.
*/
const PG_BOSS_PRODUCER_CONNECTIONS = 2;
const PG_BOSS_WORKER_CONNECTIONS = 4;
function positiveInteger(raw: string | undefined): number | null {
if (!raw) return null;
@@ -53,8 +64,14 @@ export function getConnectionBudget(): number {
return Math.max(2, configured);
}
export function getPgBossPoolMax(): number {
return Math.min(PG_BOSS_CONNECTIONS, getConnectionBudget() - 1);
export function getPgBossPoolMax(
role: "worker" | "producer" = shouldRunWorker() ? "worker" : "producer",
): number {
const wanted =
role === "worker"
? PG_BOSS_WORKER_CONNECTIONS
: PG_BOSS_PRODUCER_CONNECTIONS;
return Math.min(wanted, getConnectionBudget() - 1);
}
export function getPrismaPoolMax(): number {
@@ -129,7 +146,13 @@ export function getWorkMem(): string {
* session carries them from the first query.
*
* The timeouts are left out when disabled (timeout 0); `work_mem` is always
* set. Up to v1.41 the whole string was `undefined` at timeout 0, which would
* set, and so is `jit=off`. Postgres compiles a statement to machine code
* once its estimated cost passes `jit_above_cost`, which every analytics
* fold over a year of readings does; on the benchmark account that
* compilation took 100 to 140 ms of a 220 ms statement and saved nothing,
* because these statements run once and are bound by reading rows, not by
* evaluating expressions. JIT changes how a plan runs, never what it
* returns. Up to v1.41 the whole string was `undefined` at timeout 0, which would
* have taken `work_mem` with it. `undefined` now means exactly one thing:
* the operator turned the startup options off for a connection pooler
* ({@link sessionOptionsDisabled}).
@@ -141,7 +164,7 @@ export function buildSessionOptions(): string | undefined {
timeoutMs > 0
? `-c statement_timeout=${timeoutMs} -c idle_in_transaction_session_timeout=${timeoutMs} `
: "";
return `${timeouts}-c work_mem=${getWorkMem()} ${TIME_ZONE_OPTION}`;
return `${timeouts}-c work_mem=${getWorkMem()} -c jit=off ${TIME_ZONE_OPTION}`;
}
export function prismaPoolConfig(): PoolConfig {
+11 -3
View File
@@ -152,11 +152,19 @@ function buildAnalyteNamePattern(): RegExp {
);
}
const DERIVED_ANALYTE_NAMES = buildAnalyteNamePattern();
let derivedAnalyteNamesCache:
ReturnType<typeof derivedAnalyteNamesBuild> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function derivedAnalyteNames(): ReturnType<typeof derivedAnalyteNamesBuild> {
return (derivedAnalyteNamesCache ??= derivedAnalyteNamesBuild());
}
function derivedAnalyteNamesBuild() {
return buildAnalyteNamePattern();
}
/** Test seam: the guard suite pins that the derived pattern covers every locale. */
export function derivedAnalyteNamePattern(): RegExp {
return DERIVED_ANALYTE_NAMES;
return derivedAnalyteNames();
}
/**
@@ -175,7 +183,7 @@ export function looksLikeLabDocument(text: string): boolean {
for (const signal of LAB_SIGNALS) {
const hit =
signal.test(folded) ||
(signal === ANALYTE_SIGNAL && DERIVED_ANALYTE_NAMES.test(folded));
(signal === ANALYTE_SIGNAL && derivedAnalyteNames().test(folded));
if (hit && ++hits >= 2) return true;
}
return false;
+2 -11
View File
@@ -1,4 +1,4 @@
import { prisma } from "@/lib/db";
import { readOperatorRow } from "@/lib/settings/operator-row";
import { getEvent } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
@@ -57,16 +57,7 @@ export function loadAssistantSwitches(): Promise<AssistantFlagSet | null> {
"assistant-flags",
async () => {
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantDocumentAiEnabled: true,
},
});
const settings = await readOperatorRow();
return resolveAssistantFlags({
enabled: settings?.assistantEnabled ?? true,
coach: settings?.assistantCoachEnabled ?? true,
+13 -3
View File
@@ -355,14 +355,24 @@ function buildLocalisedAliases(): Record<string, string> {
return index;
}
const LOCALISED_LAB_ALIASES = buildLocalisedAliases();
let memoLocalisedLabAliasesCache:
ReturnType<typeof memoLocalisedLabAliasesBuild> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function memoLocalisedLabAliases(): ReturnType<
typeof memoLocalisedLabAliasesBuild
> {
return (memoLocalisedLabAliasesCache ??= memoLocalisedLabAliasesBuild());
}
function memoLocalisedLabAliasesBuild() {
return buildLocalisedAliases();
}
/**
* Test seam: the guard suite asserts the derived index covers every shipped
* locale and contradicts no hand-written alias. Production reads the constant.
*/
export function localisedLabAliases(): Readonly<Record<string, string>> {
return LOCALISED_LAB_ALIASES;
return memoLocalisedLabAliases();
}
/**
@@ -462,7 +472,7 @@ export function resolveLabCoding(
// every resolution that worked before working the same way.
const resolvedKey = LAB_LOINC_BY_KEY[key]
? key
: (LAB_ALIASES[key] ?? LOCALISED_LAB_ALIASES[key]);
: (LAB_ALIASES[key] ?? memoLocalisedLabAliases()[key]);
if (!resolvedKey) return null;
const mapping = LAB_LOINC_BY_KEY[resolvedKey];
if (!mapping) return null;
+10 -3
View File
@@ -110,7 +110,14 @@ function buildLocalisedIndex(): Record<string, QualitativeConcept> {
return index;
}
const LOCALISED_SNOMED = buildLocalisedIndex();
let localisedSnomedCache: ReturnType<typeof localisedSnomedBuild> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function localisedSnomed(): ReturnType<typeof localisedSnomedBuild> {
return (localisedSnomedCache ??= localisedSnomedBuild());
}
function localisedSnomedBuild() {
return buildLocalisedIndex();
}
/**
* Test seam: the guard suite asserts the derived index covers every shipped
@@ -120,7 +127,7 @@ const LOCALISED_SNOMED = buildLocalisedIndex();
export function localisedQualitativeIndex(): Readonly<
Record<string, QualitativeConcept>
> {
return LOCALISED_SNOMED;
return localisedSnomed();
}
/**
@@ -144,7 +151,7 @@ export function qualitativeValueConcept(
valueText: string,
): FhirCodeableConcept {
const key = normaliseQualitativeKey(valueText);
const concept = HAND_WRITTEN_SNOMED[key] ?? LOCALISED_SNOMED[key];
const concept = HAND_WRITTEN_SNOMED[key] ?? localisedSnomed()[key];
if (!concept) {
return { text: valueText };
}
@@ -0,0 +1,50 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { getServerTranslator } from "../server-translator";
import {
lazyServerCatalogs,
loadedServerLocales,
serverCatalog,
} from "../server-catalogs";
describe("server catalogs", () => {
it("reads a locale from messages/ on first use and keeps one copy", () => {
const de = serverCatalog("de");
expect(de).toEqual(
JSON.parse(
readFileSync(join(process.cwd(), "messages", "de.json"), "utf8"),
),
);
expect(serverCatalog("de")).toBe(de);
expect(loadedServerLocales()).toContain("de");
});
it("loads only the locale an entry of the lazy record is read for", () => {
const record = lazyServerCatalogs();
const before = new Set(loadedServerLocales());
void record.it;
const added = loadedServerLocales().filter((l) => !before.has(l));
expect(added.every((l) => l === "it")).toBe(true);
expect(record.it).toBe(serverCatalog("it"));
});
it("lets an assignment replace an entry", () => {
const record = lazyServerCatalogs();
record.ko = { common: { save: "x" } };
expect(record.ko).toEqual({ common: { save: "x" } });
expect(serverCatalog("ko")).not.toEqual({ common: { save: "x" } });
});
it("refuses a locale that is not shipped", () => {
expect(() => serverCatalog("xx" as "de")).toThrow(/unknown locale/);
});
it("serves the server translator from the same catalogs", () => {
const t = getServerTranslator("fr");
const fr = serverCatalog("fr") as { common: { save: string } };
expect(t.t("common.save")).toBe(fr.common.save);
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* The match form of a label, without the catalogs.
*
* `localised-label-index.ts` builds its indexes from every shipped catalog
* and is server-only; this fold is the part client code needs too (the
* medication target map folds a free-text name the same way), so it lives
* in a leaf that imports nothing.
*/
/**
* Letters Unicode decomposition leaves alone because their mark is part of
* the glyph rather than a combining character — a stroke, or a ligature. NFKD
* turns "ż" into "z" + a dot but leaves "ł" whole, so Polish would fold half
* way without this. Expressed as ASCII equivalents, the same target the
* combining-mark strip below produces.
*/
const UNDECOMPOSED_LETTERS: ReadonlyArray<readonly [RegExp, string]> = [
[/ł/g, "l"],
[/ø/g, "o"],
[/đ/g, "d"],
[/ß/g, "ss"],
[/æ/g, "ae"],
[/œ/g, "oe"],
];
/**
* Fold a label or a free-text term to its match form: compatibility-normalised
* (so "VO₂" and "VO2" agree), diacritics stripped (so "Glycémie" answers to
* "glycemie" and "Siła" to "sila"), lower-cased, and every run of
* non-alphanumerics collapsed to a single `_` with the ends trimmed (so
* "Pain (0–10)" and "pain 0 10" agree).
*
* Applied to BOTH sides of every comparison — a bundle label and the caller's
* word go through the same function, so the index cannot drift from the lookup.
*/
export function foldLabel(raw: string): string {
let folded = raw
.normalize("NFKD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase();
for (const [pattern, ascii] of UNDECOMPOSED_LETTERS) {
folded = folded.replace(pattern, ascii);
}
return folded.replace(/[^\p{L}\p{N}]+/gu, "_").replace(/^_+|_+$/gu, "");
}
+11 -5
View File
@@ -7,8 +7,8 @@
* for every user, in every language). The t() chain (active locale →
* EN → raw key) now resolves its bundles through three paths:
*
* 1. SSR pass: this module seeds its cache with the full server-side
* catalog map (`shared-resolve`) inside a `typeof window ===
* 1. SSR pass: this module seeds its cache with the lazy server-side
* catalog map (`server-catalogs`) inside a `typeof window ===
* "undefined"` branch. Turbopack compiles the branch away for the
* browser bundle (verified — zero catalog bytes reach any client
* chunk), while the server render of every client component
@@ -33,6 +33,10 @@
* seed bundles directly (`primeMessages`).
*/
import { type Locale } from "./config";
import type { lazyServerCatalogs } from "./server-catalogs";
/** The SSR branch's view of `./server-catalogs` (types only; see below). */
type ServerCatalogsModule = { lazyServerCatalogs: typeof lazyServerCatalogs };
export type MessageBundle = Record<string, unknown>;
@@ -63,10 +67,12 @@ function initialCache(): Partial<Record<Locale, MessageBundle>> {
// where a bare require of a TS module throws) — there the cache starts
// empty and `vitest.setup.ts` primes every bundle up front.
try {
const { allMessages } =
const { lazyServerCatalogs } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./shared-resolve") as typeof import("./shared-resolve");
return { ...allMessages };
require("./server-catalogs") as ServerCatalogsModule;
// Entries load on first read, so the render loads the locales it
// renders in, not all of them.
return lazyServerCatalogs();
} catch {
return {};
}
+2 -36
View File
@@ -24,44 +24,10 @@
* what the resolver ACCEPTS.
*/
import { locales, type Locale } from "./config";
import { foldLabel } from "./fold-label";
import { allMessages, resolveKey } from "./shared-resolve";
/**
* Letters Unicode decomposition leaves alone because their mark is part of
* the glyph rather than a combining character — a stroke, or a ligature. NFKD
* turns "ż" into "z" + a dot but leaves "ł" whole, so Polish would fold half
* way without this. Expressed as ASCII equivalents, the same target the
* combining-mark strip below produces.
*/
const UNDECOMPOSED_LETTERS: ReadonlyArray<readonly [RegExp, string]> = [
[/ł/g, "l"],
[/ø/g, "o"],
[/đ/g, "d"],
[/ß/g, "ss"],
[/æ/g, "ae"],
[/œ/g, "oe"],
];
/**
* Fold a label or a free-text term to its match form: compatibility-normalised
* (so "VO₂" and "VO2" agree), diacritics stripped (so "Glycémie" answers to
* "glycemie" and "Siła" to "sila"), lower-cased, and every run of
* non-alphanumerics collapsed to a single `_` with the ends trimmed (so
* "Pain (0–10)" and "pain 0 10" agree).
*
* Applied to BOTH sides of every comparison — a bundle label and the caller's
* word go through the same function, so the index cannot drift from the lookup.
*/
export function foldLabel(raw: string): string {
let folded = raw
.normalize("NFKD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase();
for (const [pattern, ascii] of UNDECOMPOSED_LETTERS) {
folded = folded.replace(pattern, ascii);
}
return folded.replace(/[^\p{L}\p{N}]+/gu, "_").replace(/^_+|_+$/gu, "");
}
export { foldLabel };
/** One locale's rendering of `key`, or `null` when that bundle omits it. */
export function labelIn(locale: Locale, key: string): string | null {
+91
View File
@@ -0,0 +1,91 @@
/**
* The message catalogs on the server: read from disk on first use, one
* locale at a time, once per process.
*
* The catalogs used to be eight static JSON imports. A production build gives
* every server bundle its own copy of a shared module (route handlers, the
* RSC/SSR renderer, the instrumentation bundle that runs the worker), so each
* of them carried the whole eight-locale chunk as a source string and parsed
* all eight on load: a boot profile of an idle process found 32 parsed
* catalog objects and two 9.7 MB chunk sources, about a fifth of the live
* heap, for a process whose users read one or two locales.
*
* Now nothing is bundled: a catalog is `messages/<locale>.json` read with
* `readFileSync` the first time a translator or a matcher asks for that
* locale, and parked on `globalThis`, so every bundle copy in the process
* shares one parsed object per locale. The read is synchronous because
* `t()` is; it happens once per locale per process and takes a few
* milliseconds.
*
* The directory is `messages/` under the working directory: the checkout in
* development and tests, `/app` in the image, which copies it there.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { locales, type Locale } from "./config";
export type ServerCatalog = Record<string, unknown>;
const KEY = Symbol.for("healthlog.i18n.serverCatalogs");
function cache(): Map<Locale, ServerCatalog> {
const slot = globalThis as unknown as {
[KEY]?: Map<Locale, ServerCatalog>;
};
slot[KEY] ??= new Map();
return slot[KEY];
}
function catalogDirectory(): string {
// Not a bundler input: the catalogs are read at run time, and tracing
// `process.cwd()` would pull the whole checkout into the output.
return join(/* turbopackIgnore: true */ process.cwd(), "messages");
}
/** One locale's catalog, read and parsed on first use. */
export function serverCatalog(locale: Locale): ServerCatalog {
const loaded = cache();
const hit = loaded.get(locale);
if (hit) return hit;
if (!(locales as readonly string[]).includes(locale)) {
throw new Error(`unknown locale: ${String(locale)}`);
}
const parsed = JSON.parse(
readFileSync(join(catalogDirectory(), `${locale}.json`), "utf8"),
) as ServerCatalog;
loaded.set(locale, parsed);
return parsed;
}
/** Locales whose catalog this process has read so far (for tests and logs). */
export function loadedServerLocales(): Locale[] {
return [...cache().keys()];
}
/**
* A `Record<Locale, catalog>` whose entries load on first read. Indexing one
* locale loads only that one; iterating every locale (the matchers that
* accept a word in any language) loads them all, as before. An assignment
* replaces the entry, which is what the SSR cache in `load-locale.ts` and
* the test helpers do.
*/
export function lazyServerCatalogs(): Record<Locale, ServerCatalog> {
const record = {} as Record<Locale, ServerCatalog>;
for (const locale of locales) {
Object.defineProperty(record, locale, {
enumerable: true,
configurable: true,
get: () => serverCatalog(locale),
set: (value: ServerCatalog) => {
Object.defineProperty(record, locale, {
value,
writable: true,
enumerable: true,
configurable: true,
});
},
});
}
return record;
}
+12 -23
View File
@@ -9,38 +9,27 @@
* Since the i18n bundle split, `allMessages` is SERVER-ONLY: the root
* layout picks the active locale's bundle out of it for the RSC handoff
* and `getServerTranslator()` resolves against it. Do NOT import this
* module from client code — the seven static JSON imports would drag
* every locale back into the client chunk (the ~1.4 MiB regression the
* split removed). Client code uses `./load-locale` (static EN fallback
* floor + dynamic per-locale imports) and `./resolve-key` instead.
* module from client code: it reads the catalogs from disk. Client code
* uses `./load-locale` (dynamic per-locale imports) and `./resolve-key`
* instead.
*/
import deMessages from "../../../messages/de.json";
import enMessages from "../../../messages/en.json";
import frMessages from "../../../messages/fr.json";
import esMessages from "../../../messages/es.json";
import itMessages from "../../../messages/it.json";
import plMessages from "../../../messages/pl.json";
import koMessages from "../../../messages/ko.json";
import nlMessages from "../../../messages/nl.json";
import { locales, type Locale } from "./config";
import { resolveKey } from "./resolve-key";
import { lazyServerCatalogs } from "./server-catalogs";
export { resolveKey };
/**
* Single source of truth for the per-locale message bundle on the
* server (layout RSC handoff, server translator, jobs).
* server (layout RSC handoff, server translator, jobs). Each entry is read
* from `messages/<locale>.json` the first time it is indexed and shared by
* every bundle in the process (`server-catalogs.ts`), so a process holds the
* locales its users read, not all of them.
*/
export const allMessages: Record<Locale, Record<string, unknown>> = {
de: deMessages,
en: enMessages,
fr: frMessages,
es: esMessages,
it: itMessages,
pl: plMessages,
ko: koMessages,
nl: nlMessages,
};
export const allMessages: Record<
Locale,
Record<string, unknown>
> = lazyServerCatalogs();
/**
* Every shipped translation of one key, in `locales` order, deduplicated and
+6 -6
View File
@@ -74,7 +74,7 @@ import {
import { shiftDateKey } from "@/lib/tz/format";
import {
pickCanonicalWorkoutRows,
workoutDuplicateSlotKey,
workoutDuplicateClusterKeys,
workoutSourceLadder,
type WorkoutPickerRow,
} from "@/lib/measurements/pick-canonical-workout-rows";
@@ -591,7 +591,7 @@ export function canonicalDayActiveEnergy<
* one source's rows count: the best-ranked on the ladder, a source off the
* ladder ranking below every listed one, and among sources off the ladder
* the one with the largest impulse. Rows of that one source still add up
* (two distinct sessions of the same sport in one five-minute slot).
* (two distinct sessions of the same sport in one duplicate cluster).
* Returns the positive impulses, one per session. Pure; exported for tests.
*/
export function canonicalWorkoutTrimps<
@@ -615,9 +615,9 @@ export function canonicalWorkoutTrimps<
string,
{ picked: Map<string, number>; dropped: number }
>();
for (const row of rows) {
const key = workoutDuplicateSlotKey(row);
const slot = slots.get(key) ?? { picked: new Map(), dropped: 0 };
const keys = workoutDuplicateClusterKeys(rows);
for (const [index, row] of rows.entries()) {
const slot = slots.get(keys[index]!) ?? { picked: new Map(), dropped: 0 };
const trimp = trimpOf(row);
if (trimp > 0) {
if (picked.has(row.id)) {
@@ -626,7 +626,7 @@ export function canonicalWorkoutTrimps<
slot.dropped = Math.max(slot.dropped, trimp);
}
}
slots.set(key, slot);
slots.set(keys[index]!, slot);
}
const out: number[] = [];
for (const slot of slots.values()) {
+2 -1
View File
@@ -108,9 +108,10 @@ describe("worker pg-boss pool", () => {
it("uses the pg-boss share of the process connection budget", () => {
createWorkerBoss("postgres://healthlog@test/db");
// A worker polls; a web-only producer keeps two (see `db.ts`).
expect(PgBoss).toHaveBeenCalledWith({
connectionString: "postgres://healthlog@test/db",
max: 2,
max: 4,
connectionTimeoutMillis: 20_000,
options: "-c TimeZone=UTC",
});
+53
View File
@@ -9,6 +9,10 @@ import type { Job } from "pg-boss";
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/logging/signal", () => ({ emitSignal: vi.fn() }));
const memory = vi.hoisted(() => ({ pressure: false }));
vi.mock("@/lib/observability/container-memory", () => ({
memoryPressureActive: () => memory.pressure,
}));
import { emitSignal } from "@/lib/logging/signal";
import { heavyLane, LANE_WAIT_WARN_MS, laneWanted } from "../heavy-lane";
@@ -266,3 +270,52 @@ describe("a long wait for the lane", () => {
await held;
});
});
describe("the lane under container memory pressure", () => {
it("sends a heavy job back while the container is near its limit", async () => {
const log: string[] = [];
const a = gate(log, "import");
const { back, defer } = deferrals();
memory.pressure = true;
try {
const outcome = await heavyLane(
"apple-health-import-v2",
a.handler,
undefined,
defer,
)([job("upload")]);
expect(outcome).toMatchObject({ ok: true, did: { lane_deferred: 1 } });
expect(a.handler).not.toHaveBeenCalled();
expect(back).toEqual([
{ queue: "apple-health-import-v2", ids: ["upload"] },
]);
} finally {
memory.pressure = false;
}
});
it("asks a holder to give way at its next boundary", async () => {
let seen: boolean[] = [];
const { defer } = deferrals();
let release!: () => void;
const gateDone = new Promise<void>((r) => (release = r));
const running = heavyLane(
"apple-health-import-v2",
async () => {
seen.push(laneWanted());
await gateDone;
seen.push(laneWanted());
return jobDone({ jobs: 1 });
},
undefined,
defer,
)([job("upload")]);
await tick();
memory.pressure = true;
release();
await running;
memory.pressure = false;
expect(seen).toEqual([false, true]);
seen = [];
});
});
+7 -4
View File
@@ -74,10 +74,13 @@ export function getGlobalBoss(): PgBoss | null {
return ((globalThis as Record<string, unknown>)[BOSS_KEY] as PgBoss) ?? null;
}
function databaseOptions(connectionString: string) {
function databaseOptions(
connectionString: string,
role: "worker" | "producer",
) {
return {
connectionString,
max: getPgBossPoolMax(),
max: getPgBossPoolMax(role),
connectionTimeoutMillis: getPoolConnectionTimeoutMs(),
// pg-boss keeps its own tables in `timestamptz`, so its queue math does
// not depend on the zone; the pin keeps every HealthLog session alike.
@@ -86,12 +89,12 @@ function databaseOptions(connectionString: string) {
}
export function createWorkerBoss(connectionString: string): PgBoss {
return new PgBoss(databaseOptions(connectionString));
return new PgBoss(databaseOptions(connectionString, "worker"));
}
function producerOptions(connectionString: string) {
return {
...databaseOptions(connectionString),
...databaseOptions(connectionString, "producer"),
migrate: false,
schedule: false,
supervise: false,
+23 -1
View File
@@ -33,6 +33,13 @@
* job that has stood in line for `LANE_WAIT_WARN_MS` is reported at `warn`
* (`job.heavy_lane.waiting_long`), once per holder, so a lane that is held
* for hours shows in the alerts instead of only in `info` lines.
*
* The container memory guard (`observability/container-memory.ts`) closes
* the lane for a while when the cgroup's working set reaches 85 % of its
* limit: a heavy job that arrives then goes back to the queue the same way,
* and a holder that runs in slices gives way at its next boundary, so the
* peak of an import does not land on top of a memory peak and end in a
* SIGKILL. The guard holds jobs back for fifteen minutes at most.
*/
import { AsyncLocalStorage } from "node:async_hooks";
import type { Job } from "pg-boss";
@@ -40,6 +47,7 @@ import type { Job } from "pg-boss";
import { prisma } from "@/lib/db";
import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome";
import { annotate } from "@/lib/logging/context";
import { memoryPressureActive } from "@/lib/observability/container-memory";
import { emitSignal } from "@/lib/logging/signal";
export interface HeavyQueue {
@@ -155,7 +163,8 @@ const holdScope = new AsyncLocalStorage<LaneHold>();
*/
export function laneWanted(): boolean {
const current = holdScope.getStore();
return current !== undefined && current === hold && current.wantedBy !== null;
if (current === undefined || current !== hold) return false;
return current.wantedBy !== null || memoryPressureActive();
}
function reportLongWait(
@@ -201,6 +210,19 @@ export function heavyLane<J extends Job<unknown>>(
return async (jobs) => {
if (!jobs.some(inLane)) return handler(jobs);
const current = hold;
if (!current && memoryPressureActive()) {
const deferral = await defer(queue, jobs);
annotate({
action: { name: "job.heavy_lane.deferred" },
meta: {
queue,
holder: "memory_pressure",
jobs: jobs.length,
deferred: deferral.deferred,
},
});
return jobDone({ lane_deferred: deferral.deferred });
}
if (current) {
const deferral = await defer(queue, jobs);
if (queue !== current.queue) current.wantedBy ??= queue;
+96 -32
View File
@@ -17,27 +17,35 @@
import { markWorkerStarted, recordError } from "@/lib/jobs/worker-status";
import { createWorkerBoss, setGlobalBoss } from "@/lib/jobs/boss-instance";
import { reportJobsCutOffAtBoot } from "@/lib/jobs/job-observer";
import { beginBootStep, reportBootSteps } from "@/lib/boot/boot-steps";
import { assertSubsystemEnabled } from "@/lib/process-type";
import { DATABASE_URL, workerLog } from "./reminder/shared";
import {
registerShutdownHandlers,
reconcileImportJobsAtBoot,
probeIntegrationStatusAtBoot,
} from "./reminder/worker-lifecycle";
import {
registerIntegrationSyncQueues,
enqueueIntegrationSyncBootDiscovery,
} from "./reminder/register-integration-sync";
import { registerStatusQueues } from "./reminder/register-status";
import {
registerRollupQueues,
enqueueRollupBootDiscovery,
} from "./reminder/register-rollup";
import { registerReminderQueues } from "./reminder/register-reminders";
import {
registerMaintenanceQueues,
enqueueMaintenanceBootDiscovery,
} from "./reminder/register-maintenance";
import type { PgBoss } from "pg-boss";
/**
* Give the event loop a turn. Each registrar's module graph is evaluated in
* one synchronous stretch when it is first imported; loading the five one
* after another, with a turn in between, keeps the longest stall to the
* largest graph instead of the sum of all five, so requests and the health
* probe are answered in between.
*/
const yieldToLoop = () => new Promise<void>((resolve) => setImmediate(resolve));
/**
* Import one registrar module as its own boot step. The registrars and the
* handler graphs behind them are loaded here, after `boss.start()`, rather
* than when this module is imported.
*/
async function loadRegistrar<T>(
name: string,
load: () => Promise<T>,
): Promise<T> {
beginBootStep(`load_${name}`);
await yieldToLoop();
const mod = await load();
await yieldToLoop();
return mod;
}
export async function startReminderWorker() {
// v1.4: refuse to boot if the operator marked this container as
@@ -58,6 +66,7 @@ export async function startReminderWorker() {
// Read before any `boss.work` binding below can take a job: whatever is
// still active now was started by a process that is gone.
beginBootStep("boss_start");
const bootedAt = new Date();
await boss.start();
await reportJobsCutOffAtBoot(bootedAt);
@@ -65,20 +74,62 @@ export async function startReminderWorker() {
markWorkerStarted();
// Graceful shutdown: drain in-flight jobs on SIGTERM/SIGINT.
registerShutdownHandlers(boss);
const lifecycle = await loadRegistrar(
"worker_lifecycle",
() => import("./reminder/worker-lifecycle"),
);
lifecycle.registerShutdownHandlers(boss);
// Register every domain's queues (createQueue + schedule + boss.work). Each
// registrar returns the queue names it provisioned; the aggregate is the
// boot-level mirror of the per-registrar `allQueues` arrays the dead-queue
// guards pin. Order preserves the monolith's createQueue / schedule / work
// sequence (integration → status → rollup → reminders → maintenance).
const registeredQueues = [
...(await registerIntegrationSyncQueues(boss)),
...(await registerStatusQueues(boss)),
...(await registerRollupQueues(boss)),
...(await registerReminderQueues(boss)),
...(await registerMaintenanceQueues(boss)),
];
const integration = await loadRegistrar(
"integration_sync",
() => import("./reminder/register-integration-sync"),
);
const registeredQueues = await registerInStep(
"integration_sync",
boss,
integration.registerIntegrationSyncQueues,
);
const status = await loadRegistrar(
"status",
() => import("./reminder/register-status"),
);
registeredQueues.push(
...(await registerInStep("status", boss, status.registerStatusQueues)),
);
const rollup = await loadRegistrar(
"rollup",
() => import("./reminder/register-rollup"),
);
registeredQueues.push(
...(await registerInStep("rollup", boss, rollup.registerRollupQueues)),
);
const reminders = await loadRegistrar(
"reminders",
() => import("./reminder/register-reminders"),
);
registeredQueues.push(
...(await registerInStep(
"reminders",
boss,
reminders.registerReminderQueues,
)),
);
const maintenance = await loadRegistrar(
"maintenance",
() => import("./reminder/register-maintenance"),
);
registeredQueues.push(
...(await registerInStep(
"maintenance",
boss,
maintenance.registerMaintenanceQueues,
)),
);
workerLog(
"info",
`[reminder-worker] registered ${registeredQueues.length} queues across 5 domain registrars`,
@@ -86,22 +137,35 @@ export async function startReminderWorker() {
// v1.4.34 — reconcile any `ImportJob` rows that were mid-parse when the
// worker last shut down, flipping orphaned rows to `failed`.
await reconcileImportJobsAtBoot();
beginBootStep("boot_reconcile");
await lifecycle.reconcileImportJobsAtBoot();
// v1.4.48 — boot probe for legacy `integration_statuses` rows that still
// carry `consecutive_failures_by_kind = NULL`.
await probeIntegrationStatusAtBoot();
await lifecycle.probeIntegrationStatusAtBoot();
// Fire-and-forget boot discovery for every self-converging backfill /
// consolidation pass. Each is idempotent across reboots and never fails
// worker boot on a miss (errors come back through the helper's result).
await enqueueRollupBootDiscovery();
await enqueueIntegrationSyncBootDiscovery();
await enqueueMaintenanceBootDiscovery();
beginBootStep("boot_discovery");
await rollup.enqueueRollupBootDiscovery();
await integration.enqueueIntegrationSyncBootDiscovery();
await maintenance.enqueueMaintenanceBootDiscovery();
await reportBootSteps({ boot_queue_count: registeredQueues.length });
return boss;
}
/** Run one registrar as its own boot step. */
async function registerInStep(
name: string,
boss: PgBoss,
register: (boss: PgBoss) => Promise<readonly string[]>,
): Promise<string[]> {
beginBootStep(`register_${name}`);
return [...(await register(boss))];
}
// Run standalone
if (
process.argv[1]?.endsWith("reminder-worker.ts") ||
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/jobs/apple-health-import-worker", () => ({
reconcileOrphanImportJobs: vi.fn(),
}));
vi.mock("@/lib/jobs/integration-status-null-probe", () => ({
probeIntegrationStatusNullBuckets: vi.fn(),
}));
vi.mock("@/lib/logging/background", () => ({ withBackgroundEvent: vi.fn() }));
vi.mock("../shared", () => ({ getWorkerPrisma: vi.fn(), workerLog: vi.fn() }));
import {
registerShutdownHandlers,
SHUTDOWN_JOB_TIMEOUT_MS,
} from "../worker-lifecycle";
function fakeProcess() {
const listeners = new Map<string, () => void>();
const exits: Array<number | string | null | undefined> = [];
const proc = {
once: (event: string, fn: () => void) => {
listeners.set(event, fn);
return proc;
},
exit: (code?: number | string | null) => {
exits.push(code);
},
};
return {
proc: proc as unknown as Pick<NodeJS.Process, "once" | "exit">,
signal: (name: string) => listeners.get(name)?.(),
exits,
};
}
describe("graceful shutdown", () => {
it("holds the process exit until running jobs have finished", async () => {
let finishJobs!: () => void;
const stopped = new Promise<void>((r) => (finishJobs = r));
const stop = vi.fn(() => stopped);
const fake = fakeProcess();
registerShutdownHandlers({ stop }, fake.proc);
fake.signal("SIGTERM");
expect(stop).toHaveBeenCalledWith({
graceful: true,
timeout: SHUTDOWN_JOB_TIMEOUT_MS,
});
// Next's handler closes the server and exits; the exit waits.
fake.proc.exit(143);
await new Promise((r) => setTimeout(r, 10));
expect(fake.exits).toEqual([]);
finishJobs();
await new Promise((r) => setTimeout(r, 0));
expect(fake.exits).toEqual([143]);
});
it("exits at the deadline when a job does not finish", async () => {
vi.useFakeTimers();
try {
const fake = fakeProcess();
registerShutdownHandlers(
{ stop: vi.fn(() => new Promise<void>(() => {})) },
fake.proc,
);
fake.signal("SIGTERM");
fake.proc.exit(143);
await vi.advanceTimersByTimeAsync(SHUTDOWN_JOB_TIMEOUT_MS);
expect(fake.exits).toEqual([]);
await vi.advanceTimersByTimeAsync(5_000);
expect(fake.exits).toEqual([143]);
} finally {
vi.useRealTimers();
}
});
it("drains once, whatever signals follow", () => {
const stop = vi.fn(() => Promise.resolve());
const fake = fakeProcess();
registerShutdownHandlers({ stop }, fake.proc);
fake.signal("SIGTERM");
fake.signal("SIGINT");
expect(stop).toHaveBeenCalledTimes(1);
});
});
+43 -18
View File
@@ -16,30 +16,55 @@ import { withBackgroundEvent } from "@/lib/logging/background";
import { getWorkerPrisma, workerLog } from "./shared";
/** How long running jobs get to finish on a stop signal. */
export const SHUTDOWN_JOB_TIMEOUT_MS = 30_000;
/**
* Graceful shutdown: drain in-flight jobs on SIGTERM/SIGINT (sent by Docker
* Compose `docker stop`, Kubernetes pod termination, Coolify redeploys).
* Without this, pending handlers were force-killed and could either be lost
* or replayed on next start. The listeners register once — re-entering the
* worker boot (e.g. on hot-reload in dev) is a no-op for the handlers because
* they capture `boss` by closure and the first signal stops everything.
*
* Next's own signal handler closes the HTTP server (finishing the requests
* in flight) and then calls `process.exit`. That exit used to win the race
* against `boss.stop`, so a running job was cut off mid-way and stayed
* `active` until its expiry, which kept an exclusive queue blocked. The
* handler therefore holds `process.exit` until the boss has stopped: both
* drains run side by side, the process leaves when the slower one is done,
* and never later than {@link SHUTDOWN_JOB_TIMEOUT_MS} plus a margin, so a
* stuck handler cannot block a deploy. Compose gives the container 45 s
* (`stop_grace_period`) for this.
*
* The listeners register once; re-entering the worker boot (hot reload in
* dev) is a no-op for them because the first signal stops everything.
*/
export function registerShutdownHandlers(boss: PgBoss): void {
let shutdownInProgress = false;
const onSignal = async (signal: "SIGTERM" | "SIGINT") => {
if (shutdownInProgress) return;
shutdownInProgress = true;
export function registerShutdownHandlers(
boss: Pick<PgBoss, "stop">,
proc: Pick<NodeJS.Process, "once" | "exit"> = process,
): void {
let drained: Promise<void> | null = null;
const onSignal = (signal: "SIGTERM" | "SIGINT") => {
if (drained) return;
workerLog("error", `received ${signal}, draining boss`);
try {
// graceful=true waits for active handlers to finish, then closes the
// pg connection pool. timeout cap so a stuck handler can't block deploys.
await boss.stop({ graceful: true, timeout: 30_000 });
} catch (err) {
workerLog("error", "boss.stop failed during shutdown", err);
}
// graceful=true waits for active handlers to finish, then closes the
// pg connection pool. The timeout caps it.
drained = boss
.stop({ graceful: true, timeout: SHUTDOWN_JOB_TIMEOUT_MS })
.catch((err: unknown) => {
workerLog("error", "boss.stop failed during shutdown", err);
});
const exit = proc.exit.bind(proc);
const deadline = new Promise<void>((resolve) =>
setTimeout(resolve, SHUTDOWN_JOB_TIMEOUT_MS + 5_000).unref(),
);
let exiting = false;
proc.exit = ((code?: number | string | null) => {
if (exiting) return undefined as never;
exiting = true;
void Promise.race([drained, deadline]).finally(() => exit(code));
return undefined as never;
}) as typeof proc.exit;
};
process.once("SIGTERM", () => void onSignal("SIGTERM"));
process.once("SIGINT", () => void onSignal("SIGINT"));
proc.once("SIGTERM", () => onSignal("SIGTERM"));
proc.once("SIGINT", () => onSignal("SIGINT"));
}
/**
+2 -2
View File
@@ -42,7 +42,7 @@ import {
getLabHistory,
resolveRichMetric,
MCP_METRIC_STATUS_DISCOVERY,
LOCALISED_METRIC_NAME_COLLISIONS,
localisedMetricNameCollisions,
metricStatusDiscoveryRows,
} from "../rich-reads";
import { locales } from "@/lib/i18n/config";
@@ -292,7 +292,7 @@ describe("resolveRichMetric — localised metric names", () => {
// the set is empty and pinned empty: a NEW collision fails here instead of
// silently resolving to whichever entry happened to be built first.
it("refuses a word two different metrics claim, and only those words", () => {
expect([...LOCALISED_METRIC_NAME_COLLISIONS].sort()).toEqual([]);
expect([...localisedMetricNameCollisions()].sort()).toEqual([]);
});
// The property, not a sample: over every metric and every shipped locale,
+16 -8
View File
@@ -85,13 +85,21 @@ export const NUTRIENT_LABELS: Readonly<Record<NutrientCode, string>> =
* `locales`, so a seventh language starts resolving the moment its bundle
* lands, with no edit here.
*/
const NUTRIENT_NAME_INDEX = buildLocalisedLabelIndex<NutrientCode>(
NUTRIENT_CODES.map((code) => ({
id: code,
messageKey: nutrientLabelKey(code),
value: code,
})),
);
let nutrientNameIndexCache:
ReturnType<typeof nutrientNameIndexBuild> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function nutrientNameIndex(): ReturnType<typeof nutrientNameIndexBuild> {
return (nutrientNameIndexCache ??= nutrientNameIndexBuild());
}
function nutrientNameIndexBuild() {
return buildLocalisedLabelIndex<NutrientCode>(
NUTRIENT_CODES.map((code) => ({
id: code,
messageKey: nutrientLabelKey(code),
value: code,
})),
);
}
/** Overview mode (no `nutrient` arg) mirrors `GET /api/nutrients`'s bounds. */
const OVERVIEW_DEFAULT_DAYS = 14;
@@ -148,7 +156,7 @@ export function resolveNutrientCode(input: string): NutrientCode | null {
if (!raw) return null;
const key = raw.toLowerCase().replace(/[\s-]+/g, "_");
if (isNutrientCode(key)) return key;
return NUTRIENT_NAME_INDEX.index.get(foldLabel(raw)) ?? null;
return nutrientNameIndex().index.get(foldLabel(raw)) ?? null;
}
/**
+13 -14
View File
@@ -551,16 +551,14 @@ export async function metricStatusDiscoveryRows(userId: string): Promise<
//
// Every OTHER measurement type is still entered, value-less, because
// ambiguity has to be judged over the whole label space rather than over the
// exposed slice of it. French and Italian once called body fat and fat mass
// by one phrase ("masse grasse" / "massa grassa") while only one of the two
// was exposed, so an index that knew about the exposed one alone would have
// seen no collision and answered kilograms to someone who asked for a
// percentage. Those labels are distinct now; the whole-space check stays so a
// future bundle cannot reintroduce the trap unseen.
//
// The alias tables stay as they are and stay English: `resting_hr`, `spo2`,
// `vo2max` are protocol slugs, not translations of anything.
const LOCALISED_METRIC_INDEX = (() => {
// exposed slice of it: French and Italian once named body fat and fat mass
// alike while only one was exposed, which answered kilograms to a question
// about a percentage. The alias tables stay English (`resting_hr`, `spo2`
// are protocol slugs). Built on first use: the index reads every catalog.
let metricIndex: ReturnType<typeof buildLocalisedMetricIndex> | undefined;
const localisedMetricIndex = () =>
(metricIndex ??= buildLocalisedMetricIndex());
function buildLocalisedMetricIndex() {
const exposed = new Map<MeasurementType, RichMetric>();
const claim = (metric: RichMetric | null) => {
if (metric && !exposed.has(metric.measurementType)) {
@@ -579,7 +577,7 @@ const LOCALISED_METRIC_INDEX = (() => {
value: exposed.get(type as MeasurementType) ?? null,
}));
return buildLocalisedLabelIndex(entries);
})();
}
/**
* Words two different measurement types claim in some bundle, and which
@@ -587,8 +585,9 @@ const LOCALISED_METRIC_INDEX = (() => {
* is empty: a NEW member is a regression to look at rather than something to
* resolve by coin flip.
*/
export const LOCALISED_METRIC_NAME_COLLISIONS: ReadonlySet<string> =
LOCALISED_METRIC_INDEX.ambiguous;
export function localisedMetricNameCollisions(): ReadonlySet<string> {
return localisedMetricIndex().ambiguous;
}
/**
* Resolve a free-text metric name to a single scalar series. Forgiving for an
@@ -632,7 +631,7 @@ export function resolveRichMetric(input: string): RichMetric | null {
// on PULSE_WAVE_VELOCITY, because "pulse-wave velocity" contains "puls".
// A value-less hit (a metric this surface does not expose) falls through
// rather than short-circuiting, so the English behaviour below is untouched.
const localised = LOCALISED_METRIC_INDEX.index.get(foldLabel(raw));
const localised = localisedMetricIndex().index.get(foldLabel(raw));
if (localised) return localised;
// 5. English display-name substring match (e.g. "heart-rate variability").
@@ -0,0 +1,329 @@
/**
* The workout picker as it stood while duplicates were matched by fixed
* five-minute clock slots. Kept only as the reference the parity tests in
* `pick-canonical-workout-rows.property.test.ts` compare the duplicate window
* against: on every input where no two workouts of one sport within five
* minutes straddle a slot boundary, both must return the same rows.
*/
import type { MeasurementSource } from "@/generated/prisma/client";
import {
getSourceLadder,
parseSourcePriority,
} from "@/lib/validations/source-priority";
/**
* Minimum row shape — same posture as `SourcePickerRow`. The helper
* only consults the three fields; downstream callers can read every
* other column they fetched alongside.
*/
export interface WorkoutPickerRow {
userId?: string;
startedAt: Date;
/**
* Session length. Together with `sportType` + `startedAt` this is the
* exact-session identity used to collapse repeated same-source sync rows.
* Optional only so fixtures can omit it; every production call site MUST
* select it, and `workout-canonical-identity-guard.test.ts` freezes that set.
*/
durationSec?: number;
sportType: string;
source: MeasurementSource;
avgHeartRate?: number | null;
maxHeartRate?: number | null;
metadata?: unknown;
}
/**
* HealthKit can resend the same session with a new external UUID. The source
* ladder correctly chooses Apple over WHOOP, but previously kept every Apple
* row in that winning bucket, which made the web list show the same workout
* two or three times.
*
* The identity is `(sportType, startedAt, durationSec)`. `endedAt` was part of
* this key when the collapse first shipped and has been dropped deliberately:
*
* - It is not a free extra check. On a workout with pauses, `endedAt` is the
* wall-clock end while `durationSec` counts only moving time, so the two
* legitimately disagree, and a stored row can carry either convention. Two
* re-sends of one session can therefore differ in `endedAt` alone, and the
* stricter key let that duplicate through.
* - It bought nothing in exchange. Nobody starts a second workout of the
* same sport in the same instant with the same duration, so start plus
* duration already identifies the session uniquely.
* - Requiring it split the callers. Four of the five call sites select
* `durationSec` but not `endedAt`, so the collapse ran on the workout list
* and silently did nothing for the Coach narration, the per-sport baseline
* and the deep-link cluster. One list, one number, two answers.
*
* A row without `durationSec` cannot be identified and passes through
* uncollapsed. That is a fixture-only path: every production call site selects
* the field and `workout-canonical-identity-guard.test.ts` keeps it that way.
*/
function exactSessionKey(row: WorkoutPickerRow): string | null {
if (row.durationSec == null) return null;
return [row.sportType, row.startedAt.getTime(), row.durationSec].join(":");
}
function rowRichness(row: WorkoutPickerRow): number {
return (
(row.avgHeartRate != null ? 2 : 0) +
(row.maxHeartRate != null ? 2 : 0) +
(row.metadata != null ? 1 : 0)
);
}
function deduplicateWinningRows<T extends WorkoutPickerRow>(rows: T[]): T[] {
const bySession = new Map<string, T>();
const passthrough: T[] = [];
for (const row of rows) {
const key = exactSessionKey(row);
if (key == null) {
passthrough.push(row);
continue;
}
const existing = bySession.get(key);
if (!existing || rowRichness(row) > rowRichness(existing)) {
bySession.set(key, row);
}
}
return [...passthrough, ...bySession.values()];
}
/**
* Bucket-slot length. Two workouts whose `startedAt` falls in the
* same 5-minute slot AND share the same sportType are treated as the
* "same" workout from different sources. Tightens further than the
* 1-minute clock skew between HK + Withings would suggest because
* iOS posts HK `startDate` rounded to the second but Withings stamps
* its `startdate` independently — five minutes gives both heads
* enough slack on a heavy-traffic morning sync without crossing the
* "two back-to-back runs" boundary (a user does NOT do two ten-minute
* runs five minutes apart in practice).
*/
const BUCKET_SLOT_MS = 5 * 60 * 1000;
/**
* The cross-source duplicate slot a workout falls in. Exported for the one
* reader that has to look past the picked row: the strain engine, whose winner
* may carry no heart-rate series while its dropped twin does.
*/
export function legacyWorkoutDuplicateSlotKey(row: WorkoutPickerRow): string {
return bucketKey(row);
}
function bucketKey(row: WorkoutPickerRow): string {
const slot = Math.floor(row.startedAt.getTime() / BUCKET_SLOT_MS);
return `${row.userId ?? ""}:${slot}:${row.sportType}`;
}
function jsonRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
const WHOOP_ZONE_DURATION_KEYS = [
"zone_zero_milli",
"zone_one_milli",
"zone_two_milli",
"zone_three_milli",
"zone_four_milli",
"zone_five_milli",
] as const;
function sanitizedWhoopZoneDurations(
metadata: unknown,
): Record<string, number> | null {
const raw = jsonRecord(jsonRecord(metadata)?.zoneDurations);
if (!raw) return null;
const sanitized: Record<string, number> = {};
for (const key of WHOOP_ZONE_DURATION_KEYS) {
const value = raw[key];
if (value === undefined) continue;
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return null;
}
sanitized[key] = value;
}
return Object.keys(sanitized).length > 0 ? sanitized : null;
}
function enrichFromWhoopTwin<T extends WorkoutPickerRow>(
winner: T,
donor: T,
): T {
const winnerMetadata = jsonRecord(winner.metadata);
const donorZones = sanitizedWhoopZoneDurations(donor.metadata);
const needsZones =
donorZones != null && winnerMetadata?.zoneDurations == null;
const needsAvg = winner.avgHeartRate == null && donor.avgHeartRate != null;
const needsMax = winner.maxHeartRate == null && donor.maxHeartRate != null;
if (!needsAvg && !needsMax && !needsZones) return winner;
return {
...winner,
...(needsAvg ? { avgHeartRate: donor.avgHeartRate } : {}),
...(needsMax ? { maxHeartRate: donor.maxHeartRate } : {}),
...(needsZones
? {
metadata: {
...(winnerMetadata ?? {}),
zoneDurations: donorZones,
},
}
: {}),
};
}
/**
* Pair winners and WHOOP donors one-to-one by nearest start time. A single
* donor can never enrich two same-source occurrences that happen to share a
* fixed slot. Stable input indexes break equal-distance ties.
*/
function enrichWinnersFromWhoop<T extends WorkoutPickerRow>(
winners: readonly T[],
donors: readonly T[],
): T[] {
if (winners.length === 0 || donors.length === 0) return [...winners];
const candidates: Array<{
winnerIndex: number;
donorIndex: number;
deltaMs: number;
}> = [];
winners.forEach((winner, winnerIndex) => {
donors.forEach((donor, donorIndex) => {
candidates.push({
winnerIndex,
donorIndex,
deltaMs: Math.abs(
winner.startedAt.getTime() - donor.startedAt.getTime(),
),
});
});
});
candidates.sort(
(left, right) =>
left.deltaMs - right.deltaMs ||
left.winnerIndex - right.winnerIndex ||
left.donorIndex - right.donorIndex,
);
const enriched = [...winners];
const matchedWinners = new Set<number>();
const matchedDonors = new Set<number>();
for (const candidate of candidates) {
if (
matchedWinners.has(candidate.winnerIndex) ||
matchedDonors.has(candidate.donorIndex)
) {
continue;
}
enriched[candidate.winnerIndex] = enrichFromWhoopTwin(
winners[candidate.winnerIndex],
donors[candidate.donorIndex],
);
matchedWinners.add(candidate.winnerIndex);
matchedDonors.add(candidate.donorIndex);
}
return enriched;
}
/**
* The source order for workouts: the `steps` metric's ladder, the
* canonical-activity bucket the Sources settings configure, falling back to
* its default when the user saved none or it does not parse.
*/
function workoutSourceLadder(
userPriorityJson: unknown,
): readonly MeasurementSource[] {
return getSourceLadder(parseSourcePriority(userPriorityJson), "steps");
}
/**
* Pick canonical rows across sources for a user's workouts.
*
* @param rows input rows (deterministic order required)
* @param userPriorityJson the user's `sourcePriorityJson` blob — same
* shape the measurement picker consults.
* Falls back to the canonical default ladder
* when null / unparseable.
* @returns the subset of input rows that survives the picker. Insertion
* order preserved.
*/
export function legacyPickCanonicalWorkoutRows<T extends WorkoutPickerRow>(
rows: readonly T[],
userPriorityJson: unknown = null,
): T[] {
if (rows.length === 0) return [];
const sourceLadder = workoutSourceLadder(userPriorityJson);
const buckets = new Map<
string,
{
rows: T[];
sources: Set<MeasurementSource>;
}
>();
for (const row of rows) {
const key = bucketKey(row);
const slot = buckets.get(key) ?? { rows: [], sources: new Set() };
slot.rows.push(row);
slot.sources.add(row.source);
buckets.set(key, slot);
}
const canonical: T[] = [];
for (const [, slot] of buckets) {
if (slot.rows.length === 1) {
// No cross-source overlap → keep the single row regardless of
// ladder membership.
canonical.push(slot.rows[0]);
continue;
}
let picked: MeasurementSource | undefined;
for (const source of sourceLadder) {
if (slot.sources.has(source)) {
picked = source;
break;
}
}
if (!picked) {
// None of the bucket's sources are listed in the ladder — never
// silently drop. Keep every row in the bucket; downstream
// callers can choose to surface a "duplicate from unrecognised
// sources" diagnostic.
canonical.push(...slot.rows);
continue;
}
const winners = deduplicateWinningRows(
slot.rows.filter((row) => row.source === picked),
);
const whoopDonors =
picked === "WHOOP"
? []
: slot.rows.filter((row) => row.source === "WHOOP");
canonical.push(...enrichWinnersFromWhoop(winners, whoopDonors));
}
// Re-sort to preserve input order — the bucket map iterates in
// insertion order but the per-bucket fan-out can interleave rows
// from later buckets ahead of earlier-bucket survivors. Caller
// wants the same `startedAt ASC` order it passed in.
canonical.sort((a, b) => {
const delta = a.startedAt.getTime() - b.startedAt.getTime();
if (delta !== 0) return delta;
// Stable-tiebreak on sportType so the output is deterministic
// even when two surviving rows share startedAt (rare; manual
// entries on the same second).
return a.sportType.localeCompare(b.sportType);
});
return canonical;
}
@@ -0,0 +1,300 @@
import { describe, expect, it } from "vitest";
import { canonicalWorkoutTrimps } from "@/lib/insights/strain-score";
import {
pickCanonicalWorkoutRows,
workoutDuplicateClusterKeys,
workoutSourceLadder,
} from "../pick-canonical-workout-rows";
import {
legacyPickCanonicalWorkoutRows,
legacyWorkoutDuplicateSlotKey,
} from "./pick-canonical-workout-rows.legacy";
type Source = "APPLE_HEALTH" | "WHOOP" | "WITHINGS" | "MANUAL" | "STRAVA";
interface Row {
id: string;
userId?: string;
startedAt: Date;
durationSec?: number;
sportType: string;
source: Source;
avgHeartRate: number | null;
maxHeartRate: number | null;
metadata: Record<string, unknown> | null;
}
const MIN = 60_000;
const WINDOW = 5 * MIN;
const SOURCES: Source[] = [
"APPLE_HEALTH",
"WHOOP",
"WITHINGS",
"MANUAL",
"STRAVA",
];
const SPORTS = ["running", "cycling", "walking"];
/** Small seeded generator, so a failure names a reproducible case. */
function rng(seed: number) {
let a = seed >>> 0;
const next = () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return {
next,
int: (lo: number, hi: number) => lo + Math.floor(next() * (hi - lo + 1)),
pick: <T>(list: readonly T[]) => list[Math.floor(next() * list.length)]!,
};
}
function randomRows(seed: number): Row[] {
const r = rng(seed);
const base = Date.UTC(2026, 4, 16, 6, 0, 0) + r.int(0, 86_400_000);
const count = r.int(1, 14);
const rows: Row[] = [];
for (let i = 0; i < count; i++) {
// Mostly near each other, so clusters form; sometimes a re-send of an
// earlier row (same start and duration) under another id.
const twin = i > 0 && r.next() < 0.2 ? rows[r.int(0, i - 1)]! : null;
const startedAt = twin
? new Date(
twin.startedAt.getTime() + (r.next() < 0.5 ? 0 : r.int(1, 90) * 1000),
)
: new Date(base + r.int(0, 40 * MIN));
rows.push({
id: `w-${seed}-${i}`,
userId: r.next() < 0.15 ? "other" : "u1",
startedAt,
durationSec:
r.next() < 0.1 ? undefined : (twin?.durationSec ?? r.int(10, 90) * 60),
sportType: twin && r.next() < 0.8 ? twin.sportType : r.pick(SPORTS),
source: r.pick(SOURCES),
avgHeartRate: r.next() < 0.5 ? r.int(100, 160) : null,
maxHeartRate: r.next() < 0.5 ? r.int(160, 190) : null,
metadata:
r.next() < 0.3
? { zoneDurations: { zone_two_milli: r.int(1, 60) * MIN } }
: null,
});
}
// Callers pass `startedAt ASC, id ASC`.
rows.sort(
(a, b) =>
a.startedAt.getTime() - b.startedAt.getTime() || a.id.localeCompare(b.id),
);
return rows;
}
/**
* True when some pair of one (user, sport) lies within the window but in two
* different fixed slots: the only inputs on which the old picker and the
* duplicate window may disagree.
*/
function straddlesASlot(rows: readonly Row[]): boolean {
for (let i = 0; i < rows.length; i++) {
for (let j = i + 1; j < rows.length; j++) {
const a = rows[i]!;
const b = rows[j]!;
if (a.userId !== b.userId || a.sportType !== b.sportType) continue;
if (Math.abs(a.startedAt.getTime() - b.startedAt.getTime()) >= WINDOW)
continue;
if (legacyWorkoutDuplicateSlotKey(a) !== legacyWorkoutDuplicateSlotKey(b))
return true;
}
}
return false;
}
const LADDERS: unknown[] = [
null,
{ steps: ["WITHINGS", "APPLE_HEALTH"] },
{ steps: ["WHOOP"] },
];
/** The strain fold as it stood on fixed slots, for the parity check. */
function legacyTrimps(
rows: readonly Row[],
ladderJson: unknown,
trimpOf: (r: Row) => number,
) {
const picked = new Set(
legacyPickCanonicalWorkoutRows(rows, ladderJson).map((r) => r.id),
);
const ladder = workoutSourceLadder(ladderJson);
const rankOf = (source: string) => {
const index = ladder.indexOf(source as (typeof ladder)[number]);
return index === -1 ? ladder.length : index;
};
const slots = new Map<
string,
{ picked: Map<string, number>; dropped: number }
>();
for (const row of rows) {
const key = legacyWorkoutDuplicateSlotKey(row);
const slot = slots.get(key) ?? { picked: new Map(), dropped: 0 };
const t = trimpOf(row);
if (t > 0) {
if (picked.has(row.id))
slot.picked.set(row.source, (slot.picked.get(row.source) ?? 0) + t);
else slot.dropped = Math.max(slot.dropped, t);
}
slots.set(key, slot);
}
const out: number[] = [];
for (const slot of slots.values()) {
let best: { rank: number; trimp: number } | null = null;
for (const [source, trimp] of slot.picked) {
const rank = rankOf(source);
if (
best === null ||
rank < best.rank ||
(rank === best.rank && trimp > best.trimp)
) {
best = { rank, trimp };
}
}
const trimp = best !== null ? best.trimp : slot.dropped;
if (trimp > 0) out.push(trimp);
}
return out;
}
describe("workout duplicate window", () => {
it("returns exactly the old rows wherever no pair straddles a slot boundary", () => {
let compared = 0;
for (let seed = 1; seed <= 4000; seed++) {
const rows = randomRows(seed);
if (straddlesASlot(rows)) continue;
compared++;
for (const ladder of LADDERS) {
expect(pickCanonicalWorkoutRows(rows, ladder), `seed ${seed}`).toEqual(
legacyPickCanonicalWorkoutRows(rows, ladder),
);
}
}
// The generator must actually produce comparable inputs, clusters
// included, or the loop above proves nothing.
expect(compared).toBeGreaterThan(1000);
});
it("gives the strain fold the same impulses wherever no pair straddles a slot", () => {
const trimpOf = (row: Row) => row.avgHeartRate ?? 0;
let compared = 0;
for (let seed = 1; seed <= 2000; seed++) {
const rows = randomRows(seed);
if (straddlesASlot(rows)) continue;
compared++;
for (const ladder of LADDERS) {
expect(
canonicalWorkoutTrimps(rows, ladder, trimpOf),
`seed ${seed}`,
).toEqual(legacyTrimps(rows, ladder, trimpOf));
}
}
expect(compared).toBeGreaterThan(500);
});
it("collapses two sources' copies of one session at any offset under five minutes", () => {
const r = rng(7);
let legacyMissed = 0;
for (let i = 0; i < 3000; i++) {
const start = Date.UTC(2026, 4, 16) + r.int(0, 86_400_000);
const offset = r.int(0, WINDOW - 1);
const rows: Row[] = [
{
id: "apple",
userId: "u1",
startedAt: new Date(start),
durationSec: 1800,
sportType: "running",
source: "APPLE_HEALTH",
avgHeartRate: null,
maxHeartRate: null,
metadata: null,
},
{
id: "withings",
userId: "u1",
startedAt: new Date(start + offset),
durationSec: 1790,
sportType: "running",
source: "WITHINGS",
avgHeartRate: null,
maxHeartRate: null,
metadata: null,
},
];
expect(pickCanonicalWorkoutRows(rows).map((row) => row.id)).toEqual([
"apple",
]);
if (legacyPickCanonicalWorkoutRows(rows).length === 2) legacyMissed++;
}
// The fixed slots missed a share of these; the window misses none.
expect(legacyMissed).toBeGreaterThan(0);
});
it("never collapses two workouts five minutes or more apart", () => {
const r = rng(11);
for (let i = 0; i < 2000; i++) {
const start = Date.UTC(2026, 4, 16) + r.int(0, 86_400_000);
const gap = WINDOW + r.int(0, 30 * MIN);
const rows: Row[] = ["APPLE_HEALTH", "WITHINGS"].map((source, k) => ({
id: `w${k}`,
userId: "u1",
startedAt: new Date(start + k * gap),
durationSec: 1200,
sportType: "running",
source: source as Source,
avgHeartRate: null,
maxHeartRate: null,
metadata: null,
}));
expect(pickCanonicalWorkoutRows(rows)).toHaveLength(2);
}
});
it("does not depend on where the clock's five-minute marks fall", () => {
// Shifting every start by the same amount keeps every gap, so it must
// keep the picked set. The fixed slots broke this property.
for (let seed = 1; seed <= 1500; seed++) {
const rows = randomRows(seed);
const shift = rng(seed * 31).int(1, WINDOW - 1);
const shifted = rows.map((row) => ({
...row,
startedAt: new Date(row.startedAt.getTime() + shift),
}));
const ids = (list: Row[]) => list.map((row) => row.id).sort();
expect(ids(pickCanonicalWorkoutRows(shifted)), `seed ${seed}`).toEqual(
ids(pickCanonicalWorkoutRows(rows)),
);
}
});
it("anchors on the first start, so a row of workouts does not chain", () => {
const at = (m: number) => new Date(Date.UTC(2026, 4, 16, 8, m, 0));
const rows: Row[] = [0, 4, 8, 12].map((m, k) => ({
id: `w${m}`,
userId: "u1",
startedAt: at(m),
durationSec: 600,
sportType: "running",
source: (k % 2 === 0 ? "APPLE_HEALTH" : "WITHINGS") as Source,
avgHeartRate: null,
maxHeartRate: null,
metadata: null,
}));
const keys = workoutDuplicateClusterKeys(rows);
expect(new Set(keys).size).toBe(2);
expect(pickCanonicalWorkoutRows(rows).map((row) => row.id)).toEqual([
"w0",
"w8",
]);
});
});
@@ -450,7 +450,12 @@ describe("pickCanonicalWorkoutRows", () => {
]);
});
it("does not chain neighbouring workouts across fixed canonical slots", () => {
it("does not chain neighbouring workouts past the first start's window", () => {
// `middle` starts four minutes after `first`: inside the duplicate
// window, so it is the same run from a lower-ranked source. `last` is
// eight minutes after `first`, outside the window measured from the
// cluster's first start, so it stays even though it is only four minutes
// after `middle`.
const rows: RowFixture[] = [
{
id: "first",
@@ -474,7 +479,6 @@ describe("pickCanonicalWorkoutRows", () => {
expect(pickCanonicalWorkoutRows(rows).map((row) => row.id)).toEqual([
"first",
"middle",
"last",
]);
});
+177
View File
@@ -0,0 +1,177 @@
/**
* The two reads `POST /api/measurements/batch` makes up front about rows the
* batch may already hold, one statement each for the whole batch.
*
* Samples: a re-sent sample used to cost one interactive reconciler
* transaction (about ten round trips) only for the reconciler to answer
* `duplicate`. One read over the batch's sample ids answers it for every row
* at once (the route's exact-duplicate prefilter).
*
* `stats:` rows: the phone re-sends the same daily totals on every sync, and
* each went through the reconciler only to write the same values back, then
* counted as `updated`, which started the whole tail: the DAY refold, rollup
* jobs, PR detection and a cache stale for the account. Such a row is still
* reported `updated`, exactly as before, but nothing is written and it starts
* nothing. "Unchanged" is the reconciler's own update with nothing to change:
* a live row holds the entry's external key AND its natural identity (both
* indexes are unique, so that row is the only candidate), already carries the
* HealthKit-statistics provenance, and every column the update would write
* already has the value it would write. The route applies it only where
* neither key occurs a second time in the batch, and never to a value the
* plausibility gate would refuse. A concurrent writer is no different from
* one that ran a moment later: its write lands after this no-op either way.
*/
import type { PrismaClient } from "@/generated/prisma/client";
/** `type::source::externalId`, the external identity of a row. */
export function externalKeyOf(
type: unknown,
source: unknown,
externalId: unknown,
): string {
return `${type}::${source}::${externalId}`;
}
/** Stored rows (live or tombstoned) at the batch's sample ids. */
export async function readStoredSampleIdentities(
db: Pick<PrismaClient, "measurement">,
userId: string,
externalIds: readonly string[],
): Promise<Map<string, { measuredAt: Date; sleepStage: unknown }>> {
const out = new Map<string, { measuredAt: Date; sleepStage: unknown }>();
if (externalIds.length === 0) return out;
const rows = await db.measurement.findMany({
where: { userId, externalId: { in: [...externalIds] } },
select: {
type: true,
source: true,
externalId: true,
measuredAt: true,
sleepStage: true,
},
});
for (const row of rows) {
out.set(externalKeyOf(row.type, row.source, row.externalId), {
measuredAt: row.measuredAt,
sleepStage: row.sleepStage,
});
}
return out;
}
/** The columns the reconciler's update writes, besides the identity. */
const STATS_COLUMNS = [
"value",
"valueMin",
"valueMax",
"unit",
"sleepStage",
"externalSourceVersion",
"glucoseContext",
"rhythmClassification",
"deviceType",
] as const;
export type StoredStatsRow = Record<
(typeof STATS_COLUMNS)[number] | "aggregationProvenance",
unknown
> & { measuredAt: Date };
/** Live stored rows at the batch's `stats:` ids, with every written column. */
export async function readStoredStatsRows(
db: Pick<PrismaClient, "measurement">,
userId: string,
externalIds: readonly string[],
): Promise<Map<string, StoredStatsRow>> {
const out = new Map<string, StoredStatsRow>();
if (externalIds.length === 0) return out;
const rows = await db.measurement.findMany({
where: { userId, externalId: { in: [...externalIds] }, deletedAt: null },
select: {
type: true,
source: true,
externalId: true,
measuredAt: true,
aggregationProvenance: true,
...Object.fromEntries(STATS_COLUMNS.map((c) => [c, true])),
},
});
for (const row of rows as unknown as Array<
StoredStatsRow & { type: string; source: string; externalId: string }
>) {
out.set(externalKeyOf(row.type, row.source, row.externalId), row);
}
return out;
}
/**
* Whether the update a `stats:` entry would make to `stored` writes nothing
* new: same instant, the statistics provenance already, every column equal.
*/
export function statsWriteUnchanged(
stored: StoredStatsRow | undefined,
desired: Record<(typeof STATS_COLUMNS)[number], unknown> & {
measuredAt: Date;
},
): boolean {
if (!stored || !(stored.measuredAt instanceof Date)) return false;
if (stored.measuredAt.getTime() !== desired.measuredAt.getTime()) {
return false;
}
if (stored.aggregationProvenance !== "HEALTHKIT_STATISTICS") return false;
return STATS_COLUMNS.every(
(column) => (stored[column] ?? null) === (desired[column] ?? null),
);
}
/** The identity columns of one batch entry. */
export interface BatchKeyRow {
type?: unknown;
source?: unknown;
externalId?: unknown;
measuredAt?: unknown;
sleepStage?: unknown;
}
/** `type::source::epochMs::sleepStage`, the natural identity of a row. */
export function naturalKeyOf(row: BatchKeyRow): string {
return `${row.type}::${row.source}::${(row.measuredAt as Date).getTime()}::${row.sleepStage ?? "null"}`;
}
/**
* Whether an entry's external key and natural identity each occur once in
* its batch. Only such an entry may be decided ahead of the reconciler: an
* earlier entry sharing either key could move or retire the stored row
* before this one is reached.
*/
export function onceInBatch(
rows: readonly BatchKeyRow[],
): (row: BatchKeyRow) => boolean {
const external = new Map<string, number>();
const natural = new Map<string, number>();
for (const row of rows) {
const e = externalKeyOf(row.type, row.source, row.externalId);
external.set(e, (external.get(e) ?? 0) + 1);
const n = naturalKeyOf(row);
natural.set(n, (natural.get(n) ?? 0) + 1);
}
return (row) =>
external.get(externalKeyOf(row.type, row.source, row.externalId)) === 1 &&
natural.get(naturalKeyOf(row)) === 1;
}
/**
* Whether a sample entry is an exact duplicate of `stored`, the row at its
* external key: the same instant and sleep stage, which makes that row its
* natural match too, the reconciler's `duplicate`.
*/
export function sampleIsStoredDuplicate(
stored: { measuredAt: Date; sleepStage: unknown } | undefined,
row: BatchKeyRow,
): boolean {
return (
stored !== undefined &&
stored.measuredAt.getTime() === (row.measuredAt as Date).getTime() &&
(stored.sleepStage ?? null) === (row.sleepStage ?? null)
);
}
+195
View File
@@ -0,0 +1,195 @@
/**
* The long-window read of a sample-dense series (pulse, CGM glucose, and the
* other dense kinds of `/api/measurements/series`): day or hour buckets in
* the account's zone, and the strip's figures over the raw readings.
*
* Both used to be separate statements over the same canonical rows, so a
* ten-year pulse read picked the canonical source per day and scanned the
* readings twice. Now the window's rows are materialised once (`src`) and
* both aggregates run over them in one statement, with exactly the SQL they
* had: `tests/integration/dense-series-read-parity.test.ts` holds the two old
* statements as the reference.
*
* Buckets: a day of a type with an activity-dependent sampling rate (pulse)
* is the weighted mean of its local hours' means, so a workout hour with
* hundreds of times the readings does not outweigh the rest of the day (see
* `day-statistic.ts`); the hour grain is already one mean per hour.
*
* Figures: aggregated over the raw rows, not the buckets, so the strip's
* mean, min, max, spread and count are what the raw path computes (`AVG`,
* `MIN`, `MAX`, `STDDEV_POP`, the exact row count). For an hourly-mean type
* the mean is the mean of the local days' values, each day the mean of its
* local hours' means, so it is the mean of the day points the chart draws;
* count, min, max and the spread stay over every reading.
*
* Parameters: `$1` the user (inside `seriesRows`), `$2` the grain, `$3` the
* zone, `$4` the window start; `seriesRows` is the FROM-clause subquery of
* `seriesRowsFrom`, its literals whitelisted there.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
dayWeightedRows,
hourWeightSql,
weightedHourMeanSql,
windowMeanSql,
zoneDayFrame,
} from "@/lib/measurements/day-mean";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
export interface DenseBucketRow {
bucket_start: Date;
mean: number;
min_value: number;
max_value: number;
}
export interface DenseFigures {
n: number;
mean: number | null;
min: number | null;
max: number | null;
sd: number | null;
}
/** The bucket aggregate over `src`, in the original statement's SQL. */
export function denseBucketsSql(hourWeighted: boolean, from: string): string {
return hourWeighted
? `
WITH localized AS (
SELECT
m."value",
m."value_min",
m."value_max",
date_trunc(
'hour',
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $3
) AS local_hour
FROM ${from}
),
hourly AS (
SELECT
date_trunc($2, local_hour) AS local_day,
AVG("value") AS hour_mean,
COUNT(*) AS n,
MIN(COALESCE("value_min", "value")) AS min_value,
MAX(COALESCE("value_max", "value")) AS max_value
FROM localized
GROUP BY local_hour
)
SELECT
local_day AT TIME ZONE $3 AS bucket_start,
${weightedHourMeanSql("hour_mean", hourWeightSql("n"))}::double precision AS mean,
MIN(min_value)::double precision AS min_value,
MAX(max_value)::double precision AS max_value
FROM hourly
GROUP BY local_day`
: `
WITH localized AS (
SELECT
m."value",
m."value_min",
m."value_max",
date_trunc(
$2,
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $3
) AS local_day
FROM ${from}
)
SELECT
local_day AT TIME ZONE $3 AS bucket_start,
AVG("value")::double precision AS mean,
MIN(COALESCE("value_min", "value"))::double precision AS min_value,
MAX(COALESCE("value_max", "value"))::double precision AS max_value
FROM localized
GROUP BY local_day`;
}
/** The figures aggregate over the CTE `src`, in the original SQL. */
export function denseFiguresSql(hourlyMean: boolean): string {
return hourlyMean
? `
SELECT
COUNT(*)::int AS n,
${windowMeanSql({ typeColumn: 'm."type"', value: 'm."value"', weight: "m.day_weight" })}::double precision
AS mean,
MIN(m."value")::double precision AS min,
MAX(m."value")::double precision AS max,
STDDEV_POP(m."value")::double precision AS sd
FROM ${dayWeightedRows("src", zoneDayFrame("$3"))} m
GROUP BY m."type"`
: `
SELECT
COUNT(*)::int AS n,
AVG(m."value")::double precision AS mean,
MIN(m."value")::double precision AS min,
MAX(m."value")::double precision AS max,
STDDEV_POP(m."value")::double precision AS sd
FROM src m`;
}
/** Buckets and figures of one dense series window, in one statement. */
export async function readDenseSeries(args: {
userId: string;
type: MeasurementType;
grain: "day" | "hour";
userTz: string;
since: Date;
seriesRows: string;
}): Promise<{
bucketRows: DenseBucketRow[];
rawAgg: DenseFigures | undefined;
}> {
const hourWeighted = args.grain === "day" && usesHourlyMeanDay(args.type);
const rows = await prisma.$queryRawUnsafe<
Array<
{ kind: "bucket" | "figures" } & Partial<DenseBucketRow> &
Partial<DenseFigures>
>
>(
`
WITH src AS MATERIALIZED (
SELECT m.*
FROM ${args.seriesRows}
WHERE m."measured_at" >= $4
),
buckets AS (${denseBucketsSql(hourWeighted, "src m")}
),
figures AS (${denseFiguresSql(usesHourlyMeanDay(args.type))}
)
SELECT 'bucket' AS kind, b.bucket_start, b.mean, b.min_value, b.max_value,
NULL::int AS n, NULL::double precision AS min,
NULL::double precision AS max, NULL::double precision AS sd
FROM buckets b
UNION ALL
SELECT 'figures', NULL, f.mean, NULL, NULL, f.n, f.min, f.max, f.sd
FROM figures f
ORDER BY kind ASC, bucket_start ASC
`,
args.userId,
args.grain,
args.userTz,
args.since,
);
const bucketRows: DenseBucketRow[] = [];
let rawAgg: DenseFigures | undefined;
for (const row of rows) {
if (row.kind === "bucket") {
bucketRows.push({
bucket_start: row.bucket_start!,
mean: row.mean!,
min_value: row.min_value!,
max_value: row.max_value!,
});
} else {
rawAgg = {
n: row.n!,
mean: row.mean ?? null,
min: row.min ?? null,
max: row.max ?? null,
sd: row.sd ?? null,
};
}
}
return { bucketRows, rawAgg };
}
@@ -19,6 +19,10 @@
*
* Locks per `.planning/research/v1434-r-1-xml-import.md` §6.
*/
import {
adoptNaturalKeyTwin,
storedUnchangedKeys,
} from "@/lib/measurements/import-unchanged-rows";
import { createReadStream } from "node:fs";
import { createHash } from "node:crypto";
import { StringDecoder } from "node:string_decoder";
@@ -856,6 +860,15 @@ export async function streamParseExportXml(
else createdByKey.set(key, [created]);
}
// Rows already stored as they are: counted, not written (see module).
const unchanged = await storedUnchangedKeys(
prisma,
userId,
chunk,
(r, i) =>
failedInsertIndexes.has(i) ||
createdByKey.has(`${r.type}::${r.externalId}`),
);
for (let index = 0; index < chunk.length; index += 1) {
const row = chunk[index];
const stat = bumpStat(row.type);
@@ -868,6 +881,11 @@ export async function streamParseExportXml(
}
const key = `${row.type}::${row.externalId}`;
if (unchanged.has(key)) {
stat.updated += 1;
rowsUpserted += 1;
continue;
}
const inserted = createdByKey.get(key)?.shift();
if (inserted) {
stat.inserted += 1;
@@ -906,38 +924,13 @@ export async function streamParseExportXml(
err instanceof Prisma.PrismaClientKnownRequestError &&
(err.code === "P2002" || err.code === "P2025")
) {
try {
const twin = await prisma.measurement.findFirst({
where: {
userId,
type: row.type,
source: "APPLE_HEALTH",
measuredAt: row.measuredAt,
sleepStage: row.sleepStage ?? null,
},
select: { id: true },
});
if (twin) {
await prisma.measurement.update({
where: { id: twin.id },
data: {
value: row.value,
unit: row.unit,
externalId: row.externalId,
externalSourceVersion: row.externalSourceVersion,
deviceType: row.deviceType,
deletedAt: null,
},
});
stat.updated += 1;
rowsUpserted += 1;
} else {
unknown[`${row.type}::natural_key_unresolved`] =
(unknown[`${row.type}::natural_key_unresolved`] ?? 0) + 1;
}
} catch {
unknown[`${row.type}::natural_key_rescue_failed`] =
(unknown[`${row.type}::natural_key_rescue_failed`] ?? 0) + 1;
const adopted = await adoptNaturalKeyTwin(prisma, userId, row);
if (adopted === "updated") {
stat.updated += 1;
rowsUpserted += 1;
} else {
unknown[`${row.type}::${adopted}`] =
(unknown[`${row.type}::${adopted}`] ?? 0) + 1;
}
} else {
unknown[`${row.type}::upsert_failed`] =
@@ -0,0 +1,124 @@
/**
* Rows of an Apple Health import chunk that the account already holds with
* exactly the values the import's update would write.
*
* A re-import of an export the account already holds sent one UPDATE per
* row the insert skipped, about two million statements for a large export,
* nearly all of them writing back what was stored. The import reads the
* stored rows of a chunk once and leaves those out; it still counts them as
* updated. A key that occurs twice in the chunk is never left out: its rows
* are written in order, as before, so the last one stands.
*/
import type {
MeasurementType,
PrismaClient,
SleepStage,
} from "@/generated/prisma/client";
export interface ImportUpdateRow {
type: MeasurementType;
externalId: string;
value: number;
measuredAt: Date;
externalSourceVersion: string | null;
sleepStage?: SleepStage | null;
deviceType: string | null;
}
/** `type::externalId` of every row whose update would change nothing. */
export async function storedUnchangedKeys(
db: Pick<PrismaClient, "measurement">,
userId: string,
chunk: readonly ImportUpdateRow[],
/** Rows the chunk already settled (inserted, or failed to insert). */
settled: (row: ImportUpdateRow, index: number) => boolean = () => false,
): Promise<Set<string>> {
const out = new Set<string>();
const rows = chunk.filter((row, index) => !settled(row, index));
if (rows.length === 0) return out;
const counts = new Map<string, number>();
for (const row of rows) {
const key = `${row.type}::${row.externalId}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const single = rows.filter(
(row) => counts.get(`${row.type}::${row.externalId}`) === 1,
);
if (single.length === 0) return out;
const stored = await db.measurement.findMany({
where: {
userId,
source: "APPLE_HEALTH",
externalId: { in: single.map((row) => row.externalId) },
},
select: {
type: true,
externalId: true,
value: true,
measuredAt: true,
externalSourceVersion: true,
sleepStage: true,
deviceType: true,
},
});
const byKey = new Map<string, (typeof stored)[number]>(
stored.map((row) => [`${row.type}::${row.externalId}`, row]),
);
for (const row of single) {
const key = `${row.type}::${row.externalId}`;
const was = byKey.get(key);
if (
was &&
was.value === row.value &&
was.measuredAt.getTime() === row.measuredAt.getTime() &&
(was.externalSourceVersion ?? null) ===
(row.externalSourceVersion ?? null) &&
(was.sleepStage ?? null) === (row.sleepStage ?? null) &&
(was.deviceType ?? null) === (row.deviceType ?? null)
) {
out.add(key);
}
}
return out;
}
/**
* The rescue when an imported row's update finds no row at its external id,
* or collides on the natural identity: a skipped insert can mean another
* external id holds the row's `(type, measuredAt, source, sleepStage)`.
* Adopt that row in place, as the old upsert rescue did. Answers what
* happened, as the import's per-type tally names it.
*/
export async function adoptNaturalKeyTwin(
db: Pick<PrismaClient, "measurement">,
userId: string,
row: ImportUpdateRow & { unit: string },
): Promise<"updated" | "natural_key_unresolved" | "natural_key_rescue_failed"> {
try {
const twin = await db.measurement.findFirst({
where: {
userId,
type: row.type,
source: "APPLE_HEALTH",
measuredAt: row.measuredAt,
sleepStage: row.sleepStage ?? null,
},
select: { id: true },
});
if (!twin) return "natural_key_unresolved";
await db.measurement.update({
where: { id: twin.id },
data: {
value: row.value,
unit: row.unit,
externalId: row.externalId,
externalSourceVersion: row.externalSourceVersion,
deviceType: row.deviceType,
deletedAt: null,
},
});
return "updated";
} catch {
return "natural_key_rescue_failed";
}
}
@@ -9,11 +9,14 @@
*
* Algorithm (v1.4.30 — matches the measurement ladder per R-F open
* question #4):
* 1. Bucket workouts by the rounded start timestamp + sport-type
* (`<5-minute slot>:<sport>`). Two HKWorkout entries for the same
* logical run start within a minute of each other on both
* sources; the rounding gives the cross-source matcher slack
* without collapsing distinct back-to-back workouts.
* 1. Cluster workouts per user and sport-type by start time: sorted by
* start, a workout joins the open cluster when it starts less than
* five minutes after the cluster's first workout, and opens a new
* one otherwise. Two HKWorkout entries for the same logical run
* start within a minute of each other on both sources; the window
* gives the cross-source matcher slack without collapsing distinct
* back-to-back workouts, and anchoring on the first start keeps a
* row of workouts a few minutes apart from chaining into one.
* 2. For each bucket, walk the source ladder (`workoutSourceLadder`)
* and pick the FIRST source that has any row in that bucket. Keep
* every row from the winning source; drop the rest.
@@ -115,30 +118,58 @@ function deduplicateWinningRows<T extends WorkoutPickerRow>(rows: T[]): T[] {
}
/**
* Bucket-slot length. Two workouts whose `startedAt` falls in the
* same 5-minute slot AND share the same sportType are treated as the
* "same" workout from different sources. Tightens further than the
* 1-minute clock skew between HK + Withings would suggest because
* iOS posts HK `startDate` rounded to the second but Withings stamps
* its `startdate` independently — five minutes gives both heads
* enough slack on a heavy-traffic morning sync without crossing the
* "two back-to-back runs" boundary (a user does NOT do two ten-minute
* runs five minutes apart in practice).
* The cross-source duplicate window. Two workouts of the same sport whose
* starts lie less than five minutes apart are treated as the "same" workout
* from different sources. iOS posts the HealthKit `startDate` rounded to the
* second while Withings stamps its `startdate` independently; five minutes
* gives both heads enough slack on a heavy-traffic morning sync without
* crossing the "two back-to-back runs" boundary (a user does NOT do two
* ten-minute runs five minutes apart in practice).
*
* This used to be a fixed five-minute clock slot (`floor(start / 5 min)`),
* so two copies of one session thirty seconds apart that straddled a slot
* boundary (08:04:50 and 08:05:20) were never compared. The window is now
* measured from the cluster's first start. Where no two workouts of one sport
* within the window straddle a slot boundary, the clusters are exactly the
* old slots: every pair inside one slot is less than five minutes apart, and
* a cluster cannot reach past its slot without such a pair.
*/
const BUCKET_SLOT_MS = 5 * 60 * 1000;
const DUPLICATE_WINDOW_MS = 5 * 60 * 1000;
/**
* The cross-source duplicate slot a workout falls in. Exported for the one
* reader that has to look past the picked row: the strain engine, whose winner
* may carry no heart-rate series while its dropped twin does.
* The duplicate cluster of every row, aligned with `rows`. Rows of different
* users or sports never share a cluster. Within one (user, sport), rows are
* taken in start order (input order on equal starts) and a row joins the open
* cluster while it starts less than {@link DUPLICATE_WINDOW_MS} after the
* cluster's first row. The key names the cluster by that first row's start,
* so it is stable for a given set of rows. Exported for the one reader that
* has to look past the picked row: the strain engine, whose winner may carry
* no heart-rate series while its dropped twin does.
*/
export function workoutDuplicateSlotKey(row: WorkoutPickerRow): string {
return bucketKey(row);
}
function bucketKey(row: WorkoutPickerRow): string {
const slot = Math.floor(row.startedAt.getTime() / BUCKET_SLOT_MS);
return `${row.userId ?? ""}:${slot}:${row.sportType}`;
export function workoutDuplicateClusterKeys(
rows: readonly WorkoutPickerRow[],
): string[] {
const groups = new Map<string, number[]>();
rows.forEach((row, index) => {
const group = `${row.userId ?? ""}:${row.sportType}`;
const list = groups.get(group);
if (list) list.push(index);
else groups.set(group, [index]);
});
const keys = new Array<string>(rows.length);
for (const [group, indexes] of groups) {
indexes.sort(
(a, b) =>
rows[a]!.startedAt.getTime() - rows[b]!.startedAt.getTime() || a - b,
);
let anchor = Number.NaN;
for (const index of indexes) {
const start = rows[index]!.startedAt.getTime();
if (!(start - anchor < DUPLICATE_WINDOW_MS)) anchor = start;
keys[index] = `${group}:${anchor}`;
}
}
return keys;
}
function jsonRecord(value: unknown): Record<string, unknown> | null {
@@ -205,7 +236,7 @@ function enrichFromWhoopTwin<T extends WorkoutPickerRow>(
/**
* Pair winners and WHOOP donors one-to-one by nearest start time. A single
* donor can never enrich two same-source occurrences that happen to share a
* fixed slot. Stable input indexes break equal-distance ties.
* duplicate cluster. Stable input indexes break equal-distance ties.
*/
function enrichWinnersFromWhoop<T extends WorkoutPickerRow>(
winners: readonly T[],
@@ -293,8 +324,9 @@ export function pickCanonicalWorkoutRows<T extends WorkoutPickerRow>(
sources: Set<MeasurementSource>;
}
>();
for (const row of rows) {
const key = bucketKey(row);
const clusterKeys = workoutDuplicateClusterKeys(rows);
for (const [index, row] of rows.entries()) {
const key = clusterKeys[index]!;
const slot = buckets.get(key) ?? { rows: [], sources: new Set() };
slot.rows.push(row);
slot.sources.add(row.source);
@@ -64,7 +64,14 @@ function buildIndex(): Map<string, Glp1SideEffectTag> {
return index;
}
const TAG_INDEX = buildIndex();
let tagIndexCache: ReturnType<typeof tagIndexBuild> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function tagIndex(): ReturnType<typeof tagIndexBuild> {
return (tagIndexCache ??= tagIndexBuild());
}
function tagIndexBuild() {
return buildIndex();
}
/**
* Test seam: the guard suite asserts the index covers every locale and that no
@@ -74,7 +81,7 @@ export function glp1SideEffectTagIndex(): ReadonlyMap<
string,
Glp1SideEffectTag
> {
return TAG_INDEX;
return tagIndex();
}
/**
@@ -89,7 +96,7 @@ export function glp1SideEffectTagIndex(): ReadonlyMap<
export function resolveGlp1SideEffectTag(
raw: string,
): Glp1SideEffectTag | null {
return TAG_INDEX.get(normaliseSideEffectTag(raw)) ?? null;
return tagIndex().get(normaliseSideEffectTag(raw)) ?? null;
}
/** What one `MoodEntry.tags` column contributed. */
+1 -1
View File
@@ -28,7 +28,7 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { findDrugIdByBrand } from "@/lib/medications/glp1-knowledge";
import { foldLabel } from "@/lib/i18n/localised-label-index";
import { foldLabel } from "@/lib/i18n/fold-label";
/** The medication classes the storyline can reason about (closed set). */
export type MedTargetClass = "antihypertensive" | "antidiabetic" | "glp1";
+2 -5
View File
@@ -36,7 +36,7 @@
* 0343 folded the blob's `coach` key into the switch and removed it, and a
* `coach` key that still turns up in the blob is ignored.
*/
import { prisma } from "@/lib/db";
import { readOperatorRow } from "@/lib/settings/operator-row";
import { getAssistantFlags } from "@/lib/feature-flags";
import { getEvent } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
@@ -84,10 +84,7 @@ export function resolveOperatorAvailability(
/** The raw availability blob; a read error reads as "nothing disabled". */
async function loadAvailabilityBlob(): Promise<unknown> {
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { moduleAvailabilityJson: true },
});
const settings = await readOperatorRow();
return settings?.moduleAvailabilityJson ?? null;
} catch {
getEvent()?.addWarning(
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import { InFlightRegistry } from "../in-flight";
import {
CONTAINER_BACKOFF_MAX_MS,
CONTAINER_MEMORY_TASK,
ContainerMemoryGuard,
memoryPressureActive,
readContainerMemory,
type ContainerMemoryReading,
} from "../container-memory";
const MB = 1024 * 1024;
function files(map: Record<string, string>) {
return (path: string) => {
if (!(path in map)) throw new Error(`ENOENT ${path}`);
return map[path]!;
};
}
describe("readContainerMemory", () => {
it("reads cgroup v2 and subtracts the reclaimable page cache", () => {
const reading = readContainerMemory(
files({
"/sys/fs/cgroup/memory.current": `${900 * MB}\n`,
"/sys/fs/cgroup/memory.max": `${1536 * MB}\n`,
"/sys/fs/cgroup/memory.stat": `anon 1\ninactive_file ${100 * MB}\nactive_file 5\n`,
}),
);
expect(reading).toEqual({ workingSet: 800 * MB, limit: 1536 * MB });
});
it("falls back to cgroup v1", () => {
const reading = readContainerMemory(
files({
"/sys/fs/cgroup/memory/memory.usage_in_bytes": `${500 * MB}`,
"/sys/fs/cgroup/memory/memory.limit_in_bytes": `${1024 * MB}`,
"/sys/fs/cgroup/memory/memory.stat": `total_inactive_file ${20 * MB}`,
}),
);
expect(reading).toEqual({ workingSet: 480 * MB, limit: 1024 * MB });
});
it("reads nothing without a limit or outside a cgroup", () => {
expect(
readContainerMemory(
files({
"/sys/fs/cgroup/memory.current": "123",
"/sys/fs/cgroup/memory.max": "max",
}),
),
).toBeNull();
expect(readContainerMemory(files({}))).toBeNull();
});
});
describe("ContainerMemoryGuard", () => {
function guard(readings: Array<ContainerMemoryReading | null>) {
let at = 0;
let i = 0;
const lines: Array<{ task: string; meta: Record<string, unknown> }> = [];
const g = new ContainerMemoryGuard({
now: () => at,
read: () => readings[Math.min(i++, readings.length - 1)] ?? null,
registry: new InFlightRegistry(),
emit: (task, _level, meta) => lines.push({ task, meta }),
figures: () => ({ heap_used_mb: 1 }),
});
return {
tick: (ms = 5_000) => {
at += ms;
g.check();
},
lines,
};
}
const reading = (ratio: number) => ({
workingSet: Math.round(ratio * 1000 * MB),
limit: 1000 * MB,
});
it("reports once at 85 % and holds heavy jobs back while it lasts", () => {
const g = guard([reading(0.5), reading(0.9), reading(0.9), reading(0.6)]);
g.tick();
expect(memoryPressureActive()).toBe(false);
g.tick();
expect(memoryPressureActive()).toBe(true);
expect(g.lines).toHaveLength(1);
expect(g.lines[0]!.task).toBe(CONTAINER_MEMORY_TASK);
expect(g.lines[0]!.meta).toMatchObject({
container_working_set_mb: 900,
container_limit_mb: 1000,
heap_used_mb: 1,
});
g.tick();
expect(g.lines).toHaveLength(1);
g.tick();
expect(memoryPressureActive()).toBe(false);
});
it("lets heavy jobs through once the pressure has lasted fifteen minutes", () => {
const g = guard([reading(0.9)]);
g.tick();
expect(memoryPressureActive()).toBe(true);
g.tick(CONTAINER_BACKOFF_MAX_MS);
expect(memoryPressureActive()).toBe(false);
});
it("never raises the flag outside a cgroup", () => {
const g = guard([null]);
g.tick();
expect(memoryPressureActive()).toBe(false);
expect(g.lines).toHaveLength(0);
});
});
+198
View File
@@ -0,0 +1,198 @@
import { readFileSync } from "node:fs";
import { megabytes } from "@/lib/support/heap-limit";
import type { InFlightRegistry } from "./in-flight";
/**
* Container memory guard: the process's whole footprint against the cgroup
* limit, not just the V8 heap.
*
* The heap monitor sees the V8 heap only. A large share of this process's
* memory lives outside it: the GeoLite2 databases read into Buffers, the
* query compiler's WebAssembly memory, canvas and pdf.js for document
* thumbnails, zlib streams of a backup. When the cgroup runs out the kernel
* kills the process with SIGKILL and nothing is logged at all, however calm
* the heap line looked.
*
* Every five seconds (the heap monitor's tick) the guard reads the cgroup's
* working set: `memory.current` minus the reclaimable `inactive_file` page
* cache, the figure `docker stats` and the kubelet report. At 85 % of the
* limit it writes one `runtime.memory.pressure` line at `warn` with the work
* in flight, re-armed once the working set is back under 70 % and thirty
* minutes have passed, and raises a flag the heavy job lane reads to send
* new heavy jobs back to the queue for a minute instead of starting them
* (`memoryPressureActive`). A pressure that does not ease within fifteen
* minutes is taken as the container's normal size rather than a peak: the
* flag drops, so a container sized at its limit is never starved of its
* nightly jobs, and the pressure line has said so.
*
* Outside a cgroup with a limit (a developer machine, a host without one)
* the guard reads nothing and never raises the flag.
*/
export const CONTAINER_MEMORY_TASK = "runtime.memory.pressure";
export const CONTAINER_PRESSURE_RATIO = 0.85;
export const CONTAINER_REARM_RATIO = 0.7;
export const CONTAINER_PRESSURE_COOLDOWN_MS = 30 * 60_000;
/** How long new heavy jobs are held back before the pressure counts as normal. */
export const CONTAINER_BACKOFF_MAX_MS = 15 * 60_000;
export interface ContainerMemoryReading {
/** Working set: usage minus reclaimable page cache, bytes. */
workingSet: number;
/** The cgroup's limit, bytes. */
limit: number;
}
const UNLIMITED_BYTES = 1024 ** 4;
function readNumber(raw: string): number | null {
const text = raw.trim();
if (!/^\d+$/.test(text)) return null;
const n = Number(text);
return Number.isFinite(n) ? n : null;
}
function statField(stat: string, field: string): number {
const line = stat.split("\n").find((l) => l.startsWith(`${field} `));
return line ? (readNumber(line.slice(field.length + 1)) ?? 0) : 0;
}
/**
* The cgroup working set and limit, cgroup v2 first, then v1. `null` when
* there is no limit or the files cannot be read.
*/
export function readContainerMemory(
read: (path: string) => string = (path) => readFileSync(path, "utf8"),
): ContainerMemoryReading | null {
const layouts = [
{
usage: "/sys/fs/cgroup/memory.current",
limit: "/sys/fs/cgroup/memory.max",
stat: "/sys/fs/cgroup/memory.stat",
inactive: "inactive_file",
},
{
usage: "/sys/fs/cgroup/memory/memory.usage_in_bytes",
limit: "/sys/fs/cgroup/memory/memory.limit_in_bytes",
stat: "/sys/fs/cgroup/memory/memory.stat",
inactive: "total_inactive_file",
},
];
for (const layout of layouts) {
let usage: number | null;
let limit: number | null;
try {
usage = readNumber(read(layout.usage));
limit = readNumber(read(layout.limit));
} catch {
continue;
}
if (usage === null || limit === null) return null;
if (limit <= 0 || limit >= UNLIMITED_BYTES) return null;
let inactive = 0;
try {
inactive = statField(read(layout.stat), layout.inactive);
} catch {
// Without the stat file the usage stands as it is.
}
return { workingSet: Math.max(0, usage - inactive), limit };
}
return null;
}
const PRESSURE_FLAG = Symbol.for("healthlog.containerMemory.pressure");
type FlagSlot = { [PRESSURE_FLAG]?: boolean };
/**
* Whether new heavy jobs should wait: the working set is at or above 85 % of
* the limit and has not stayed there for fifteen minutes yet. Kept on
* `globalThis` because the lane and the guard can sit in different server
* bundles of one process.
*/
export function memoryPressureActive(): boolean {
return (globalThis as FlagSlot)[PRESSURE_FLAG] === true;
}
export interface ContainerMemoryDeps {
now: () => number;
read: () => ContainerMemoryReading | null;
registry: InFlightRegistry;
emit: (
taskName: string,
level: "info" | "warn",
meta: Record<string, unknown>,
) => void;
/** Extra figures for the line (the heap's). */
figures: () => Record<string, unknown>;
}
/** The guard's decisions, one `check()` per tick; separate for the tests. */
export class ContainerMemoryGuard {
private armed = true;
private lastReportAt: number | null = null;
private pressureSince: number | null = null;
constructor(private readonly deps: ContainerMemoryDeps) {}
/** The latest reading, for the heap line. */
last: ContainerMemoryReading | null = null;
check(): void {
const { now, read } = this.deps;
const at = now();
const reading = read();
this.last = reading;
const slot = globalThis as FlagSlot;
if (!reading) {
slot[PRESSURE_FLAG] = false;
return;
}
const ratio = reading.workingSet / reading.limit;
if (ratio >= CONTAINER_PRESSURE_RATIO) {
this.pressureSince ??= at;
slot[PRESSURE_FLAG] = at - this.pressureSince < CONTAINER_BACKOFF_MAX_MS;
} else {
this.pressureSince = null;
slot[PRESSURE_FLAG] = false;
}
if (
!this.armed &&
ratio < CONTAINER_REARM_RATIO &&
this.lastReportAt !== null &&
at - this.lastReportAt >= CONTAINER_PRESSURE_COOLDOWN_MS
) {
this.armed = true;
}
if (this.armed && ratio >= CONTAINER_PRESSURE_RATIO) {
this.armed = false;
this.lastReportAt = at;
const inFlight = this.deps.registry.report(at, 50);
this.deps.emit(CONTAINER_MEMORY_TASK, "warn", {
...this.deps.figures(),
container_working_set_mb: megabytes(reading.workingSet),
container_limit_mb: megabytes(reading.limit),
container_used_ratio: Math.round(ratio * 1000) / 1000,
pressure_ratio: CONTAINER_PRESSURE_RATIO,
heavy_jobs_held_back_max_minutes: CONTAINER_BACKOFF_MAX_MS / 60_000,
in_flight_oldest: inFlight.oldest,
in_flight_by_route: inFlight.by_route,
});
}
}
}
/** The working set and limit as heap-line fields, or nothing outside a cgroup. */
export function containerMemoryFields(
reading: ContainerMemoryReading | null,
): Record<string, number> {
if (!reading) return {};
return {
container_working_set_mb: megabytes(reading.workingSet),
container_limit_mb: megabytes(reading.limit),
};
}
+51 -6
View File
@@ -1,6 +1,10 @@
import { currentBootStep } from "@/lib/boot/boot-steps";
import { startupPhase } from "@/lib/boot/startup-state";
import { WideEventBuilder } from "@/lib/logging/event-builder";
import { emitIfSampled } from "@/lib/logging/transports";
import { inFlightRegistry } from "./in-flight";
/**
* Event-loop lag monitor — the instance-level "was the PROCESS stalled"
* signal that per-request durations cannot carry.
@@ -24,6 +28,13 @@ import { emitIfSampled } from "@/lib/logging/transports";
* same timers phase, and the stall sample can vanish into the window
* that was just reset (reproduced under vitest).
*
* Attribution: a stall line says what the process was doing. It carries the
* process uptime, the background role's startup phase, the boot step that
* was running (`boot-steps.ts`), and the oldest requests and jobs in flight
* with the routes most of them share, the same report the heap pressure line
* writes. The monitor is the first thing `register()` starts, so the loading
* of the database client and of the worker graph is inside its view.
*
* The snapshot is published on a `globalThis` slot rather than imported
* by the event builder: the builder is bundled into the Edge-runtime
* proxy, and reading a global keeps the dependency one-directional
@@ -56,19 +67,52 @@ type GlobalSlots = {
[STARTED]?: boolean;
};
/** What a stall line says. */
export interface EventLoopStall {
lag_ms: number;
tick_interval_ms: number;
uptime_s: number;
startup_phase: string | null;
boot_step: string | null;
in_flight_oldest: unknown[];
in_flight_by_route: unknown[];
}
export interface EventLoopLagMonitorOptions {
tickIntervalMs?: number;
stallThresholdMs?: number;
/** Test seam — replaces the wide-event emit. */
emitStall?: (stall: { lag_ms: number; tick_interval_ms: number }) => void;
emitStall?: (stall: EventLoopStall) => void;
}
function defaultEmitStall(stall: {
lag_ms: number;
tick_interval_ms: number;
}): void {
/** The attribution a stall carries; never throws. */
export function stallAttribution(
now: number = Date.now(),
): Omit<EventLoopStall, "lag_ms" | "tick_interval_ms"> {
let inFlight: { oldest: unknown[]; by_route: unknown[] } = {
oldest: [],
by_route: [],
};
try {
inFlight = inFlightRegistry().report(now, 5);
} catch {
// The report is a garnish.
}
return {
uptime_s: Math.round(process.uptime() * 10) / 10,
startup_phase: startupPhase(now),
boot_step: currentBootStep(),
in_flight_oldest: inFlight.oldest,
in_flight_by_route: inFlight.by_route,
};
}
function defaultEmitStall(stall: EventLoopStall): void {
const evt = new WideEventBuilder("background");
evt.setBackground({ task_name: "event_loop_stall", result: stall });
evt.setBackground({
task_name: "event_loop_stall",
result: stall as unknown as Record<string, unknown>,
});
evt.elevateLevel("warn");
evt.finish();
emitIfSampled(evt.toJSON());
@@ -110,6 +154,7 @@ export function startEventLoopLagMonitor(
emitStall({
lag_ms: Math.round(lagMs),
tick_interval_ms: tickIntervalMs,
...stallAttribution(),
});
}
+23 -1
View File
@@ -11,6 +11,11 @@ import { WideEventBuilder } from "@/lib/logging/event-builder";
import { emitIfSampled } from "@/lib/logging/transports";
import { megabytes } from "@/lib/support/heap-limit";
import {
ContainerMemoryGuard,
containerMemoryFields,
readContainerMemory,
} from "./container-memory";
import { inFlightRegistry, type InFlightRegistry } from "./in-flight";
/**
@@ -351,10 +356,22 @@ export function startHeapMonitor(overrides: Partial<HeapMonitorDeps> = {}) {
slots[STARTED] = true;
const registry = overrides.registry ?? inFlightRegistry();
// The container guard rides the same tick, and its latest reading rides
// every heap line, so the trend shows the cgroup beside the heap.
const guard = new ContainerMemoryGuard({
now: Date.now,
read: () => readContainerMemory(),
registry,
emit: emitHeapLine,
figures: () => ({ ...defaultReadFigures(registry) }),
});
const monitor = new HeapMonitor({
now: Date.now,
readHeap: defaultReadHeap,
readFigures: () => defaultReadFigures(registry),
readFigures: () => ({
...defaultReadFigures(registry),
...containerMemoryFields(guard.last),
}),
registry,
emit: emitHeapLine,
snapshotEnabled: envFlag("HEALTHLOG_HEAP_SNAPSHOT"),
@@ -363,6 +380,11 @@ export function startHeapMonitor(overrides: Partial<HeapMonitorDeps> = {}) {
});
const timer = setInterval(() => {
try {
guard.check();
} catch {
// A diagnostic must never take the process down.
}
try {
monitor.check();
} catch {
@@ -115,10 +115,10 @@ describe("the provenance query orders like the canonical pick", () => {
`ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text`,
),
);
// The shared pick groups to (type, day, source) first and orders the
// groups by the same keys, the UTC day named `d`.
expect(squash(canonicalMeasurementsCte(rank))).toContain(
squash(
`ORDER BY "type", date_trunc('day', "measured_at"), (${rank}), "source"::text`,
),
squash(`ORDER BY "type", d, (${rank}), "source"::text`),
);
// Values are bound, never spliced.
expect(userId).toBe("u1");
+56
View File
@@ -0,0 +1,56 @@
/**
* The operator's settings that every app boot reads, in one statement per
* request.
*
* `/api/auth/me` resolved six answers out of the `AppSettings` singleton (the
* MFA policy, the Assistant switches, the module availability, the provider
* presence, the AI usage bounds and the reasoning controls), each with its own
* `SELECT` of a few columns, so one client boot read the same row six times.
* Each reader now takes its columns from this one read, memoised for the
* request (and read afresh for every call inside a background job, whose one
* event spans a whole multi-user pass, like the readers it serves).
*
* A reader keeps its own error handling: a failed read rejects here, and the
* memo forgets a rejected read so the next caller tries again.
*/
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { memoizePerRequest } from "@/lib/request-cache";
export const OPERATOR_ROW_SELECT = {
mfaRequired: true,
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantDocumentAiEnabled: true,
moduleAvailabilityJson: true,
adminAiKeyEncrypted: true,
adminAiModel: true,
adminAiBaseUrl: true,
adminCodexConnectionStatus: true,
adminCodexAccessTokenEncrypted: true,
adminCodexRefreshTokenEncrypted: true,
adminCodexAccountIdEncrypted: true,
aiUsageMaxLevel: true,
aiUsageDefaultLevel: true,
aiSyncRegenGapHours: true,
aiReasoningEnabled: true,
aiReasoningMaxEffort: true,
} as const satisfies Prisma.AppSettingsSelect;
export type OperatorRow = Prisma.AppSettingsGetPayload<{
select: typeof OPERATOR_ROW_SELECT;
}>;
export function readOperatorRow(): Promise<OperatorRow | null> {
return memoizePerRequest(
"operator-settings-row",
() =>
prisma.appSettings.findUnique({
where: { id: "singleton" },
select: OPERATOR_ROW_SELECT,
}),
{ freshInBackground: true },
);
}
@@ -0,0 +1,111 @@
/**
* Re-importing an Apple Health export the account already holds, against a
* real Postgres.
*
* Every row the insert skips used to cost one UPDATE writing back what was
* stored. A row whose stored values already equal what the update would
* write is now left out: the per-type counts are the same as before (each
* such row counts as updated), the stored rows are the same, and the import
* sends no UPDATE for them. A row whose value changed is still updated, and
* a key the chunk carries twice is still written in order.
*/
import { mkdtempSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { beforeEach, describe, expect, it } from "vitest";
import { streamParseExportXml } from "@/lib/measurements/import-apple-health-export";
import { makeUser } from "../kit/factories";
import { countStatements } from "./query-counter";
import { getPrismaClient, truncateAllTables } from "./setup";
const prisma = getPrismaClient();
function writeXml(records: string): string {
const dir = mkdtempSync(join(tmpdir(), "healthlog-reimport-"));
const path = join(dir, "export.xml");
writeFileSync(
path,
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE HealthData [<!ELEMENT HealthData (Record)*>]>
<HealthData locale="en_US">
${records}
</HealthData>`,
);
return path;
}
function weights(values: number[]): string {
return values
.map(
(value, day) => ` <Record type="HKQuantityTypeIdentifierBodyMass"
unit="kg" sourceName="Scale"
startDate="2026-05-${String(day + 1).padStart(2, "0")} 08:00:00 +0200"
endDate="2026-05-${String(day + 1).padStart(2, "0")} 08:00:00 +0200"
value="${value}"/>`,
)
.join("\n");
}
async function stored(userId: string) {
return prisma.measurement.findMany({
where: { userId, type: "WEIGHT" },
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, externalId: true },
});
}
describe("Apple Health re-import of unchanged rows", () => {
let userId: string;
const values = Array.from({ length: 20 }, (_, i) => 70 + i / 10);
beforeEach(async () => {
await truncateAllTables(prisma);
userId = (await makeUser()).id;
const first = await streamParseExportXml({
xmlPath: writeXml(weights(values)),
userId,
userTimezone: "Europe/Berlin",
prisma,
});
expect(first.perType.WEIGHT?.inserted).toBe(values.length);
});
it("counts every row as updated, as before, and sends no UPDATE", async () => {
const before = await stored(userId);
const measured = await countStatements(() =>
streamParseExportXml({
xmlPath: writeXml(weights(values)),
userId,
userTimezone: "Europe/Berlin",
prisma,
}),
);
expect(measured.result.perType.WEIGHT?.inserted).toBe(0);
expect(measured.result.perType.WEIGHT?.updated).toBe(values.length);
expect(await stored(userId)).toEqual(before);
const updates = measured.statements.filter((sql) =>
/^\s*UPDATE "public"\."measurements"/i.test(sql),
);
expect(updates).toEqual([]);
});
it("still updates a row whose value changed", async () => {
const changed = [...values];
changed[3] = 99.9;
const result = await streamParseExportXml({
xmlPath: writeXml(weights(changed)),
userId,
userTimezone: "Europe/Berlin",
prisma,
});
expect(result.perType.WEIGHT?.updated).toBe(values.length);
const rows = await stored(userId);
// The externalId hashes the sample, so a new value is a new sample of
// the same instant: it adopts the stored row through the natural key.
expect(rows).toHaveLength(values.length);
expect(rows[3]!.value).toBe(99.9);
});
});
@@ -0,0 +1,230 @@
/**
* The canonical-source pick, against a real Postgres: the shipped statement
* (group the window to `(type, day, source)` first, then `DISTINCT ON`)
* against the statement it replaced (`DISTINCT ON` over every raw row),
* kept verbatim below as the reference.
*
* Both must return exactly the same rows, for every reader shape the helper
* serves (all types or one type, a trailing window or none), under the
* default ladders and under user ladders that reorder them, over a fixture
* that carries what could tell them apart: overlapping sources on the same
* day, a source off the ladder, a day where the winning source's only row is
* tombstoned, readings either side of UTC midnight and of a DST change in
* the user's zone, empty days, and several readings per source per day.
*/
import { beforeAll, describe, expect, it } from "vitest";
import type {
MeasurementSource,
MeasurementType,
} from "@/generated/prisma/client";
import {
buildSourceRankCase,
canonicalMeasurementsCte,
} from "@/lib/analytics/source-rank-sql";
import { makeUser } from "../kit/factories";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER_ID = "user-canonical-parity";
const DAY_MS = 86_400_000;
/** The pick as it stood before it grouped first. */
function legacyCanonicalMeasurementsCte(
rankUnqualified: string,
sinceInterval?: string,
onlyType?: MeasurementType,
): string {
const sinceInner = sinceInterval
? `AND "measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '${sinceInterval}'`
: "";
const sinceOuter = sinceInterval
? `AND mm."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '${sinceInterval}'`
: "";
const typeInner = onlyType
? `AND "type" = '${onlyType}'::"measurement_type"`
: "";
const typeOuter = onlyType
? `AND mm."type" = '${onlyType}'::"measurement_type"`
: "";
return `
SELECT mm.*
FROM measurements mm
JOIN (
SELECT DISTINCT ON ("type", date_trunc('day', "measured_at"))
"type" AS t,
date_trunc('day', "measured_at") AS d,
"source" AS canon
FROM measurements
WHERE "user_id" = $1
AND "deleted_at" IS NULL
${sinceInner}
${typeInner}
ORDER BY "type", date_trunc('day', "measured_at"), (${rankUnqualified}), "source"::text
) c
ON c.t = mm."type"
AND c.d = date_trunc('day', mm."measured_at")
AND c.canon = mm."source"
WHERE mm."user_id" = $1
AND mm."deleted_at" IS NULL
${sinceOuter}
${typeOuter}`;
}
/** Small seeded generator, so the fixture is the same on every run. */
function rng(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const SOURCES: MeasurementSource[] = [
"APPLE_HEALTH",
"WITHINGS",
"WHOOP",
"OURA",
"MANUAL",
// Off every ladder below: the name tiebreak decides.
"NIGHTSCOUT",
];
const TYPES: MeasurementType[] = [
"PULSE",
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
"WEIGHT",
"ACTIVITY_STEPS",
"BLOOD_GLUCOSE",
];
async function seed(): Promise<number> {
const prisma = getPrismaClient();
await makeUser({ id: USER_ID, timezone: "Europe/Berlin" });
const random = rng(42);
const now = Date.now();
const rows: Array<{
userId: string;
type: MeasurementType;
source: MeasurementSource;
value: number;
unit: string;
measuredAt: Date;
deletedAt: Date | null;
}> = [];
// 140 days: across the 90-day window edge, and over the last DST change.
for (let day = 0; day < 140; day++) {
if (day % 11 === 5) continue; // an empty day
const dayStart = Math.floor((now - day * DAY_MS) / DAY_MS) * DAY_MS;
for (const type of TYPES) {
const sourceCount = 1 + Math.floor(random() * 3);
const sources = [...SOURCES]
.sort(() => random() - 0.5)
.slice(0, sourceCount);
for (const source of sources) {
const readings = 1 + Math.floor(random() * 4);
for (let r = 0; r < readings; r++) {
// Mostly mid-day, sometimes a minute either side of UTC midnight.
const offset =
r === 0 && random() < 0.2
? random() < 0.5
? 60_000
: DAY_MS - 60_000
: Math.floor(random() * DAY_MS);
rows.push({
userId: USER_ID,
type,
source,
value: Math.round(random() * 1000) / 10,
unit: "x",
measuredAt: new Date(dayStart + offset),
// A tombstone, sometimes the only row its source had that day.
deletedAt: random() < 0.08 ? new Date() : null,
});
}
}
}
}
// The natural identity is unique; drop accidental collisions.
const seen = new Set<string>();
const unique = rows.filter((row) => {
const key = `${row.type}|${row.source}|${row.measuredAt.getTime()}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
await prisma.measurement.createMany({ data: unique });
return unique.length;
}
const LADDERS: Array<[string, unknown]> = [
["default ladders", null],
[
"user ladders that reorder them",
{
pulse: ["APPLE_HEALTH", "WITHINGS"],
restingHeartRate: ["OURA", "APPLE_HEALTH", "WHOOP"],
hrv: ["APPLE_HEALTH"],
weight: ["MANUAL", "WITHINGS"],
steps: ["WITHINGS", "APPLE_HEALTH"],
},
],
];
const SHAPES: Array<[string, string | undefined, MeasurementType | undefined]> =
[
["every type, 90 days", "90 days", undefined],
["every type, no window", undefined, undefined],
["one type, 366 days", "366 days", "PULSE"],
["one unranked type, 30 days", "30 days", "BLOOD_GLUCOSE"],
["one type, 7 days", "7 days", "RESTING_HEART_RATE"],
];
describe("canonical-source pick parity", () => {
let seeded = 0;
beforeAll(async () => {
await truncateAllTables(getPrismaClient());
seeded = await seed();
});
it("seeds a fixture worth comparing", () => {
expect(seeded).toBeGreaterThan(2000);
});
for (const [ladderLabel, ladder] of LADDERS) {
for (const [shapeLabel, since, onlyType] of SHAPES) {
it(`returns the same rows: ${ladderLabel}, ${shapeLabel}`, async () => {
const prisma = getPrismaClient();
const rank = buildSourceRankCase(ladder, '"type"', '"source"');
const select = (cte: string) =>
prisma.$queryRawUnsafe<
Array<{ id: string; type: string; source: string }>
>(
`WITH cm AS (${cte}) SELECT id, type::text AS type, source::text AS source FROM cm ORDER BY id`,
USER_ID,
);
const [now, before] = await Promise.all([
select(canonicalMeasurementsCte(rank, since, onlyType)),
select(legacyCanonicalMeasurementsCte(rank, since, onlyType)),
]);
expect(now.length).toBeGreaterThan(0);
expect(now).toEqual(before);
// The pick dropped something, so the comparison is not trivial.
if (!onlyType || onlyType === "PULSE") {
const live = await prisma.measurement.count({
where: {
userId: USER_ID,
deletedAt: null,
...(onlyType ? { type: onlyType } : {}),
},
});
if (!since) expect(now.length).toBeLessThan(live);
}
});
}
}
});
@@ -0,0 +1,209 @@
/**
* The dense-series read, against a real Postgres: the shipped single
* statement (`readDenseSeries`) against the two statements it replaced,
* kept below as the reference (the bucket SQL and the figures SQL are the
* same builders, so the reference differs only in reading the canonical rows
* twice instead of once).
*
* Buckets and figures must match for every shape the route asks for: day
* and hour grains; an hourly-mean type (pulse, two ranked sources), an
* unranked dense type (glucose) and a ranked non-hourly one (oxygen
* saturation); zones with a DST change and a half-hour offset; a fixture
* with gaps, tombstones and readings around local and UTC midnight.
*/
import { beforeAll, describe, expect, it } from "vitest";
import type {
MeasurementSource,
MeasurementType,
} from "@/generated/prisma/client";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
import {
denseBucketsSql,
denseFiguresSql,
readDenseSeries,
type DenseBucketRow,
type DenseFigures,
} from "@/lib/measurements/dense-series-read";
import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
import { makeUser } from "../kit/factories";
import { getPrismaClient, truncateAllTables } from "./setup";
const prisma = getPrismaClient();
const DAY_MS = 86_400_000;
/** The two statements as the route sent them before. */
async function legacyDenseSeries(args: {
userId: string;
type: MeasurementType;
grain: "day" | "hour";
userTz: string;
since: Date;
seriesRows: string;
}) {
const hourWeighted = args.grain === "day" && usesHourlyMeanDay(args.type);
const bucketRows = await prisma.$queryRawUnsafe<DenseBucketRow[]>(
`${denseBucketsSql(hourWeighted, `${args.seriesRows} WHERE m."measured_at" >= $4`)}
ORDER BY bucket_start ASC`,
args.userId,
args.grain,
args.userTz,
args.since,
);
// The figures statement took the window start as `$2` and the zone as
// `$3`, the latter only for an hourly-mean type.
const hourly = usesHourlyMeanDay(args.type);
const [rawAgg] = await prisma.$queryRawUnsafe<DenseFigures[]>(
`WITH src AS (SELECT m.* FROM ${args.seriesRows} WHERE m."measured_at" >= $2)
${denseFiguresSql(hourly)}`,
args.userId,
args.since,
...(hourly ? [args.userTz] : []),
);
return { bucketRows, rawAgg };
}
function rng(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
let userId = "";
async function seed() {
userId = (await makeUser({ timezone: "Europe/Berlin" })).id;
const random = rng(7);
const now = Date.now();
const rows: Array<{
userId: string;
type: MeasurementType;
source: MeasurementSource;
value: number;
valueMin: number | null;
valueMax: number | null;
unit: string;
measuredAt: Date;
deletedAt: Date | null;
}> = [];
const plan: Array<[MeasurementType, MeasurementSource[], number]> = [
["PULSE", ["APPLE_HEALTH", "WITHINGS"], 60],
["BLOOD_GLUCOSE", ["NIGHTSCOUT", "MANUAL"], 70],
["OXYGEN_SATURATION", ["APPLE_HEALTH", "WITHINGS"], 96],
];
// 200 days: across the last two DST changes, with gaps.
for (let day = 0; day < 200; day++) {
if (day % 13 === 4) continue;
const dayStart = Math.floor((now - day * DAY_MS) / DAY_MS) * DAY_MS;
for (const [type, sources, base] of plan) {
for (const source of sources) {
if (random() < 0.3) continue;
const readings = 3 + Math.floor(random() * 12);
for (let r = 0; r < readings; r++) {
const at = dayStart + Math.floor(random() * DAY_MS);
const value = Math.round((base + random() * 30) * 10) / 10;
const banded = type === "PULSE" && random() < 0.3;
rows.push({
userId,
type,
source,
value,
valueMin: banded ? value - 10 : null,
valueMax: banded ? value + 15 : null,
unit: "x",
measuredAt: new Date(at),
deletedAt: random() < 0.05 ? new Date() : null,
});
}
}
}
}
const seen = new Set<string>();
await prisma.measurement.createMany({
data: rows.filter((row) => {
const key = `${row.type}|${row.source}|${row.measuredAt.getTime()}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
}),
});
}
const CASES: Array<[MeasurementType, "day" | "hour", number]> = [
["PULSE", "day", 180],
["PULSE", "hour", 60],
["BLOOD_GLUCOSE", "day", 180],
["BLOOD_GLUCOSE", "hour", 30],
["OXYGEN_SATURATION", "day", 120],
];
const ZONES = ["UTC", "Europe/Berlin", "Asia/Kolkata", "America/New_York"];
function close(a: number | null | undefined, b: number | null | undefined) {
if (a == null || b == null) return expect(a ?? null).toBe(b ?? null);
expect(Math.abs(a - b)).toBeLessThanOrEqual(1e-9 * Math.max(1, Math.abs(b)));
}
describe("dense-series read parity", () => {
beforeAll(async () => {
await truncateAllTables(prisma);
await seed();
});
for (const [type, grain, days] of CASES) {
for (const userTz of ZONES) {
it(`matches the two statements: ${type}, ${grain}, ${days} d, ${userTz}`, async () => {
const args = {
userId,
type,
grain,
userTz,
since: new Date(Date.now() - days * DAY_MS),
seriesRows: seriesRowsFrom(null, type, days),
};
const [now, before] = await Promise.all([
readDenseSeries(args),
legacyDenseSeries(args),
]);
expect(before.bucketRows.length).toBeGreaterThan(10);
expect(now.bucketRows.map((r) => r.bucket_start.getTime())).toEqual(
before.bucketRows.map((r) => r.bucket_start.getTime()),
);
now.bucketRows.forEach((row, i) => {
const ref = before.bucketRows[i]!;
close(row.mean, ref.mean);
close(row.min_value, ref.min_value);
close(row.max_value, ref.max_value);
});
expect(now.rawAgg?.n).toBe(before.rawAgg?.n);
close(now.rawAgg?.mean, before.rawAgg?.mean);
close(now.rawAgg?.min, before.rawAgg?.min);
close(now.rawAgg?.max, before.rawAgg?.max);
close(now.rawAgg?.sd, before.rawAgg?.sd);
});
}
}
it("answers no figures row for an hourly-mean type without readings, as before", async () => {
const empty = await makeUser();
const args = {
userId: empty.id,
type: "PULSE" as const,
grain: "day" as const,
userTz: "UTC",
since: new Date(Date.now() - 365 * DAY_MS),
seriesRows: seriesRowsFrom(null, "PULSE", 365),
};
const [now, before] = await Promise.all([
readDenseSeries(args),
legacyDenseSeries(args),
]);
expect(now).toEqual(before);
expect(now.rawAgg).toBeUndefined();
});
});
@@ -66,16 +66,16 @@ const DAY_MS = 86_400_000;
* the header for how to move one.
*/
const BUDGET = {
authMe: 18,
dashboardSummary: 86,
dashboardSnapshot: 103,
authMe: 13,
dashboardSummary: 84,
dashboardSnapshot: 99,
series30: 6,
series365: 6,
series3650: 6,
dayView: 48,
healthStatus: 60,
correlations: 44,
batchIngest500: 31,
series365: 5,
series3650: 5,
dayView: 47,
healthStatus: 59,
correlations: 43,
batchIngest500: 28,
} as const;
type Handler = (
@@ -0,0 +1,189 @@
/**
* `POST /api/measurements/batch`: a `stats:` row re-posted with nothing
* changed, through the real API kit against the real database.
*
* The phone re-sends the same daily totals on every sync. Such a re-post:
* - answers exactly what it answered before: per entry `updated`, and the
* same `updated` count in the summary;
* - writes nothing (the stored row keeps its `syncVersion` and
* `updatedAt`), and starts nothing: no rollup refold, no cache
* invalidation, no PR detection;
* - goes the old way as soon as one column differs (the value, the unit,
* the device), and for a tombstoned row (a resurrection), so those are
* written and start the tail as before;
* - costs a handful of statements for a batch of them, instead of a
* reconciler transaction each.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { POST } from "@/app/api/measurements/batch/route";
import { callRoute } from "../../kit/call-route";
import {
makeSession,
makeUser,
type SessionFixture,
} from "../../kit/factories";
import { countStatements } from "../query-counter";
import { getPrismaClient, truncateAllTables } from "../setup";
const tail = vi.hoisted(() => ({ rollups: 0, invalidations: 0, prs: 0 }));
vi.mock("@/lib/rollups/after-measurement-mutation", async () => {
const actual = await vi.importActual<
typeof import("@/lib/rollups/after-measurement-mutation")
>("@/lib/rollups/after-measurement-mutation");
return {
...actual,
afterMeasurementMutation: (
...args: Parameters<typeof actual.afterMeasurementMutation>
) => {
tail.rollups += 1;
return actual.afterMeasurementMutation(...args);
},
};
});
vi.mock("@/lib/cache/invalidate", async () => {
const actual = await vi.importActual<typeof import("@/lib/cache/invalidate")>(
"@/lib/cache/invalidate",
);
return {
...actual,
invalidateUserMeasurements: (
...args: Parameters<typeof actual.invalidateUserMeasurements>
) => {
tail.invalidations += 1;
return actual.invalidateUserMeasurements(...args);
},
};
});
vi.mock("@/lib/jobs/pr-detection", async () => {
const actual = await vi.importActual<
typeof import("@/lib/jobs/pr-detection")
>("@/lib/jobs/pr-detection");
return {
...actual,
enqueuePrDetection: async () => {
tail.prs += 1;
},
};
});
const prisma = getPrismaClient();
const ROUTE = "/api/measurements/batch";
const DAYS = ["2026-10-01", "2026-10-02", "2026-10-03", "2026-10-04"];
function stepsEntry(day: string, value: number, deviceType = "watch") {
return {
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
value,
unit: "count",
startDate: `${day}T00:00:00.000Z`,
endDate: `${day}T23:59:59.000Z`,
externalId: `stats:HKQuantityTypeIdentifierStepCount:${day}`,
deviceType,
};
}
let auth: SessionFixture;
let userId: string;
async function post(entries: unknown[]) {
return callRoute<{
updated: number;
inserted: number;
entries: Array<{ status: string }>;
}>(POST, { method: "POST", route: ROUTE, auth, body: { entries } });
}
function resetTail() {
tail.rollups = 0;
tail.invalidations = 0;
tail.prs = 0;
}
beforeEach(async () => {
await truncateAllTables(prisma);
const user = await makeUser({ timezone: "UTC" });
userId = user.id;
auth = await makeSession(user);
const first = await post(DAYS.map((d, i) => stepsEntry(d, 1000 + i)));
expect(first.status).toBe(200);
expect(first.data.inserted).toBe(DAYS.length);
resetTail();
});
async function storedVersions() {
const rows = await prisma.measurement.findMany({
where: { userId, externalId: { startsWith: "stats:" } },
orderBy: { externalId: "asc" },
select: { syncVersion: true, updatedAt: true, value: true },
});
return rows;
}
describe("an unchanged stats: re-post", () => {
it("answers `updated` as before, writes nothing and starts nothing", async () => {
const before = await storedVersions();
const measured = await countStatements(() =>
post(DAYS.map((d, i) => stepsEntry(d, 1000 + i))),
);
const res = measured.result;
expect(res.status).toBe(200);
expect(res.data.updated).toBe(DAYS.length);
expect(res.data.inserted).toBe(0);
expect(res.data.entries.map((e) => e.status)).toEqual(
DAYS.map(() => "updated"),
);
expect(await storedVersions()).toEqual(before);
expect(tail).toEqual({ rollups: 0, invalidations: 0, prs: 0 });
// Four reconciler transactions used to be about forty statements.
expect(measured.count).toBeLessThanOrEqual(12);
});
it("writes and starts the tail when one column differs", async () => {
const before = (await storedVersions()).map((r) => r.syncVersion);
const res = await post([
stepsEntry(DAYS[0]!, 1000), // unchanged
stepsEntry(DAYS[1]!, 5555), // new value
stepsEntry(DAYS[2]!, 1002, "phone"), // new device
]);
expect(res.data.entries.map((e) => e.status)).toEqual([
"updated",
"updated",
"updated",
]);
expect(res.data.updated).toBe(3);
const after = await storedVersions();
expect(after.map((r) => r.syncVersion)).toEqual([
before[0],
before[1]! + 1,
before[2]! + 1,
before[3],
]);
expect(after[1]!.value).toBe(5555);
expect(tail.rollups).toBe(1);
expect(tail.invalidations).toBeGreaterThanOrEqual(1);
expect(tail.prs).toBe(1);
});
it("resurrects a tombstoned row the old way", async () => {
await prisma.measurement.updateMany({
where: {
userId,
externalId: `stats:HKQuantityTypeIdentifierStepCount:${DAYS[3]}`,
},
data: { deletedAt: new Date() },
});
const res = await post([stepsEntry(DAYS[3]!, 1003)]);
expect(res.data.entries[0]!.status).toBe("updated");
const row = await prisma.measurement.findFirst({
where: {
userId,
externalId: `stats:HKQuantityTypeIdentifierStepCount:${DAYS[3]}`,
},
select: { deletedAt: true },
});
expect(row?.deletedAt).toBeNull();
expect(tail.rollups).toBe(1);
});
});
+17 -2
View File
@@ -117,8 +117,21 @@ function countBuildsPerKey(cache: {
return builds;
}
/**
* The slice's pre-fold remainder is a cell of its own in the same bucket (a
* closed block, `pre-fold-remainder.ts`), built when the slice takes the
* rollup path; it is counted apart from the route's two cells.
*/
function preFoldBuildsOf(builds: Map<string, number>, userId: string) {
return [...builds.entries()]
.filter(([k]) => k.startsWith(`${userId}|pre-fold-remainder|`))
.reduce((n, [, c]) => n + c, 0);
}
function buildsOf(builds: Map<string, number>, userId: string) {
const entries = [...builds.entries()].filter(([k]) => k.startsWith(userId));
const entries = [...builds.entries()].filter(
([k]) => k.startsWith(userId) && !k.includes("|pre-fold-remainder|"),
);
return {
defaultBody: entries
.filter(([k]) => k.startsWith(`${userId}|default|`))
@@ -230,7 +243,9 @@ describe("v1.4.34 IW-G — server-cache route integration", () => {
summaries: 2,
keys: 2,
});
expect(caches.analytics.stats().misses).toBe(4);
expect(caches.analytics.stats().misses).toBe(
4 + preFoldBuildsOf(builds, user.id),
);
// Sanity: the body reflects the new write.
const body = (await third.json()) as {