mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
The compression that landed last release was real and was measured in the wrong place. On a development machine with a multi-gigabyte heap the pass finished; the container the job runs in is capped at 1 GB, which gives V8 a 524 MB limit, and there the same pass died of memory about fifteen seconds in. Because the job shares the application process, that was not one failed backup — it was a restart, and every signed-in session on the host went with it. Reproduced before touching anything, under `--max-old-space-size=450` against a seeded account of 445 000 measurements, 30 000 mood entries and 60 000 intake events: `FATAL ERROR: Reached heap limit` at 34 s. The same fixture doubled to 890 000 measurements now completes inside a 296 MB limit, holding 16 MB of live heap where the old writer held 168 MB. The document is written incrementally now. `buildFullBackupPayload` takes a `deferBulk` mode that declares the three unbounded tables instead of reading them, and `streamFullBackupJson` walks the payload in key order, pulls those tables through page by page, and releases every other section as soon as its JSON reaches the sink. `packBackupBlobStreaming` feeds that into gzip and an AES-256-GCM stream, so nothing between the database rows and the stored value ever exists whole. Both writers go through the same per-row serialisers, and the round-trip suite pins their output byte for byte rather than trusting that they agree. The intake and mood reads are ordered totally now. A keyset cursor needs it, and a partial order also let two exports of one account differ over a tie, which every multi-dose day has. The streamed ciphertext is a new shape at rest, because GCM only produces its tag once the last block is in and a leading tag is what makes the old layout unstreamable. It carries a marker no older writer can produce, so the two families are disjoint by construction rather than by sniffing. All three stored shapes — plain, compressed, streamed — restore, proved both ways. The writer also watches its own live heap and stops at 80 % of V8's limit. An account too large for its host is now one failed backup, counted in the run's meta, instead of a restart for everybody on it. The stored column is the ceiling that is left: `data_backups.data` is a single `text` value, so the finished artifact has to exist as one string, and that is the only part of the job still growing with the record. The operator runbook says so, with the number at which it starts to matter.
232 lines
9.1 KiB
TypeScript
232 lines
9.1 KiB
TypeScript
/**
|
|
* The weekly backup, run against a record big enough to have killed the
|
|
* process, with its live memory measured rather than assumed.
|
|
*
|
|
* What this pins. The `data-backup` job used to build the whole payload as an
|
|
* object graph and then `JSON.stringify` it, so both the graph and the string
|
|
* were resident at once and both scaled with the record. On a seeded account
|
|
* of 445 000 measurements under `--max-old-space-size=450` that is
|
|
* `FATAL ERROR: Reached heap limit` about thirty seconds in — and because the
|
|
* job shares the app process, one account's size restarted the instance for
|
|
* everybody on it.
|
|
*
|
|
* Why it measures the way it does. `process.memoryUsage().heapUsed` on its own
|
|
* is not a measurement: V8 lets garbage float in proportion to the heap limit,
|
|
* and a test fork's limit is several times a container's, so the same code
|
|
* "peaks" at wildly different numbers depending on who is running it. Every
|
|
* reading below is taken after a forced collection, so what is compared is
|
|
* what each writer HOLDS.
|
|
*
|
|
* The two halves are the whole point. One says the streaming writer stays
|
|
* inside a budget; the other says the materialising builder does not fit that
|
|
* budget on the same fixture in the same process. Without the second, the
|
|
* budget could be any number at all and the first would still pass — green
|
|
* because nothing was measured rather than because something was proved.
|
|
*
|
|
* The fixture is seeded in one `INSERT … generate_series` rather than through
|
|
* Prisma. 120 000 rows through the client would dominate the runtime and prove
|
|
* nothing about the writer.
|
|
*/
|
|
import v8 from "node:v8";
|
|
import vm from "node:vm";
|
|
|
|
import { beforeAll, afterAll, describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
packBackupBlobStreaming,
|
|
unpackBackupBlob,
|
|
} from "@/lib/export/backup-blob";
|
|
import {
|
|
BackupHeapBudgetExceededError,
|
|
streamFullBackupJson,
|
|
} from "@/lib/export/full-backup-stream";
|
|
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
const OWNER_ID = "backup-streaming-owner";
|
|
const MEASUREMENT_ROWS = 120_000;
|
|
const MOOD_ROWS = 8_000;
|
|
|
|
/**
|
|
* What the streaming writer may hold on top of the process's own baseline.
|
|
*
|
|
* Measured, not chosen: on this fixture the streaming writer holds 16 MB — a
|
|
* page of rows and the base64 answer so far — and materialising the same
|
|
* fixture holds 168 MB, the object graph and the document at once. The budget
|
|
* sits with a wide margin either side of it, which is what makes it a test
|
|
* rather than a coin toss.
|
|
*/
|
|
const STREAM_BUDGET_BYTES = 48 * 1024 * 1024;
|
|
|
|
const prisma = getPrismaClient();
|
|
|
|
/**
|
|
* A collection this process can ask for, without the runner having to be
|
|
* started with `--expose-gc`. The flag is turned on, the binding is pulled out
|
|
* of a throwaway context, and the flag is turned back off — so nothing about
|
|
* how the rest of the suite runs changes.
|
|
*/
|
|
const forceGc = ((): (() => void) => {
|
|
v8.setFlagsFromString("--expose-gc");
|
|
const gc = vm.runInNewContext("gc") as () => void;
|
|
v8.setFlagsFromString("--no-expose-gc");
|
|
return gc;
|
|
})();
|
|
|
|
/** Heap held after a forced collection. Garbage is not a measurement. */
|
|
function liveHeapBytes(): number {
|
|
// Twice: the first pass frees the objects, the second collects what the
|
|
// first pass's own bookkeeping released.
|
|
forceGc();
|
|
forceGc();
|
|
return process.memoryUsage().heapUsed;
|
|
}
|
|
|
|
async function seedLargeRecord(): Promise<void> {
|
|
await prisma.user.create({
|
|
data: { id: OWNER_ID, username: "backup-streaming-owner" },
|
|
});
|
|
// One statement, one round trip. Every row carries a note on one row in
|
|
// forty and ciphertext on one in twenty-five so the base64 arm of the
|
|
// serialiser is exercised at scale, and one in two hundred is a tombstone
|
|
// because the delta sync reads exactly those and a backup that drops them
|
|
// resurrects deleted readings on the next device sync.
|
|
await prisma.$executeRawUnsafe(
|
|
`INSERT INTO measurements (
|
|
id, user_id, type, value, unit, source, measured_at, notes,
|
|
notes_encrypted, external_id, created_at, updated_at, sync_version,
|
|
deleted_at)
|
|
SELECT
|
|
'sm' || lpad(g::text, 10, '0'),
|
|
$1,
|
|
'PULSE'::measurement_type,
|
|
60 + (g % 40),
|
|
'bpm',
|
|
'APPLE_HEALTH'::measurement_source,
|
|
timestamp '2019-01-01 00:00:00' + (g * interval '30 seconds'),
|
|
CASE WHEN g % 40 = 0 THEN 'a note recorded with reading ' || g END,
|
|
CASE WHEN g % 25 = 0
|
|
THEN decode(md5(g::text) || md5((g + 1)::text), 'hex') END,
|
|
'stream-' || g,
|
|
timestamp '2019-01-01 00:00:00' + (g * interval '30 seconds'),
|
|
timestamp '2019-01-01 00:00:00' + (g * interval '30 seconds'),
|
|
1,
|
|
CASE WHEN g % 200 = 0
|
|
THEN timestamp '2026-01-01 00:00:00' + (g * interval '1 second') END
|
|
FROM generate_series(1, ${MEASUREMENT_ROWS}) AS g`,
|
|
OWNER_ID,
|
|
);
|
|
await prisma.$executeRawUnsafe(
|
|
`INSERT INTO mood_entries (
|
|
id, user_id, date, mood, score, source, mood_logged_at, synced_at,
|
|
created_at, updated_at, tz, note, sync_version, deleted_at)
|
|
SELECT
|
|
'sd' || lpad(g::text, 10, '0'),
|
|
$1,
|
|
to_char(timestamp '2019-01-01' + (g * interval '1 hour'), 'YYYY-MM-DD'),
|
|
'okay', 3, 'MOODLOG',
|
|
timestamp '2019-01-01' + (g * interval '1 hour'),
|
|
now(), now(), now(), 'Europe/Berlin',
|
|
CASE WHEN g % 3 = 0 THEN 'a journal line about day ' || g END,
|
|
1,
|
|
CASE WHEN g % 150 = 0 THEN now() END
|
|
FROM generate_series(1, ${MOOD_ROWS}) AS g`,
|
|
OWNER_ID,
|
|
);
|
|
}
|
|
|
|
describe("weekly backup under a memory budget", () => {
|
|
beforeAll(async () => {
|
|
expect(
|
|
typeof forceGc,
|
|
"without a real collection every reading in this file measures " +
|
|
"uncollected garbage and nothing here can fail",
|
|
).toBe("function");
|
|
await truncateAllTables(prisma);
|
|
await seedLargeRecord();
|
|
}, 240_000);
|
|
|
|
afterAll(async () => {
|
|
await truncateAllTables(prisma);
|
|
});
|
|
|
|
it("writes a restorable blob while holding a bounded amount of the record", async () => {
|
|
const baseline = liveHeapBytes();
|
|
let peakHeld = 0;
|
|
let chunks = 0;
|
|
|
|
const blob = await packBackupBlobStreaming(async (write) => {
|
|
const counts = await streamFullBackupJson(
|
|
prisma,
|
|
OWNER_ID,
|
|
async (chunk) => {
|
|
await write(chunk);
|
|
// Sampled rather than continuous: a forced collection per chunk
|
|
// would dominate the runtime, and one in forty still lands inside
|
|
// every phase of the walk.
|
|
if (chunks++ % 40 === 0) {
|
|
peakHeld = Math.max(peakHeld, liveHeapBytes() - baseline);
|
|
}
|
|
},
|
|
// The purpose the weekly job asks for: tombstones and ciphertext ride
|
|
// verbatim, which is the arm that has to fit in memory.
|
|
{ purpose: "disaster-recovery" },
|
|
);
|
|
expect(counts.measurements).toBe(MEASUREMENT_ROWS);
|
|
expect(counts.moodEntries).toBe(MOOD_ROWS);
|
|
});
|
|
peakHeld = Math.max(peakHeld, liveHeapBytes() - baseline);
|
|
|
|
expect(
|
|
peakHeld,
|
|
`the streaming writer held ${Math.round(peakHeld / 1024 / 1024)} MB of ` +
|
|
"a record it is supposed to pass through a page at a time",
|
|
).toBeLessThan(STREAM_BUDGET_BYTES);
|
|
|
|
// A blob that writes but does not read is worse than none.
|
|
const restored = JSON.parse(unpackBackupBlob(blob)) as {
|
|
measurements: Array<{ deletedAt: string | null }>;
|
|
moodEntries: unknown[];
|
|
};
|
|
expect(restored.measurements).toHaveLength(MEASUREMENT_ROWS);
|
|
expect(restored.moodEntries).toHaveLength(MOOD_ROWS);
|
|
// Tombstones ride along, or the next device sync resurrects them.
|
|
expect(
|
|
restored.measurements.filter((row) => row.deletedAt !== null).length,
|
|
).toBe(Math.floor(MEASUREMENT_ROWS / 200));
|
|
}, 300_000);
|
|
|
|
it("would not fit that budget if the record were materialised", async () => {
|
|
const baseline = liveHeapBytes();
|
|
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
|
|
purpose: "disaster-recovery",
|
|
});
|
|
const json = JSON.stringify(payload);
|
|
// Both are deliberately still reachable at the reading: holding the
|
|
// graph and the document at the same time is exactly the shape that
|
|
// exhausted the container.
|
|
const held = liveHeapBytes() - baseline;
|
|
expect(json.length).toBeGreaterThan(0);
|
|
expect(payload.measurements).toHaveLength(MEASUREMENT_ROWS);
|
|
expect(
|
|
held,
|
|
"materialising this fixture no longer costs what the budget in this " +
|
|
"file assumes; re-measure the budget rather than widening it",
|
|
).toBeGreaterThan(STREAM_BUDGET_BYTES * 2);
|
|
}, 300_000);
|
|
|
|
it("fails as a job rather than as a process when the record does not fit", async () => {
|
|
// The budget the writer enforces on itself is the reason an oversized
|
|
// account is now a failed backup for that account instead of a restart
|
|
// for every account on the host.
|
|
await expect(
|
|
packBackupBlobStreaming((write) =>
|
|
streamFullBackupJson(prisma, OWNER_ID, write, {
|
|
purpose: "disaster-recovery",
|
|
heapCeilingBytes: 1,
|
|
}),
|
|
),
|
|
).rejects.toBeInstanceOf(BackupHeapBudgetExceededError);
|
|
}, 120_000);
|
|
});
|