diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9048aa07d..152d56968 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 `
`; 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;
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index bb71a2e8e..54c7d32f5 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -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.
diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md
index 88b88f0eb..c55033afd 100644
--- a/docs/self-hosting/getting-started.md
+++ b/docs/self-hosting/getting-started.md
@@ -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
diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md
index 8bde98d1b..6776d44a3 100644
--- a/docs/self-hosting/scaling.md
+++ b/docs/self-hosting/scaling.md
@@ -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
diff --git a/e2e/ai-optional-account.spec.ts b/e2e/ai-optional-account.spec.ts
index 3e7f138fe..6a27a80cd 100644
--- a/e2e/ai-optional-account.spec.ts
+++ b/e2e/ai-optional-account.spec.ts
@@ -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 {
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");
diff --git a/e2e/ai-optional-overview.spec.ts b/e2e/ai-optional-overview.spec.ts
index dac4533bd..ba0158dd0 100644
--- a/e2e/ai-optional-overview.spec.ts
+++ b/e2e/ai-optional-overview.spec.ts
@@ -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 {
+ 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([]);
diff --git a/e2e/utils/deferred-sections.ts b/e2e/utils/deferred-sections.ts
index 0ca4748ea..247ab9d80 100644
--- a/e2e/utils/deferred-sections.ts
+++ b/e2e/utils/deferred-sections.ts
@@ -36,3 +36,40 @@ export async function revealDeferredSections(page: Page): Promise {
.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 {
+ 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);
+ }
+}
diff --git a/package.json b/package.json
index 3655fa286..32ef4824a 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/public/sw.js b/public/sw.js
index 32351a83e..0c7bb5af9 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -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/*`
diff --git a/src/lib/apple-health/own-origin.ts b/src/lib/apple-health/own-origin.ts
index 34f6f2f8c..1a60a05aa 100644
--- a/src/lib/apple-health/own-origin.ts
+++ b/src/lib/apple-health/own-origin.ts
@@ -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
diff --git a/src/lib/export/__tests__/backup-preview.test.ts b/src/lib/export/__tests__/backup-preview.test.ts
index 8016c6c4c..b82ac0b6f 100644
--- a/src/lib/export/__tests__/backup-preview.test.ts
+++ b/src/lib/export/__tests__/backup-preview.test.ts
@@ -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) {
+ 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 = () =>
diff --git a/src/lib/export/backup-preview.ts b/src/lib/export/backup-preview.ts
index 91eebab21..446998d36 100644
--- a/src/lib/export/backup-preview.ts
+++ b/src/lib/export/backup-preview.ts
@@ -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;
+ 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 {
+ 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 };
diff --git a/src/lib/jobs/__tests__/backup-pass-attempts.test.ts b/src/lib/jobs/__tests__/backup-pass-attempts.test.ts
new file mode 100644
index 000000000..722fec5ff
--- /dev/null
+++ b/src/lib/jobs/__tests__/backup-pass-attempts.test.ts
@@ -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;
+ runs: number;
+ interruptedFirst?: string;
+}): string[][] {
+ let clock = 0;
+ const attempts = new Map();
+ const copiedAt = new Map();
+ 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));
+ });
+});
diff --git a/src/lib/jobs/__tests__/restore-drill.test.ts b/src/lib/jobs/__tests__/restore-drill.test.ts
index d223c5a45..81eb035ef 100644
--- a/src/lib/jobs/__tests__/restore-drill.test.ts
+++ b/src/lib/jobs/__tests__/restore-drill.test.ts
@@ -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(),
diff --git a/src/lib/jobs/backup-pass-attempts.ts b/src/lib/jobs/backup-pass-attempts.ts
index 05c0d919b..389252334 100644
--- a/src/lib/jobs/backup-pass-attempts.ts
+++ b/src/lib/jobs/backup-pass-attempts.ts
@@ -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 {
+ const out = new Map();
+ 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