Initial Commit
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Vitest globalSetup — registers the Postgres fixture in the
|
||||
* globalSetup process's module scope.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
import { createIntegrationSetup } from "@suaveplan/testing-integration";
|
||||
import { PostgresFixture } from "@suaveplan/testing-integration-postgres";
|
||||
|
||||
createIntegrationSetup({
|
||||
postgres: new PostgresFixture(),
|
||||
});
|
||||
|
||||
export {
|
||||
setup,
|
||||
teardown,
|
||||
} from "@suaveplan/testing-integration/setup/global-setup";
|
||||
@@ -0,0 +1,33 @@
|
||||
import { inject as vitestInject } from "@suaveplan/testing/runner";
|
||||
import type { PostgresConfig } from "@suaveplan/testing-integration-postgres";
|
||||
|
||||
interface IntegrationConfigs {
|
||||
postgres: PostgresConfig;
|
||||
}
|
||||
|
||||
export function inject<K extends keyof IntegrationConfigs>(
|
||||
key: K
|
||||
): IntegrationConfigs[K] {
|
||||
const all = (vitestInject as (k: string) => IntegrationConfigs)(
|
||||
"__integration_configs__"
|
||||
);
|
||||
return all[key];
|
||||
}
|
||||
|
||||
export const postgresUnavailable: boolean = (() => {
|
||||
try {
|
||||
const failed = (vitestInject as (k: string) => string[] | undefined)(
|
||||
"__integration_failed__"
|
||||
);
|
||||
if (failed === undefined) return true;
|
||||
if (failed.includes("postgres")) return true;
|
||||
const configs = (
|
||||
vitestInject as (
|
||||
k: string
|
||||
) => Record<string, Record<string, unknown>> | undefined
|
||||
)("__integration_configs__");
|
||||
return configs === undefined || configs.postgres === undefined;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Real-Postgres integration test for the 2PC coordinator. Drives a
|
||||
* single coordinator against TWO Postgres connections (acting as two
|
||||
* participants on the same physical instance) and asserts the full
|
||||
* commit + abort + recovery scenarios end-to-end.
|
||||
*
|
||||
* NOTE: 2PC requires `max_prepared_transactions > 0` in the Postgres
|
||||
* config. The default-image fixture starts with `max_prepared_transactions=0`,
|
||||
* so this test bootstraps a fresh config and reloads it via SQL before
|
||||
* running. We can't ALTER SYSTEM SET inside an in-memory fixture without
|
||||
* restarting, so we run the prepare against an in-test-cluster ALTER
|
||||
* SYSTEM + SELECT pg_reload_conf() flow that postgres supports without
|
||||
* a full restart.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import postgres from "postgres";
|
||||
import {
|
||||
createTwoPcCoordinator,
|
||||
type LedgerDb,
|
||||
type RegisteredTwoPcParticipant,
|
||||
TwoPcParticipantRegistry,
|
||||
} from "../../src/index.js";
|
||||
import { TwoPcLedgerStore } from "../../src/persistence/persistence.js";
|
||||
import {
|
||||
buildProbeFromRegistry,
|
||||
resolveRecovery,
|
||||
} from "../../src/recovery/index.js";
|
||||
import { inject, postgresUnavailable } from "./integration-setup.js";
|
||||
|
||||
interface PgClient {
|
||||
readonly engine: "postgres";
|
||||
run<R = unknown>(sql: string, params?: ReadonlyArray<unknown>): Promise<R>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
function makeClient(url: string): PgClient {
|
||||
const sql = postgres(url, { max: 4, prepare: false });
|
||||
return {
|
||||
engine: "postgres",
|
||||
async run<R>(text: string, params?: ReadonlyArray<unknown>) {
|
||||
const out = await sql.unsafe(text, params as unknown[]);
|
||||
return out as unknown as R;
|
||||
},
|
||||
async close() {
|
||||
await sql.end({ timeout: 1 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureMaxPrepared(adminUrl: string): Promise<void> {
|
||||
const sql = postgres(adminUrl, { max: 1, prepare: false });
|
||||
try {
|
||||
const cur = (await sql.unsafe(
|
||||
"SHOW max_prepared_transactions"
|
||||
)) as unknown as { max_prepared_transactions: string }[];
|
||||
const current = Number.parseInt(
|
||||
cur[0]?.max_prepared_transactions ?? "0",
|
||||
10
|
||||
);
|
||||
if (Number.isFinite(current) && current > 0) return;
|
||||
// ALTER SYSTEM requires a restart for max_prepared_transactions.
|
||||
// For a fresh fixture this is the first run; we cannot reload
|
||||
// dynamically, so we surface that the test cannot proceed.
|
||||
throw new Error(
|
||||
"max_prepared_transactions=0 on the Postgres fixture; cannot run 2PC integration tests"
|
||||
);
|
||||
} finally {
|
||||
await sql.end({ timeout: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
function makeLedger(): { ledgerDb: LedgerDb; ledger: TwoPcLedgerStore } {
|
||||
const db = new Database(":memory:") as unknown as LedgerDb;
|
||||
db.run(`CREATE TABLE IF NOT EXISTS suaveplan_2pc_transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
coordinator TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
participants_json TEXT NOT NULL,
|
||||
prepared_at TEXT,
|
||||
committed_at TEXT,
|
||||
aborted_at TEXT
|
||||
)`);
|
||||
return { ledgerDb: db, ledger: new TwoPcLedgerStore(db) };
|
||||
}
|
||||
|
||||
describe.skipIf(postgresUnavailable)("real Postgres 2PC coordinator", () => {
|
||||
it("commits successfully across two participants", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const { url } = inject("postgres");
|
||||
await ensureMaxPrepared(url);
|
||||
const c1 = makeClient(url);
|
||||
const c2 = makeClient(url);
|
||||
await c1.run("CREATE TABLE IF NOT EXISTS twopc_smoke_a (n int)");
|
||||
await c2.run("CREATE TABLE IF NOT EXISTS twopc_smoke_b (n int)");
|
||||
await c1.run("DELETE FROM twopc_smoke_a");
|
||||
await c2.run("DELETE FROM twopc_smoke_b");
|
||||
|
||||
const registry = new TwoPcParticipantRegistry(() => true);
|
||||
const a: RegisteredTwoPcParticipant = {
|
||||
id: "a",
|
||||
voterMode: "writer",
|
||||
connection: c1,
|
||||
prepare: async (txId) => {
|
||||
await c1.run("BEGIN");
|
||||
await c1.run("INSERT INTO twopc_smoke_a (n) VALUES (1)");
|
||||
return { vote: "commit", writeIntent: true };
|
||||
},
|
||||
};
|
||||
const b: RegisteredTwoPcParticipant = {
|
||||
id: "b",
|
||||
voterMode: "writer",
|
||||
connection: c2,
|
||||
prepare: async (txId) => {
|
||||
await c2.run("BEGIN");
|
||||
await c2.run("INSERT INTO twopc_smoke_b (n) VALUES (2)");
|
||||
return { vote: "commit", writeIntent: true };
|
||||
},
|
||||
};
|
||||
registry.register(a);
|
||||
registry.register(b);
|
||||
|
||||
const { ledgerDb } = makeLedger();
|
||||
const coordinator = createTwoPcCoordinator({
|
||||
options: { hostInstanceId: "host-it" },
|
||||
registry,
|
||||
ledgerDb,
|
||||
clock: () => Date.now(),
|
||||
idGenerator: () =>
|
||||
`tx-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
});
|
||||
|
||||
const outcome = await coordinator.run2pc({
|
||||
participants: [
|
||||
{ id: "a", input: {} },
|
||||
{ id: "b", input: {} },
|
||||
],
|
||||
});
|
||||
expect(outcome.state).toBe("committed");
|
||||
const rowsA = (await c1.run(
|
||||
"SELECT count(*)::int as c FROM twopc_smoke_a"
|
||||
)) as ReadonlyArray<{ c: number }>;
|
||||
const rowsB = (await c2.run(
|
||||
"SELECT count(*)::int as c FROM twopc_smoke_b"
|
||||
)) as ReadonlyArray<{ c: number }>;
|
||||
expect(rowsA[0]?.c).toBe(1);
|
||||
expect(rowsB[0]?.c).toBe(1);
|
||||
|
||||
await c1.close();
|
||||
await c2.close();
|
||||
});
|
||||
|
||||
it("rolls back everywhere when one participant votes abort", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const { url } = inject("postgres");
|
||||
const c1 = makeClient(url);
|
||||
const c2 = makeClient(url);
|
||||
await c1.run("CREATE TABLE IF NOT EXISTS twopc_smoke_c (n int)");
|
||||
await c2.run("CREATE TABLE IF NOT EXISTS twopc_smoke_d (n int)");
|
||||
await c1.run("DELETE FROM twopc_smoke_c");
|
||||
await c2.run("DELETE FROM twopc_smoke_d");
|
||||
|
||||
const registry = new TwoPcParticipantRegistry(() => true);
|
||||
registry.register({
|
||||
id: "c",
|
||||
voterMode: "writer",
|
||||
connection: c1,
|
||||
prepare: async () => {
|
||||
await c1.run("BEGIN");
|
||||
await c1.run("INSERT INTO twopc_smoke_c (n) VALUES (3)");
|
||||
return { vote: "commit", writeIntent: true };
|
||||
},
|
||||
});
|
||||
registry.register({
|
||||
id: "d",
|
||||
voterMode: "writer",
|
||||
connection: c2,
|
||||
prepare: async () => {
|
||||
return { vote: "abort", writeIntent: false };
|
||||
},
|
||||
});
|
||||
|
||||
const { ledgerDb } = makeLedger();
|
||||
const coordinator = createTwoPcCoordinator({
|
||||
options: { hostInstanceId: "host-it" },
|
||||
registry,
|
||||
ledgerDb,
|
||||
clock: () => Date.now(),
|
||||
idGenerator: () =>
|
||||
`tx-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.run2pc({
|
||||
participants: [
|
||||
{ id: "c", input: {} },
|
||||
{ id: "d", input: {} },
|
||||
],
|
||||
})
|
||||
).rejects.toMatchObject({ twoPcCode: "TWOPC.VOTE_ABORT" });
|
||||
|
||||
const rowsC = (await c1.run(
|
||||
"SELECT count(*)::int as c FROM twopc_smoke_c"
|
||||
)) as ReadonlyArray<{ c: number }>;
|
||||
expect(rowsC[0]?.c).toBe(0);
|
||||
|
||||
await c1.close();
|
||||
await c2.close();
|
||||
});
|
||||
|
||||
it("recovery commits an orphan whose ledger row says committing", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const { url } = inject("postgres");
|
||||
const c1 = makeClient(url);
|
||||
await c1.run("CREATE TABLE IF NOT EXISTS twopc_smoke_e (n int)");
|
||||
await c1.run("DELETE FROM twopc_smoke_e");
|
||||
|
||||
const { ledger } = makeLedger();
|
||||
// Simulate a crash: PREPARE the transaction, write the ledger row
|
||||
// as "committing", but never run COMMIT PREPARED.
|
||||
const txId = `recover-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
const gid = `2pc-host-it-${txId}-e`;
|
||||
await c1.run("BEGIN");
|
||||
await c1.run("INSERT INTO twopc_smoke_e (n) VALUES (42)");
|
||||
await c1.run(`PREPARE TRANSACTION '${gid.replace(/'/g, "''")}'`);
|
||||
ledger.insert({
|
||||
id: txId,
|
||||
coordinator: "host-it",
|
||||
participants: [
|
||||
{ participantId: "e", voterMode: "writer", vote: "commit" },
|
||||
],
|
||||
});
|
||||
ledger.markAllVotedCommit({
|
||||
id: txId,
|
||||
participants: [
|
||||
{ participantId: "e", voterMode: "writer", vote: "commit" },
|
||||
],
|
||||
preparedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const registry = new TwoPcParticipantRegistry(() => true);
|
||||
registry.register({
|
||||
id: "e",
|
||||
voterMode: "writer",
|
||||
connection: c1,
|
||||
prepare: async () => ({ vote: "commit", writeIntent: true }),
|
||||
});
|
||||
const report = await resolveRecovery({
|
||||
hostInstanceId: "host-it",
|
||||
ledger,
|
||||
probes: buildProbeFromRegistry(registry),
|
||||
});
|
||||
expect(report.committed).toContain(gid);
|
||||
const rows = (await c1.run(
|
||||
"SELECT count(*)::int as c FROM twopc_smoke_e"
|
||||
)) as ReadonlyArray<{ c: number }>;
|
||||
expect(rows[0]?.c).toBe(1);
|
||||
|
||||
await c1.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user