mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
commit
0cefac9d68
94 files changed
+14850
-658
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Vendored
+23
@@ -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();
|
||||
}
|
||||
Vendored
+16
-3
@@ -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}|`);
|
||||
|
||||
Vendored
+11
-11
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, "");
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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 = [];
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user