mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
perf(backup): keyset-page the intake/mood/cycle reads in the off-host DR dump
Measurements were already paged, but the medication-intake, mood, and cycle tables in the same per-user payload were still unbounded full-table findMany reads — peak heap tracked the largest tenant's history. Give them the same id-keyset page walk (full row projection, so payload bytes are unchanged) so at most one page is resident per round-trip.
This commit is contained in:
1 parent
329f78d295
commit
3fcae204f7
2 files changed
+112
-7
No files matched your search
@@ -6,6 +6,7 @@ import {
|
||||
runOffhostBackup,
|
||||
runOffhostRoundtripTest,
|
||||
} from "../offhost-backup";
|
||||
import { MEASUREMENT_BACKUP_PAGE_SIZE } from "../backup/measurement-page";
|
||||
|
||||
const ENC_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
@@ -138,6 +139,56 @@ describe("runOffhostBackup", () => {
|
||||
expect(parsed.userId).toBe("u1");
|
||||
});
|
||||
|
||||
it("keyset-pages the intake/mood/cycle tables across multiple pages", async () => {
|
||||
const s3 = makeS3Mock();
|
||||
// A full first page (=== page size) followed by a short page proves the
|
||||
// reader walks the id cursor instead of one unbounded findMany.
|
||||
const fullPage = Array.from(
|
||||
{ length: MEASUREMENT_BACKUP_PAGE_SIZE },
|
||||
(_, i) => ({
|
||||
id: `e${String(i).padStart(5, "0")}`,
|
||||
userId: "u1",
|
||||
takenAt: new Date("2026-05-01T00:00:00Z"),
|
||||
}),
|
||||
);
|
||||
const tailPage = [
|
||||
{ id: "z9999", userId: "u1", takenAt: new Date("2026-05-02T00:00:00Z") },
|
||||
];
|
||||
const intakeFindMany = vi.fn(
|
||||
async (args: { where?: { id?: { gt?: string } } }) =>
|
||||
args?.where?.id?.gt ? tailPage : fullPage,
|
||||
);
|
||||
|
||||
const prisma = {
|
||||
user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) },
|
||||
measurement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medication: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationIntakeEvent: { findMany: intakeFindMany },
|
||||
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
|
||||
const report = await runOffhostBackup(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prisma as any,
|
||||
s3,
|
||||
new Date("2026-05-08T00:00:00Z"),
|
||||
);
|
||||
expect(report.uploaded).toBe(1);
|
||||
// Two round-trips: the full page, then the short tail that ends the walk.
|
||||
expect(intakeFindMany).toHaveBeenCalledTimes(2);
|
||||
expect(intakeFindMany.mock.calls[0][0]).toMatchObject({
|
||||
orderBy: { id: "asc" },
|
||||
take: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
});
|
||||
// Every paged row lands in the decrypted payload — no rows dropped.
|
||||
const ct = s3.store.get("2026-05-08/user-u1.json.enc")!;
|
||||
const parsed = JSON.parse(decryptBackup(ct, Buffer.from(ENC_KEY, "hex")));
|
||||
expect(parsed.intakeEvents).toHaveLength(MEASUREMENT_BACKUP_PAGE_SIZE + 1);
|
||||
});
|
||||
|
||||
it("counts per-user failures without aborting the whole run", async () => {
|
||||
const s3 = makeS3Mock();
|
||||
const prisma = {
|
||||
|
||||
@@ -279,16 +279,70 @@ export async function runOffhostBackup(
|
||||
where: { userId: user.id },
|
||||
include: { schedules: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({ where: { userId: user.id } }),
|
||||
prisma.moodEntry.findMany({ where: { userId: user.id } }),
|
||||
// The intake / mood / cycle tables scale with history depth the same
|
||||
// way measurements do (multiple intakes + a mood + a cycle-day-log per
|
||||
// day over years), so they get the same keyset-paged read: at most one
|
||||
// page is resident per round-trip instead of the entire table landing
|
||||
// in one array alongside the giant JSON.stringify below. The projection
|
||||
// keeps the full row (no select), so payload bytes are identical to the
|
||||
// prior default findMany — only peak heap during the read is bounded.
|
||||
collectPagedMeasurements({
|
||||
fetchPage: (afterId, take) =>
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(afterId ? { id: { gt: afterId } } : {}),
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take,
|
||||
}),
|
||||
project: (row) => row,
|
||||
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
}),
|
||||
collectPagedMeasurements({
|
||||
fetchPage: (afterId, take) =>
|
||||
prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(afterId ? { id: { gt: afterId } } : {}),
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take,
|
||||
}),
|
||||
project: (row) => row,
|
||||
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
}),
|
||||
// v1.15.0 — cycle tables in the DR snapshot. Raw rows (this is a
|
||||
// disaster-recovery dump, not the canonical user-facing backup);
|
||||
// `notesEncrypted` stays ciphertext.
|
||||
// `notesEncrypted` stays ciphertext. The profile is a single row, so it
|
||||
// stays a direct read; the cycle + day-log tables are paged.
|
||||
prisma.cycleProfile.findUnique({ where: { userId: user.id } }),
|
||||
prisma.menstrualCycle.findMany({ where: { userId: user.id } }),
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { symptomLinks: true },
|
||||
collectPagedMeasurements({
|
||||
fetchPage: (afterId, take) =>
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(afterId ? { id: { gt: afterId } } : {}),
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take,
|
||||
}),
|
||||
project: (row) => row,
|
||||
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
}),
|
||||
collectPagedMeasurements({
|
||||
fetchPage: (afterId, take) =>
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(afterId ? { id: { gt: afterId } } : {}),
|
||||
},
|
||||
include: { symptomLinks: true },
|
||||
orderBy: { id: "asc" },
|
||||
take,
|
||||
}),
|
||||
project: (row) => row,
|
||||
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user