mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
fix(restore): skip expired tombstones and give the chart rebuild its own limit
A restore now leaves out measurement tombstones deleted longer ago than the tombstone retention. The nightly purge removes exactly those rows, the delta feed answers cursorExpired for every cursor that could still be served one, and the consolidation repairs look only inside the same horizon. Younger tombstones keep their readers (a lagging paired client, the undo of a recent delete, the repairs, the duplicate guard on re-uploaded samples) and are written as before. The backup file is unchanged. On a seeded account of 1.89 million readings whose 1.68 million tombstones were past retention, the restore on a slowed database took 5.4 min instead of 15. The rollup fold after the commit rebuilt five years of buckets under the 60 s request statement limit and could be dropped on a slow disk, leaving the charts empty until the next boot. `recomputeUserRollups` takes a `statementTimeoutMs` that scopes each aggregate and persist transaction with SET LOCAL, and the restore passes its after-commit allowance. Fixture tombstones in two round-trip tests were dated in July 2026 and had aged past the retention; they are now relative to the run.
This commit is contained in:
1 parent
c133acb332
commit
fc13b9de5c
5 files changed
+189
-35
No files matched your search
@@ -182,7 +182,11 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
const tombstoneDeletedAt = new Date("2026-07-01T12:00:00.000Z");
|
||||
// Recent, so the restore writes it back: a tombstone older than the
|
||||
// tombstone retention is left out, as the nightly purge would drop it.
|
||||
const tombstoneDeletedAt = new Date(
|
||||
Math.floor(Date.now() / 1000) * 1000 - 86_400_000,
|
||||
);
|
||||
const deletedMeasurement = await prisma.measurement.create({
|
||||
data: {
|
||||
id: "measurement-deleted-dr",
|
||||
@@ -919,7 +923,11 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
source: "IMPORT" as const,
|
||||
externalId: `external-${index}`,
|
||||
syncVersion: 3,
|
||||
deletedAt: index % 10 === 0 ? "2026-07-11T00:00:00.000Z" : null,
|
||||
// Recent: a tombstone past the retention is not written back.
|
||||
deletedAt:
|
||||
index % 10 === 0
|
||||
? new Date(Date.now() - 86_400_000).toISOString()
|
||||
: null,
|
||||
}));
|
||||
const payload = backupPayloadSchema.parse({
|
||||
schemaVersion: "2",
|
||||
|
||||
@@ -15,12 +15,16 @@
|
||||
* because nothing was slow. The restore then has to finish and give back
|
||||
* exactly the rows the backup holds.
|
||||
*
|
||||
* The last test makes the database refuse the clearing step with a disk-full
|
||||
* error and checks the job says so, and that nothing changed.
|
||||
* The third test makes the database refuse the clearing step with a
|
||||
* disk-full error and checks the job says so, and that nothing changed. The
|
||||
* last makes every write of chart buckets outlast the session limit and
|
||||
* checks the rebuild after the commit still lands every granularity.
|
||||
*
|
||||
* Mutation check: remove the `set_config('statement_timeout', …)` call at the
|
||||
* top of the restore transaction and the restore test goes red with
|
||||
* `cause: "timeout"`.
|
||||
* Mutation checks: remove the `set_config('statement_timeout', …)` call at
|
||||
* the top of the restore transaction and the restore test goes red with
|
||||
* `cause: "timeout"`; drop `statementTimeoutMs` from the restore's
|
||||
* `recomputeUserRollups` call and the rebuild test finds no buckets; write
|
||||
* every tombstone again and the restore test finds the expired ones back.
|
||||
*/
|
||||
import {
|
||||
afterAll,
|
||||
@@ -146,8 +150,14 @@ async function seedAccountWithBackup() {
|
||||
source: "APPLE_HEALTH" as const,
|
||||
measuredAt: new Date(start + i * 60_000),
|
||||
externalId: `hk-${i}`,
|
||||
// Most of them deleted, as after the nightly consolidation.
|
||||
deletedAt: i % 8 === 0 ? null : new Date(start),
|
||||
// Most of them deleted, as after the nightly consolidation: half of
|
||||
// those yesterday, half long past the tombstone retention.
|
||||
deletedAt:
|
||||
i % 8 === 0
|
||||
? null
|
||||
: i % 2 === 0
|
||||
? new Date(start)
|
||||
: new Date(Date.now() - 86_400_000),
|
||||
})),
|
||||
});
|
||||
const { id } = await storeBackupBlob(
|
||||
@@ -218,7 +228,14 @@ describe("restore under a short statement timeout (#1031)", () => {
|
||||
expect(job?.failure ?? null).toBeNull();
|
||||
expect(res.status).toBe(200);
|
||||
expect(job?.status).toBe("succeeded");
|
||||
expect(await measurementsOf(user.id)).toEqual(before);
|
||||
// Every row comes back but the tombstones past retention, which the
|
||||
// nightly purge would remove anyway.
|
||||
const kept = before.filter(
|
||||
(m) => m.deletedAt === null || m.deletedAt.getTime() > Date.UTC(2025, 0),
|
||||
);
|
||||
expect(kept.some((m) => m.deletedAt !== null)).toBe(true);
|
||||
expect(kept.length).toBeLessThan(before.length);
|
||||
expect(await measurementsOf(user.id)).toEqual(kept);
|
||||
});
|
||||
|
||||
it("a refused clearing step names its cause and changes nothing", async () => {
|
||||
@@ -249,4 +266,37 @@ describe("restore under a short statement timeout (#1031)", () => {
|
||||
code: "53100",
|
||||
});
|
||||
});
|
||||
|
||||
it("the chart rebuild after the commit outlasts the session limit too", async () => {
|
||||
const { user, backupId } = await seedAccountWithBackup();
|
||||
const prisma = housekeeping();
|
||||
// Every write of rollup buckets takes longer than the session limit.
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE FUNCTION restore_test_slow_rollups() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_sleep(6); RETURN NULL; END $$`,
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE TRIGGER restore_test_slow_rollups BEFORE INSERT ON measurement_rollups FOR EACH STATEMENT EXECUTE FUNCTION restore_test_slow_rollups()`,
|
||||
);
|
||||
try {
|
||||
expect((await restore(backupId)).status).toBe(200);
|
||||
const buckets = await prisma.measurementRollup.groupBy({
|
||||
by: ["granularity"],
|
||||
where: { userId: user.id },
|
||||
_count: true,
|
||||
});
|
||||
expect(buckets.map((b) => b.granularity).sort()).toEqual([
|
||||
"DAY",
|
||||
"MONTH",
|
||||
"WEEK",
|
||||
"YEAR",
|
||||
]);
|
||||
} finally {
|
||||
await prisma.$executeRawUnsafe(
|
||||
`DROP TRIGGER IF EXISTS restore_test_slow_rollups ON measurement_rollups`,
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`DROP FUNCTION IF EXISTS restore_test_slow_rollups()`,
|
||||
);
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -76,6 +76,7 @@ async function seedAccount() {
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
|
||||
const start = Date.UTC(2024, 0, 1);
|
||||
const yesterday = new Date(Date.now() - 86_400_000);
|
||||
await prisma.measurement.createMany({
|
||||
data: Array.from({ length: COUNT }, (_, i) => ({
|
||||
id: `m-${String(i).padStart(5, "0")}`,
|
||||
@@ -91,7 +92,9 @@ async function seedAccount() {
|
||||
deviceType: "watch",
|
||||
syncVersion: 1 + (i % 4),
|
||||
notesEncrypted: i === 17 ? encryptNote("after the long run") : null,
|
||||
deletedAt: i % 97 === 0 ? new Date(start) : null,
|
||||
// Deleted yesterday, inside the tombstone retention, so a weekly
|
||||
// backup's restore writes it back.
|
||||
deletedAt: i % 97 === 0 ? yesterday : null,
|
||||
})),
|
||||
});
|
||||
await prisma.personalRecord.create({
|
||||
|
||||
Reference in new issue
Block a user