Merge pull request #1161 from MBombeck/release/v1.40.1

chore(release): v1.40.1
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-05 00:06:19 +02:00
commit 324e59914e
32 files changed
+1182 -158

No files matched your search

+40
View File
@@ -1,5 +1,45 @@
# Changelog
## [1.40.1] — 2026-10-04
Restore opens older encrypted values again; faster pages on a long-used
server; a day of pulse is the mean of its hours; Apple Health imports leave
out HealthLog's own samples; more memory for the app by default.
### Fixed
- **Restore key probe.** A legacy unprefixed ciphertext was read as a base64
string and refused a restore the key opens; legacy values open as strings,
probes come only from sections that count, binary probes use their AAD.
The drill checks every account of the newest date.
- **Daily pulse (#1107, migration 0372).** A day is the mean of its local
hours' means and a window the mean of its days, in every reader; stored
pulse rollups are recomputed. Count, min and max stay over every reading.
- **Apple Health import (#1109).** Records carrying HealthLog's origin
marker or a HealthLog id are left out and counted; cycle samples too.
- **Blood pressure series.** One source per day for both values.
- **Backups.** Heartbeat on both backup jobs; per-account attempts recorded
(migration 0373), interrupted accounts go last; the preview is built from
the writer's counts.
- **Document previews.** A permanent render failure is recorded (migration 0374) instead of re-queued at every start.
- **Umami.** Events from a tracker built with a custom endpoint are proxied
to the configured Umami host.
- **Display.** Rest Mode shows its start date; the favicon is transparent;
the sidebar no longer flashes scrollbars while it expands.
### Changed
- **Performance.** One Prisma client per process instead of one per bundle;
the rollup coverage probe uses a loose index scan and concurrent readers
share one probe; timezone offsets are memoised per hour; chart text is
measured outside `<body>`; Insights mounts lower sections near the
viewport; a nav prefetch is not re-sent on mount; MCP single-metric reads
build only the requested metric; the backup schema loads on first use.
- **Lab list (#1057).** The range bar and name use a wide card.
- **Compose.** `APP_MEMORY_LIMIT` defaults to `1536m` (792 MB heap).
- Dependencies: next 16.3.8, eslint-config-next 16.3.8, zod-openapi 6.0.2,
testcontainers 12.2.0, p-limit 7.3.3.
## [1.40.0] — 2026-10-04
Back up the encryption key and refuse a key that cannot open the data;
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.40.0
version: 1.40.1
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+6 -1
View File
@@ -13,7 +13,12 @@ already installed, longer if a TLS-fronted public hostname is in scope
unchanged.
- **2 GB RAM, 10 GB free disk.** The Postgres data volume grows with
measurement history; the Apple Health import worker can briefly
hold a 1.5 GB upload in `/tmp` while it parses.
hold a 1.5 GB upload in `/tmp` while it parses. The app container is
capped at `APP_MEMORY_LIMIT`, `1536m` by default (Node then sizes its
heap at about 792 MB). On a 2 GB host that leaves too little for
Postgres and the system, so set `APP_MEMORY_LIMIT=1g` in `.env`
(a heap of about 524 MB); a long-used account then runs closer to
that heap, and the weekly backup with it.
- **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever
integration endpoints you plan to enable (Withings, OpenAI, etc.).
The opt-in environmental-context module additionally reaches
+5 -1
View File
@@ -163,7 +163,11 @@ the knob as `DB_CONNECTION_LIMIT`, baked into `DATABASE_URL` as
`connection_limit`; `DATABASE_POOL_MAX` remains a working explicit
override. Resolution order: `DB_CONNECTION_LIMIT`, then
`DATABASE_POOL_MAX`, then the `connection_limit` URL parameter, then
the default of 20.
the default of 20. In the bundled single-container setup
(`HEALTHLOG_PROCESS_TYPE=all`) the web requests and the background
worker run in one process and share that one Prisma pool, so a budget
sized for the web traffic alone leaves the worker's jobs queueing for
the same connections.
Plan total Postgres slots as **container_count × budget**.
A stock Postgres 16 container ships with `max_connections = 100`, so
+12 -5
View File
@@ -25,6 +25,7 @@ import {
seedStoredBriefing,
} from "./setup/ai-optional-fixture";
import { expect, test } from "./setup/test";
import { revealDeferredSectionsSettled } from "./utils/deferred-sections";
test.use({ storageState: AI_OPTIONAL_STORAGE_STATE_PATH });
test.describe.configure({ mode: "serial" });
@@ -36,12 +37,18 @@ test.afterAll(async () => {
await resetAiOptionalAccount();
});
/**
* The overview with every section mounted. Sections past the first three
* render as an empty sentinel until they near the viewport, so a count of
* zero taken before they mount would pass whatever they paint.
*/
async function openOverview(page: Page): Promise<void> {
await page.goto("/insights");
await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible({
timeout: 20_000,
});
await page.waitForLoadState("networkidle");
await revealDeferredSectionsSettled(page);
}
test("J5: withdrawing AI consent deletes the notes written under it", async ({
@@ -80,12 +87,12 @@ test("J4: the Coach memory stays readable and deletable with the Coach hidden",
const title = `Stored conversation ${Date.now()}`;
await seedHiddenCoachWithConversation(title);
// Hide Coach takes the Coach page and its launcher away...
await page.goto("/insights");
await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible({
timeout: 20_000,
});
// Hide Coach takes the Coach page, its launcher and every per-card
// hand-off away...
await openOverview(page);
await expect(page.locator('[data-slot="coach-fab"]')).toHaveCount(0);
await expect(page.locator('[data-slot="ask-coach-action"]')).toHaveCount(0);
await expect(page.locator('[data-slot="ask-coach-icon"]')).toHaveCount(0);
// ...and Settings, AI lists what the Coach stored.
await page.goto("/settings/ai");
+13
View File
@@ -21,9 +21,19 @@ import type { Page, Request, Response } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockUnavailable, serveAiBlock } from "./setup/ai-capabilities";
import { expect, test } from "./setup/test";
import { revealDeferredSectionsSettled } from "./utils/deferred-sections";
test.use({ storageState: STORAGE_STATE_PATH });
/**
* Mount every overview section, then let their reads land. Sections past the
* first three render as an empty sentinel until they near the viewport, so a
* count of zero taken before this would pass whatever those sections paint.
*/
async function mountWholeOverview(page: Page): Promise<void> {
await revealDeferredSectionsSettled(page);
}
/** Routes that call a model or serve model-written text. */
const AI_ROUTE = new RegExp(
[
@@ -89,6 +99,7 @@ test.describe("J1: AI switched off by the operator", () => {
);
// Let every section's reads land before judging what painted.
await page.waitForLoadState("networkidle");
await mountWholeOverview(page);
// A read refused for want of AI paints one of these on the overview:
// the section boundary, the error card, or the dense error row.
@@ -137,6 +148,7 @@ test.describe("J3: no AI provider", () => {
timeout: 20_000,
});
await page.waitForLoadState("networkidle");
await mountWholeOverview(page);
await expect(page.locator('[data-slot="coach-fab"]')).toHaveCount(0);
await expect(page.locator('[data-slot="daily-briefing"]')).toHaveCount(0);
// The per-card Coach hand-offs paint only while the Coach is available.
@@ -163,6 +175,7 @@ test.describe("J3: no AI provider", () => {
{ timeout: 20_000 },
);
await page.waitForLoadState("networkidle");
await mountWholeOverview(page);
await expect(page.locator('[data-slot="ai-setup-hint"]')).toHaveCount(0);
expect(network.aiRequests).toEqual([]);
+37
View File
@@ -36,3 +36,40 @@ export async function revealDeferredSections(page: Page): Promise<void> {
.toBe(0);
await page.evaluate(() => document.querySelector("main")?.scrollTo(0, 0));
}
/**
* `revealDeferredSections`, then wait until the reads the newly mounted
* sections fired have landed: no request in flight for 750 ms. A
* `waitForLoadState("networkidle")` cannot do this: the page reached that
* state once already, so it resolves at once, and a count of zero taken then
* passes before a deferred section has painted anything.
*/
export async function revealDeferredSectionsSettled(page: Page): Promise<void> {
let inFlight = 0;
const started = () => {
inFlight += 1;
};
const ended = () => {
inFlight -= 1;
};
page.on("request", started);
page.on("requestfinished", ended);
page.on("requestfailed", ended);
try {
await revealDeferredSections(page);
let quietSince = Date.now();
await expect
.poll(
() => {
if (inFlight > 0) quietSince = Date.now();
return Date.now() - quietSince >= 750;
},
{ timeout: 20_000, intervals: [100] },
)
.toBe(true);
} finally {
page.off("request", started);
page.off("requestfinished", ended);
page.off("requestfailed", ended);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.40.0",
"version": "1.40.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.40.0";
/* @sw-version-fallback */ "v1.40.1";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+7 -3
View File
@@ -4,9 +4,13 @@
* from the app, and any Withings, import or manual row the server-to-Health
* mirror writes. Builds from before the marker existed wrote the id alone.
*
* Such a sample is already in the database under its own source. When the same
* sample comes back through an Apple Health export it is a second copy of that
* reading, so the export import leaves it out.
* Such a sample is usually already in the database under its own source. When
* it comes back through an Apple Health export it is a second copy of that
* reading, so the export import leaves it out, but only once its
* `HKExternalUUID` names a row of the importing account (a cycle sample: once
* that account holds a day-log on its day). The marker alone says HealthLog
* wrote the sample, not that this instance still holds it: after a move to a
* new instance without a backup, the export is how those values come back.
*
* This is not the predicate the app's own sync uses. The app decides "our own
* echo" from `HKExternalUUID` plus the sample's authoring source being the app
@@ -101,8 +101,8 @@ describe("createBackupPreviewCollector", () => {
const expectedKeys = new BackupKeyIdCollector();
expectedKeys.visit(FILE);
it("says what a full read of the copy says", () => {
const collector = createBackupPreviewCollector();
it("says what a full read of the copy says", async () => {
const collector = await createBackupPreviewCollector();
observeAsTheWriterDoes(FILE, collector.observe);
const result = collector.finish(countsOf(FILE));
expect(result).not.toBeNull();
@@ -112,8 +112,8 @@ describe("createBackupPreviewCollector", () => {
expect(result!.keys.keyIds()).toEqual(["v3"]);
});
it("answers null for a copy that does not name its version, owner and date", () => {
const collector = createBackupPreviewCollector();
it("answers null for a copy that does not name its version, owner and date", async () => {
const collector = await createBackupPreviewCollector();
const { userId, ...rest } = FILE;
void userId;
observeAsTheWriterDoes(rest, collector.observe);
@@ -121,6 +121,64 @@ describe("createBackupPreviewCollector", () => {
});
});
describe("createBackupPreviewCollector against the schema", () => {
// A copy the schema refuses must not be described as restorable: a null
// preview sends the preview route to read the copy, which says why not.
async function previewOf(file: Record<string, unknown>) {
const collector = await createBackupPreviewCollector();
observeAsTheWriterDoes(file, collector.observe);
return collector.finish(countsOf(FILE));
}
it("answers null when a bulk row violates the schema", async () => {
const measurements = FILE.measurements.map((m, i) =>
i === 1 ? { ...m, measuredAt: "not a date" } : m,
);
expect(await previewOf({ ...FILE, measurements })).toBeNull();
});
it("answers null when a whole section violates the schema", async () => {
const labResults = [{ ...FILE.labResults[0], takenAt: 42 }];
expect(await previewOf({ ...FILE, labResults })).toBeNull();
});
it("answers null when the header does not parse", async () => {
expect(
await previewOf({ ...FILE, exportedAt: "yesterday-ish" }),
).toBeNull();
});
it("answers null for a canonical copy with a measurement without an id", async () => {
const measurements = FILE.measurements.map((m, i) =>
i === 0 ? { ...m, id: undefined } : m,
);
expect(await previewOf({ ...FILE, measurements })).toBeNull();
});
it("agrees with the whole-document parse on every case above", () => {
// The oracle: each refused file is refused by the full schema too.
const bad = [
{
...FILE,
measurements: FILE.measurements.map((m, i) =>
i === 1 ? { ...m, measuredAt: "not a date" } : m,
),
},
{ ...FILE, labResults: [{ ...FILE.labResults[0], takenAt: 42 }] },
{ ...FILE, exportedAt: "yesterday-ish" },
{
...FILE,
measurements: FILE.measurements.map((m, i) =>
i === 0 ? { ...m, id: undefined } : m,
),
},
];
for (const file of bad) {
expect(backupPayloadSchema.safeParse(file).success).toBe(false);
}
});
});
describe("storedPreviewFor", () => {
const chunked = { data: null, chunkCount: 4, chunkStreamId: "s1" };
const preview = () =>
+62 -6
View File
@@ -35,7 +35,10 @@ import {
} from "@/lib/export/backup-key-ids";
import type { FullBackupCounts } from "@/lib/export/full-backup-payload";
import type { StoredBackupRef } from "@/lib/export/stored-backup";
import type { BackupSummary } from "@/lib/validations/backup-summary";
import {
BACKUP_SCHEMA_VERSION,
type BackupSummary,
} from "@/lib/validations/backup-summary";
export interface BackupPreview {
version: 1;
@@ -207,7 +210,9 @@ export interface BackupPreviewCollector {
observe(member: string, value: unknown): void;
/**
* The counts and key uses, once the writer has finished; null when the
* copy did not name its schema version, owner and date.
* copy did not name its schema version, owner and date, or when a section
* did not pass the backup schema. A null preview sends the preview route
* to read the copy itself, which then says why it cannot be restored.
*/
finish(counts: FullBackupCounts): {
summary: BackupSummary;
@@ -215,19 +220,64 @@ export interface BackupPreviewCollector {
} | null;
}
type SchemaModule = typeof import("@/lib/validations/backup");
/**
* A check of one member of the payload against `backupPayloadSchema`, as the
* writer shows it: a whole section, or one row of a bulk table. An array
* section is checked an element at a time, so the check holds one element's
* copy at most, never a section's. Each value is checked in its JSON form,
* exactly as it lands in the copy. A member the schema does not name passes,
* as it does under the schema's passthrough.
*/
function sectionChecker(
mod: SchemaModule,
): (member: string, value: unknown) => boolean {
const shape = mod.backupPayloadSchema.shape as Record<string, z.ZodType>;
const asWritten = (value: unknown): unknown =>
value === undefined ? undefined : JSON.parse(JSON.stringify(value));
const unwrap = (schema: z.ZodType): z.ZodType =>
schema instanceof z.ZodDefault
? unwrap(schema.unwrap() as z.ZodType)
: schema;
return (member, value) => {
const declared = shape[member];
if (!declared) return true;
const section = unwrap(declared);
if (section instanceof z.ZodArray) {
const element = section.element as z.ZodType;
const rows = Array.isArray(value) ? value : [value];
return rows.every((row) => element.safeParse(asWritten(row)).success);
}
return declared.safeParse(asWritten(value)).success;
};
}
/**
* Work out a weekly copy's preview from what its writer already has in hand:
* each section as it is written, for the key uses, and the writer's own
* counts. Nothing is parsed and nothing is kept but the key samples, so the
* preview costs the pass no memory that grows with the record.
* each section as it is written, for the key uses and the schema check, and
* the writer's own counts. Nothing is parsed back and nothing is kept but the
* key samples, so the preview costs the pass no memory that grows with the
* record. The check loads the backup schema (`backup-summary.ts` says why it
* is loaded on first use), a fixed cost of the module, not of the record.
*/
export function createBackupPreviewCollector(): BackupPreviewCollector {
export async function createBackupPreviewCollector(): Promise<BackupPreviewCollector> {
const check = sectionChecker(await import("@/lib/validations/backup"));
const keys = new BackupKeyIdCollector();
const header: Partial<
Record<"schemaVersion" | "userId" | "exportedAt", string>
> = {};
let valid = true;
let measurementWithoutId = false;
return {
observe(member, value) {
if (valid && !check(member, value)) valid = false;
if (member === "measurements") {
const rows: unknown[] = Array.isArray(value) ? value : [value];
if (rows.some((row) => !(row as { id?: unknown } | null)?.id)) {
measurementWithoutId = true;
}
}
if (
(member === "schemaVersion" ||
member === "userId" ||
@@ -242,6 +292,12 @@ export function createBackupPreviewCollector(): BackupPreviewCollector {
finish(counts) {
const { schemaVersion, userId, exportedAt } = header;
if (!schemaVersion || !userId || !exportedAt) return null;
if (!valid) return null;
// The schema's one whole-document rule: a canonical copy names every
// measurement by a stable id.
if (schemaVersion === BACKUP_SCHEMA_VERSION && measurementWithoutId) {
return null;
}
const summary = { schemaVersion, userId, exportedAt } as BackupSummary;
for (const field of SUMMARY_COUNTS) summary[field] = counts[field];
return { summary, keys };
@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import {
heldBackAccounts,
orderInterruptedLast,
} from "../backup-pass-attempts";
type Row = { userId: string; startedAt: Date; finishedAt: Date | null };
/**
* A weekly-style pass over `ids`: oldest copy first, held-back accounts last,
* at most `budget` accounts per run, `kills` the accounts whose record kills
* the process. Returns the accounts copied in each run.
*/
function simulate(opts: {
ids: string[];
budget: number;
kills: Set<string>;
runs: number;
interruptedFirst?: string;
}): string[][] {
let clock = 0;
const attempts = new Map<string, Row>();
const copiedAt = new Map<string, number>();
if (opts.interruptedFirst) {
attempts.set(opts.interruptedFirst, {
userId: opts.interruptedFirst,
startedAt: new Date(++clock),
finishedAt: null,
});
}
const copied: string[][] = [];
for (let run = 0; run < opts.runs; run += 1) {
const order = orderInterruptedLast(
[...opts.ids]
.map((id) => ({ id }))
.sort(
(a, b) => (copiedAt.get(a.id) ?? -1) - (copiedAt.get(b.id) ?? -1),
),
heldBackAccounts([...attempts.values()]),
);
const thisRun: string[] = [];
for (const { id } of order.slice(0, opts.budget)) {
const row = attempts.get(id) ?? {
userId: id,
startedAt: new Date(0),
finishedAt: null,
};
row.startedAt = new Date(++clock);
attempts.set(id, row);
if (opts.kills.has(id)) break; // the process dies; the run ends here
row.finishedAt = new Date(++clock);
copiedAt.set(id, clock);
thisRun.push(id);
}
copied.push(thisRun);
}
return copied;
}
describe("heldBackAccounts", () => {
const at = (ms: number) => new Date(ms);
it("holds back an interrupted attempt that is the newest of the pass", () => {
const held = heldBackAccounts([
{ userId: "x", startedAt: at(30), finishedAt: at(10) },
{ userId: "a", startedAt: at(20), finishedAt: at(21) },
]);
expect([...held.keys()]).toEqual(["x"]);
});
it("releases it once a later run has started on another account", () => {
const held = heldBackAccounts([
{ userId: "x", startedAt: at(30), finishedAt: null },
{ userId: "a", startedAt: at(40), finishedAt: at(41) },
]);
expect(held.size).toBe(0);
});
it("ignores finished attempts", () => {
const held = heldBackAccounts([
{ userId: "a", startedAt: at(40), finishedAt: at(41) },
]);
expect(held.size).toBe(0);
});
});
describe("the order across runs", () => {
const ids = ["x", "a", "b", "c", "d"];
it("keeps a record that kills the process from costing the others their copies", () => {
// `x` has the oldest copy and kills every run that reaches it.
const runs = simulate({ ids, budget: 99, kills: new Set(["x"]), runs: 6 });
// The first run meets it unwarned; from then on it goes last every run,
// because each crash makes its attempt the newest again.
expect(runs[0]).toEqual([]);
for (const run of runs.slice(1)) {
expect([...run].sort()).toEqual(["a", "b", "c", "d"]);
}
});
it("still copies an interrupted account when every run stops on its budget", () => {
// `x` was interrupted once (a restart, not its record). Every run stops
// after three accounts, so kept last for good it would never be reached.
const runs = simulate({
ids,
budget: 3,
kills: new Set(),
runs: 4,
interruptedFirst: "x",
});
const firstCopy = runs.findIndex((run) => run.includes("x"));
expect(firstCopy).toBeGreaterThanOrEqual(0);
expect(firstCopy).toBeLessThanOrEqual(1);
// And every account gets a copy within the four runs.
expect(new Set(runs.flat())).toEqual(new Set(ids));
});
});
@@ -7,7 +7,9 @@ import {
runRestoreDrill,
RESTORE_DRILL_CRON,
RESTORE_DRILL_QUEUE,
RESTORE_DRILL_SEND_OPTIONS,
} from "../restore-drill";
import { QUEUE_RUNTIME } from "../queue-runtime";
vi.mock("../report-worker-error", () => ({
reportWorkerError: vi.fn().mockResolvedValue(undefined),
@@ -86,6 +88,36 @@ describe("runRestoreDrill", () => {
expect(RESTORE_DRILL_CRON).toBe("11 4 1 * *");
});
it("runs as a locked long pass that pages each account once per run", () => {
// Every account's object is read, so the pg-boss default of fifteen
// minutes is no ceiling for it; no retry, so a failed account is paged
// once per run instead of three times.
expect(RESTORE_DRILL_SEND_OPTIONS.expireInSeconds).toBeGreaterThan(15 * 60);
expect(RESTORE_DRILL_SEND_OPTIONS.retryLimit).toBe(0);
expect(QUEUE_RUNTIME[RESTORE_DRILL_QUEUE]).toMatchObject({
runtime: "long",
exclusive: "lockedPass",
});
});
it("stops between accounts on its budget and counts the ones it did not reach", async () => {
const s3 = makeS3Mock({
"2026-06-01/user-aaa.json.enc": backupObject({ userId: "user-aaa" }),
"2026-06-01/user-mmm.json.enc": backupObject({ userId: "user-mmm" }),
"2026-06-01/user-zzz.json.enc": backupObject({ userId: "user-zzz" }),
});
let asked = 0;
const report = await runRestoreDrill(
s3,
new Date("2026-06-02T04:11:00Z"),
() => ++asked > 1,
);
expect(report.accounts.map((a) => a.objectKey)).toEqual([
"2026-06-01/user-aaa.json.enc",
]);
expect(report.unchecked).toBe(2);
});
it("fetches, decrypts, and parses the newest backup object", async () => {
const s3 = makeS3Mock({
"2026-05-30/user-old.json.enc": backupObject(),
+55 -1
View File
@@ -16,6 +16,16 @@
* takes that account last, after everybody else has their copy, and the admin
* backups page names it. Every write and read here is best effort: the marker
* is bookkeeping about a backup, and must never be the reason one fails.
*
* Last only once. A pass that regularly stops on its time budget never reaches
* the end of its list, so an account kept last for as long as its attempt
* stays open would never be attempted again, and would never get a copy. An
* interrupted account is therefore held back only until a later run has
* started on another account (`accountsToTakeLast`): from then on it takes its
* ordinary place again. If it kills the process again, its new attempt is the
* newest one and it goes last once more. So a crash loop costs the other
* accounts at most every second run, and the account itself is attempted at
* least every second run.
*/
import type { PrismaClient } from "@/generated/prisma/client";
@@ -83,7 +93,51 @@ export async function readInterruptedBackupAttempts(
}
/**
* `accounts` in their order, with every interrupted one moved behind the
* The interrupted accounts a pass should take last: those whose interrupted
* attempt is still the newest attempt of the pass, i.e. no later run has
* started on another account since. Rows are one per account. Pure, so the
* rule can be tested apart from the database.
*/
export function heldBackAccounts(
rows: ReadonlyArray<{
userId: string;
startedAt: Date;
finishedAt: Date | null;
}>,
): Map<string, Date> {
const out = new Map<string, Date>();
for (const row of rows) {
const open = row.finishedAt === null || row.finishedAt < row.startedAt;
if (!open) continue;
const passedOver = rows.some(
(other) => other.userId !== row.userId && other.startedAt > row.startedAt,
);
if (!passedOver) out.set(row.userId, row.startedAt);
}
return out;
}
/**
* What a pass orders by: the interrupted accounts it takes last
* (`heldBackAccounts`). Empty when the table cannot be read.
*/
export async function readAccountsToTakeLast(
prisma: Db,
pass: BackupPass,
): Promise<Map<string, Date>> {
try {
const rows = await prisma.backupPassAttempt.findMany({
where: { pass },
select: { userId: true, startedAt: true, finishedAt: true },
});
return heldBackAccounts(rows);
} catch {
return new Map();
}
}
/**
* `accounts` in their order, with every held-back one moved behind the
* rest; among those, the one interrupted longest ago first.
*/
export function orderInterruptedLast<T extends { id: string }>(
+1
View File
@@ -200,6 +200,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet<string> = new Set([
"restore_drill_medications",
"restore_drill_mood_entries",
"restore_drill_plaintext_bytes",
"restore_drill_unchecked",
"restore_drill_stale",
"restore_fail_cause",
"restore_failed",
+5 -4
View File
@@ -39,7 +39,7 @@ import {
markBackupAttemptFinished,
markBackupAttemptStarted,
orderInterruptedLast,
readInterruptedBackupAttempts,
readAccountsToTakeLast,
} from "@/lib/jobs/backup-pass-attempts";
import { BACKUP_HEARTBEAT_SECONDS } from "@/lib/jobs/data-backup-policy";
import { annotate, getEvent } from "@/lib/logging/context";
@@ -930,14 +930,15 @@ export async function runOffhostBackup(
// By id, with an account whose last walk never came back behind the rest:
// a record that killed the process once would otherwise lead every retry
// and keep every account after it out of the bucket
// (`backup-pass-attempts.ts`).
// and keep every account after it out of the bucket. Behind them for one
// run only, so a pass that stops on its budget still reaches it the run
// after (`backup-pass-attempts.ts`).
const users = orderInterruptedLast(
await prisma.user.findMany({
select: { id: true },
orderBy: { id: "asc" },
}),
await readInterruptedBackupAttempts(prisma, OFFHOST_BACKUP_QUEUE),
await readAccountsToTakeLast(prisma, OFFHOST_BACKUP_QUEUE),
);
const doneThisRun = new Set<string>();
if (runStartedAt) {
+9 -1
View File
@@ -36,6 +36,7 @@ import { DOCUMENT_AI_RUN_EXPIRE_SECONDS } from "@/lib/jobs/document-ai-run";
import { DATA_BACKUP_SEND_OPTIONS } from "@/lib/jobs/data-backup-policy";
import { OFFHOST_BACKUP_EXPIRE_SECONDS } from "@/lib/jobs/offhost-backup";
import { BACKUP_RESTORE_EXPIRE_SECONDS } from "@/lib/jobs/backup-restore";
import { RESTORE_DRILL_EXPIRE_SECONDS } from "@/lib/jobs/restore-drill";
import { APPLE_HEALTH_IMPORT_SEND_OPTIONS } from "@/lib/jobs/apple-health-import-worker";
import { MEDICATION_INTAKE_IMPORT_SEND_OPTIONS } from "@/lib/jobs/medication-intake-import";
@@ -267,7 +268,14 @@ export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
exclusive: "lockedPass",
why: "Uploads every account's record off-host.",
},
"data-restore-drill": short("Fetches and stream-parses one off-host object."),
"data-restore-drill": {
runtime: "long",
expireInSeconds: RESTORE_DRILL_EXPIRE_SECONDS,
expiryVia: ["RESTORE_DRILL_SEND_OPTIONS"],
stop: { file: "lib/jobs/restore-drill.ts", fn: "handleRestoreDrill" },
exclusive: "lockedPass",
why: "Fetches and stream-parses every account's newest off-host object.",
},
"offhost-backup-purge": short("At most 200 delete requests per run."),
"backup-restore": {
runtime: "long",
+6 -4
View File
@@ -27,7 +27,7 @@ import {
markBackupAttemptFinished,
markBackupAttemptStarted,
orderInterruptedLast,
readInterruptedBackupAttempts,
readAccountsToTakeLast,
} from "@/lib/jobs/backup-pass-attempts";
import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy";
@@ -154,7 +154,7 @@ export async function handleDataBackup(
where: { type: "WEEKLY_AUTO" },
select: { userId: true, createdAt: true },
}),
readInterruptedBackupAttempts(prisma, DATA_BACKUP_QUEUE),
readAccountsToTakeLast(prisma, DATA_BACKUP_QUEUE),
]);
const copiedAt = new Map(
copies.map((copy) => [copy.userId, copy.createdAt.getTime()]),
@@ -163,7 +163,9 @@ export async function handleDataBackup(
// attempt never came back goes behind all of them: it is the one with
// the oldest copy, so it would lead every retry, and a record that
// killed the process once would keep every account after it from a
// copy (`backup-pass-attempts.ts`).
// copy. Only until a later run has started on another account, so a
// pass that stops on its budget still reaches it the run after
// (`backup-pass-attempts.ts`).
const users = orderInterruptedLast(
[...accounts].sort(
(a, b) => (copiedAt.get(a.id) ?? -1) - (copiedAt.get(b.id) ?? -1),
@@ -205,7 +207,7 @@ export async function handleDataBackup(
// so its size does not depend on this process's memory (#1031).
// The restore preview is taken from the writer's own counts and
// the sections as they are written, never by parsing the copy.
const preview = createBackupPreviewCollector();
const preview = await createBackupPreviewCollector();
let counts: FullBackupCounts | undefined;
const { bytes: storedBytes } = await storeBackupBlob(
prisma,
@@ -83,6 +83,7 @@ import {
handleRestoreDrill,
RESTORE_DRILL_CRON,
RESTORE_DRILL_QUEUE,
RESTORE_DRILL_SEND_OPTIONS,
} from "@/lib/jobs/restore-drill";
import {
BACKUP_RESTORE_QUEUE,
@@ -551,8 +552,10 @@ const allQueues = [
// a bulk DELETE or UPDATE that timed out will time out again on an immediate
// retry, so pg-boss's default two would run the same doomed statement three
// times a night. The queues without it are the ones whose failure is
// transient — the TLS probe, the off-host upload, the restore drill, the
// per-user environment fetch — and those still want the default retries.
// transient — the TLS probe, the off-host upload, the per-user environment
// fetch — and those still want the default retries. The restore drill sends
// `retryLimit: 0` in its own options for the other reason: it pages each
// failed account itself, and a retry would page the same accounts again.
const schedules: ScheduleEntry[] = [
// v1.25 (W-ENV) — daily 02:10 Europe/Berlin discovery tick (empty payload)
// that fans out one per-user environment fetch per opted-in account.
@@ -571,7 +574,7 @@ const schedules: ScheduleEntry[] = [
cronIsTheRetry,
],
[OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_CRON, OFFHOST_BACKUP_SEND_OPTIONS],
[RESTORE_DRILL_QUEUE, RESTORE_DRILL_CRON],
[RESTORE_DRILL_QUEUE, RESTORE_DRILL_CRON, RESTORE_DRILL_SEND_OPTIONS],
// Nightly backstop for the purge the deletion kicks straight away: a
// request the bucket refused, or one written while the queue was down.
[OFFHOST_PURGE_QUEUE, OFFHOST_PURGE_CRON, cronIsTheRetry],
@@ -832,11 +835,13 @@ export async function registerMaintenanceQueues(
{ localConcurrency: 1, includeMetadata: true },
lockedPass(OFFHOST_BACKUP_QUEUE, () => WHOLE_PASS, handleOffhostBackup),
);
// Locked: a drill that outlives a restart or a manual run never reads the
// bucket beside another one.
await createAndWork(
boss,
RESTORE_DRILL_QUEUE,
{ localConcurrency: 1 },
handleRestoreDrill,
lockedPass(RESTORE_DRILL_QUEUE, () => WHOLE_PASS, handleRestoreDrill),
);
await createAndWork(
boss,
+43 -4
View File
@@ -21,6 +21,13 @@
* Schedule: 04:11 on the 1st of each month (`11 4 1 * *`), after the
* 02:30 nightly upload and the 03:xx cleanup window, on a minute slot
* no other cron uses.
*
* The drill reads every account's object, so it runs as a long pass: an
* explicit expiry (`RESTORE_DRILL_EXPIRE_SECONDS`), a stop between accounts
* on the job's budget, a lock so two drills never overlap (`lockedPass` at
* the binding), and no retry. A retry would re-read the same objects and
* page the same failed accounts a second and third time; each account is
* reported once per run, and the next drill is the retry.
*/
import type { Job } from "pg-boss";
import { BackupJsonError, scanBackupJson } from "@/lib/export/backup-json-scan";
@@ -38,12 +45,28 @@ import {
type S3Like,
} from "@/lib/jobs/offhost-backup";
import { reportWorkerError } from "@/lib/jobs/report-worker-error";
import { jobBudget } from "@/lib/jobs/job-budget";
import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
import { withBackgroundEvent } from "@/lib/logging/background";
export const RESTORE_DRILL_QUEUE = "data-restore-drill";
export const RESTORE_DRILL_CRON = "11 4 1 * *";
/**
* Four hours, the off-host upload's own window: the drill downloads and
* stream-parses every object that upload wrote in one night.
*/
export const RESTORE_DRILL_EXPIRE_SECONDS = 4 * 60 * 60;
/**
* What the monthly schedule sends with: the expiry above and no retry
* (`retryLimit: 0`, the maintenance schedules' `cronIsTheRetry`).
*/
export const RESTORE_DRILL_SEND_OPTIONS = {
expireInSeconds: RESTORE_DRILL_EXPIRE_SECONDS,
retryLimit: 0,
} as const;
/**
* A drill run that finds the newest backup older than this is reported
* as a failure even when decrypt + parse succeed: the nightly uploader
@@ -80,6 +103,8 @@ export interface RestoreDrillReport {
accounts: RestoreDrillAccount[];
/** The accounts whose object did not pass. */
failed: RestoreDrillAccount[];
/** Objects of the newest date the run stopped before, on its budget. */
unchecked: number;
}
function countArray(value: unknown): number {
@@ -178,6 +203,7 @@ async function drillObject(
export async function runRestoreDrill(
s3Override?: S3Like,
now: Date = new Date(),
shouldStop: () => boolean = () => false,
): Promise<RestoreDrillReport> {
const cfg = loadOffhostConfig();
if (!cfg) {
@@ -208,6 +234,8 @@ export async function runRestoreDrill(
const accounts: RestoreDrillAccount[] = [];
for (const objectKey of newest) {
// Between accounts, never inside one: the job's budget is spent.
if (shouldStop()) break;
const account: RestoreDrillAccount = {
objectKey,
ok: true,
@@ -239,6 +267,7 @@ export async function runRestoreDrill(
stale: ageDays > MAX_BACKUP_AGE_DAYS,
accounts,
failed: accounts.filter((a) => !a.ok),
unchecked: newest.length - accounts.length,
};
}
@@ -252,10 +281,10 @@ export async function runRestoreDrill(
export async function handleRestoreDrill(
jobs: Job<object>[],
): Promise<JobOutcome> {
void jobs;
const shouldStop = jobBudget(jobs);
return withBackgroundEvent("job.restore_drill", async (evt) => {
try {
const report = await runRestoreDrill();
const report = await runRestoreDrill(undefined, new Date(), shouldStop);
const total = (pick: (a: RestoreDrillAccount) => number) =>
report.accounts.reduce((sum, a) => sum + pick(a), 0);
const meta = {
@@ -270,6 +299,7 @@ export async function handleRestoreDrill(
restore_drill_intake_events: total((a) => a.recordCounts.intakeEvents),
restore_drill_mood_entries: total((a) => a.recordCounts.moodEntries),
restore_drill_stale: report.stale,
restore_drill_unchecked: report.unchecked,
};
for (const [key, value] of Object.entries(meta)) evt.addMeta(key, value);
evt.addMeta(
@@ -301,9 +331,18 @@ export async function handleRestoreDrill(
{ dateKey: report.dateKey, ageDays: report.ageDays },
);
}
if (report.unchecked > 0) {
await reportWorkerError(
RESTORE_DRILL_QUEUE,
new Error(
`Restore drill stopped on its time budget with ${report.unchecked} of ${report.accounts.length + report.unchecked} backup objects unchecked.`,
),
{ dateKey: report.dateKey, unchecked: report.unchecked },
);
}
if (report.failed.length > 0) {
// Already paged above, per account. A retry reads the same objects
// and cannot change the verdict before the next upload.
// Already paged above, per account. The queue does not retry: a
// retry reads the same objects and would page the same accounts again.
return jobFailed(
`restore drill: ${report.failed.length} of ${report.accounts.length} accounts failed`,
new Error(report.failed.map((a) => a.objectKey).join(", ")),
+22 -24
View File
@@ -245,34 +245,32 @@ function weightedRowsSql(
if (!/^[a-z_][a-z0-9_]*$/.test(source)) {
throw new Error(`source must be a CTE name: ${source}`);
}
const hourOf = (alias: string) =>
`date_trunc('hour', ${frameTs(frame, `${alias}."measured_at"`)})`;
const dayOf = (alias: string) =>
`date_trunc('day', ${frameTs(frame, `${alias}."measured_at"`)})`;
const src = bySource ? `, t."source"` : "";
const part = bySource ? `, x."source"` : "";
const join = bySource ? ` AND hw."source" = s."source"` : "";
// One scan of the CTE and window functions only. The earlier shape joined
// the CTE to an hour grouping of itself; with stale or missing statistics
// (a fresh import below the autoanalyze threshold) the planner ran that
// join as a nested loop and a year of pulse ran into the statement timeout.
//
// `dm_hour_n` counts the readings in a row's hour and `dm_hour_first` marks
// one row per hour, so the sum of `dm_hour_first` over a day is the day's
// hour count. Both columns ride along on the row; every caller aggregates.
const partition = (alias: string, unit: "hour" | "day") => {
const keys = bySource
? `${alias}."type", ${alias}."source"`
: `${alias}."type"`;
const ts = frameTs(frame, `${alias}."measured_at"`);
return `PARTITION BY ${keys}, date_trunc('${unit}', ${ts})`;
};
return `(
SELECT s.*,
(CASE WHEN ${isHourlyMeanTypeSql('s."type"')}
THEN 1.0 / (hw.n * hw.hours)
THEN 1.0 / (s.dm_hour_n * SUM(s.dm_hour_first) OVER (${partition("s", "day")}))
ELSE 1.0 END)::double precision AS day_weight
FROM ${source} s
LEFT JOIN (
SELECT x."type"${part}, x.local_hour, x.n,
COUNT(*) OVER (PARTITION BY x."type"${part}, x.local_day) AS hours
FROM (
SELECT t."type"${src},
${hourOf("t")} AS local_hour,
${dayOf("t")} AS local_day,
COUNT(*) AS n
FROM ${source} t
WHERE ${isHourlyMeanTypeSql('t."type"')}
GROUP BY ${bySource ? "1, 2, 3, 4" : "1, 2, 3"}
) x
) hw
ON hw."type" = s."type"${join}
AND hw.local_hour = ${hourOf("s")}
FROM (
SELECT t.*,
COUNT(*) OVER (${partition("t", "hour")}) AS dm_hour_n,
(ROW_NUMBER() OVER (${partition("t", "hour")}) = 1)::int AS dm_hour_first
FROM ${source} t
) s
)`;
}
@@ -122,11 +122,14 @@ export interface ImportJobResult {
clinical: { skipped: number };
/**
* Records left out because HealthLog wrote them into Apple Health itself and
* already holds them under their own source, each counted once however often
* the export repeats it: `byMarker` carry the app's origin marker,
* `byExternalId` carry a HealthLog row id as `HKExternalUUID` (samples from
* before the marker existed), `matchedManual` match a manual entry (same type
* and value, within 2 s) without either.
* this account already holds them under their own source, each counted once
* however often the export repeats it: `byMarker` carry the app's origin
* marker and a HealthLog row id of this account (a cycle sample: a day-log of
* this account on its day), `byExternalId` carry such a row id without the
* marker (samples from before the marker existed), `matchedManual` match a
* manual entry (same type and value, within 2 s). A marked sample this
* account does not hold (a move to a new instance without a backup) is
* imported like any other.
*/
writtenByHealthLog: {
byMarker: number;
@@ -186,8 +189,11 @@ interface PreparedMeasurement {
* at the close tag, once its `<MetadataEntry>` children have been read.
*/
interface PendingRecord {
/** Queue the record; `healthLogId` is the `HKExternalUUID` it carried. */
commit: (healthLogId: string | null) => void;
/**
* Queue the record; `healthLogId` is the `HKExternalUUID` it carried and
* `marked` whether it (or its correlation) carried the origin marker.
*/
commit: (healthLogId: string | null, marked: boolean) => void;
ownOrigin: boolean;
healthLogId: string | null;
/** The record's sample identity, computed only when it is left out. */
@@ -369,7 +375,11 @@ export interface StreamParseInput {
/** Prisma client to flush rows through. */
prisma: Pick<
PrismaClient,
"measurement" | "workout" | "$transaction" | "$queryRawUnsafe"
| "measurement"
| "workout"
| "cycleDayLog"
| "$transaction"
| "$queryRawUnsafe"
>;
/**
* Live progress hook. Called every `PROGRESS_TICK_RECORDS` records
@@ -449,6 +459,15 @@ export async function streamParseExportXml(
sampleKey: string;
ownOrigin: boolean;
} | null = null;
// Marked cycle samples, held to the end of the parse: one counts as written
// by HealthLog only when this account holds a day-log on its day.
const markedCycle: Array<{
hkType: string;
dayKey: string;
rawValue: string | undefined;
protectionUsed?: boolean;
sampleKey: string;
}> = [];
// Cumulative fold: type -> local day -> hashed source identity -> subtotal.
const cumulativeBucket = new Map<
@@ -471,13 +490,25 @@ export async function streamParseExportXml(
countedOwn.add(sampleKey);
writtenByHealthLog[reason] += 1;
};
// The `HKExternalUUID` a queued spot row carried, checked at flush.
// The `HKExternalUUID` a queued spot row carried, checked at flush, and the
// queued rows that carried the origin marker.
const healthLogIdOf = new Map<PreparedMeasurement, string>();
const markedRows = new Set<PreparedMeasurement>();
// Marked cumulative samples that named a HealthLog row id, held to the end
// of the parse and added to their day only when the id is not this
// account's. Small: one per mirrored total.
const markedCumulative: Array<{
type: MeasurementType;
healthLogId: string;
sampleKey: string;
add: () => void;
}> = [];
// A `<Correlation>` (a blood pressure reading) wraps its records, and the
// marker may sit on the correlation itself, before or after them. Its records
// are held until it closes, then all kept or all left out together.
let currentCorrelation: {
ownOrigin: boolean;
healthLogId: string | null;
held: PendingRecord[];
} | null = null;
// Spot-row batch awaiting flush.
@@ -536,12 +567,29 @@ export async function streamParseExportXml(
};
// ── Flush helpers ──────────────────────────────────────────
// A sample the app wrote before it stamped its origin marker still carries
// the HealthLog row id as `HKExternalUUID`. A hit on one of this account's
// rows (deleted ones included: the sample is ours either way) of the same
// kind is an exact match. Blood pressure is one row per half, while the app
// stamps both samples of a reading with one id (the systolic row's, the
// diastolic row's in older builds), so either half matches either id.
// A sample the app wrote carries the HealthLog row id as `HKExternalUUID`,
// with the origin marker or (before the marker existed) without it. A hit on
// one of this account's rows (deleted ones included: the sample is ours
// either way) of the same kind is an exact match. The marker alone proves
// nothing about THIS account: after a move to a new instance without a
// backup, re-importing the export must bring the mirrored values back.
// Blood pressure is one row per half, while the app stamps both samples of a
// reading with one id (the systolic row's, the diastolic row's in older
// builds), so either half matches either id.
const ownRowKinds = async (
ids: ReadonlySet<string>,
): Promise<Map<string, string>> => {
const out = new Map<string, string>();
const list = [...ids];
for (let i = 0; i < list.length; i += 1000) {
const known = await prisma.measurement.findMany({
where: { userId, id: { in: list.slice(i, i + 1000) } },
select: { id: true, type: true },
});
for (const row of known) out.set(row.id, kindOf(row.type));
}
return out;
};
const withoutHealthLogIds = async (
rows: PreparedMeasurement[],
): Promise<PreparedMeasurement[]> => {
@@ -551,17 +599,16 @@ export async function streamParseExportXml(
if (id) ids.add(id);
}
if (ids.size === 0) return rows;
const known = await prisma.measurement.findMany({
where: { userId, id: { in: [...ids] } },
select: { id: true, type: true },
});
if (known.length === 0) return rows;
const typeOf = new Map(known.map((row) => [row.id, row.type]));
const kindOfId = await ownRowKinds(ids);
if (kindOfId.size === 0) return rows;
return rows.filter((row) => {
const id = healthLogIdOf.get(row);
const rowType = id ? typeOf.get(id) : undefined;
if (!rowType || kindOf(rowType) !== kindOf(row.type)) return true;
countOwn("byExternalId", row.externalId);
const rowKind = id ? kindOfId.get(id) : undefined;
if (!rowKind || rowKind !== kindOf(row.type)) return true;
countOwn(
markedRows.has(row) ? "byMarker" : "byExternalId",
row.externalId,
);
return false;
});
};
@@ -663,7 +710,10 @@ export async function streamParseExportXml(
const chunk = await withoutManualMirrors(
await withoutHealthLogIds(incoming),
);
for (const row of incoming) healthLogIdOf.delete(row);
for (const row of incoming) {
healthLogIdOf.delete(row);
markedRows.delete(row);
}
for (const row of chunk) widenSpan(row.measuredAt);
const insertedArrivals: Array<{
id: string;
@@ -993,6 +1043,61 @@ export async function streamParseExportXml(
}
};
const consumeCycle = (rec: {
hkType: string;
dayKey: string;
rawValue: string | undefined;
protectionUsed?: boolean;
}): void => {
const consumed = cycleAccumulator.consume(
rec.hkType,
rec.dayKey,
rec.rawValue,
rec.protectionUsed,
);
if (!consumed) {
// Recognised identifier but unrecognised value — count it under
// `unknown` with the reason tag so operators can spot it.
unknown[`${rec.hkType}::cycle_unmapped`] =
(unknown[`${rec.hkType}::cycle_unmapped`] ?? 0) + 1;
}
};
// Marked cumulative samples: left out when their id is a row of this
// account of the same kind, added to their day otherwise.
const settleMarkedCumulative = async (): Promise<void> => {
if (markedCumulative.length === 0) return;
const kindOfId = await ownRowKinds(
new Set(markedCumulative.map((m) => m.healthLogId)),
);
for (const m of markedCumulative.splice(0, markedCumulative.length)) {
if (kindOfId.get(m.healthLogId) === kindOf(m.type)) {
countOwn("byMarker", m.sampleKey);
} else {
m.add();
}
}
};
// Marked cycle samples: left out when this account holds a day-log on
// their day (deleted ones included), folded in otherwise.
const settleMarkedCycle = async (): Promise<void> => {
if (markedCycle.length === 0) return;
const days = [...new Set(markedCycle.map((rec) => rec.dayKey))];
const held = new Set<string>();
for (let i = 0; i < days.length; i += 1000) {
const logs = await prisma.cycleDayLog.findMany({
where: { userId, date: { in: days.slice(i, i + 1000) } },
select: { date: true },
});
for (const log of logs) held.add(log.date);
}
for (const rec of markedCycle.splice(0, markedCycle.length)) {
if (held.has(rec.dayKey)) countOwn("byMarker", rec.sampleKey);
else consumeCycle(rec);
}
};
// ── SAX parser configuration ────────────────────────────────
const parser = sax.parser(true, { trim: true });
let pendingError: Error | null = null;
@@ -1121,27 +1226,44 @@ export async function streamParseExportXml(
attrs.startDate,
attrs.endDate,
),
commit: (healthLogId) => {
commit: (healthLogId, marked) => {
if (CUMULATIVE_HK_TYPES.has(mapped.type)) {
const dayKey = dayKeyForUserTz(mapped.takenAt, userTimezone);
const sourceHash = hashCumulativeSourceIdentity(
attrs.sourceName,
attrs.device,
);
let byDay = cumulativeBucket.get(mapped.type);
if (!byDay) {
byDay = new Map();
cumulativeBucket.set(mapped.type, byDay);
const add = () => {
let byDay = cumulativeBucket.get(mapped.type);
if (!byDay) {
byDay = new Map();
cumulativeBucket.set(mapped.type, byDay);
}
let bySource = byDay.get(dayKey);
if (!bySource) {
bySource = new Map();
byDay.set(dayKey, bySource);
}
bySource.set(
sourceHash,
(bySource.get(sourceHash) ?? 0) + mapped.value,
);
};
if (marked && healthLogId) {
markedCumulative.push({
type: mapped.type,
healthLogId,
sampleKey: hashSampleKey(
hkType,
attrs.value ?? "",
attrs.startDate,
attrs.endDate,
),
add,
});
} else {
add();
}
let bySource = byDay.get(dayKey);
if (!bySource) {
bySource = new Map();
byDay.set(dayKey, bySource);
}
bySource.set(
sourceHash,
(bySource.get(sourceHash) ?? 0) + mapped.value,
);
} else {
// Spot row: derive a stable externalId, queue for flush.
const externalId = hashSampleKey(
@@ -1163,6 +1285,7 @@ export async function streamParseExportXml(
};
spotBatch.push(row);
if (healthLogId) healthLogIdOf.set(row, healthLogId);
if (marked) markedRows.add(row);
}
},
};
@@ -1244,7 +1367,7 @@ export async function streamParseExportXml(
}
if (name === "Correlation") {
currentCorrelation = { ownOrigin: false, held: [] };
currentCorrelation = { ownOrigin: false, healthLogId: null, held: [] };
return;
}
@@ -1255,8 +1378,12 @@ export async function streamParseExportXml(
else if (currentCycleRecord) currentCycleRecord.ownOrigin = true;
else if (currentCorrelation) currentCorrelation.ownOrigin = true;
}
if (pendingRecord && attrs.key === HK_EXTERNAL_UUID_METADATA_KEY) {
pendingRecord.healthLogId = attrs.value || null;
if (attrs.key === HK_EXTERNAL_UUID_METADATA_KEY) {
// On a record, or on the correlation that wraps it.
if (pendingRecord) pendingRecord.healthLogId = attrs.value || null;
else if (currentCorrelation && !currentCycleRecord) {
currentCorrelation.healthLogId = attrs.value || null;
}
}
// Attach the SexualActivity protection flag to the open cycle record.
// Apple writes `HKMetadataKeySexualActivityProtectionUsed` with a
@@ -1306,18 +1433,16 @@ export async function streamParseExportXml(
const rec = pendingRecord;
pendingRecord = null;
if (currentCorrelation) currentCorrelation.held.push(rec);
else if (rec.ownOrigin) countOwn("byMarker", rec.sampleKey());
else rec.commit(rec.healthLogId);
else rec.commit(rec.healthLogId, rec.ownOrigin);
}
if (tagName === "Correlation" && currentCorrelation) {
const correlation = currentCorrelation;
currentCorrelation = null;
for (const rec of correlation.held) {
if (correlation.ownOrigin || rec.ownOrigin) {
countOwn("byMarker", rec.sampleKey());
} else {
rec.commit(rec.healthLogId);
}
rec.commit(
rec.healthLogId ?? correlation.healthLogId,
correlation.ownOrigin || rec.ownOrigin,
);
}
}
if (tagName === "Workout" && currentWorkout) {
@@ -1328,22 +1453,12 @@ export async function streamParseExportXml(
const rec = currentCycleRecord;
currentCycleRecord = null;
if (rec.ownOrigin) {
// A day HealthLog mirrored into Apple Health from its own day-log.
countOwn("byMarker", rec.sampleKey);
// A day HealthLog mirrored into Apple Health from a day-log; decided
// at the end of the parse against this account's day-logs.
markedCycle.push(rec);
return;
}
const consumed = cycleAccumulator.consume(
rec.hkType,
rec.dayKey,
rec.rawValue,
rec.protectionUsed,
);
if (!consumed) {
// Recognised identifier but unrecognised value — count it under
// `unknown` with the reason tag so operators can spot it.
unknown[`${rec.hkType}::cycle_unmapped`] =
(unknown[`${rec.hkType}::cycle_unmapped`] ?? 0) + 1;
}
consumeCycle(rec);
}
};
@@ -1421,7 +1536,9 @@ export async function streamParseExportXml(
// threshold during the parse.
await flushSpotBatch();
await flushWorkoutBatch();
await settleMarkedCumulative();
await flushCumulativeBuckets();
await settleMarkedCycle();
// v1.15.0 — fold the accumulated reproductive samples into CYCLE
// day-logs. Gated on cycle-tracking being enabled for the account so a
@@ -125,6 +125,28 @@ describe("refetchMedicationsOnMount", () => {
unsubscribe();
});
it("refetches a prefetched read that was invalidated before the mount", async () => {
// Hover the nav link, then log a dose elsewhere before the page mounts:
// the dose invalidates the medication reads, and the prefetched answer
// no longer holds the compliance the user just changed.
const { client, calls } = setup();
prefetchMedicationsList(client);
await vi.waitFor(() => expect(calls("/api/medications")).toBe(1));
await vi.waitFor(() =>
expect(client.getQueryState(queryKeys.medications())?.status).toBe(
"success",
),
);
await client.invalidateQueries({ queryKey: queryKeys.medications() });
expect(client.getQueryState(queryKeys.medications())?.isInvalidated).toBe(
true,
);
const { refetched, unsubscribe } = await mountList(client);
expect(refetched).toBe(true);
unsubscribe();
});
it("still refetches on mount when the cache holds an older read", async () => {
const { client } = setup();
const queryFn = vi.fn(async () => []);
+8 -1
View File
@@ -93,11 +93,18 @@ export function prefetchMedicationsList(
* the identical request a second time, a few hundred milliseconds after the
* first answer landed, on every visit to the page. A return to the page with
* an older cache entry, or a direct load, still refetches on mount.
*
* An invalidated entry always refetches: a dose logged elsewhere in the app
* between the prefetch and the mount invalidates these reads, and the
* prefetched answer is then the stale one.
*/
export function refetchMedicationsOnMount(
read: MedicationsRead,
): (query: { state: { dataUpdatedAt: number } }) => boolean | "always" {
): (query: {
state: { dataUpdatedAt: number; isInvalidated: boolean };
}) => boolean | "always" {
return (query) => {
if (query.state.isInvalidated) return "always";
const startedAt = prefetchStartedAt.get(read);
const answeredThisVisit =
startedAt !== undefined &&
+1 -1
View File
@@ -597,7 +597,7 @@ async function runRollupAggregate(
* every reading, so the band and the windowed slope are untouched.
*
* Hours and days are UTC, matching the UTC day this tier buckets by; migration
* 0373 applies the same statistic to rows stored before it.
* 0372 applies the same statistic to rows stored before it.
*/
async function applyHourlyMeanDay(
db: Pick<typeof prisma, "$queryRawUnsafe">,
@@ -9,9 +9,10 @@
verbatim; that has to be checked against a real export (see #1109).
Records, in order:
1. weight 80.1 carries the HealthLog origin marker -> left out
1. weight 80.1 origin marker and a HealthLog row id -> left out
when the importing account holds that row
2. weight 80.2 user-entered, no marker -> imported
3. weight 80.3 marker among other metadata entries -> left out
3. weight 80.3 marker among other metadata entries, no id -> imported
4. weight 80.4 no metadata at all -> imported
5. weight 80.5 origin key with another value -> imported
-->
@@ -9,7 +9,12 @@
* twice and the rollups count it twice.
*
* Asserted against a real Postgres:
* - a record carrying the marker is left out, and counted;
* - a record carrying the marker and a row id of this account is left out,
* and counted; the marker alone proves nothing about this account (a move
* to a new instance without a backup), so a marked record whose id is
* missing, unknown or another account's is imported, a deleted row of this
* account still counts, and a marked cycle day counts only when the
* account holds a day-log on it;
* - a record without it lands as before;
* - the marker is read from a child `<MetadataEntry>` that follows the
* record's open tag, so the record is committed at its close tag;
@@ -56,13 +61,46 @@ ${records}
const weight = (value: number, end: string, children = "") =>
` <Record type="HKQuantityTypeIdentifierBodyMass" sourceName="Health" unit="kg" startDate="${end}" endDate="${end}" value="${value}">${children}</Record>`;
const OWN = `<MetadataEntry key="dev.healthlog.app.origin" value="healthlog"/>`;
const MARKER = `<MetadataEntry key="dev.healthlog.app.origin" value="healthlog"/>`;
const ext = (id: string) =>
`<MetadataEntry key="HKExternalUUID" value="${id}"/>`;
/** The row the app mirrored: a weight of this account, its id on the sample. */
const OWN_WEIGHT_ID = "own-weight-row";
const OWN_SYS_ID = "own-sys-row";
const OWN = `${MARKER}${ext(OWN_WEIGHT_ID)}`;
const OWN_BP = `${MARKER}${ext(OWN_SYS_ID)}`;
const OTHER = `<MetadataEntry key="HKWasUserEntered" value="1"/>`;
/**
* A row of this account the samples point at. Withings and ten minutes off the
* samples, so neither the series under test nor the manual-value match sees it.
*/
async function seedOwnRow(
userId: string,
id: string,
type: "WEIGHT" | "BLOOD_PRESSURE_SYS" | "ACTIVITY_STEPS",
deletedAt: Date | null = null,
) {
await prisma.measurement.create({
data: {
id,
userId,
type,
value: type === "ACTIVITY_STEPS" ? 1000 : 70,
unit:
type === "WEIGHT" ? "kg" : type === "ACTIVITY_STEPS" ? "steps" : "mmHg",
source: "WITHINGS",
measuredAt: new Date("2026-05-14T06:10:00.000Z"),
deletedAt,
},
});
}
async function run(records: string) {
const user = await prisma.user.create({
data: { username: "own-samples", email: "own@example.test", role: "USER" },
});
await seedOwnRow(user.id, OWN_WEIGHT_ID, "WEIGHT");
const result = await streamParseExportXml({
xmlPath: writeXml(records),
userId: user.id,
@@ -139,6 +177,7 @@ describe("blood pressure correlations and self-closing records", () => {
const user = await prisma.user.create({
data: { username: "own-bp", email: "bp@example.test", role: "USER" },
});
await seedOwnRow(user.id, OWN_SYS_ID, "BLOOD_PRESSURE_SYS");
const result = await streamParseExportXml({
xmlPath: writeXml(records),
userId: user.id,
@@ -148,6 +187,7 @@ describe("blood pressure correlations and self-closing records", () => {
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
source: "APPLE_HEALTH",
type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] },
},
orderBy: [{ type: "asc" }],
@@ -173,7 +213,7 @@ describe("blood pressure correlations and self-closing records", () => {
[bpRecord("Systolic", 121, AT), bpRecord("Diastolic", 79, AT)].join(
"\n",
),
`\n ${OWN}`,
`\n ${OWN_BP}`,
),
);
expect(values).toEqual([]);
@@ -186,7 +226,7 @@ describe("blood pressure correlations and self-closing records", () => {
bpRecord("Diastolic", 79, AT),
].join("\n");
const { values } = await runBp(
` <Correlation type="HKCorrelationTypeIdentifierBloodPressure" sourceName="Health" startDate="${AT}" endDate="${AT}">\n${records}\n ${OWN}\n </Correlation>`,
` <Correlation type="HKCorrelationTypeIdentifierBloodPressure" sourceName="Health" startDate="${AT}" endDate="${AT}">\n${records}\n ${OWN_BP}\n </Correlation>`,
);
expect(values).toEqual([]);
});
@@ -195,7 +235,7 @@ describe("blood pressure correlations and self-closing records", () => {
const { values } = await runBp(
correlation(
[
bpRecord("Systolic", 121, AT, `\n ${OWN}\n `),
bpRecord("Systolic", 121, AT, `\n ${OWN_BP}\n `),
bpRecord("Diastolic", 79, AT),
].join("\n"),
),
@@ -210,7 +250,7 @@ describe("blood pressure correlations and self-closing records", () => {
[bpRecord("Systolic", 121, AT), bpRecord("Diastolic", 79, AT)].join(
"\n",
),
`\n ${OWN}`,
`\n ${OWN_BP}`,
),
bpRecord("Systolic", 130, "2026-05-14 09:00:00 +0200"),
].join("\n"),
@@ -230,10 +270,11 @@ describe("the synthetic export fixture", () => {
// `export-own-origin.synthetic.xml` is invented, in the shape Apple's DTD
// documents. It shows the parser reads a file laid out like an export; it
// cannot show that Apple writes a custom metadata key verbatim.
it("leaves out the two marked records and imports the other three", async () => {
it("leaves out the marked record this account holds and imports the other four", async () => {
const user = await prisma.user.create({
data: { username: "own-fixture", email: "fx@example.test", role: "USER" },
});
await seedOwnRow(user.id, "00000000-0000-0000-0000-000000000001", "WEIGHT");
const result = await streamParseExportXml({
xmlPath: join(
process.cwd(),
@@ -247,9 +288,9 @@ describe("the synthetic export fixture", () => {
where: { userId: user.id, type: "WEIGHT", source: "APPLE_HEALTH" },
orderBy: { measuredAt: "asc" },
});
expect(rows.map((r) => r.value)).toEqual([80.2, 80.4, 80.5]);
expect(rows.map((r) => r.value)).toEqual([80.2, 80.3, 80.4, 80.5]);
expect(result.writtenByHealthLog).toEqual({
byMarker: 2,
byMarker: 1,
byExternalId: 0,
matchedManual: 0,
});
@@ -421,8 +462,6 @@ describe("the manual-mirror lookup stays off the hot path", () => {
describe("a sample whose HKExternalUUID is a HealthLog row id", () => {
const AT = "2026-05-14 08:00:00 +0200";
const ext = (id: string) =>
`<MetadataEntry key="HKExternalUUID" value="${id}"/>`;
async function runWith(
seed: (userId: string) => Promise<Record<string, string>>,
@@ -524,11 +563,12 @@ describe("a sample whose HKExternalUUID is a HealthLog row id", () => {
describe("each sample left out is counted once", () => {
it("does not count a marked record twice when it appears top level and inside its correlation", async () => {
const AT = "2026-05-14 08:00:00 +0200";
const sys = bpRecord("Systolic", 121, AT, `\n ${OWN}\n `);
const dia = bpRecord("Diastolic", 79, AT, `\n ${OWN}\n `);
const sys = bpRecord("Systolic", 121, AT, `\n ${OWN_BP}\n `);
const dia = bpRecord("Diastolic", 79, AT, `\n ${OWN_BP}\n `);
const user = await prisma.user.create({
data: { username: "own-once", email: "once@example.test", role: "USER" },
});
await seedOwnRow(user.id, OWN_SYS_ID, "BLOOD_PRESSURE_SYS");
const result = await streamParseExportXml({
xmlPath: writeXml(
[sys, dia, correlation([sys, dia].join("\n"))].join("\n"),
@@ -542,7 +582,7 @@ describe("each sample left out is counted once", () => {
});
describe("a cycle sample HealthLog wrote itself", () => {
it("is left out of the day-log import", async () => {
it("is left out on a day this account holds, imported on a day it does not", async () => {
const user = await prisma.user.create({
data: {
username: "own-cycle",
@@ -552,11 +592,20 @@ describe("a cycle sample HealthLog wrote itself", () => {
timezone: "Europe/Berlin",
},
});
// The day the app mirrored from: a day-log of this account.
await prisma.cycleDayLog.create({
data: { userId: user.id, date: "2026-03-02", flow: "MEDIUM" },
});
const flow = (day: string, children = "") =>
` <Record type="HKCategoryTypeIdentifierMenstrualFlow" sourceName="Health" value="HKCategoryValueMenstrualFlowMedium" startDate="${day} 08:00:00 +0000" endDate="${day} 08:00:00 +0000">${children}</Record>`;
const result = await streamParseExportXml({
xmlPath: writeXml(
[flow("2026-03-02", OWN), flow("2026-03-03")].join("\n"),
[
flow("2026-03-02", MARKER),
flow("2026-03-03"),
// Marked, but no day-log here: a move to a new instance.
flow("2026-03-04", MARKER),
].join("\n"),
),
userId: user.id,
userTimezone: "Europe/Berlin",
@@ -566,7 +615,119 @@ describe("a cycle sample HealthLog wrote itself", () => {
where: { userId: user.id },
orderBy: { date: "asc" },
});
expect(days.map((d) => d.date)).toEqual(["2026-03-03"]);
expect(days.map((d) => d.date)).toEqual([
"2026-03-02",
"2026-03-03",
"2026-03-04",
]);
// The held day stays the account's own: the marked sample did not
// re-import it as an Apple Health day.
expect(days[0].source).toBe("MANUAL");
expect(result.writtenByHealthLog.byMarker).toBe(1);
});
});
describe("a marked record this account does not hold", () => {
// A move to a new instance without a backup: the export still carries the
// marker on every value the app mirrored, but none of the rows exist here.
it("is imported when the id is missing, unknown or another account's", async () => {
const other = await prisma.user.create({
data: { username: "own-move-2", email: "mv2@example.test", role: "USER" },
});
await seedOwnRow(other.id, "foreign-weight-row", "WEIGHT");
const user = await prisma.user.create({
data: { username: "own-move", email: "mv@example.test", role: "USER" },
});
const result = await streamParseExportXml({
xmlPath: writeXml(
[
weight(80.1, "2026-05-14 08:00:00 +0200", MARKER),
weight(
80.2,
"2026-05-14 09:00:00 +0200",
`${MARKER}${ext("00000000-0000-4000-8000-000000000009")}`,
),
weight(
80.3,
"2026-05-14 10:00:00 +0200",
`${MARKER}${ext("foreign-weight-row")}`,
),
].join("\n"),
),
userId: user.id,
userTimezone: "Europe/Berlin",
prisma,
});
const rows = await prisma.measurement.findMany({
where: { userId: user.id, type: "WEIGHT", source: "APPLE_HEALTH" },
orderBy: { measuredAt: "asc" },
});
expect(rows.map((r) => r.value)).toEqual([80.1, 80.2, 80.3]);
expect(result.writtenByHealthLog).toEqual({
byMarker: 0,
byExternalId: 0,
matchedManual: 0,
});
});
it("is still left out when its id names a deleted row of this account", async () => {
const user = await prisma.user.create({
data: {
username: "own-deleted",
email: "del@example.test",
role: "USER",
},
});
await seedOwnRow(user.id, OWN_WEIGHT_ID, "WEIGHT", new Date());
const result = await streamParseExportXml({
xmlPath: writeXml(weight(80.1, "2026-05-14 08:00:00 +0200", OWN)),
userId: user.id,
userTimezone: "Europe/Berlin",
prisma,
});
const rows = await prisma.measurement.findMany({
where: { userId: user.id, type: "WEIGHT", source: "APPLE_HEALTH" },
});
expect(rows).toHaveLength(0);
expect(result.writtenByHealthLog.byMarker).toBe(1);
});
it("adds a marked cumulative sample to its day unless its id is this account's", async () => {
const user = await prisma.user.create({
data: { username: "own-steps", email: "st@example.test", role: "USER" },
});
await seedOwnRow(user.id, "own-steps-row", "ACTIVITY_STEPS");
const steps = (value: number, at: string, children: string) =>
` <Record type="HKQuantityTypeIdentifierStepCount" sourceName="Health" unit="count" startDate="${at}" endDate="${at}" value="${value}">${children}</Record>`;
const result = await streamParseExportXml({
xmlPath: writeXml(
[
steps(
700,
"2026-05-14 08:00:00 +0200",
`${MARKER}${ext("own-steps-row")}`,
),
steps(
300,
"2026-05-14 09:00:00 +0200",
`${MARKER}${ext("not-a-row")}`,
),
steps(200, "2026-05-14 10:00:00 +0200", ""),
].join("\n"),
),
userId: user.id,
userTimezone: "Europe/Berlin",
prisma,
});
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
type: "ACTIVITY_STEPS",
source: "APPLE_HEALTH",
},
});
// 300 + 200: the 700 the app mirrored from this account's row stays out.
expect(rows.map((r) => r.value)).toEqual([500]);
expect(result.writtenByHealthLog.byMarker).toBe(1);
});
});
@@ -118,6 +118,11 @@ describe("weekly copy preview under a memory budget", () => {
});
it("holds nothing of the record once the producer is done", async () => {
// The backup schema the preview checks each section against is a module
// loaded once per process (about 12 MB, `backup-summary.ts`), not memory
// that grows with the record: load it before the baseline, as a running
// server has long since done.
await import("@/lib/validations/backup");
const baseline = liveHeapBytes();
let peakHeld = 0;
let heldAtEnd = 0;
@@ -127,7 +132,7 @@ describe("weekly copy preview under a memory budget", () => {
rawPeak = Math.max(rawPeak, process.memoryUsage().heapUsed);
}, 5);
// Wired exactly as the weekly job wires it (`backup-handlers.ts`).
const preview = createBackupPreviewCollector();
const preview = await createBackupPreviewCollector();
let counts: FullBackupCounts | undefined;
try {
await storeBackupBlob(
+2 -2
View File
@@ -1495,7 +1495,7 @@ describe("every model the plan claims two-ended survives a real restore", () =>
// below then covers BOTH, since the row that gets restored comes from the
// streamed bytes.
let streamedJson = "";
const previewCollector = createBackupPreviewCollector();
const previewCollector = await createBackupPreviewCollector();
const streamedCounts = await streamFullBackupJson(
prisma,
OWNER_ID,
@@ -3779,7 +3779,7 @@ describe("every column of every two-ended model survives a real restore", () =>
const exportedAt = new Date("2026-08-01T00:00:00.000Z");
let json = "";
const previewCollector = createBackupPreviewCollector();
const previewCollector = await createBackupPreviewCollector();
const counts = await streamFullBackupJson(
prisma,
OWNER_ID,
@@ -0,0 +1,232 @@
/**
* The shared pulse day weight (`dayWeightedRows`) must not depend on planner
* statistics. A new account importing a year of pulse lands below the
* autoanalyze threshold, so the first reads run against a table the planner
* believes is empty. The earlier shape joined the CTE to an hour grouping of
* itself; on such a table the planner chose a nested loop and the all-time
* pulse aggregate ran into the 60 s production statement timeout.
*
* This file seeds a year of dense pulse (well over 100k rows) with autovacuum
* off for the table and no ANALYZE, then:
*
* - times the real all-time reader (`readAllTimeExtremes`) and a bare
* day-weighted mean, each well under the timeout;
* - after ANALYZE, compares the weights per day, per frame and per source
* against the earlier self-join SQL kept below as the oracle.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import type { Prisma } from "@/generated/prisma/client";
import { readAllTimeExtremes } from "@/lib/insights/feature-blocks";
import {
SESSION_DAY_FRAME,
dayWeightedRows,
zoneDayFrame,
type DayFrame,
} from "@/lib/measurements/day-mean";
const prisma = getPrismaClient();
const USER = "day-weight-scale";
const DAY = 86_400_000;
const MINUTE = 60_000;
const DAYS = 365;
/** Generous against a 60 s production timeout, far above what one scan takes. */
const BOUND_MS = 15_000;
const start =
Date.UTC(
new Date().getUTCFullYear(),
new Date().getUTCMonth(),
new Date().getUTCDate(),
) -
DAYS * DAY;
/**
* About 330 readings a day, unevenly spread so hours carry different counts,
* plus a second source on every fifth day so the per-source weights differ.
*/
function seedRows(): Prisma.MeasurementCreateManyInput[] {
const rows: Prisma.MeasurementCreateManyInput[] = [];
let i = 0;
for (let d = 0; d < DAYS; d += 1) {
for (let m = 0; m < 24 * 60; m += 4) {
if ((m + d) % 13 === 0) continue;
const hour = Math.floor(m / 60);
// Night hours sparse, day hours dense.
if (hour < 6 && m % 12 !== 0) continue;
rows.push({
id: `${USER}-a-${i}`,
userId: USER,
type: "PULSE",
unit: "bpm",
source: "APPLE_HEALTH",
value: 55 + ((i * 7 + d) % 50),
measuredAt: new Date(start + d * DAY + m * MINUTE + (i % 3) * 1000),
});
i += 1;
}
if (d % 5 === 0) {
for (const h of [7, 7, 13, 22]) {
rows.push({
id: `${USER}-w-${i}`,
userId: USER,
type: "PULSE",
unit: "bpm",
source: "WITHINGS",
value: 60 + (i % 20),
measuredAt: new Date(
start + d * DAY + h * 3_600_000 + (i % 50) * MINUTE,
),
});
i += 1;
}
}
}
return rows;
}
/** The earlier self-join, kept verbatim as the oracle for the weights. */
function oracleWeightedRows(
source: string,
frame: DayFrame,
bySource: boolean,
): string {
const ts = (column: string) =>
frame.kind === "session"
? column
: `((${column} AT TIME ZONE 'UTC') AT TIME ZONE ${frame.tzSql})`;
const hourOf = (alias: string) =>
`date_trunc('hour', ${ts(`${alias}."measured_at"`)})`;
const dayOf = (alias: string) =>
`date_trunc('day', ${ts(`${alias}."measured_at"`)})`;
const src = bySource ? `, t."source"` : "";
const part = bySource ? `, x."source"` : "";
const join = bySource ? ` AND hw."source" = s."source"` : "";
return `(
SELECT s.*,
(CASE WHEN (s."type")::text IN ('PULSE')
THEN 1.0 / (hw.n * hw.hours)
ELSE 1.0 END)::double precision AS day_weight
FROM ${source} s
LEFT JOIN (
SELECT x."type"${part}, x.local_hour, x.n,
COUNT(*) OVER (PARTITION BY x."type"${part}, x.local_day) AS hours
FROM (
SELECT t."type"${src},
${hourOf("t")} AS local_hour,
${dayOf("t")} AS local_day,
COUNT(*) AS n
FROM ${source} t
WHERE (t."type")::text IN ('PULSE')
GROUP BY ${bySource ? "1, 2, 3, 4" : "1, 2, 3"}
) x
) hw
ON hw."type" = s."type"${join}
AND hw.local_hour = ${hourOf("s")}
)`;
}
const SRC_CTE = `WITH src AS (
SELECT m."id", m."type", m."source", m."measured_at", m."value"
FROM measurements m
WHERE m."user_id" = $1 AND m."deleted_at" IS NULL AND m."type" = 'PULSE'
)`;
async function timed<T>(fn: () => Promise<T>): Promise<[T, number]> {
const t0 = performance.now();
const out = await fn();
return [out, performance.now() - t0];
}
let rowCount = 0;
beforeAll(async () => {
await truncateAllTables(prisma);
await prisma.$executeRawUnsafe(
"ALTER TABLE measurements SET (autovacuum_enabled = false)",
);
await prisma.user.create({ data: { id: USER, username: USER } });
const rows = seedRows();
rowCount = rows.length;
for (let k = 0; k < rows.length; k += 20_000) {
await prisma.measurement.createMany({ data: rows.slice(k, k + 20_000) });
}
}, 180_000);
afterAll(async () => {
await prisma.$executeRawUnsafe(
"ALTER TABLE measurements RESET (autovacuum_enabled)",
);
});
describe("day weight on an unanalysed table", () => {
it("seeds a year of dense pulse", () => {
expect(rowCount).toBeGreaterThan(100_000);
});
it("answers the all-time pulse aggregate well under the timeout", async () => {
const [out, ms] = await timed(() => readAllTimeExtremes(USER, ["PULSE"]));
console.info(`[day-weight] readAllTimeExtremes ${ms.toFixed(0)} ms`);
expect(out.get("PULSE")?.mean).toBeGreaterThan(55);
expect(ms).toBeLessThan(BOUND_MS);
});
it("answers a bare day-weighted mean well under the timeout", async () => {
const [rows, ms] = await timed(() =>
prisma.$queryRawUnsafe<Array<{ mean: number }>>(
`${SRC_CTE}
SELECT (SUM(w."value" * w.day_weight) / SUM(w.day_weight))::double precision AS mean
FROM ${dayWeightedRows("src", zoneDayFrame("$2"), { bySource: true })} w`,
USER,
"Europe/Berlin",
),
);
console.info(`[day-weight] zone + bySource mean ${ms.toFixed(0)} ms`);
expect(rows[0].mean).toBeGreaterThan(55);
expect(ms).toBeLessThan(BOUND_MS);
});
});
describe("day weight against the earlier self-join", () => {
beforeAll(async () => {
// Statistics now, so the oracle plans the way it did in production.
await prisma.$executeRawUnsafe("ANALYZE measurements");
}, 120_000);
const cases: Array<[string, DayFrame, boolean, unknown[]]> = [
["session frame", SESSION_DAY_FRAME, false, []],
["session frame by source", SESSION_DAY_FRAME, true, []],
["Berlin frame", zoneDayFrame("$2"), false, ["Europe/Berlin"]],
["Kolkata frame by source", zoneDayFrame("$2"), true, ["Asia/Kolkata"]],
];
it.each(cases)(
"%s gives every row the same weight",
async (_label, frame, bySource, extra) => {
const query = (sub: string) =>
prisma.$queryRawUnsafe<Array<{ id: string; w: number }>>(
`${SRC_CTE}
SELECT w."id" AS id, w.day_weight AS w
FROM ${sub} w
ORDER BY w."id"`,
USER,
...extra,
);
const fresh = await query(dayWeightedRows("src", frame, { bySource }));
const oracle = await query(oracleWeightedRows("src", frame, bySource));
expect(fresh.length).toBe(rowCount);
expect(fresh.length).toBe(oracle.length);
let mismatches = 0;
for (let k = 0; k < fresh.length; k += 1) {
if (
fresh[k].id !== oracle[k].id ||
Math.abs(fresh[k].w - oracle[k].w) > 1e-12
) {
mismatches += 1;
}
}
expect(mismatches).toBe(0);
},
);
});
@@ -218,13 +218,10 @@ beforeAll(async () => {
// The mood feature warms its rollup tier fire-and-forget on first read;
// settle it now so both builds read the same tier.
await ensureUserMoodRollupsFresh(userId);
// Planner statistics for the rows just written. Without them the
// all-time pulse aggregate (`readAllTimeExtremes`, day-weighted for pulse)
// plans against an empty table and runs for ~14 s per call until
// autovacuum's analyze happens to land, which on a loaded runner took the
// first case past its 60 s budget. A production table always has
// statistics, so this measures what production runs.
await prisma.$executeRawUnsafe("ANALYZE measurements, measurement_rollups");
// No ANALYZE here, on purpose: a fresh import below the autoanalyze
// threshold is what production runs too, and the all-time pulse aggregate
// must not depend on planner statistics
// (`day-weight-unanalysed-scale.test.ts`).
}, 120_000);
describe("get_metric_series built from one source's rows", () => {