Files
HealthLog/tests/integration/measurement-hot-update.test.ts
T
Marc-André Bombeck 9c89503019 fix(migrations): bound 0412's lock wait where it takes effect and retry a lock timeout on boot
migrate deploy runs a migration file statement by statement, so the
top-level SET LOCAL lock_timeout in 0412 did nothing: the migration
waited as long as the lock was held, with every write to measurements
queued behind it, and a file that failed half-way stayed half-applied.
0412 now takes its locks inside one DO block with a statement-scoped
timeout, which rolls back whole, after an idempotent CREATE OR REPLACE.

The entrypoint runs migrate deploy through scripts/migrate-deploy.mjs.
A 55P03 failure of a migration marked -- @retry-on-lock-timeout is
marked rolled back and retried after 15/30/60/120/240 s, read from
Prisma's report or, after P3009, from the stored row. Every other
failure stays fatal. A guard holds migrations to the shape that makes
the marker true, and 0411/0413 add their columns with IF NOT EXISTS so
a manual re-run is safe too.
2026-10-20 10:45:14 +02:00

123 lines
4.1 KiB
TypeScript

/**
* Migration 0412: an update of `measurements` that changes nothing keeps its
* stamps and stays on its page.
*
* `updated_at` sits in the sync-feed index, so an update that moved it could
* never be heap-only, and every provider re-sync that rewrote unchanged rows
* grew the table by a full row version and an entry in every index. Proven
* here against Postgres: the no-op update keeps `updated_at` and
* `sync_version` and is counted as HOT in the transaction's own statistics,
* a real change still moves both, the nightly check reads the table's
* statistics and leaves the vacuuming to autovacuum. The migration's lock
* wait is proven through the real `migrate deploy` in
* `migrate-deploy-lock-retry.test.ts`.
*/
import { Client } from "pg";
import { beforeEach, describe, expect, it } from "vitest";
import {
resolveSteps,
runMeasurementMaintenance,
tableHealth,
} from "@/lib/jobs/measurement-maintenance";
import { getPrismaClient, truncateAllTables } from "./setup";
const prisma = getPrismaClient();
async function seedReading() {
const user = await prisma.user.create({
data: {
username: "hot-update",
email: "hot-update@example.test",
role: "USER",
timezone: "UTC",
},
});
return prisma.measurement.create({
data: {
userId: user.id,
type: "WEIGHT",
value: 72.3,
unit: "kg",
measuredAt: new Date("2026-10-01T07:00:00.000Z"),
source: "WITHINGS",
},
});
}
beforeEach(async () => {
await truncateAllTables(prisma);
});
describe("an update that changes nothing", () => {
it("keeps updated_at and sync_version and stays on its page", async () => {
const row = await seedReading();
await new Promise((r) => setTimeout(r, 20));
const hot = await prisma.$transaction(async (tx) => {
await tx.measurement.update({
where: { id: row.id },
data: { value: 72.3, deletedAt: null, syncVersion: { increment: 1 } },
});
const [stats] = await tx.$queryRaw<Array<{ hot: bigint; upd: bigint }>>`
SELECT n_tup_hot_upd AS hot, n_tup_upd AS upd
FROM pg_stat_xact_user_tables WHERE relname = 'measurements'`;
return stats;
});
expect(Number(hot.upd)).toBe(1);
expect(Number(hot.hot)).toBe(1);
const after = await prisma.measurement.findUniqueOrThrow({
where: { id: row.id },
});
expect(after.updatedAt.getTime()).toBe(row.updatedAt.getTime());
expect(after.syncVersion).toBe(row.syncVersion);
});
it("still moves both stamps when a value changes", async () => {
const row = await seedReading();
await new Promise((r) => setTimeout(r, 20));
await prisma.measurement.update({
where: { id: row.id },
data: { value: 72.4, syncVersion: { increment: 1 } },
});
const after = await prisma.measurement.findUniqueOrThrow({
where: { id: row.id },
});
expect(after.updatedAt.getTime()).toBeGreaterThan(row.updatedAt.getTime());
expect(after.syncVersion).toBe(row.syncVersion + 1);
});
it("gives the table room on each page and an earlier autovacuum", async () => {
const [rel] = await prisma.$queryRaw<Array<{ options: string[] | null }>>`
SELECT reloptions AS options FROM pg_class WHERE relname = 'measurements'`;
expect(rel.options).toEqual(
expect.arrayContaining([
"fillfactor=85",
"autovacuum_vacuum_scale_factor=0.05",
]),
);
});
});
describe("the nightly check", () => {
it("reads the statistics and leaves the vacuuming to autovacuum", async () => {
await seedReading();
const db = new Client({ connectionString: process.env.DATABASE_URL });
await db.connect();
try {
await db.query("ANALYZE measurements");
const health = await tableHealth(db);
expect(health.heapBytes).toBeGreaterThan(0);
const steps = resolveSteps({});
expect(steps).toEqual({ vacuum: false, reindex: false });
const summary = await runMeasurementMaintenance(db, steps);
expect(summary.outcome).toBe("nothing_due");
expect(summary.steps).toEqual([]);
} finally {
await db.end();
}
});
});