mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the login, register, token and session paths, mirroring columns every migration under prisma/migrations already creates. The entrypoint applies those migrations before the server listens and stops the boot when one fails, so the mirror never had work to do, and DDL on a request path needs the table's owner: a connection that may only read and write rows fails every login on it. Drop the module, its call sites and the mocks that stubbed it out.
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, vi } 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);
|
|
});
|