mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
141 lines
4.3 KiB
TypeScript
141 lines
4.3 KiB
TypeScript
/**
|
|
* 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([]);
|
|
});
|
|
});
|