mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
176 lines
6.7 KiB
TypeScript
176 lines
6.7 KiB
TypeScript
/**
|
|
* #1031 — what a restore holds while it writes the measurements, measured.
|
|
*
|
|
* A self-hoster restored 1.8 million readings in the default 1 GB container
|
|
* (V8 heap limit 524 MB). Near the end of the measurement write the server
|
|
* restarted, and the job started over from the beginning. Reproduced against
|
|
* the same restore of 1.8 million Apple Health readings, the restore itself
|
|
* held a steady 60 MB more than the process did before it started, from the
|
|
* hundredth statement to the last: not the rows it was writing, but the
|
|
* parameters of the hundred statements before them.
|
|
*
|
|
* Prisma's debug logger keeps the arguments of its last hundred calls whether
|
|
* or not logging is on, and the client and the Postgres adapter log each
|
|
* statement with its parameters. A restore that wrote 1 000 readings per
|
|
* statement therefore kept about the last 100 000 readings alive as copies.
|
|
* Bounded, and the same at 150 000 readings as at 1.8 million, but 60 MB out
|
|
* of a heap that a long-running server already fills well past half. The
|
|
* insert now carries at most a hundred rows per statement, and the history
|
|
* holds about a tenth of that.
|
|
*
|
|
* How it measures. Every reading is taken after a forced collection, so what
|
|
* is compared is what the restore HOLDS, not what V8 left lying about. The
|
|
* anchor is taken when the measurement write starts, after the checks and the
|
|
* clearing, so the modules and compiled queries they load are not counted.
|
|
* The fixture is sized so the old statement size fills the whole history
|
|
* well before the end: 150 statements of 1 000 readings.
|
|
*
|
|
* Mutation check: set `MEASUREMENT_INSERT_ROWS_PER_STATEMENT` to 1 000 and
|
|
* the growth test goes red at about 64 MB against the 20 MB budget.
|
|
*/
|
|
import v8 from "node:v8";
|
|
import vm from "node:vm";
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
const OWNER = "restore-memory-owner";
|
|
const ROWS = 150_000;
|
|
|
|
/**
|
|
* How much the heap may grow during the measurement write. Measured, not
|
|
* chosen: about 8 MB with a hundred rows per statement, about 64 MB with a
|
|
* thousand, on this fixture.
|
|
*/
|
|
const GROWTH_BUDGET_BYTES = 20 * 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;
|
|
}
|
|
|
|
const mb = (bytes: number): string => (bytes / 1024 / 1024).toFixed(1);
|
|
|
|
let backupId = "";
|
|
|
|
beforeAll(async () => {
|
|
const prisma = getPrismaClient();
|
|
await truncateAllTables(prisma);
|
|
await prisma.user.create({
|
|
data: { id: OWNER, username: OWNER, role: "ADMIN" },
|
|
});
|
|
const { encryptNote } = await import("@/lib/crypto/note-cipher");
|
|
const note = Buffer.from(encryptNote("a note kept with a reading")!);
|
|
// Apple Health's shape: an external id and a source version on every row,
|
|
// ciphertext on one in twenty-five, a note on one in forty, and a recently
|
|
// deleted row on one in two hundred. One statement, so the fixture costs
|
|
// seconds rather than dominating the file.
|
|
await prisma.$executeRawUnsafe(
|
|
`INSERT INTO measurements (
|
|
id, user_id, type, value, unit, source, measured_at, notes,
|
|
notes_encrypted, external_id, external_source_version, device_type,
|
|
created_at, updated_at, sync_version, deleted_at)
|
|
SELECT
|
|
'c' || lpad(to_hex(g), 24, '0'),
|
|
$1,
|
|
(ARRAY['PULSE', 'ACTIVITY_STEPS', 'ACTIVE_ENERGY_BURNED',
|
|
'WALKING_RUNNING_DISTANCE'])[1 + (g % 4)]::measurement_type,
|
|
60 + (g % 40),
|
|
'count',
|
|
'APPLE_HEALTH'::measurement_source,
|
|
timestamp '2019-01-01 00:00:00' + (g * interval '47 seconds'),
|
|
CASE WHEN g % 40 = 0 THEN 'a note recorded with reading ' || g END,
|
|
CASE WHEN g % 25 = 0 THEN $2::bytea END,
|
|
'5B3E1C2A-' || lpad(to_hex(g), 8, '0') || '-4A1F-9C3D-8E7F6A5B4C3D',
|
|
'17.4',
|
|
'Apple Watch',
|
|
timestamp '2019-01-01 00:00:00' + (g * interval '47 seconds'),
|
|
timestamp '2019-01-01 00:00:00' + (g * interval '47 seconds'),
|
|
1,
|
|
CASE WHEN g % 200 = 0 THEN now() - interval '1 day' END
|
|
FROM generate_series(1, ${ROWS}) AS g`,
|
|
OWNER,
|
|
note,
|
|
);
|
|
const { storeBackupBlob } = await import("@/lib/export/store-backup-blob");
|
|
const { streamFullBackupJson } =
|
|
await import("@/lib/export/full-backup-stream");
|
|
const stored = await storeBackupBlob(
|
|
prisma,
|
|
{ userId: OWNER, type: "WEEKLY_AUTO" },
|
|
(write) =>
|
|
streamFullBackupJson(prisma, OWNER, write, {
|
|
purpose: "disaster-recovery",
|
|
}),
|
|
);
|
|
backupId = stored.id;
|
|
}, 240_000);
|
|
|
|
afterAll(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
});
|
|
|
|
describe("backup restore under a memory budget (#1031)", () => {
|
|
it("holds a bounded amount while it writes the measurements", async () => {
|
|
const prisma = getPrismaClient();
|
|
const { restoreBackup } = await import("@/lib/export/restore-backup");
|
|
const { STORED_BACKUP_SELECT } = await import("@/lib/export/stored-backup");
|
|
const backup = await prisma.dataBackup.findUniqueOrThrow({
|
|
where: { id: backupId },
|
|
select: STORED_BACKUP_SELECT,
|
|
});
|
|
|
|
let anchor: number | null = null;
|
|
let peak = 0;
|
|
let lastWritten = 0;
|
|
let samples = 0;
|
|
const outcome = await restoreBackup({
|
|
backup,
|
|
actorUserId: OWNER,
|
|
ipAddress: null,
|
|
restoreInstanceSettings: false,
|
|
// Called from inside the write, between two batches, which is where
|
|
// anything the restore holds across batches is visible.
|
|
progress: (phase, progress) => {
|
|
if (phase !== "measurements") return;
|
|
lastWritten = progress.measurementsWritten;
|
|
if (anchor === null) {
|
|
anchor = peak = liveHeapBytes();
|
|
return;
|
|
}
|
|
samples++;
|
|
peak = Math.max(peak, liveHeapBytes());
|
|
},
|
|
});
|
|
|
|
expect(outcome.ok).toBe(true);
|
|
expect(await prisma.measurement.count({ where: { userId: OWNER } })).toBe(
|
|
ROWS,
|
|
);
|
|
// Not vacuous: the write was sampled, and sampled past the point where
|
|
// the old statement size had filled the history.
|
|
expect(anchor).not.toBeNull();
|
|
expect(samples).toBeGreaterThan(5);
|
|
expect(lastWritten).toBeGreaterThanOrEqual(120_000);
|
|
|
|
const growth = peak - (anchor ?? 0);
|
|
process.stderr.write(
|
|
`[restore-memory] ${ROWS} readings: the heap grew by ${mb(growth)} MB ` +
|
|
`during the measurement write (${samples} samples)\n`,
|
|
);
|
|
expect(
|
|
growth,
|
|
`the restore held ${mb(growth)} MB more while writing the measurements`,
|
|
).toBeLessThan(GROWTH_BUDGET_BYTES);
|
|
}, 300_000);
|
|
});
|