Files
HealthLog/tests/integration/large-backup-round-trip.test.ts
T
Marc-André Bombeck fc13b9de5c fix(restore): skip expired tombstones and give the chart rebuild its own limit
A restore now leaves out measurement tombstones deleted longer ago than
the tombstone retention. The nightly purge removes exactly those rows,
the delta feed answers cursorExpired for every cursor that could still
be served one, and the consolidation repairs look only inside the same
horizon. Younger tombstones keep their readers (a lagging paired client,
the undo of a recent delete, the repairs, the duplicate guard on
re-uploaded samples) and are written as before. The backup file is
unchanged. On a seeded account of 1.89 million readings whose 1.68
million tombstones were past retention, the restore on a slowed database
took 5.4 min instead of 15.

The rollup fold after the commit rebuilt five years of buckets under the
60 s request statement limit and could be dropped on a slow disk, leaving
the charts empty until the next boot. `recomputeUserRollups` takes a
`statementTimeoutMs` that scopes each aggregate and persist transaction
with SET LOCAL, and the restore passes its after-commit allowance.

Fixture tombstones in two round-trip tests were dated in July 2026 and
had aged past the retention; they are now relative to the run.
2026-09-27 13:21:18 +02:00

207 lines
7.1 KiB
TypeScript

/**
* #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);
const yesterday = new Date(Date.now() - 86_400_000);
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,
// Deleted yesterday, inside the tombstone retention, so a weekly
// backup's restore writes it back.
deletedAt: i % 97 === 0 ? yesterday : 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("./restore-job-driver");
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");
});
});