Merge branch 'fix/large-account-jobs' into release/v1.39.1

This commit is contained in:
Marc-André Bombeck committed 2026-09-25 06:49:16 +02:00
commit d81518b5bf
86 files changed
+5867 -898

No files matched your search

@@ -0,0 +1,116 @@
/**
* #1031 — the rollup fold after an import covers what the import wrote.
*
* The worker folded the rollup table from the account's first measurement to
* its last, whatever the import carried, and so also folded history older
* than the five-year window every other fold path keeps to. It now folds the
* span of the rows this import wrote, clamped to that window.
*/
import { createHash } from "node:crypto";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { strToU8, zipSync } from "fflate";
import type { Job } from "pg-boss";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import {
APPLE_HEALTH_IMPORT_PARSER_REVISION,
handleAppleHealthImport,
_setWorkerPrismaForTests,
type AppleHealthImportPayload,
} from "@/lib/jobs/apple-health-import-worker";
import { ROLLUP_FOLD_WINDOW_MS } from "@/lib/rollups/measurement-rollups";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: vi.fn(() => null),
}));
const USER = "user-import-rollup-window";
function record(at: Date, value: number): string {
const stamp = at.toISOString().replace("T", " ").slice(0, 19) + " +0000";
return `<Record type="HKQuantityTypeIdentifierBodyMass" unit="kg" startDate="${stamp}" endDate="${stamp}" value="${value}" sourceName="Scale"/>`;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
_setWorkerPrismaForTests(getPrismaClient());
await getPrismaClient().user.create({
data: {
id: USER,
username: USER,
email: `${USER}@example.test`,
timezone: "UTC",
},
});
});
describe("import rollup fold window", () => {
it("folds the imported span, never past the fold window, and leaves older history alone", async () => {
const prisma = getPrismaClient();
const now = Date.now();
const day = 86_400_000;
// Already in the account before the import: a reading eight years old.
await prisma.measurement.create({
data: {
userId: USER,
type: "WEIGHT",
value: 90,
unit: "kg",
source: "MANUAL",
measuredAt: new Date(now - 8 * 365 * day),
},
});
// The import: one reading seven years old, a few recent ones.
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<HealthData locale="en_US">
${record(new Date(now - 7 * 365 * day), 88)}
${record(new Date(now - 40 * day), 84)}
${record(new Date(now - 30 * day), 83)}
${record(new Date(now - 20 * day), 82)}
</HealthData>`;
const dir = mkdtempSync(join(tmpdir(), "hl-import-window-"));
const path = join(dir, "export.zip");
const bytes = Buffer.from(
zipSync({ "apple_health_export/export.xml": strToU8(xml) }),
);
writeFileSync(path, bytes);
await prisma.importJob.create({
data: {
userId: USER,
pgBossJobId: "window-job",
status: "queued",
uploadBytes: bytes.length,
uploadSha256: createHash("sha256").update(bytes).digest("hex"),
parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
},
});
const outcome = await handleAppleHealthImport({
id: "window-job",
data: {
userId: USER,
uploadPath: path,
uploadBytes: bytes.length,
enqueuedAt: new Date().toISOString(),
},
} as unknown as Job<AppleHealthImportPayload>);
expect(outcome.ok).toBe(true);
const buckets = await prisma.measurementRollup.findMany({
where: { userId: USER, type: "WEIGHT", granularity: "DAY" },
orderBy: { bucketStart: "asc" },
});
const windowStart = now - ROLLUP_FOLD_WINDOW_MS;
expect(buckets.length).toBeGreaterThanOrEqual(3);
for (const bucket of buckets) {
expect(bucket.bucketStart.getTime()).toBeGreaterThan(windowStart - day);
}
});
});
@@ -0,0 +1,94 @@
/**
* #1031 — the cumulative-record repair must not lose records when the
* re-derivation fails.
*
* The repair deleted every suspect cumulative record, committed, and only
* then re-ran the detector. A detector that failed (a timeout on a large
* account, a crash) left the records deleted, and because discovery looks
* for the suspect rows the deletion removed, the account was never
* repaired again: its step, energy and distance records were simply gone.
* The delete and the re-derivation now commit together or not at all.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
const detect = vi.hoisted(() => ({ fail: false }));
vi.mock("@/lib/personal-records/pr-detection-worker", async (importActual) => {
const actual =
await importActual<
typeof import("@/lib/personal-records/pr-detection-worker")
>();
return {
...actual,
detectPersonalRecordsForUser: async (
...args: Parameters<typeof actual.detectPersonalRecordsForUser>
) => {
if (detect.fail) throw new Error("detector ran out of time");
return actual.detectPersonalRecordsForUser(...args);
},
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const { runCumulativePrRederivationForUser, CUMULATIVE_PR_FIX_CUTOFF } =
await import("@/lib/personal-records/cumulative-pr-rederivation");
const USER = "user-pr-rederive-atomic";
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
detect.fail = false;
await prisma.user.create({
data: { id: USER, username: USER, email: `${USER}@example.test` },
});
const before = new Date(CUMULATIVE_PR_FIX_CUTOFF.getTime() - 86_400_000);
await prisma.personalRecord.create({
data: {
userId: USER,
metricType: "ACTIVITY_STEPS",
direction: "MAX",
value: 99_999,
unit: "steps",
achievedAt: new Date("2025-03-01T12:00:00Z"),
createdAt: before,
},
});
// Enough steps for the detector's warm-up gate, one clear best day.
await prisma.measurement.createMany({
data: Array.from({ length: 10 }, (_, day) => ({
userId: USER,
type: "ACTIVITY_STEPS" as const,
value: day === 4 ? 12_000 : 5_000,
unit: "steps",
source: "APPLE_HEALTH" as const,
measuredAt: new Date(Date.UTC(2025, 3, 1 + day, 12)),
externalId: `s-${day}`,
})),
});
});
describe("runCumulativePrRederivationForUser", () => {
it("keeps the suspect record when the re-derivation fails", async () => {
detect.fail = true;
await expect(runCumulativePrRederivationForUser(USER)).rejects.toThrow(
"detector ran out of time",
);
const left = await getPrismaClient().personalRecord.findMany({
where: { userId: USER },
});
expect(left.map((r) => r.value)).toEqual([99_999]);
});
it("replaces the suspect record with the re-derived one when it succeeds", async () => {
const summary = await runCumulativePrRederivationForUser(USER);
expect(summary.rowsDeleted).toBe(1);
const left = await getPrismaClient().personalRecord.findMany({
where: { userId: USER },
});
expect(left.map((r) => r.value)).toEqual([12_000]);
});
});
+59 -2
View File
@@ -16,6 +16,7 @@ import { PgBoss } from "pg-boss";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
readActiveJobsStartedBefore,
readFailingQueues,
readQueueFailureForUser,
} from "@/lib/jobs/job-failures";
@@ -28,8 +29,16 @@ const OTHER_QUEUE = "reminder-check";
let boss: PgBoss;
/** Send a job, take it, and fail it terminally so it lands in `state='failed'`. */
async function failOneJob(queue: string, data: object, error: string) {
const jobId = await boss.send(queue, data, { retryLimit: 0 });
async function failOneJob(
queue: string,
data: object,
error: string,
expireInSeconds?: number,
) {
const jobId = await boss.send(queue, data, {
retryLimit: 0,
...(expireInSeconds === undefined ? {} : { expireInSeconds }),
});
if (!jobId) throw new Error("queue rejected the job");
const [job] = await boss.fetch(queue);
if (!job) throw new Error("nothing to fetch");
@@ -81,6 +90,26 @@ describe("readFailingQueues", () => {
expect(other?.lastError).toBe("reminder dispatch failed");
});
it("says what a bare timeout means, with the job's own limit (#1031)", async () => {
await getPrismaClient().$executeRawUnsafe(
`DELETE FROM pgboss.job WHERE state = 'failed'`,
);
// pg-boss writes these words both for a pass too slow for its limit and
// for a job whose process died under it; alone they sent an operator
// nowhere.
await failOneJob(QUEUE, {}, "job timed out", 900);
await failOneJob(OTHER_QUEUE, {}, "job timed out", 7200);
const failing = await readFailingQueues();
const fifteen = failing?.find((entry) => entry.queue === QUEUE);
expect(fifteen?.lastError).toMatch(
/^job timed out: did not finish within its 15 min limit\./,
);
expect(fifteen?.lastError).toMatch(/restarted/);
const twoHours = failing?.find((entry) => entry.queue === OTHER_QUEUE);
expect(twoHours?.lastError).toMatch(/within its 2 h limit/);
});
it("reports an empty list — not null — when nothing is failing", async () => {
await getPrismaClient().$executeRawUnsafe(
`DELETE FROM pgboss.job WHERE state = 'failed'`,
@@ -132,6 +161,34 @@ describe("readQueueFailureForUser", () => {
});
});
describe("readActiveJobsStartedBefore (#1031)", () => {
it("lists a job still marked active from before the given instant, and nothing after it", async () => {
const jobId = await boss.send(
OTHER_QUEUE,
{},
{ retryLimit: 0, expireInSeconds: 7200 },
);
const [job] = await boss.fetch(OTHER_QUEUE);
expect(job?.id).toBe(jobId);
const cutOff = await readActiveJobsStartedBefore(
new Date(Date.now() + 60_000),
);
expect(cutOff).toContainEqual(
expect.objectContaining({
queue: OTHER_QUEUE,
id: jobId,
expireSeconds: 7200,
}),
);
await expect(
readActiveJobsStartedBefore(new Date(Date.now() - 60 * 60_000)),
).resolves.toEqual([]);
await boss.complete(OTHER_QUEUE, jobId!);
});
});
describe("no queue schema at all", () => {
it("answers null rather than throwing, so the status page still renders", async () => {
const prisma = getPrismaClient();
@@ -0,0 +1,266 @@
/**
* Issue #1031 — maintenance jobs on a large account.
*
* A self-hoster with 1.25 million measurement rows (a multi-year Apple Health
* export) saw the dense intra-day retention, the nightly drain tick and the
* data backup all fail with `job timed out`. Reproduced against a seeded
* account of the same size (`scripts/seed-large-account.ts`): the drains held
* a type's whole history in memory before folding a day, spent about 90
* statements per folded day, and paged with a cursor Postgres could not use as
* an index condition; the backup added about 640 MB to the process to store
* one 64 MB row. pg-boss declared the jobs dead at their expiry without
* stopping them.
*
* A million rows do not fit a test run, so this file pins the properties the
* fix rests on at a scale that does: a pass that runs out of budget stops
* cleanly and the next run finishes the job with the same result as one
* uninterrupted run, and the backup is stored piecewise with the previous
* copy intact if a run fails halfway.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { runDenseIntradayRetention } from "@/lib/measurements/dense-intraday-retention";
import { consolidateDailyMean } from "@/lib/measurements/consolidate-daily-mean";
import { unpackBackupBlob } from "@/lib/export/backup-blob";
import { storeBackupBlob } from "@/lib/export/store-backup-blob";
const TZ = "Europe/Berlin";
const RUN_A = "user-1031-stopped";
const RUN_B = "user-1031-straight";
const DAYS = 12;
/** Oldest seeded instant: well outside the 90-day raw window. */
const START = Date.UTC(2026, 0, 1, 0, 7);
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
for (const id of [RUN_A, RUN_B]) {
await prisma.user.create({
data: { id, username: id, email: `${id}@example.test`, timezone: TZ },
});
}
});
/** The same history for both users: heart rate every 20 minutes, HRV hourly. */
async function seedHistory(userId: string): Promise<void> {
const rows = [];
for (let minute = 0; minute < DAYS * 24 * 60; minute += 20) {
const at = new Date(START + minute * 60_000);
rows.push({
userId,
type: "PULSE" as const,
value: 55 + ((minute / 20) % 40),
unit: "bpm",
source: "APPLE_HEALTH" as const,
measuredAt: at,
externalId: `hk-hr-${minute}`,
});
if (minute % 60 === 0) {
rows.push({
userId,
type: "HEART_RATE_VARIABILITY" as const,
value: 30 + ((minute / 60) % 25),
unit: "ms",
source: "APPLE_HEALTH" as const,
measuredAt: at,
externalId: `hk-hrv-${minute}`,
});
}
if (minute % 40 === 0) {
rows.push({
userId,
type: "WALKING_SPEED" as const,
value: 4 + ((minute / 40) % 10) / 10,
unit: "km/h",
source: "APPLE_HEALTH" as const,
measuredAt: at,
externalId: `hk-ws-${minute}`,
});
}
}
await getPrismaClient().measurement.createMany({ data: rows });
}
/** What a reader sees: every live row, identity-free. */
async function liveRows(userId: string) {
const rows = await getPrismaClient().measurement.findMany({
where: { userId, deletedAt: null },
select: {
type: true,
value: true,
unit: true,
source: true,
measuredAt: true,
externalId: true,
},
orderBy: [{ type: "asc" }, { measuredAt: "asc" }],
});
return rows.map((row) => ({
...row,
measuredAt: row.measuredAt.toISOString(),
}));
}
/** A `shouldStop` that lets `days` day buckets through, then says stop. */
function afterDays(days: number): () => boolean {
let left = days;
return () => left-- <= 0;
}
describe("a pass that runs out of budget (#1031)", () => {
it("dense retention: stops cleanly, and the next run ends where one straight run ends", async () => {
await seedHistory(RUN_A);
await seedHistory(RUN_B);
const prisma = getPrismaClient();
const first = await runDenseIntradayRetention(prisma, {
userId: RUN_A,
shouldStop: afterDays(5),
log: () => {},
});
expect(first.stoppedEarly).toBe(true);
expect(first.totals.daysConsolidated).toBe(5);
// What the stopped run folded is committed: the next scan no longer sees
// it, so the second run folds only what is left.
const second = await runDenseIntradayRetention(prisma, {
userId: RUN_A,
log: () => {},
});
expect(second.stoppedEarly).toBe(false);
const straight = await runDenseIntradayRetention(prisma, {
userId: RUN_B,
log: () => {},
});
expect(first.totals.daysConsolidated + second.totals.daysConsolidated).toBe(
straight.totals.daysConsolidated,
);
expect(
first.totals.perSampleRowsSoftDeleted +
second.totals.perSampleRowsSoftDeleted,
).toBe(straight.totals.perSampleRowsSoftDeleted);
// A third run finds nothing: the pass converged.
const third = await runDenseIntradayRetention(prisma, {
userId: RUN_A,
log: () => {},
});
expect(third.totals.daysConsolidated).toBe(0);
const stoppedResult = (await liveRows(RUN_A)).filter(
(row) => row.type !== "WALKING_SPEED",
);
const straightResult = (await liveRows(RUN_B)).filter(
(row) => row.type !== "WALKING_SPEED",
);
expect(stoppedResult.length).toBeGreaterThan(0);
expect(stoppedResult).toEqual(straightResult);
});
it("daily-mean consolidation: the same, for the nightly tick's middle pass", async () => {
await seedHistory(RUN_A);
await seedHistory(RUN_B);
const prisma = getPrismaClient();
const first = await consolidateDailyMean(prisma, {
userId: RUN_A,
shouldStop: afterDays(4),
log: () => {},
});
expect(first.stoppedEarly).toBe(true);
expect(first.totals.daysConsolidated).toBe(4);
await consolidateDailyMean(prisma, { userId: RUN_A, log: () => {} });
await consolidateDailyMean(prisma, { userId: RUN_B, log: () => {} });
const stopped = (await liveRows(RUN_A)).filter(
(row) => row.type === "WALKING_SPEED",
);
const straight = (await liveRows(RUN_B)).filter(
(row) => row.type === "WALKING_SPEED",
);
// One daily mean per local day: twelve UTC days touch thirteen Berlin days.
expect(stopped).toHaveLength(DAYS + 1);
expect(stopped).toEqual(straight);
});
});
describe("storeBackupBlob (#1031)", () => {
/** A producer writing `rows` JSON rows in small pieces. */
function producer(rows: number, failAfter?: number) {
return async (write: (chunk: string) => Promise<void>) => {
await write('{"measurements":[');
for (let i = 0; i < rows; i++) {
if (failAfter !== undefined && i === failAfter) {
throw new Error("record read failed halfway");
}
await write(
`${i === 0 ? "" : ","}${JSON.stringify({ id: `m-${i}`, value: i })}`,
);
}
await write("]}");
};
}
function expectedJson(rows: number): string {
return JSON.stringify({
measurements: Array.from({ length: rows }, (_, i) => ({
id: `m-${i}`,
value: i,
})),
});
}
it("stores a copy that reads back as exactly the JSON produced", async () => {
const prisma = getPrismaClient();
const { bytes } = await storeBackupBlob(
prisma,
{ userId: RUN_A, type: "WEEKLY_AUTO" },
producer(20_000),
);
const row = await prisma.dataBackup.findUniqueOrThrow({
where: { userId_type: { userId: RUN_A, type: "WEEKLY_AUTO" } },
});
expect(row.data.length).toBe(bytes);
expect(unpackBackupBlob(row.data)).toBe(expectedJson(20_000));
});
it("keeps the previous copy when a run fails halfway, and the next run still stores", async () => {
const prisma = getPrismaClient();
await storeBackupBlob(
prisma,
{ userId: RUN_A, type: "WEEKLY_AUTO" },
producer(10),
);
const before = await prisma.dataBackup.findUniqueOrThrow({
where: { userId_type: { userId: RUN_A, type: "WEEKLY_AUTO" } },
});
await expect(
storeBackupBlob(
prisma,
{ userId: RUN_A, type: "WEEKLY_AUTO" },
producer(20_000, 15_000),
),
).rejects.toThrow("record read failed halfway");
const after = await prisma.dataBackup.findUniqueOrThrow({
where: { userId_type: { userId: RUN_A, type: "WEEKLY_AUTO" } },
});
expect(after.data).toBe(before.data);
expect(after.createdAt.getTime()).toBe(before.createdAt.getTime());
// The temporary table went with the failed transaction.
await storeBackupBlob(
prisma,
{ userId: RUN_A, type: "WEEKLY_AUTO" },
producer(30),
);
const replaced = await prisma.dataBackup.findUniqueOrThrow({
where: { userId_type: { userId: RUN_A, type: "WEEKLY_AUTO" } },
});
expect(unpackBackupBlob(replaced.data)).toBe(expectedJson(30));
});
});
@@ -0,0 +1,203 @@
/**
* #1031 — an account too large to export or restore.
*
* For an account of 1.25 million measurements the full export took the app
* down, and its weekly backup could not be restored, downloaded or summarised
* at all: the backup's JSON (662 MB) is longer than any string V8 can hold,
* and every reader turned the file into one string first. Measured on a
* seeded account of that size in a 1 GB container, the streamed paths now
* export in 25 s, upload in under 20 s and restore in 94 s, and the restored
* rows match the file exactly.
*
* A million rows do not fit a test run. This file runs the same paths at a
* size that spans many pages and batches, and checks the rows come back
* exactly: the export streamed into the response, uploaded as gzip through
* the raw-body path, and restored through the streamed two-pass reader; and a
* weekly backup restored the same way, every column intact, with a personal
* record still pointing at the measurement it was found in.
*/
import { gzipSync } from "node:zlib";
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
import { storeBackupBlob } from "@/lib/export/store-backup-blob";
import { encryptNote } from "@/lib/crypto/note-cipher";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const COUNT = 7_300; // more than one read page and several write batches
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
async function seedAccount() {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "large-account",
email: "large-account@example.test",
role: "ADMIN",
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 600_000) },
});
cookieJar.set("healthlog_session", session.id);
const start = Date.UTC(2024, 0, 1);
await prisma.measurement.createMany({
data: Array.from({ length: COUNT }, (_, i) => ({
id: `m-${String(i).padStart(5, "0")}`,
userId: user.id,
type: i % 3 === 0 ? ("PULSE" as const) : ("SLEEP_DURATION" as const),
value: 50 + (i % 40) + 0.25,
valueMax: i % 3 === 0 ? 120 : null,
unit: i % 3 === 0 ? "bpm" : "minutes",
source: "APPLE_HEALTH" as const,
measuredAt: new Date(start + i * 300_000),
externalId: `hk-${i}`,
sleepStage: i % 3 === 0 ? null : ("CORE" as const),
deviceType: "watch",
syncVersion: 1 + (i % 4),
notesEncrypted: i === 17 ? encryptNote("after the long run") : null,
deletedAt: i % 97 === 0 ? new Date(start) : null,
})),
});
await prisma.personalRecord.create({
data: {
userId: user.id,
metricType: "PULSE",
direction: "MAX",
value: 89.25,
unit: "bpm",
achievedAt: new Date(start + 3 * 300_000),
sourceMeasurementId: "m-00003",
},
});
return user;
}
async function measurementsOf(userId: string) {
return getPrismaClient().measurement.findMany({
where: { userId },
orderBy: { id: "asc" },
omit: { createdAt: true, updatedAt: true },
});
}
async function restore(id: string) {
const { POST } = await import("@/app/api/admin/backups/[id]/restore/route");
const res = await POST(
new Request(`http://localhost/api/admin/backups/${id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id }) },
);
return res;
}
describe("large backups round-trip through the streamed paths (#1031)", () => {
it("export → gzip upload → restore gives back every live measurement", async () => {
const user = await seedAccount();
const live = (await measurementsOf(user.id)).filter((m) => !m.deletedAt);
const { GET } = await import("@/app/api/export/full-backup/route");
const exported = await GET(
new Request("http://localhost/api/export/full-backup") as never,
);
expect(exported.status).toBe(200);
const file = Buffer.from(await exported.arrayBuffer());
expect(JSON.parse(file.toString("utf8")).measurements).toHaveLength(
live.length,
);
const { POST: upload } =
await import("@/app/api/admin/backups/upload/route");
const uploaded = await upload(
new Request("http://localhost/api/admin/backups/upload", {
method: "POST",
headers: { "content-type": "application/gzip" },
body: gzipSync(file),
}) as never,
);
expect(uploaded.status).toBe(201);
const { data } = (await uploaded.json()) as {
data: { id: string; summary: { measurements: number } };
};
expect(data.summary.measurements).toBe(live.length);
expect((await restore(data.id)).status).toBe(200);
const restored = await measurementsOf(user.id);
// A portable file carries the fields a person reads, not the sync
// bookkeeping, so those are what must come back.
const readable = (rows: typeof restored) =>
rows.map((m) => ({
id: m.id,
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt.toISOString(),
source: m.source,
}));
expect(readable(restored)).toEqual(readable(live));
});
it("a weekly backup restores every column, and a record's pointer survives", async () => {
const user = await seedAccount();
const before = await measurementsOf(user.id);
const prisma = getPrismaClient();
const { id } = await storeBackupBlob(
prisma,
{ userId: user.id, type: "WEEKLY_AUTO" },
(write) =>
streamFullBackupJson(prisma, user.id, write, {
purpose: "disaster-recovery",
}),
);
// Change the account so the restore has something to undo.
await prisma.measurement.deleteMany({ where: { userId: user.id } });
const res = await restore(id);
expect(res.status).toBe(200);
expect(await measurementsOf(user.id)).toEqual(before);
const record = await prisma.personalRecord.findFirstOrThrow({
where: { userId: user.id },
});
expect(record.sourceMeasurementId).toBe("m-00003");
});
});
@@ -0,0 +1,139 @@
/**
* `insertMeasurementRows` replaced `createMany` in the restore because
* Prisma's cached plan for a 1 000-row `createMany` kept about 145 MB alive
* (#1031). It writes raw SQL, so this pins that a row written through it
* reads back exactly as the same row written through Prisma, every column
* set, and every column null where it may be.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import {
insertMeasurementRows,
type MeasurementInsertRow,
} from "@/lib/export/measurement-bulk-insert";
const USER = "user-bulk-insert";
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.user.create({
data: { id: USER, username: USER, email: `${USER}@example.test` },
});
});
function full(id: string, at: string): MeasurementInsertRow {
return {
id,
userId: USER,
type: "SLEEP_DURATION",
value: 47.25,
valueMin: 1.5,
valueMax: 99.75,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: new Date(at),
notes: "legacy note",
notesEncrypted: new Uint8Array([0, 1, 2, 254, 255]),
externalId: `ext-${id}`,
externalSourceVersion: "17.4",
aggregationProvenance: "HEALTHKIT_STATISTICS",
glucoseContext: null,
sleepStage: "REM",
rhythmClassification: null,
deviceType: "watch",
syncVersion: 7,
deletedAt: new Date("2026-02-03T04:05:06.789Z"),
createdAt: new Date("2025-01-02T03:04:05.678Z"),
updatedAt: new Date("2025-06-07T08:09:10.111Z"),
};
}
function sparse(id: string, at: string): MeasurementInsertRow {
return {
id,
userId: USER,
type: "BLOOD_GLUCOSE",
value: 5.4,
valueMin: null,
valueMax: null,
unit: "mmol/L",
source: "MANUAL",
measuredAt: new Date(at),
notes: null,
notesEncrypted: null,
externalId: null,
externalSourceVersion: null,
aggregationProvenance: null,
glucoseContext: "FASTING",
sleepStage: null,
rhythmClassification: null,
deviceType: null,
syncVersion: 1,
deletedAt: null,
};
}
describe("insertMeasurementRows", () => {
it("writes every column exactly as Prisma create does", async () => {
const prisma = getPrismaClient();
const rows = [
[
full("a-bulk", "2026-01-01T10:00:00.123Z"),
full("a-prisma", "2026-01-02T10:00:00.123Z"),
],
[
sparse("b-bulk", "2026-01-03T10:00:00.000Z"),
sparse("b-prisma", "2026-01-04T10:00:00.000Z"),
],
] as const;
for (const [viaBulk, viaPrisma] of rows) {
await prisma.$transaction((tx) => insertMeasurementRows(tx, [viaBulk]));
await prisma.measurement.create({ data: viaPrisma as never });
}
for (const [viaBulk, viaPrisma] of rows) {
const a = await prisma.measurement.findUniqueOrThrow({
where: { id: viaBulk.id },
});
const b = await prisma.measurement.findUniqueOrThrow({
where: { id: viaPrisma.id },
});
const strip = (row: typeof a) => ({
...row,
id: undefined,
externalId: row.externalId?.replace(/-(bulk|prisma)$/, ""),
measuredAt: undefined,
// Filled with the current time when the row carries none.
...(viaBulk.createdAt
? {}
: { createdAt: undefined, updatedAt: undefined }),
});
expect(strip(a)).toEqual(strip(b));
expect(a.measuredAt.toISOString()).toBe(viaBulk.measuredAt.toISOString());
}
const sparseRow = await prisma.measurement.findUniqueOrThrow({
where: { id: "b-bulk" },
});
expect(Date.now() - sparseRow.createdAt.getTime()).toBeLessThan(60_000);
});
it("inserts a whole batch in one statement and reports the count", async () => {
const prisma = getPrismaClient();
const batch = Array.from({ length: 2_500 }, (_, i) =>
sparse(
`m-${i}`,
new Date(Date.UTC(2026, 0, 1) + i * 60_000).toISOString(),
),
);
const inserted = await prisma.$transaction((tx) =>
insertMeasurementRows(tx, batch),
);
expect(inserted).toBe(2_500);
expect(await prisma.measurement.count({ where: { userId: USER } })).toBe(
2_500,
);
});
});
@@ -228,4 +228,50 @@ describe("off-host backup ledger (real Postgres)", () => {
await prisma.user.delete({ where: { id: "account-one" } });
expect(await prisma.offhostBackupState.count()).toBe(0);
});
it("a retry of the same run resumes after the accounts it already uploaded (#1031)", async () => {
const prisma = getPrismaClient();
for (const id of ["acct-a", "acct-b", "acct-c"]) await seedUser(id);
const bucket = makeBucket();
const puts: string[] = [];
const putStream = bucket.putStream;
bucket.putStream = async (key, body) => {
puts.push(key);
return putStream(key, body);
};
// The job was created a moment ago; its retries share that instant.
const runStartedAt = new Date(Date.now() - 60_000);
const day = runStartedAt.toISOString().slice(0, 10);
// First attempt: out of budget after one account.
let asked = 0;
const first = await runOffhostBackup(prisma, bucket, new Date(), {
runStartedAt,
shouldStop: () => asked++ >= 1,
});
expect(first.stoppedEarly).toBe(true);
expect(first.uploaded).toBe(1);
// The retry: same run, so the account already done is skipped.
const retry = await runOffhostBackup(prisma, bucket, new Date(), {
runStartedAt,
});
expect(retry.stoppedEarly).toBe(false);
expect(retry.alreadyUploaded).toBe(1);
expect(retry.uploaded).toBe(2);
// Each account uploaded exactly once, all under the run's own day.
expect(puts.sort()).toEqual([
`${day}/user-acct-a.json.enc`,
`${day}/user-acct-b.json.enc`,
`${day}/user-acct-c.json.enc`,
]);
// A new run (a later job) uploads everybody again.
const next = await runOffhostBackup(prisma, bucket, new Date(), {
runStartedAt: new Date(),
});
expect(next.alreadyUploaded).toBe(0);
expect(next.uploaded).toBe(3);
});
});
@@ -0,0 +1,111 @@
/**
* #1031 — the best cumulative day, found a day at a time.
*
* `findBestCumulativeDay` read every row of a cumulative type into memory,
* every half hour, for every account. On an account holding 784 000 active
* energy rows it ran a 524 MB heap out of memory. It now reads a local day at
* a time in time order, which gives the same answer because the canonical-
* source pick it applies is per day.
*
* Pinned here at a size that spans several read pages: two sources report
* the same days, the source ladder must decide each day on its own, the
* best day sits in the middle of the history, and no read of the history is
* unbounded.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const { detectPersonalRecordsForUser } =
await import("@/lib/personal-records/pr-detection-worker");
const USER = "user-pr-cumulative-paged";
const DAYS = 40;
const PER_DAY = 300; // 12 000 Apple rows plus a Withings total per day
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: USER,
username: USER,
email: `${USER}@example.test`,
timezone: "UTC",
},
});
});
describe("best cumulative day across many pages", () => {
it("sums each day's canonical source and finds the best day without an unbounded read", async () => {
const prisma = getPrismaClient();
const start = Date.UTC(2025, 0, 1, 6);
const rows = [];
for (let day = 0; day < DAYS; day++) {
// Day 23 is the best Apple day; the Withings total on day 5 is larger
// still but loses to Apple on that day by the default ladder, so it
// must not win: if the days were pooled across sources it would.
const perSample = day === 23 ? 40 : 20;
for (let i = 0; i < PER_DAY; i++) {
rows.push({
userId: USER,
type: "ACTIVITY_STEPS" as const,
value: perSample,
unit: "steps",
source: "APPLE_HEALTH" as const,
measuredAt: new Date(start + day * 86_400_000 + i * 60_000),
externalId: `hk-${day}-${i}`,
});
}
rows.push({
userId: USER,
type: "ACTIVITY_STEPS" as const,
value: day === 5 ? 50_000 : 1_000,
unit: "steps",
source: "WITHINGS" as const,
measuredAt: new Date(start + day * 86_400_000 + 12 * 3_600_000),
externalId: `w-${day}`,
});
}
await prisma.measurement.createMany({ data: rows });
// Every read of the steps history, through a client that records it.
const reads: Array<number | undefined> = [];
const recording = prisma.$extends({
query: {
measurement: {
findMany({ args, query }) {
if (JSON.stringify(args.where ?? {}).includes("ACTIVITY_STEPS")) {
reads.push(args.take);
}
return query(args);
},
},
},
});
await detectPersonalRecordsForUser(USER, {
silent: true,
prisma: recording as unknown as typeof prisma,
});
const record = await prisma.personalRecord.findFirstOrThrow({
where: { userId: USER, metricType: "ACTIVITY_STEPS" },
});
expect(record.value).toBe(40 * PER_DAY);
expect(record.achievedAt.toISOString()).toBe(
new Date(start + 23 * 86_400_000 + (PER_DAY - 1) * 60_000).toISOString(),
);
// Several bounded pages, never one read of the whole history.
expect(reads.length).toBeGreaterThan(1);
for (const take of reads) {
expect(take).toBeDefined();
expect(take!).toBeLessThanOrEqual(5_000);
}
});
});