From 7248347188596608bb2a33f76d5295957e91a42e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 07:18:56 +0200 Subject: [PATCH] test(integration): release the held lock when the migration has met it The 0412 lock-retry case let go of the lock on a 2.5 s timer. On a loaded runner the migrate CLI took longer than that to start, so the first attempt found the lock already free, applied cleanly and never produced the 55P03 the case is about. The lock is now released once the attempt is seen queued behind it in pg_stat_activity and gone again, which is the moment its own timeout fired, and the case fails outright if no attempt ever queued. It holds with the CLI start delayed by four seconds. --- .../migrate-deploy-lock-retry.test.ts | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/tests/integration/migrate-deploy-lock-retry.test.ts b/tests/integration/migrate-deploy-lock-retry.test.ts index b9678feaf..d4b0c6137 100644 --- a/tests/integration/migrate-deploy-lock-retry.test.ts +++ b/tests/integration/migrate-deploy-lock-retry.test.ts @@ -145,6 +145,47 @@ async function holdLock(): Promise { return holder; } +/** Sessions of this database queued behind a lock, other than the holder. */ +async function lockWaiters(db: Client, holderPid: number): Promise { + const { rows } = await db.query<{ n: string }>( + `SELECT count(*) AS n FROM pg_stat_activity + WHERE datname = current_database() + AND wait_event_type = 'Lock' + AND pid <> $1`, + [holderPid], + ); + return Number(rows[0]?.n ?? 0); +} + +/** + * Let go of the held lock once a migration attempt has queued behind it and + * given up. Resolves false when no attempt queued within the budget, so the + * test reports that instead of passing on a run that never met the lock. + */ +async function releaseAfterFirstTimeout(holder: Client): Promise { + const { rows } = await holder.query<{ pid: number }>( + "SELECT pg_backend_pid() AS pid", + ); + const holderPid = rows[0].pid; + const watcher = await connect(); + const until = async (predicate: (n: number) => boolean) => { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + if (predicate(await lockWaiters(watcher, holderPid))) return true; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return false; + }; + try { + const queued = await until((n) => n > 0); + if (queued) await until((n) => n === 0); + await holder.query("ROLLBACK"); + return queued; + } finally { + await watcher.end(); + } +} + async function state(db: Client) { const rows = await db.query( `SELECT migration_name, finished_at IS NOT NULL AS finished, @@ -181,15 +222,23 @@ describe("migration 0412 under a held lock", () => { add0412(dir, { marked: true }); const holder = await holdLock(); // The first attempt meets the lock; the holder lets go during the backoff. - const release = setTimeout(() => { - void holder.query("ROLLBACK"); - }, 2_500); + // The release follows the migration rather than a clock: it waits until + // the first attempt is seen queued behind the lock and then gone again + // (its one-second timeout fired), and lets go before the retry. A fixed + // delay raced the CLI's own start-up, which on a loaded runner outlasted + // the delay, so the first attempt found the lock already free. + const released = releaseAfterFirstTimeout(holder); const startedAt = Date.now(); const result = await migrateDeploy(dir); - clearTimeout(release); + const releasedInTime = await released; await holder.query("ROLLBACK").catch(() => {}); await holder.end(); + expect( + releasedInTime, + "the first attempt never queued behind the held lock", + ).toBe(true); + expect(result.code, result.stderr).toBe(0); expect(result.stderr).toContain("Database error code: 55P03"); expect(result.stdout).toContain(