mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Restoring a large account was one request: 94 s for 1.25 million readings.
A reverse proxy that cuts a request at 60 s (nginx's default) showed the
operator an error for a restore that went on to finish on the server.
The restore request now checks the typed confirmation, that the backup
exists and that its stored copy opens, admits one job for the account and
answers 202 with its id. The `backup-restore` queue runs the restore and
records its phase, counts and outcome on a `backup_restore_jobs` row
(migration 0347), which `GET /api/admin/backups/restores` and
`GET /api/admin/backups/restores/{id}` read back.
What a restore checks and writes is unchanged. The body moved from the
route into `restoreBackup` (src/lib/export/restore-backup.ts): every
refusal still happens before the first delete and the replacement is still
one transaction, so a failure part-way leaves the account as it was. While
it runs the account keeps showing its current data, and other writes are
neither refused nor held, as before.
- One restore per account: a partial unique index admits one queued or
running job, and a second request gets 409 `backup.restore.active` naming
the running job. A per-account advisory lock inside the transaction holds
even if a worker thought gone is still writing.
- Time budget: the job's expiry is two hours and a file whose transaction
limit would run past three quarters of it is refused before anything is
deleted (`jobDeadline`, beside `jobBudget`).
- Resuming: the job writes a heartbeat every two seconds. A boot sweep
queues a job whose worker stopped again from the start, at most twice,
then fails it as interrupted. The admission check releases an abandoned
job so it never holds the account's slot.
- The staged file is the stored `DataBackup` row, already encrypted at
rest. It stays whatever the outcome, so a retry needs no new upload. The
job records a digest of the copy and refuses one that was replaced while
it waited.
- Failures carry a stable code and a sentence that says what to do.
- The console follows the job: phase and counts while it runs, the outcome
after, and after a reload it finds the job again from the server. A poll
that fails on the network is never shown as a failed restore. A web
process separate from the worker evicts the account's caches when it
first reports the restore finished. The success line goes through the
outcome module, so an empty file is not drawn as a tick.
The model is registered in the backup plan (not carried) and the wipe plan
(wiped with the account); the queue in the maintenance registrar, the
subsystem surface and the job-fact allowlist; the new code in the error
catalogue; the routes in the OpenAPI contract. The tests that pin restore
semantics now drive the real route and the real job through one helper, and
`backup-restore-job.test.ts` pins the asynchronous contract itself.
204 lines
7.0 KiB
TypeScript
204 lines
7.0 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);
|
|
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("./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");
|
|
});
|
|
});
|