mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
/**
|
|
* The weekly backup of an account whose weight is in its stored bytes: the
|
|
* document vault and the ECG traces (#1239).
|
|
*
|
|
* What this pins. The streaming writer pulled the three tables that are large
|
|
* in ROWS through a page at a time, and read every other section whole. The
|
|
* documents are large in BYTES instead (up to a hundred megabytes each), and
|
|
* whole they cost about five times the vault in heap: the stored bytes, their
|
|
* base64, the section's JSON, and the preview's copies of each row. A single
|
|
* account with 190 MB of documents took a 524 MB process down every time its
|
|
* weekly copy ran, while the measurements beside them (1.25 million rows)
|
|
* fitted easily. Here the documents are read one at a time and each one's
|
|
* bytes a slice at a time, and the traces a page at a time.
|
|
*
|
|
* Four things are proved on one fixture: the streamed copy is byte for byte
|
|
* what the materialising builder writes, including documents that span
|
|
* several slices and one that ends exactly on a slice boundary; the preview
|
|
* taken from the writer agrees with a full read; the writer holds a bounded
|
|
* amount while the materialised form does not fit that bound; and the copy
|
|
* restores every document and strip exactly.
|
|
*/
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import v8 from "node:v8";
|
|
import vm from "node:vm";
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { readStoredBackup } from "./stored-backup-read";
|
|
import { encryptDocumentContent } from "@/lib/documents/store";
|
|
import { encryptWaveformToBytes } from "@/lib/withings/ecg-waveform-codec";
|
|
import { BackupKeyIdCollector } from "@/lib/export/backup-key-ids";
|
|
import { createBackupPreviewCollector } from "@/lib/export/backup-preview";
|
|
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
|
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
|
import { restoreBackup } from "@/lib/export/restore-backup";
|
|
import { storeBackupBlob } from "@/lib/export/store-backup-blob";
|
|
import { STORED_BACKUP_SELECT } from "@/lib/export/stored-backup";
|
|
import { parseBackupPayload } from "@/lib/validations/backup";
|
|
import { summarizeBackup } from "@/lib/validations/backup-summary";
|
|
|
|
const prisma = getPrismaClient();
|
|
const OWNER_ID = "backup-document-owner";
|
|
|
|
/** The slice the document reader takes (`DOCUMENT_SLICE_BYTES`). */
|
|
const SLICE = 1536 * 1024;
|
|
/** Ten documents of four megabytes: three slices each, the last one short. */
|
|
const LARGE_DOCUMENTS = 10;
|
|
const LARGE_BYTES = 4 * 1024 * 1024;
|
|
/** More strips than one page of the trace reader (50). */
|
|
const ECG_STRIPS = 120;
|
|
|
|
/**
|
|
* What the writer may hold on top of the process's baseline. Measured on this
|
|
* fixture: the streaming writer holds 4 MB (one slice, its base64, a page of
|
|
* strips), and materialising it holds 61 MB. The bound sits with a wide
|
|
* margin either side.
|
|
*/
|
|
const STREAM_BUDGET_BYTES = 16 * 1024 * 1024;
|
|
|
|
const forceGc = ((): (() => void) => {
|
|
v8.setFlagsFromString("--expose-gc");
|
|
const gc = vm.runInNewContext("gc") as () => void;
|
|
v8.setFlagsFromString("--no-expose-gc");
|
|
return gc;
|
|
})();
|
|
|
|
function liveHeapBytes(): number {
|
|
forceGc();
|
|
forceGc();
|
|
return process.memoryUsage().heapUsed;
|
|
}
|
|
|
|
function reportPeak(label: string, heldBytes: number): void {
|
|
process.stderr.write(
|
|
`[backup-document-memory] ${label}: ${Math.round(heldBytes / 1024 / 1024)} MB held, ` +
|
|
`${Math.round(v8.getHeapStatistics().heap_size_limit / 1024 / 1024)} MB heap limit\n`,
|
|
);
|
|
}
|
|
|
|
/** The ciphertext overhead of the active document codec, in bytes. */
|
|
const CODEC_OVERHEAD =
|
|
encryptDocumentContent(Buffer.alloc(1)).content.byteLength - 1;
|
|
|
|
async function addDocument(index: number, cipherBytes: number): Promise<void> {
|
|
const { content, codec } = encryptDocumentContent(
|
|
randomBytes(Math.max(1, cipherBytes - CODEC_OVERHEAD)),
|
|
);
|
|
await prisma.inboundDocument.create({
|
|
data: {
|
|
id: `doc-${String(index).padStart(3, "0")}`,
|
|
userId: OWNER_ID,
|
|
filename: `scan-${index}.pdf`,
|
|
mimeType: "application/pdf",
|
|
byteSize: content.byteLength,
|
|
contentEncrypted: content,
|
|
contentCodec: codec,
|
|
contentSha256: createHash("sha256").update(content).digest("hex"),
|
|
title: `Scan ${index}`,
|
|
// Two documents share an instant, so the order needs its tiebreak.
|
|
createdAt: new Date(Date.UTC(2026, 0, 1 + Math.min(index, 5))),
|
|
},
|
|
});
|
|
}
|
|
|
|
async function seed(): Promise<void> {
|
|
await prisma.user.create({
|
|
data: { id: OWNER_ID, username: "backup-document-owner", role: "ADMIN" },
|
|
});
|
|
for (let i = 0; i < LARGE_DOCUMENTS; i++) await addDocument(i, LARGE_BYTES);
|
|
// Exactly two slices: the reader has to stop on an empty third read.
|
|
await addDocument(LARGE_DOCUMENTS, 2 * SLICE);
|
|
// One short enough that the preview sees the whole value.
|
|
await addDocument(LARGE_DOCUMENTS + 1, 200);
|
|
const samples = Array.from({ length: 2_000 }, (_, i) =>
|
|
Math.round(Math.sin(i / 9) * 800),
|
|
);
|
|
await prisma.ecgRecording.createMany({
|
|
data: Array.from({ length: ECG_STRIPS }, (_, i) => ({
|
|
id: `ecg-${String(i).padStart(4, "0")}`,
|
|
userId: OWNER_ID,
|
|
source: "APPLE_HEALTH" as const,
|
|
externalRecordingId: `strip-${i}`,
|
|
// Pairs share an instant (at two rates, the natural key), so the paged
|
|
// order needs its tiebreak.
|
|
recordedAt: new Date(Date.UTC(2025, 0, 1 + Math.floor(i / 2))),
|
|
waveformEncrypted: encryptWaveformToBytes(samples),
|
|
samplingFrequency: i % 2 === 0 ? 512 : 256,
|
|
sampleCount: samples.length,
|
|
})),
|
|
});
|
|
}
|
|
|
|
async function documentsOf(userId: string) {
|
|
const rows = await prisma.inboundDocument.findMany({
|
|
where: { userId },
|
|
orderBy: { id: "asc" },
|
|
select: { id: true, byteSize: true, contentEncrypted: true, title: true },
|
|
});
|
|
return rows.map(({ contentEncrypted, ...row }) => ({
|
|
...row,
|
|
sha: createHash("sha256").update(contentEncrypted).digest("hex"),
|
|
}));
|
|
}
|
|
|
|
async function stripsOf(userId: string) {
|
|
const rows = await prisma.ecgRecording.findMany({
|
|
where: { userId },
|
|
orderBy: { id: "asc" },
|
|
select: { id: true, recordedAt: true, waveformEncrypted: true },
|
|
});
|
|
return rows.map(({ waveformEncrypted, ...row }) => ({
|
|
...row,
|
|
waveform: Buffer.from(waveformEncrypted).toString("base64"),
|
|
}));
|
|
}
|
|
|
|
describe("weekly backup of an account heavy in stored bytes", () => {
|
|
beforeAll(async () => {
|
|
expect(typeof forceGc).toBe("function");
|
|
await truncateAllTables(prisma);
|
|
await seed();
|
|
}, 240_000);
|
|
|
|
afterAll(async () => {
|
|
await truncateAllTables(prisma);
|
|
});
|
|
|
|
it("writes what the materialising builder writes, and previews it alike", async () => {
|
|
const exportedAt = new Date("2026-08-01T00:00:00.000Z");
|
|
let streamed = "";
|
|
const preview = await createBackupPreviewCollector();
|
|
const counts = await streamFullBackupJson(
|
|
prisma,
|
|
OWNER_ID,
|
|
(chunk) => {
|
|
streamed += chunk;
|
|
},
|
|
{ purpose: "disaster-recovery", exportedAt, observe: preview.observe },
|
|
);
|
|
expect(counts.documents).toBe(LARGE_DOCUMENTS + 2);
|
|
expect(counts.ecgRecordings).toBe(ECG_STRIPS);
|
|
|
|
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
|
|
purpose: "disaster-recovery",
|
|
exportedAt,
|
|
});
|
|
expect(
|
|
streamed === JSON.stringify(payload),
|
|
"the streaming writer and the materialising builder disagree",
|
|
).toBe(true);
|
|
|
|
const fromWriter = preview.finish(counts);
|
|
expect(fromWriter).not.toBeNull();
|
|
expect(fromWriter!.summary).toEqual(
|
|
summarizeBackup(parseBackupPayload(streamed)),
|
|
);
|
|
const readBack = new BackupKeyIdCollector();
|
|
readBack.visit(JSON.parse(streamed));
|
|
expect(fromWriter!.keys.toStored()).toEqual(readBack.toStored());
|
|
}, 300_000);
|
|
|
|
it("holds a bounded amount of the vault while storing it", async () => {
|
|
const baseline = liveHeapBytes();
|
|
let peakHeld = 0;
|
|
let pieces = 0;
|
|
await storeBackupBlob(
|
|
prisma,
|
|
{ userId: OWNER_ID, type: "WEEKLY_AUTO" },
|
|
(write) =>
|
|
streamFullBackupJson(
|
|
prisma,
|
|
OWNER_ID,
|
|
async (chunk) => {
|
|
await write(chunk);
|
|
if (pieces++ % 8 === 0) {
|
|
peakHeld = Math.max(peakHeld, liveHeapBytes() - baseline);
|
|
}
|
|
},
|
|
{ purpose: "disaster-recovery" },
|
|
),
|
|
);
|
|
peakHeld = Math.max(peakHeld, liveHeapBytes() - baseline);
|
|
reportPeak("streamed", peakHeld);
|
|
expect(peakHeld).toBeLessThan(STREAM_BUDGET_BYTES);
|
|
|
|
// The other half: the same fixture, materialised, does not fit the bound,
|
|
// so the bound above measures something.
|
|
const before = liveHeapBytes();
|
|
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
|
|
purpose: "disaster-recovery",
|
|
});
|
|
const json = JSON.stringify(payload);
|
|
const held = liveHeapBytes() - before;
|
|
reportPeak("materialised", held);
|
|
expect(json.length).toBeGreaterThan(LARGE_DOCUMENTS * LARGE_BYTES);
|
|
expect(held).toBeGreaterThan(STREAM_BUDGET_BYTES * 2);
|
|
}, 300_000);
|
|
|
|
it("restores every document and strip exactly from the stored copy", async () => {
|
|
const documents = await documentsOf(OWNER_ID);
|
|
const strips = await stripsOf(OWNER_ID);
|
|
const { id } = await storeBackupBlob(
|
|
prisma,
|
|
{ userId: OWNER_ID, type: "WEEKLY_AUTO" },
|
|
(write) =>
|
|
streamFullBackupJson(prisma, OWNER_ID, write, {
|
|
purpose: "disaster-recovery",
|
|
}),
|
|
);
|
|
const copy = JSON.parse(await readStoredBackup(prisma, id)) as {
|
|
documents: Array<{ id: string; contentEncrypted: string }>;
|
|
};
|
|
expect(copy.documents).toHaveLength(LARGE_DOCUMENTS + 2);
|
|
|
|
await prisma.inboundDocument.deleteMany({ where: { userId: OWNER_ID } });
|
|
await prisma.ecgRecording.deleteMany({ where: { userId: OWNER_ID } });
|
|
const backup = await prisma.dataBackup.findUniqueOrThrow({
|
|
where: { id },
|
|
select: STORED_BACKUP_SELECT,
|
|
});
|
|
const outcome = await restoreBackup({
|
|
backup,
|
|
actorUserId: OWNER_ID,
|
|
ipAddress: null,
|
|
restoreInstanceSettings: false,
|
|
});
|
|
expect(outcome.ok, JSON.stringify(outcome)).toBe(true);
|
|
expect(await documentsOf(OWNER_ID)).toEqual(documents);
|
|
expect(await stripsOf(OWNER_ID)).toEqual(strips);
|
|
}, 300_000);
|
|
});
|