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
359 lines
10 KiB
TypeScript
359 lines
10 KiB
TypeScript
import { execFileSync, spawnSync } from "node:child_process";
|
|
import {
|
|
chmodSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
export type PostgresFixture = {
|
|
projectName: string;
|
|
databaseEnvFile: string;
|
|
hostPort: number;
|
|
connectionUrl(role: string, password: string): string;
|
|
psql(sql: string): string;
|
|
psqlAs(role: string, password: string, sql: string): string;
|
|
psqlScriptAs(role: string, password: string, sql: string): string;
|
|
stop(): void;
|
|
};
|
|
|
|
const databaseEnvironment = `POSTGRES_DB=jyotisha
|
|
POSTGRES_USER=postgres
|
|
POSTGRES_PASSWORD=postgres-test-password
|
|
SCHEMA_OWNER_PASSWORD=schema-owner-test-password
|
|
IDENTITY_RUNTIME_PASSWORD=identity-runtime-test-password
|
|
APP_RUNTIME_PASSWORD=app-runtime-test-password
|
|
SERVICE_RUNTIME_PASSWORD=service-runtime-test-password
|
|
ADMIN_RUNTIME_PASSWORD=admin-runtime-test-password
|
|
MIGRATION_RUNNER_PASSWORD=migration-runner-test-password
|
|
BACKUP_READER_PASSWORD=backup-reader-test-password
|
|
STAGING_BACKUP_ENCRYPTION_KEY=staging-backup-test-password
|
|
SCHEMA_DATABASE_URL=postgresql://schema_owner:schema-owner-test-password@postgres:5432/jyotisha
|
|
`;
|
|
|
|
function isPortAvailable(port: number): boolean {
|
|
return (
|
|
spawnSync(
|
|
process.execPath,
|
|
[
|
|
"-e",
|
|
`const server = require("node:net").createServer();
|
|
server.once("error", () => process.exit(1));
|
|
server.listen({ host: "127.0.0.1", port: Number(process.argv[1]) }, () =>
|
|
server.close(() => process.exit(0)),
|
|
);`,
|
|
String(port),
|
|
],
|
|
{ stdio: "ignore" },
|
|
).status === 0
|
|
);
|
|
}
|
|
|
|
type PortReservation = { port: number; directory: string };
|
|
|
|
function reserveAvailablePort(): PortReservation {
|
|
for (let port = 55432; port <= 55531; port += 1) {
|
|
const directory = join(tmpdir(), `jyotisha-postgres-port-${port}.lock`);
|
|
try {
|
|
mkdirSync(directory);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
|
|
throw error;
|
|
}
|
|
|
|
if (isPortAvailable(port)) {
|
|
return { port, directory };
|
|
}
|
|
rmSync(directory, { force: true, recursive: true });
|
|
}
|
|
|
|
throw new Error("no available PostgreSQL test port in 55432..55531");
|
|
}
|
|
|
|
function releasePort(reservation: PortReservation): void {
|
|
rmSync(reservation.directory, { force: true, recursive: true });
|
|
}
|
|
|
|
const DEFAULT_MAX_CONCURRENT_POSTGRES_FIXTURES = 2;
|
|
/**
|
|
* 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 };
|
|
|
|
function maxConcurrentPostgresFixtures(): number {
|
|
const raw = process.env.JYOTISHA_POSTGRES_MAX_FIXTURES;
|
|
if (raw === undefined || raw === "") {
|
|
return DEFAULT_MAX_CONCURRENT_POSTGRES_FIXTURES;
|
|
}
|
|
const parsed = Number(raw);
|
|
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
throw new Error("JYOTISHA_POSTGRES_MAX_FIXTURES must be a positive integer");
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function fixtureSlotsRoot(): string {
|
|
return join(tmpdir(), "jyotisha-postgres-slots");
|
|
}
|
|
|
|
function fixtureSlotDirectory(id: number): string {
|
|
return join(fixtureSlotsRoot(), `slot-${id}`);
|
|
}
|
|
|
|
function pidIsAlive(pid: number): boolean {
|
|
if (!Number.isInteger(pid) || pid <= 0) {
|
|
return false;
|
|
}
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function tryClaimSlot(id: number): boolean {
|
|
const directory = fixtureSlotDirectory(id);
|
|
mkdirSync(fixtureSlotsRoot(), { recursive: true });
|
|
try {
|
|
mkdirSync(directory);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
try {
|
|
const pid = Number(readFileSync(join(directory, "pid"), "utf8"));
|
|
if (pidIsAlive(pid)) return false;
|
|
rmSync(directory, { force: true, recursive: true });
|
|
mkdirSync(directory);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
writeFileSync(join(directory, "pid"), String(process.pid));
|
|
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();
|
|
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)) {
|
|
return {
|
|
id,
|
|
release() {
|
|
rmSync(fixtureSlotDirectory(id), { force: true, recursive: true });
|
|
},
|
|
};
|
|
}
|
|
}
|
|
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): 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.`,
|
|
);
|
|
}
|
|
|
|
export function startPostgresFixture(): PostgresFixture {
|
|
const slot = acquireFixtureSlot();
|
|
const projectName = `jyotisha-postgres-${process.pid}-${Date.now()}`;
|
|
let temporaryDirectory: string;
|
|
try {
|
|
temporaryDirectory = mkdtempSync(join(tmpdir(), "jyotisha-postgres-"));
|
|
} catch (error) {
|
|
slot.release();
|
|
throw error;
|
|
}
|
|
const databaseEnvFile = join(temporaryDirectory, "database.env");
|
|
let portReservation: PortReservation;
|
|
try {
|
|
portReservation = reserveAvailablePort();
|
|
} catch (error) {
|
|
rmSync(temporaryDirectory, { force: true, recursive: true });
|
|
slot.release();
|
|
throw error;
|
|
}
|
|
const hostPort = portReservation.port;
|
|
const composeArguments = [
|
|
"compose",
|
|
"--project-name",
|
|
projectName,
|
|
"--env-file",
|
|
databaseEnvFile,
|
|
"-f",
|
|
"../deploy/docker-compose.postgres.yml",
|
|
"-f",
|
|
"../deploy/docker-compose.postgres-ci.yml",
|
|
];
|
|
const environment = {
|
|
...process.env,
|
|
DATABASE_ENV_FILE: databaseEnvFile,
|
|
POSTGRES_HOST_PORT: String(hostPort),
|
|
};
|
|
|
|
try {
|
|
writeFileSync(databaseEnvFile, databaseEnvironment, { mode: 0o600 });
|
|
chmodSync(databaseEnvFile, 0o600);
|
|
execFileSync(
|
|
"docker",
|
|
[...composeArguments, "up", "-d", "--wait", "postgres"],
|
|
{ env: environment, stdio: "inherit" },
|
|
);
|
|
} catch (error) {
|
|
try {
|
|
try {
|
|
// The teardown below deletes the only account of why the server refused to start.
|
|
execFileSync(
|
|
"docker",
|
|
[...composeArguments, "logs", "--no-color", "--tail", "80", "postgres"],
|
|
{ env: environment, stdio: "inherit" },
|
|
);
|
|
} catch {
|
|
// Preserve the original startup failure.
|
|
}
|
|
try {
|
|
execFileSync(
|
|
"docker",
|
|
[...composeArguments, "down", "-v", "--remove-orphans"],
|
|
{ env: environment, stdio: "inherit" },
|
|
);
|
|
} catch {
|
|
// Preserve the original startup failure.
|
|
}
|
|
} finally {
|
|
releasePort(portReservation);
|
|
rmSync(temporaryDirectory, { force: true, recursive: true });
|
|
slot.release();
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
projectName,
|
|
databaseEnvFile,
|
|
hostPort,
|
|
connectionUrl(role, password) {
|
|
return `postgresql://${role}:${password}@127.0.0.1:${hostPort}/jyotisha`;
|
|
},
|
|
psql(sql) {
|
|
return execFileSync(
|
|
"docker",
|
|
[...composeArguments, "exec", "-T", "postgres", "psql", "-U", "postgres", "-d", "jyotisha", "-Atc", sql],
|
|
{ encoding: "utf8", env: environment },
|
|
)
|
|
.trim()
|
|
// boolean::text is "false"; psql -A prints "f". Keep colon-joined
|
|
// results on the t/f alphabet so tests can assert "f:" stably.
|
|
.replace(/(^|:)false(?=:|$)/gm, "$1f");
|
|
},
|
|
psqlAs(role, password, sql) {
|
|
return execFileSync(
|
|
"docker",
|
|
[
|
|
...composeArguments,
|
|
"exec",
|
|
"-T",
|
|
"-e",
|
|
`PGPASSWORD=${password}`,
|
|
"postgres",
|
|
"psql",
|
|
"-v",
|
|
"ON_ERROR_STOP=1",
|
|
"-U",
|
|
role,
|
|
"-d",
|
|
"jyotisha",
|
|
"-Atc",
|
|
sql,
|
|
],
|
|
{ encoding: "utf8", env: environment },
|
|
).trim();
|
|
},
|
|
psqlScriptAs(role, password, sql) {
|
|
return execFileSync(
|
|
"docker",
|
|
[
|
|
...composeArguments,
|
|
"exec",
|
|
"-T",
|
|
"-e",
|
|
`PGPASSWORD=${password}`,
|
|
"postgres",
|
|
"psql",
|
|
"-X",
|
|
"-At",
|
|
"-v",
|
|
"ON_ERROR_STOP=1",
|
|
"--single-transaction",
|
|
"-f",
|
|
"-",
|
|
"-U",
|
|
role,
|
|
"-d",
|
|
"jyotisha",
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: environment,
|
|
input: sql,
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
},
|
|
).trim();
|
|
},
|
|
stop() {
|
|
try {
|
|
execFileSync(
|
|
"docker",
|
|
[...composeArguments, "down", "-v", "--remove-orphans"],
|
|
{ env: environment, stdio: "inherit" },
|
|
);
|
|
} finally {
|
|
releasePort(portReservation);
|
|
rmSync(temporaryDirectory, { force: true, recursive: true });
|
|
slot.release();
|
|
}
|
|
},
|
|
};
|
|
}
|