Skip to content

Commit c3d185b

Browse files
committed
test(testcontainers): add DB blip harness for connection-resilience tests
Adds DbBlipController + a postgresBlipTest fixture that sever a test Postgres connection via pg_terminate_backend, so a vertical can prove its DB code survives a disconnect without a proxy or extra container. Includes tests that exercise the harness: a severed read fails, an in-flight statement is terminated mid-flight, a non-idempotent write double-applies on retry while the idempotent form does not, and a pg-driver-adapter client recovers a model read through a blip. TRI-13551
1 parent 43ecf15 commit c3d185b

5 files changed

Lines changed: 279 additions & 1 deletion

File tree

internal-packages/testcontainers/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,15 @@
1515
"dependencies": {
1616
"@clickhouse/client": "^1.11.1",
1717
"@trigger.dev/database": "workspace:*",
18-
"ioredis": "~5.6.0"
18+
"ioredis": "~5.6.0",
19+
"pg": "8.15.6"
1920
},
2021
"devDependencies": {
2122
"@internal/run-ops-database": "workspace:*",
23+
"@prisma/adapter-pg": "6.14.0",
2224
"@testcontainers/postgresql": "^11.14.0",
2325
"@testcontainers/redis": "^11.14.0",
26+
"@types/pg": "8.11.14",
2427
"std-env": "^3.9.0",
2528
"testcontainers": "^11.14.0",
2629
"tinyexec": "^0.3.0"
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { expect } from "vitest";
2+
import { Pool } from "pg";
3+
import { PrismaPg } from "@prisma/adapter-pg";
4+
import { PrismaClient } from "@trigger.dev/database";
5+
import { postgresBlipTest } from "./index";
6+
7+
// A minimal infra retry, standing in for the shared read-retry util so this
8+
// file can demonstrate the harness end-to-end on its own.
9+
async function withRetry<T>(fn: () => Promise<T>, maxAttempts = 8): Promise<T> {
10+
let lastError: unknown;
11+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
12+
try {
13+
return await fn();
14+
} catch (error) {
15+
lastError = error;
16+
await new Promise((r) => setTimeout(r, Math.min(50 * (attempt + 1), 250)));
17+
}
18+
}
19+
throw lastError;
20+
}
21+
22+
// Production runs the pg driver adapter, so the client under test is adapter-backed.
23+
async function adapterClient(connectionString: string) {
24+
const pool = new Pool({ connectionString });
25+
const client = new PrismaClient({ adapter: new PrismaPg(pool) });
26+
return { client, dispose: async () => void (await client.$disconnect(), await pool.end()) };
27+
}
28+
29+
async function createProbeTable(client: PrismaClient) {
30+
await client.$executeRawUnsafe(
31+
`CREATE TABLE IF NOT EXISTS blip_probe (id uuid PRIMARY KEY, tag text NOT NULL)`
32+
);
33+
}
34+
35+
async function countTag(client: PrismaClient, tag: string): Promise<number> {
36+
const rows = await client.$queryRawUnsafe<{ n: number }[]>(
37+
`SELECT count(*)::int AS n FROM blip_probe WHERE tag = $1`,
38+
tag
39+
);
40+
return rows[0]?.n ?? 0;
41+
}
42+
43+
postgresBlipTest(
44+
"a pooled adapter client transparently survives an idle-connection drop",
45+
{ timeout: 60_000 },
46+
async ({ postgresContainer, blip }) => {
47+
const { client, dispose } = await adapterClient(postgresContainer.getConnectionUri());
48+
try {
49+
await client.user.count(); // warm the pool
50+
const terminated = await blip.severIdle();
51+
expect(terminated).toBeGreaterThan(0);
52+
// The pool evicts the dead idle connection; the next read just works.
53+
await new Promise((r) => setTimeout(r, 200));
54+
const count = await client.user.count();
55+
expect(typeof count).toBe("number");
56+
} finally {
57+
await dispose();
58+
}
59+
}
60+
);
61+
62+
postgresBlipTest(
63+
"severDuringNextStatement fails an in-flight statement",
64+
{ timeout: 60_000 },
65+
async ({ postgresContainer, blip }) => {
66+
const { client, dispose } = await adapterClient(postgresContainer.getConnectionUri());
67+
try {
68+
const slow = client.$queryRawUnsafe(`SELECT pg_sleep(3)`);
69+
// PrismaPromise is lazy — form the assertion so the query actually starts.
70+
const rejected = expect(slow).rejects.toThrow();
71+
await blip.severDuringNextStatement({ queryContains: "pg_sleep" });
72+
await rejected;
73+
} finally {
74+
await dispose();
75+
}
76+
}
77+
);
78+
79+
postgresBlipTest(
80+
"a read recovers after a mid-flight blip",
81+
{ timeout: 60_000 },
82+
async ({ postgresContainer, blip }) => {
83+
const { client, dispose } = await adapterClient(postgresContainer.getConnectionUri());
84+
try {
85+
const severed = client.$queryRawUnsafe(`SELECT pg_sleep(3)`).catch(() => undefined);
86+
await blip.severDuringNextStatement({ queryContains: "pg_sleep" });
87+
await severed;
88+
const count = await withRetry(() => client.user.count());
89+
expect(typeof count).toBe("number");
90+
} finally {
91+
await dispose();
92+
}
93+
}
94+
);
95+
96+
postgresBlipTest(
97+
"a non-idempotent write double-applies on retry after a post-commit blip; the idempotent form does not",
98+
{ timeout: 60_000 },
99+
async ({ postgresContainer, blip }) => {
100+
const { client, dispose } = await adapterClient(postgresContainer.getConnectionUri());
101+
try {
102+
await createProbeTable(client);
103+
104+
// Model the dangerous case: the write commits, then a later statement in
105+
// the same op is severed mid-flight (ack lost), and the caller retries.
106+
let nonIdempotentAttempts = 0;
107+
const nonIdempotentWrite = async () => {
108+
nonIdempotentAttempts++;
109+
await client.$executeRawUnsafe(
110+
`INSERT INTO blip_probe (id, tag) VALUES (gen_random_uuid(), 'non-idempotent')`
111+
);
112+
if (nonIdempotentAttempts === 1) {
113+
await client.$queryRawUnsafe(`SELECT pg_sleep(3)`); // severed → throws after the commit
114+
}
115+
};
116+
const nonIdempotentDone = withRetry(nonIdempotentWrite);
117+
await blip.severDuringNextStatement({ queryContains: "pg_sleep" });
118+
await nonIdempotentDone;
119+
expect(await countTag(client, "non-idempotent")).toBe(2); // the hazard, proven
120+
121+
// The idempotent form: a fixed id + ON CONFLICT makes the replay a no-op.
122+
let idempotentAttempts = 0;
123+
const idempotentWrite = async () => {
124+
idempotentAttempts++;
125+
await client.$executeRawUnsafe(
126+
`INSERT INTO blip_probe (id, tag)
127+
VALUES ('00000000-0000-0000-0000-000000000001', 'idempotent')
128+
ON CONFLICT (id) DO NOTHING`
129+
);
130+
if (idempotentAttempts === 1) {
131+
await client.$queryRawUnsafe(`SELECT pg_sleep(3)`);
132+
}
133+
};
134+
const idempotentDone = withRetry(idempotentWrite);
135+
await blip.severDuringNextStatement({ queryContains: "pg_sleep" });
136+
await idempotentDone;
137+
expect(await countTag(client, "idempotent")).toBe(1); // exactly once despite retry
138+
} finally {
139+
await dispose();
140+
}
141+
}
142+
);
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { Client } from "pg";
2+
3+
/**
4+
* Simulates a connection blip against a test Postgres (via a separate admin
5+
* connection that terminates backends), so a vertical can prove its DB code
6+
* survives a disconnect. Reproduces the mid-statement / stale-connection
7+
* signatures (P1017, "Connection terminated unexpectedly").
8+
*/
9+
export type DbBlipController = {
10+
/** Terminate every client backend except this harness's own, so the next
11+
* operation hits a dead connection. Returns the number terminated. */
12+
severIdle(): Promise<number>;
13+
14+
/** Poll for an active statement (optionally matching `queryContains`), then
15+
* terminate it mid-flight. Rejects if none appears within `timeoutMs`. */
16+
severDuringNextStatement(opts?: {
17+
queryContains?: string;
18+
timeoutMs?: number;
19+
pollMs?: number;
20+
}): Promise<void>;
21+
};
22+
23+
/** A {@link DbBlipController} plus the teardown for its admin connection. */
24+
export type DbBlipHandle = DbBlipController & { close(): Promise<void> };
25+
26+
// Tag the admin connection so we can exclude it from the backends we kill.
27+
const ADMIN_APPLICATION_NAME = "trigger-db-blip-admin";
28+
29+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
30+
31+
/** Opens an isolated admin connection and returns a handle that can sever the
32+
* other connections on that database. `close()` in teardown. */
33+
export async function createDbBlipController(connectionUri: string): Promise<DbBlipHandle> {
34+
// Raw pg (not Prisma): the control connection must be one identifiable backend we can exclude from the sever, independent of the client under test.
35+
const admin = new Client({
36+
connectionString: connectionUri,
37+
application_name: ADMIN_APPLICATION_NAME,
38+
});
39+
await admin.connect();
40+
// Swallow async connection errors so a consumer that severs a DB the admin
41+
// isn't excluded from (or drops it while open) can't crash the test worker.
42+
admin.on("error", () => {});
43+
44+
async function severIdle(): Promise<number> {
45+
const result = await admin.query(
46+
`SELECT pg_terminate_backend(pid)
47+
FROM pg_stat_activity
48+
WHERE datname = current_database()
49+
AND pid <> pg_backend_pid()
50+
AND application_name IS DISTINCT FROM $1`,
51+
[ADMIN_APPLICATION_NAME]
52+
);
53+
return result.rowCount ?? 0;
54+
}
55+
56+
async function severDuringNextStatement(opts?: {
57+
queryContains?: string;
58+
timeoutMs?: number;
59+
pollMs?: number;
60+
}): Promise<void> {
61+
const queryContains = opts?.queryContains ?? null;
62+
const timeoutMs = opts?.timeoutMs ?? 5000;
63+
const pollMs = opts?.pollMs ?? 25;
64+
const deadline = Date.now() + timeoutMs;
65+
66+
while (Date.now() < deadline) {
67+
const found = await admin.query<{ pid: number }>(
68+
`SELECT pid
69+
FROM pg_stat_activity
70+
WHERE datname = current_database()
71+
AND state = 'active'
72+
AND pid <> pg_backend_pid()
73+
AND application_name IS DISTINCT FROM $1
74+
AND ($2::text IS NULL OR query ILIKE '%' || $2 || '%')
75+
LIMIT 1`,
76+
[ADMIN_APPLICATION_NAME, queryContains]
77+
);
78+
79+
const pid = found.rows[0]?.pid;
80+
if (pid !== undefined) {
81+
await admin.query("SELECT pg_terminate_backend($1)", [pid]);
82+
return;
83+
}
84+
85+
await sleep(pollMs);
86+
}
87+
88+
throw new Error(
89+
`severDuringNextStatement: no active statement${
90+
queryContains ? ` matching ${JSON.stringify(queryContains)}` : ""
91+
} appeared within ${timeoutMs}ms`
92+
);
93+
}
94+
95+
return { severIdle, severDuringNextStatement, close: () => admin.end() };
96+
}

internal-packages/testcontainers/src/index.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
runClickhouseMigrations,
1414
truncateClickhouseTables,
1515
} from "./clickhouse";
16+
import { createDbBlipController, type DbBlipController } from "./dbBlip";
1617
import { getTaskMetadata, logCleanup, logSetup } from "./logs";
1718
import { type MinIOConnectionConfig, type StartedMinIOContainer, MinIOContainer } from "./minio";
1819
import {
@@ -35,6 +36,7 @@ export {
3536
} from "./utils";
3637
export { OtelCollectorContainer, StartedOtelCollectorContainer } from "./otelCollector";
3738
export { laggingReplica, type LaggingModel } from "./laggingReplica";
39+
export { createDbBlipController, type DbBlipController, type DbBlipHandle } from "./dbBlip";
3840
export { logCleanup };
3941
export type { MinIOConnectionConfig };
4042

@@ -353,6 +355,32 @@ export const postgresTest = withWarmup(
353355
}
354356
);
355357

358+
export type PostgresBlipTestContext = PostgresTestContext & { blip: DbBlipController };
359+
360+
const blipFromContainer = async (
361+
{ postgresContainer }: { postgresContainer: StartedPostgreSqlContainer } & TestContext,
362+
use: Use<DbBlipController>
363+
) => {
364+
const handle = await createDbBlipController(postgresContainer.getConnectionUri());
365+
try {
366+
await use(handle);
367+
} finally {
368+
await handle.close();
369+
}
370+
};
371+
372+
// postgresTest + a DbBlipController bound to the same per-test database.
373+
export const postgresBlipTest = withWarmup(
374+
test.extend<PostgresBlipTestContext>({
375+
postgresContainer: clonedPostgresContainer,
376+
prisma: prismaFromContainer,
377+
blip: blipFromContainer,
378+
}),
379+
async () => {
380+
await getWorkerPostgresContainer();
381+
}
382+
);
383+
356384
type HeteroPostgresTestContext = {
357385
// PG14 (legacy / control-plane DB analog)
358386
postgresContainer14: StartedPostgreSqlContainer;

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)