Merge branch 'release/v1.39.4' into fix/v1394-review-correctness

This commit is contained in:
Marc-André Bombeck committed 2026-09-27 13:27:59 +02:00
commit 5173049130
26 files changed
+4034 -54

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({