Skip to content

Commit 8d78ec5

Browse files
authored
test(testcontainers): add a DB connection-blip harness (#4862)
## Summary Adds a test-only harness for simulating a Postgres connection blip, so tests can prove their database code survives a dropped connection. It exports `createDbBlipController` and a `postgresBlipTest` fixture from `@internal/testcontainers`. ## How it works The harness severs connections from a separate admin connection using `pg_terminate_backend`, scoped to the test's own database, so it composes with any Prisma client under test and stays isolated across parallel tests. Two modes: - `severIdle()` kills idle backends. A pooled (driver-adapter) client absorbs this transparently: the pool evicts the dead connection and the next query just works. - `severDuringNextStatement()` kills a statement mid-flight, surfacing a connection error to the caller; the client then recovers on the next retry. The bundled tests demonstrate both, plus the correctness property that matters before adding retries anywhere: a non-idempotent write double-applies when retried after a post-commit blip, while an idempotent write (deterministic id plus `ON CONFLICT`) stays at exactly one row.
1 parent 7447919 commit 8d78ec5

5 files changed

Lines changed: 325 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: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { describe, 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+
// A severed idle connection makes the pg Pool emit 'error'; swallow it so an
26+
// unhandled event can't crash the test worker before recovery is asserted.
27+
pool.on("error", () => {});
28+
const client = new PrismaClient({ adapter: new PrismaPg(pool) });
29+
const dispose = async () => {
30+
try {
31+
await client.$disconnect();
32+
} finally {
33+
await pool.end();
34+
}
35+
};
36+
return { client, dispose };
37+
}
38+
39+
async function createProbeTable(client: PrismaClient) {
40+
await client.$executeRawUnsafe(
41+
`CREATE TABLE IF NOT EXISTS blip_probe (id uuid PRIMARY KEY, tag text NOT NULL)`
42+
);
43+
}
44+
45+
async function countTag(client: PrismaClient, tag: string): Promise<number> {
46+
const rows = await client.$queryRawUnsafe<{ n: number }[]>(
47+
`SELECT count(*)::int AS n FROM blip_probe WHERE tag = $1`,
48+
tag
49+
);
50+
return rows[0]?.n ?? 0;
51+
}
52+
53+
describe("DbBlipController", () => {
54+
postgresBlipTest(
55+
"a pooled adapter client transparently survives an idle-connection drop",
56+
{ timeout: 60_000 },
57+
async ({ postgresContainer, blip }) => {
58+
const { client, dispose } = await adapterClient(postgresContainer.getConnectionUri());
59+
try {
60+
await client.user.count(); // warm the pool
61+
const terminated = await blip.severIdle();
62+
expect(terminated).toBeGreaterThan(0);
63+
// The pool evicts the dead idle connection; the next read just works.
64+
await new Promise((r) => setTimeout(r, 200));
65+
const count = await client.user.count();
66+
expect(typeof count).toBe("number");
67+
} finally {
68+
await dispose();
69+
}
70+
}
71+
);
72+
73+
postgresBlipTest(
74+
"severDuringNextStatement fails an in-flight statement",
75+
{ timeout: 60_000 },
76+
async ({ postgresContainer, blip }) => {
77+
const { client, dispose } = await adapterClient(postgresContainer.getConnectionUri());
78+
try {
79+
const slow = client.$queryRawUnsafe(`SELECT pg_sleep(3)`);
80+
// PrismaPromise is lazy — form the assertion so the query actually starts.
81+
const rejected = expect(slow).rejects.toThrow();
82+
await blip.severDuringNextStatement({ queryContains: "pg_sleep" });
83+
await rejected;
84+
} finally {
85+
await dispose();
86+
}
87+
}
88+
);
89+
90+
postgresBlipTest(
91+
"a read recovers after a mid-flight blip",
92+
{ timeout: 60_000 },
93+
async ({ postgresContainer, blip }) => {
94+
const { client, dispose } = await adapterClient(postgresContainer.getConnectionUri());
95+
try {
96+
const severed = client.$queryRawUnsafe(`SELECT pg_sleep(3)`).catch(() => undefined);
97+
await blip.severDuringNextStatement({ queryContains: "pg_sleep" });
98+
await severed;
99+
const count = await withRetry(() => client.user.count());
100+
expect(typeof count).toBe("number");
101+
} finally {
102+
await dispose();
103+
}
104+
}
105+
);
106+
107+
postgresBlipTest(
108+
"a non-idempotent write double-applies on retry after a post-commit blip; the idempotent form does not",
109+
{ timeout: 60_000 },
110+
async ({ postgresContainer, blip }) => {
111+
const { client, dispose } = await adapterClient(postgresContainer.getConnectionUri());
112+
try {
113+
await createProbeTable(client);
114+
115+
// Model the dangerous case: the write commits, then a later statement in
116+
// the same op is severed mid-flight (ack lost), and the caller retries.
117+
let nonIdempotentAttempts = 0;
118+
const nonIdempotentWrite = async () => {
119+
nonIdempotentAttempts++;
120+
await client.$executeRawUnsafe(
121+
`INSERT INTO blip_probe (id, tag) VALUES (gen_random_uuid(), 'non-idempotent')`
122+
);
123+
if (nonIdempotentAttempts === 1) {
124+
await client.$queryRawUnsafe(`SELECT pg_sleep(3)`); // severed → throws after the commit
125+
}
126+
};
127+
const nonIdempotentDone = withRetry(nonIdempotentWrite);
128+
await blip.severDuringNextStatement({ queryContains: "pg_sleep" });
129+
await nonIdempotentDone;
130+
expect(await countTag(client, "non-idempotent")).toBe(2); // the hazard, proven
131+
132+
// The idempotent form: a fixed id + ON CONFLICT makes the replay a no-op.
133+
let idempotentAttempts = 0;
134+
const idempotentWrite = async () => {
135+
idempotentAttempts++;
136+
await client.$executeRawUnsafe(
137+
`INSERT INTO blip_probe (id, tag)
138+
VALUES ('00000000-0000-0000-0000-000000000001', 'idempotent')
139+
ON CONFLICT (id) DO NOTHING`
140+
);
141+
if (idempotentAttempts === 1) {
142+
await client.$queryRawUnsafe(`SELECT pg_sleep(3)`);
143+
}
144+
};
145+
const idempotentDone = withRetry(idempotentWrite);
146+
await blip.severDuringNextStatement({ queryContains: "pg_sleep" });
147+
await idempotentDone;
148+
expect(await countTag(client, "idempotent")).toBe(1); // exactly once despite retry
149+
} finally {
150+
await dispose();
151+
}
152+
}
153+
);
154+
155+
// Regression: queryContains must match as literal text, not as an ILIKE pattern.
156+
// The active query contains "fooXbar"; under ILIKE the pattern "foo_bar" (with the
157+
// wildcard `_`) would wrongly match and terminate it. The literal matcher must not,
158+
// so the sever times out instead of killing the wrong statement.
159+
postgresBlipTest(
160+
"severDuringNextStatement matches queryContains literally, not as an ILIKE pattern",
161+
{ timeout: 60_000 },
162+
async ({ postgresContainer, blip }) => {
163+
const { client, dispose } = await adapterClient(postgresContainer.getConnectionUri());
164+
const slow = client
165+
.$queryRawUnsafe(`SELECT pg_sleep(3) /* marker fooXbar */`)
166+
.catch(() => undefined);
167+
try {
168+
await expect(
169+
blip.severDuringNextStatement({ queryContains: "foo_bar", timeoutMs: 1000, pollMs: 25 })
170+
).rejects.toThrow(/no active statement/i);
171+
} finally {
172+
await dispose();
173+
await slow;
174+
}
175+
}
176+
);
177+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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 idle client backend except this harness's own, so the
11+
* next operation hits a dead connection. Returns the number terminated. */
12+
severIdle(): Promise<number>;
13+
14+
/** Poll for an active client statement (optionally matching `queryContains`
15+
* literally), then terminate it mid-flight. Rejects if none appears within
16+
* `timeoutMs`. Terminating by pid isn't atomic with statement completion, so
17+
* target a statement with a real execution window (e.g. `pg_sleep`) — a query
18+
* that finishes first leaves its connection idle and it is closed anyway. */
19+
severDuringNextStatement(opts?: {
20+
queryContains?: string;
21+
timeoutMs?: number;
22+
pollMs?: number;
23+
}): Promise<void>;
24+
};
25+
26+
/** A {@link DbBlipController} plus the teardown for its admin connection. */
27+
export type DbBlipHandle = DbBlipController & { close(): Promise<void> };
28+
29+
// Reserved application_name for the harness's control connections. The severs
30+
// exclude every connection using it (by name, plus their own pid), so multiple
31+
// controllers on one database can't kill each other's admin. A client-under-test
32+
// must not use this name.
33+
const ADMIN_APPLICATION_NAME = "trigger-db-blip-admin";
34+
35+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
36+
37+
/** Opens an isolated admin connection and returns a handle that can sever the
38+
* other connections on that database. `close()` in teardown. */
39+
export async function createDbBlipController(connectionUri: string): Promise<DbBlipHandle> {
40+
// Raw pg (not Prisma): the control connection must be one identifiable backend we can exclude from the sever, independent of the client under test.
41+
const admin = new Client({
42+
connectionString: connectionUri,
43+
application_name: ADMIN_APPLICATION_NAME,
44+
});
45+
await admin.connect();
46+
// Swallow async connection errors so a consumer that severs a DB the admin
47+
// isn't excluded from (or drops it while open) can't crash the test worker.
48+
admin.on("error", () => {});
49+
50+
async function severIdle(): Promise<number> {
51+
const result = await admin.query<{ terminated: boolean }>(
52+
`SELECT pg_terminate_backend(pid) AS terminated
53+
FROM pg_stat_activity
54+
WHERE datname = current_database()
55+
AND pid <> pg_backend_pid()
56+
AND backend_type = 'client backend'
57+
AND application_name IS DISTINCT FROM $1
58+
AND state = 'idle'`,
59+
[ADMIN_APPLICATION_NAME]
60+
);
61+
// Count only backends that were actually terminated (a backend that exits
62+
// between selection and signalling returns false).
63+
return result.rows.filter((row) => row.terminated === true).length;
64+
}
65+
66+
async function severDuringNextStatement(opts?: {
67+
queryContains?: string;
68+
timeoutMs?: number;
69+
pollMs?: number;
70+
}): Promise<void> {
71+
const queryContains = opts?.queryContains ?? null;
72+
const timeoutMs = opts?.timeoutMs ?? 5000;
73+
const pollMs = opts?.pollMs ?? 25;
74+
const deadline = Date.now() + timeoutMs;
75+
76+
while (Date.now() < deadline) {
77+
// Select and terminate in one statement so the backend can't go idle
78+
// between picking it and killing it; return only when it was terminated.
79+
const terminated = await admin.query<{ ok: boolean }>(
80+
`SELECT pg_terminate_backend(pid) AS ok
81+
FROM pg_stat_activity
82+
WHERE datname = current_database()
83+
AND state = 'active'
84+
AND pid <> pg_backend_pid()
85+
AND backend_type = 'client backend'
86+
AND application_name IS DISTINCT FROM $1
87+
AND ($2::text IS NULL OR strpos(lower(query), lower($2)) > 0)
88+
LIMIT 1`,
89+
[ADMIN_APPLICATION_NAME, queryContains]
90+
);
91+
92+
if (terminated.rows[0]?.ok === true) {
93+
return;
94+
}
95+
96+
await sleep(pollMs);
97+
}
98+
99+
throw new Error(
100+
`severDuringNextStatement: no active statement${
101+
queryContains ? ` matching ${JSON.stringify(queryContains)}` : ""
102+
} appeared within ${timeoutMs}ms`
103+
);
104+
}
105+
106+
return { severIdle, severDuringNextStatement, close: () => admin.end() };
107+
}

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)