mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
fix(migrations): retire the measurements prefix index without waiting at boot
0416 ran DROP INDEX CONCURRENTLY at boot, which waits without bound for every transaction on the table; a pg_dump or a backfill outlasted the start period and the next boot stopped at P3009. The migration now tries a plain drop with a two-second lock wait and tolerates the timeout, and the nightly measurement maintenance drops what is left concurrently under a bounded wait, retrying the next night. A fresh or shadow database takes the lock at once, so the history still ends where the schema does. The guard refuses a concurrent drop in a migration and ties every retired index to a tolerant one.
This commit is contained in:
1 parent
a275a5bb65
commit
2b81b4f23e
5 files changed
+388
-19
No files matched your search
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Retiring an index on `measurements` without the boot waiting on the table.
|
||||
*
|
||||
* Migration 0416 drops `measurements_user_id_type_measured_at_idx` only when
|
||||
* the lock is there at once and otherwise leaves it, successfully; the
|
||||
* nightly measurement maintenance drops what is left with
|
||||
* `DROP INDEX CONCURRENTLY IF EXISTS` under a bounded lock wait. Proven here
|
||||
* against Postgres, each with the transaction that used to hold the boot:
|
||||
* the migration finishes while a reader holds the table and drops the index
|
||||
* once it does not; the maintenance step gives up within its wait, puts the
|
||||
* session's lock timeout back, and drops the index on the next run.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { Client } from "pg";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MAINTENANCE_SESSION_OPTIONS,
|
||||
RETIRED_MEASUREMENT_INDEXES,
|
||||
runMeasurementMaintenance,
|
||||
} from "@/lib/jobs/measurement-maintenance";
|
||||
|
||||
const INDEX = "measurements_user_id_type_measured_at_idx";
|
||||
const MIGRATION = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"prisma/migrations/0416_drop_measurements_type_prefix_index/migration.sql",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
function connect(options?: string): Client {
|
||||
const url = new URL(process.env.DATABASE_URL ?? "");
|
||||
url.searchParams.delete("schema");
|
||||
return new Client({ connectionString: url.toString(), options });
|
||||
}
|
||||
|
||||
async function present(db: Client): Promise<boolean> {
|
||||
const { rows } = await db.query<{ present: boolean }>(
|
||||
`SELECT to_regclass($1) IS NOT NULL AS present`,
|
||||
[INDEX],
|
||||
);
|
||||
return rows[0].present;
|
||||
}
|
||||
|
||||
let db: Client;
|
||||
let holder: Client;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = connect(MAINTENANCE_SESSION_OPTIONS);
|
||||
holder = connect();
|
||||
await db.connect();
|
||||
await holder.connect();
|
||||
// The state on an instance that has not run 0416 yet.
|
||||
await db.query(
|
||||
`CREATE INDEX IF NOT EXISTS "${INDEX}" ON "measurements" ("user_id", "type", "measured_at")`,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await holder.query("ROLLBACK").catch(() => {});
|
||||
await holder.end();
|
||||
await db.query(`DROP INDEX IF EXISTS "${INDEX}"`);
|
||||
await db.end();
|
||||
});
|
||||
|
||||
describe("migration 0416", () => {
|
||||
it("finishes while a reader holds the table and leaves the index", async () => {
|
||||
await holder.query("BEGIN");
|
||||
await holder.query(`SELECT count(*) FROM "measurements"`);
|
||||
|
||||
const startedAt = Date.now();
|
||||
await db.query(MIGRATION);
|
||||
|
||||
expect(Date.now() - startedAt).toBeLessThan(10_000);
|
||||
expect(await present(db)).toBe(true);
|
||||
});
|
||||
|
||||
it("counter-check: drops the index when nothing holds the table", async () => {
|
||||
await db.query(MIGRATION);
|
||||
expect(await present(db)).toBe(false);
|
||||
// And again, as a retried boot would.
|
||||
await db.query(MIGRATION);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the maintenance step for a retired index", () => {
|
||||
it("names the index the migration retires", () => {
|
||||
expect(RETIRED_MEASUREMENT_INDEXES).toContain(INDEX);
|
||||
});
|
||||
|
||||
it("gives up within its wait, restores the session, and drops it on the next run", async () => {
|
||||
await holder.query("BEGIN");
|
||||
await holder.query(
|
||||
`LOCK TABLE "measurements" IN SHARE UPDATE EXCLUSIVE MODE`,
|
||||
);
|
||||
|
||||
const blocked = await runMeasurementMaintenance(
|
||||
db,
|
||||
{ vacuum: false, reindex: false },
|
||||
{ retiredLockTimeout: "300ms" },
|
||||
);
|
||||
expect(blocked.outcome).toBe("nothing_due");
|
||||
expect(blocked.steps).toEqual([
|
||||
expect.objectContaining({
|
||||
step: "drop_retired",
|
||||
target: INDEX,
|
||||
ok: false,
|
||||
error: expect.stringContaining("55P03"),
|
||||
}),
|
||||
]);
|
||||
// The session's own wait (five minutes) is back for the rest of the run.
|
||||
const { rows } = await db.query<{ value: string }>(
|
||||
`SELECT current_setting('lock_timeout') AS value`,
|
||||
);
|
||||
expect(rows[0].value).toBe("5min");
|
||||
|
||||
await holder.query("ROLLBACK");
|
||||
const next = await runMeasurementMaintenance(db, {
|
||||
vacuum: false,
|
||||
reindex: false,
|
||||
});
|
||||
expect(next.steps).toEqual([
|
||||
expect.objectContaining({
|
||||
step: "drop_retired",
|
||||
target: INDEX,
|
||||
ok: true,
|
||||
}),
|
||||
]);
|
||||
expect(await present(db)).toBe(false);
|
||||
|
||||
// Gone: the run after that has nothing to do.
|
||||
const after = await runMeasurementMaintenance(db, {
|
||||
vacuum: false,
|
||||
reindex: false,
|
||||
});
|
||||
expect(after.steps).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user