mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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.
123 lines
4.1 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|