Skip to content

Commit 25f18be

Browse files
tianzhouclaude
andauthored
fix(postgres): keep the process alive when the server drops a checked-out connection (#480)
pg-pool only listens for a client's 'error' while the client is idle: the listener is removed on checkout and re-attached on release. When the server ends the connection while a query is in flight, pg emits 'error' on the Client itself, and with nobody listening Node reports an unhandled 'error' event and exits the whole DBHub process. #423 added a pool-level listener, which only covers idle clients. The reported trigger is a multi-statement batch whose second statement runs pg_terminate_backend(pg_backend_pid()): the ROLLBACK sent by rollbackQuietly is still queued when the socket closes, so the client is checked out when the event fires. The same window exists for a single statement under readonly (BEGIN READ ONLY takes the same path, and the plugin/MCPB bundles are read-only by default), and for any drop that arrives as a socket error without a FATAL message first (failover, proxy reset), where even a plain single statement crashes. Route every pool.connect() through acquireClient(), which attaches a client-level 'error' listener for the whole checkout and passes the captured error to release() so pg-pool closes and discards the client instead of handing the dead connection to the next caller. This mirrors what pool.query() already does for its own checkout. Adds a loopback protocol-level test that ends the connection mid-batch, inside a READ ONLY transaction, and by a bare socket reset, and checks that the tool call errors, the process survives, and the next query gets a fresh connection. The two pool mocks now expose the EventEmitter surface a real pg.Client has. Fixes #479 Claude-Session: https://claude.ai/code/session_017Eyu55gHK6LVo6am2nvCrz Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2dbf01a commit 25f18be

4 files changed

Lines changed: 173 additions & 25 deletions

File tree

‎src/connectors/__tests__/pool-error-listener.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import { EventEmitter } from "events";
1717
*/
1818

1919
class FakePgPool extends EventEmitter {
20-
connect = vi.fn().mockResolvedValue({ release: vi.fn() });
20+
// The client is a real EventEmitter, like pg.Client: the connector attaches a
21+
// per-checkout 'error' listener to it (bytebase/dbhub#479).
22+
connect = vi.fn().mockResolvedValue(Object.assign(new EventEmitter(), { release: vi.fn() }));
2123
end = vi.fn().mockResolvedValue(undefined);
2224
}
2325

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { createServer, type Socket } from "node:net";
2+
import { once } from "node:events";
3+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
4+
import { PostgresConnector } from "../postgres/index.js";
5+
6+
// A loopback PostgreSQL protocol peer that ends the connection on demand while
7+
// a query is in flight, the way pg_terminate_backend, a failover or a proxy
8+
// does. pg emits 'error' on the Client for an unexpected disconnect; while the
9+
// client is checked out of the pool nothing but the connector is listening, so
10+
// without a listener the whole process exits (#479).
11+
function message(type: string, body: Buffer) {
12+
const length = Buffer.alloc(4);
13+
length.writeInt32BE(body.length + 4);
14+
return Buffer.concat([Buffer.from(type), length, body]);
15+
}
16+
const ready = message("Z", Buffer.from("I"));
17+
const sockets = new Set<Socket>();
18+
let connections = 0;
19+
const server = createServer(socket => {
20+
connections++;
21+
sockets.add(socket);
22+
socket.on("close", () => sockets.delete(socket));
23+
let startup = true;
24+
let buffer: Buffer = Buffer.alloc(0);
25+
socket.on("data", data => {
26+
buffer = Buffer.concat([buffer, data]);
27+
while (buffer.length >= (startup ? 4 : 5)) {
28+
const length = buffer.readInt32BE(startup ? 0 : 1) + (startup ? 0 : 1);
29+
if (buffer.length < length) return;
30+
const packet = buffer.subarray(0, length);
31+
buffer = buffer.subarray(length);
32+
if (startup) {
33+
startup = false;
34+
socket.write(Buffer.concat([message("R", Buffer.alloc(4)), ready]));
35+
} else if (packet[0] === 81) { // simple query
36+
const sql = packet.subarray(5, length - 1).toString();
37+
if (sql.includes("pg_terminate_backend")) {
38+
// FATAL, then the backend closes the socket.
39+
socket.end(message("E", Buffer.from("SFATAL\0C57P01\0Mterminating connection due to administrator command\0\0")));
40+
} else if (sql.includes("reset_socket")) {
41+
// No error message first: the TCP connection just goes away.
42+
socket.destroy();
43+
} else {
44+
socket.write(Buffer.concat([message("C", Buffer.from("SELECT 0\0")), ready]));
45+
}
46+
} else if (packet[0] === 88) { // terminate
47+
socket.end();
48+
}
49+
}
50+
});
51+
});
52+
53+
let connector: PostgresConnector;
54+
55+
beforeEach(async () => {
56+
connections = 0;
57+
server.listen(0, "127.0.0.1");
58+
await once(server, "listening");
59+
const port = (server.address() as { port: number }).port;
60+
connector = new PostgresConnector();
61+
await connector.connect(`postgres://user@127.0.0.1:${port}/db?sslmode=disable`);
62+
expect(connections).toBe(1);
63+
});
64+
65+
afterEach(async () => {
66+
await connector.disconnect();
67+
for (const socket of sockets) socket.destroy();
68+
await new Promise<void>(resolve => server.close(() => resolve()));
69+
vi.restoreAllMocks();
70+
});
71+
72+
async function expectRecovery(): Promise<void> {
73+
// Let the socket 'end'/'close' land while the process is still alive.
74+
await new Promise(resolve => setTimeout(resolve, 50));
75+
// The dropped client was discarded, not returned to the pool: the next
76+
// query opens a fresh connection instead of drawing the dead one.
77+
await expect(connector.executeSQL("SELECT 1", {})).resolves.toMatchObject({ resultSets: [{ rowCount: 0 }] });
78+
expect(connections).toBe(2);
79+
}
80+
81+
it("survives the server ending the connection mid-batch (multi-statement)", async () => {
82+
await expect(connector.executeSQL("SELECT 1; SELECT pg_terminate_backend(pg_backend_pid())", {}))
83+
.rejects.toThrow("terminating connection due to administrator command");
84+
await expectRecovery();
85+
});
86+
87+
it("survives the server ending the connection inside a READ ONLY transaction", async () => {
88+
await expect(connector.executeSQL("SELECT pg_terminate_backend(pg_backend_pid())", { readonly: true }))
89+
.rejects.toThrow("terminating connection due to administrator command");
90+
await expectRecovery();
91+
});
92+
93+
it("survives the connection being reset without an error message", async () => {
94+
await expect(connector.executeSQL("SELECT reset_socket()", {}))
95+
.rejects.toThrow("Connection terminated unexpectedly");
96+
await expectRecovery();
97+
});

‎src/connectors/__tests__/postgres-query-timeout.test.ts‎

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,28 @@ const clientTimeout = () => new Error("Query read timeout");
1919
class FakePgPool extends EventEmitter {
2020
statements: string[] = [];
2121
failOn: (sql: string) => Error | undefined = () => undefined;
22-
client = {
22+
/**
23+
* pg-pool's release for the checked-out client. pg-pool reassigns
24+
* `client.release` on every checkout, and the connector wraps it per
25+
* checkout to attach an 'error' listener (bytebase/dbhub#479), so the
26+
* underlying mock is reinstalled by connect() and asserted on directly.
27+
*/
28+
release = vi.fn();
29+
// A real EventEmitter, like pg.Client.
30+
client = Object.assign(new EventEmitter(), {
2331
processID: BACKEND_PID,
2432
query: vi.fn(async (sql: string) => {
2533
this.statements.push(sql);
2634
const failure = this.failOn(sql);
2735
if (failure) throw failure;
2836
return { rows: [{ ok: 1 }], rowCount: 1 };
2937
}),
30-
release: vi.fn(),
31-
};
32-
connect = vi.fn(async () => this.client);
38+
release: this.release as (err?: Error | boolean) => void,
39+
});
40+
connect = vi.fn(async () => {
41+
this.client.release = this.release;
42+
return this.client;
43+
});
3344
query = vi.fn(async (_config: unknown) => ({ rows: [{ pg_cancel_backend: true }], rowCount: 1 }));
3445
end = vi.fn().mockResolvedValue(undefined);
3546
}
@@ -53,7 +64,7 @@ async function connect() {
5364
queryTimeoutSeconds: 30,
5465
});
5566
// connect() checks out and releases a client to test the connection.
56-
pool.client.release.mockClear();
67+
pool.release.mockClear();
5768
pool.connect.mockClear();
5869
return connector;
5970
}
@@ -78,8 +89,8 @@ describe("PostgreSQL client-side query timeout", () => {
7889
expect(pool.statements).not.toContain("ROLLBACK");
7990
// Released exactly once, with a truthy argument: pg-pool closes the socket
8091
// and drops the client instead of returning it to the pool.
81-
expect(pool.client.release).toHaveBeenCalledTimes(1);
82-
expect(pool.client.release).toHaveBeenCalledWith(true);
92+
expect(pool.release).toHaveBeenCalledTimes(1);
93+
expect(pool.release).toHaveBeenCalledWith(true);
8394
// The statement is cancelled on the server over another pooled connection,
8495
// bounded by its own short timeout...
8596
expect(pool.query).toHaveBeenCalledWith({
@@ -89,7 +100,7 @@ describe("PostgreSQL client-side query timeout", () => {
89100
});
90101
// ...after the client was discarded, so the cancel can use its pool slot
91102
// (pool_max_connections = 1 would otherwise wait on itself).
92-
expect(pool.client.release.mock.invocationCallOrder[0]).toBeLessThan(
103+
expect(pool.release.mock.invocationCallOrder[0]).toBeLessThan(
93104
pool.query.mock.invocationCallOrder[0]
94105
);
95106
});
@@ -102,7 +113,7 @@ describe("PostgreSQL client-side query timeout", () => {
102113
await expect(connector.executeSQL("SELECT pg_sleep(600)", { readonly: true })).rejects.toThrow(
103114
"Query read timeout"
104115
);
105-
expect(pool.client.release).toHaveBeenCalledWith(true);
116+
expect(pool.release).toHaveBeenCalledWith(true);
106117
});
107118

108119
it("keeps the client for a server-side statement_timeout cancellation", async () => {
@@ -119,8 +130,8 @@ describe("PostgreSQL client-side query timeout", () => {
119130
// The server ended the statement itself: the client is clean, so it is
120131
// rolled back and returned to the pool as usual, and nothing is cancelled.
121132
expect(pool.statements[pool.statements.length - 1]).toBe("ROLLBACK");
122-
expect(pool.client.release).toHaveBeenCalledTimes(1);
123-
expect(pool.client.release).toHaveBeenCalledWith();
133+
expect(pool.release).toHaveBeenCalledTimes(1);
134+
expect(pool.release).toHaveBeenCalledWith();
124135
expect(pool.query).not.toHaveBeenCalled();
125136
});
126137
});

‎src/connectors/postgres/index.ts‎

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ export class PostgresConnector implements Connector {
291291
});
292292

293293
// Test the connection
294-
const client = await this.pool.connect();
294+
const client = await this.acquireClient();
295295
client.release();
296296
} catch (err) {
297297
// Tear down the pool if it was created before the failure, otherwise it
@@ -318,7 +318,7 @@ export class PostgresConnector implements Connector {
318318
throw new Error("Not connected to database");
319319
}
320320

321-
const client = await this.pool.connect();
321+
const client = await this.acquireClient();
322322
try {
323323
const result = await client.query(`
324324
SELECT schema_name
@@ -338,7 +338,7 @@ export class PostgresConnector implements Connector {
338338
throw new Error("Not connected to database");
339339
}
340340

341-
const client = await this.pool.connect();
341+
const client = await this.acquireClient();
342342
try {
343343
// Use the configured default schema (from search_path config, defaults to 'public')
344344
const schemaToUse = schema || this.defaultSchema;
@@ -368,7 +368,7 @@ export class PostgresConnector implements Connector {
368368
throw new Error("Not connected to database");
369369
}
370370

371-
const client = await this.pool.connect();
371+
const client = await this.acquireClient();
372372
try {
373373
const schemaToUse = schema || this.defaultSchema;
374374

@@ -394,7 +394,7 @@ export class PostgresConnector implements Connector {
394394
throw new Error("Not connected to database");
395395
}
396396

397-
const client = await this.pool.connect();
397+
const client = await this.acquireClient();
398398
try {
399399
// Use the configured default schema (from search_path config, defaults to 'public')
400400
const schemaToUse = schema || this.defaultSchema;
@@ -421,7 +421,7 @@ export class PostgresConnector implements Connector {
421421
throw new Error("Not connected to database");
422422
}
423423

424-
const client = await this.pool.connect();
424+
const client = await this.acquireClient();
425425
try {
426426
// Use the configured default schema (from search_path config, defaults to 'public')
427427
const schemaToUse = schema || this.defaultSchema;
@@ -477,7 +477,7 @@ export class PostgresConnector implements Connector {
477477
throw new Error("Not connected to database");
478478
}
479479

480-
const client = await this.pool.connect();
480+
const client = await this.acquireClient();
481481
try {
482482
// Use the configured default schema (from search_path config, defaults to 'public')
483483
const schemaToUse = schema || this.defaultSchema;
@@ -519,7 +519,7 @@ export class PostgresConnector implements Connector {
519519
throw new Error("Not connected to database");
520520
}
521521

522-
const client = await this.pool.connect();
522+
const client = await this.acquireClient();
523523
try {
524524
// Use the configured default schema (from search_path config, defaults to 'public')
525525
const schemaToUse = schema || this.defaultSchema;
@@ -551,7 +551,7 @@ export class PostgresConnector implements Connector {
551551
throw new Error("Not connected to database");
552552
}
553553

554-
const client = await this.pool.connect();
554+
const client = await this.acquireClient();
555555
try {
556556
const schemaToUse = schema || this.defaultSchema;
557557

@@ -581,7 +581,7 @@ export class PostgresConnector implements Connector {
581581
throw new Error("Not connected to database");
582582
}
583583

584-
const client = await this.pool.connect();
584+
const client = await this.acquireClient();
585585
try {
586586
const [connectionsResult, maxConnectionsResult, bufferCacheResult] = await Promise.all([
587587
client.query(`
@@ -643,7 +643,7 @@ export class PostgresConnector implements Connector {
643643
throw new Error("Not connected to database");
644644
}
645645

646-
const client = await this.pool.connect();
646+
const client = await this.acquireClient();
647647
try {
648648
// Use the configured default schema (from search_path config, defaults to 'public')
649649
const schemaToUse = schema || this.defaultSchema;
@@ -680,7 +680,7 @@ export class PostgresConnector implements Connector {
680680
throw new Error("Not connected to database");
681681
}
682682

683-
const client = await this.pool.connect();
683+
const client = await this.acquireClient();
684684
try {
685685
// Use the configured default schema (from search_path config, defaults to 'public')
686686
const schemaToUse = schema || this.defaultSchema;
@@ -773,7 +773,7 @@ export class PostgresConnector implements Connector {
773773
throw new Error("Not connected to database");
774774
}
775775

776-
const client = await this.pool.connect();
776+
const client = await this.acquireClient();
777777
// Captured up front, before a timeout can lead to the client being discarded.
778778
const backendPid = (client as unknown as { processID: number | null }).processID;
779779
let isClientDiscarded = false;
@@ -891,6 +891,44 @@ export class PostgresConnector implements Connector {
891891
}
892892
}
893893

894+
/**
895+
* Checks a client out of the pool with an 'error' listener attached for the
896+
* whole checkout, and releases it with that error so the pool discards it.
897+
*
898+
* pg-pool only listens for a client's 'error' while the client is idle: the
899+
* listener is removed on checkout and re-attached on release. If the server
900+
* ends the connection while a query is in flight (pg_terminate_backend, a
901+
* failover, idle_in_transaction_session_timeout, a proxy reset), pg emits
902+
* 'error' on the Client itself, and with nobody listening Node exits the
903+
* whole process (#479). The pool-level listener in connect() cannot help:
904+
* it only covers idle clients. pool.query() attaches exactly this kind of
905+
* per-checkout listener; this does the same for the pool.connect() path.
906+
*/
907+
private async acquireClient(): Promise<pg.PoolClient> {
908+
if (!this.pool) {
909+
throw new Error("Not connected to database");
910+
}
911+
const client = await this.pool.connect();
912+
let connectionError: Error | undefined;
913+
const onError = (err: Error) => {
914+
connectionError = err;
915+
};
916+
client.on("error", onError);
917+
const release = client.release;
918+
client.release = (err?: Error | boolean) => {
919+
client.removeListener("error", onError);
920+
// Releasing with a truthy argument makes the pool close and drop the
921+
// client instead of handing the dead connection to the next caller.
922+
const reason = err ?? connectionError;
923+
if (reason === undefined) {
924+
release();
925+
} else {
926+
release(reason);
927+
}
928+
};
929+
return client;
930+
}
931+
894932
/**
895933
* Best-effort rollback after a failed statement, so a failed ROLLBACK (e.g.
896934
* a dropped connection) cannot mask the original error. Skipped for a

0 commit comments

Comments
 (0)