fix(test): wait for a PostgreSQL fixture slot while the queue moves (BUG-1254)
Independent Staging Quality Gate / validate (push) Successful in 14m10s
Independent Staging Quality Gate / publish (push) Successful in 4m3s

The slot wait was a fixed 5 minutes from the moment a file started queueing.
With 46 fixture files sharing 2 slots the queue outlasted that, so whichever
file came last failed the gate at random (runs 1742 and 1746). The deadline
now restarts whenever a slot changes hands; only 10 minutes with no slot
changing hands (a hung holder) fails. The 2-slot cap from BUG-281 stays.

Full suite 4967 / fail 24, identical to 171a3f25; tsc 0; lint 0 errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-10-06 23:21:48 +08:00
co-authored by Claude Opus 5.5
parent fe9d03a9b7
commit 52d14e8fef
4 changed files with 71 additions and 7 deletions
+42 -3
View File
@@ -79,7 +79,15 @@ function releasePort(reservation: PortReservation): void {
}
const DEFAULT_MAX_CONCURRENT_POSTGRES_FIXTURES = 2;
const FIXTURE_SLOT_WAIT_MS = 5 * 60 * 1000;
/**
* How long the slot queue may stand still before a waiter gives up (BUG-1254).
* It used to be a fixed 5-minute wait from the moment a file started queueing;
* with 46 fixture files sharing 2 slots the queue itself outlasted that, and
* whichever file happened to be last failed the gate at random. The window now
* restarts whenever a slot changes hands, so a moving queue is waited out and
* only a queue where no slot changes for this long (a hung holder) fails.
*/
export const FIXTURE_SLOT_STALL_MS = 10 * 60 * 1000;
type FixtureSlot = { id: number; release(): void };
@@ -135,9 +143,37 @@ function tryClaimSlot(id: number): boolean {
return true;
}
/** Who holds each slot right now: `id:pid` per held slot, empty when free. */
function slotOwnersSnapshot(limit: number): string {
const owners: string[] = [];
for (let id = 0; id < limit; id += 1) {
try {
owners.push(`${id}:${readFileSync(join(fixtureSlotDirectory(id), "pid"), "utf8")}`);
} catch {
owners.push(`${id}:`);
}
}
return owners.join("|");
}
/**
* The waiter's deadline after one poll: pushed out by a full stall window when
* the slot owners changed since the last poll, unchanged otherwise.
*/
export function nextFixtureSlotDeadline(
previousOwners: string,
currentOwners: string,
deadline: number,
now: number,
stallMs: number = FIXTURE_SLOT_STALL_MS,
): number {
return currentOwners === previousOwners ? deadline : now + stallMs;
}
function acquireFixtureSlot(): FixtureSlot {
const limit = maxConcurrentPostgresFixtures();
const deadline = Date.now() + FIXTURE_SLOT_WAIT_MS;
let owners = slotOwnersSnapshot(limit);
let deadline = Date.now() + FIXTURE_SLOT_STALL_MS;
while (Date.now() < deadline) {
for (let id = 0; id < limit; id += 1) {
if (tryClaimSlot(id)) {
@@ -150,9 +186,12 @@ function acquireFixtureSlot(): FixtureSlot {
}
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
const current = slotOwnersSnapshot(limit);
deadline = nextFixtureSlotDeadline(owners, current, deadline, Date.now());
owners = current;
}
throw new Error(
`timed out waiting for a PostgreSQL fixture slot (${limit} concurrent compose networks). Docker address pools cannot host one network per parallel test file.`,
`timed out waiting for a PostgreSQL fixture slot (${limit} concurrent compose networks): no slot changed hands for ${FIXTURE_SLOT_STALL_MS / 60000} minutes, so a holder is likely hung. Docker address pools cannot host one network per parallel test file.`,
);
}
@@ -3,6 +3,8 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { FIXTURE_SLOT_STALL_MS, nextFixtureSlotDeadline } from "./helpers/postgres-fixture.ts";
const fixtureSource = readFileSync(
fileURLToPath(new URL("./helpers/postgres-fixture.ts", import.meta.url)),
"utf8",
@@ -42,3 +44,25 @@ test("large PostgreSQL scripts use stdin without changing shared -c helpers", ()
assert.match(fixtureSource, /psql\(sql\)[\s\S]*"-Atc",\s*sql/);
assert.match(fixtureSource, /psqlAs\(role, password, sql\)[\s\S]*"-Atc",\s*sql/);
});
test("a waiter keeps waiting while the slot queue moves and gives up only when it stalls (BUG-1254)", () => {
// Fixed 5-minute waits failed the gate at random once 46 fixture files
// queued for 2 slots: the queue itself outlasted the wait.
assert.doesNotMatch(fixtureSource, /FIXTURE_SLOT_WAIT_MS/);
assert.equal(FIXTURE_SLOT_STALL_MS, 10 * 60 * 1000);
const start = 1_000_000;
const deadline = start + FIXTURE_SLOT_STALL_MS;
// Nobody released or claimed a slot: the deadline does not move.
assert.equal(nextFixtureSlotDeadline("0:11|1:12", "0:11|1:12", deadline, start + 60_000), deadline);
// A slot changed hands: a full stall window from now.
const later = start + 9 * 60_000;
assert.equal(nextFixtureSlotDeadline("0:11|1:12", "0:13|1:12", deadline, later), later + FIXTURE_SLOT_STALL_MS);
// A slot was released and is momentarily free: that is progress too.
assert.equal(nextFixtureSlotDeadline("0:11|1:12", "0:|1:12", deadline, later), later + FIXTURE_SLOT_STALL_MS);
// The acquire loop recomputes the deadline from the owners after every poll.
assert.match(
fixtureSource,
/function acquireFixtureSlot\(\)[\s\S]*slotOwnersSnapshot\(limit\)[\s\S]*deadline = nextFixtureSlotDeadline\(owners, current, deadline, Date\.now\(\)\)/,
);
assert.match(fixtureSource, /no slot changed hands for/);
});