Skip to content

Commit c363004

Browse files
authored
fix(postgres): enforce query timeout on server (#415)
1 parent 47c59c8 commit c363004

3 files changed

Lines changed: 81 additions & 4 deletions

File tree

src/connectors/__tests__/dsn-parser.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,18 @@ describe('DSN Parser - PostgreSQL SSL Modes', () => {
9999
});
100100
});
101101

102+
describe('DSN Parser - PostgreSQL query timeout', () => {
103+
it('configures a server-side statement timeout before the client fallback', async () => {
104+
const parser = new PostgresConnector().dsnParser;
105+
const config = await parser.parse('postgres://user:pass@localhost:5432/db', {
106+
queryTimeoutSeconds: 30,
107+
});
108+
109+
expect(config.statement_timeout).toBe(30_000);
110+
expect(config.query_timeout).toBe(35_000);
111+
});
112+
});
113+
102114
describe('DSN Parser - AWS IAM Authentication', () => {
103115
describe('MySQL', () => {
104116
const connector = new MySQLConnector();

src/connectors/__tests__/postgres.integration.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,65 @@ describe('PostgreSQL Connector Integration Tests', () => {
214214
postgresTest.createErrorHandlingTests();
215215
postgresTest.createSSLTests();
216216
describe('PostgreSQL-specific Features', () => {
217+
it('should cancel a timed-out query on the PostgreSQL server', async () => {
218+
const timedConnector = new PostgresConnector();
219+
const observer = new PostgresConnector();
220+
const probe = 'dbhub_query_timeout_probe';
221+
222+
const runningProbeCount = async (): Promise<number> => {
223+
const result = await observer.executeSQL(
224+
`SELECT count(*)::int AS count
225+
FROM pg_stat_activity
226+
WHERE state = 'active'
227+
AND query LIKE '%${probe}%'
228+
AND query NOT LIKE '%pg_stat_activity%'`,
229+
{}
230+
);
231+
return result.resultSets[0].rows[0].count;
232+
};
233+
234+
const waitFor = async (
235+
predicate: () => Promise<boolean>,
236+
timeoutMs: number
237+
): Promise<boolean> => {
238+
const deadline = Date.now() + timeoutMs;
239+
while (Date.now() < deadline) {
240+
if (await predicate()) return true;
241+
await new Promise((resolve) => setTimeout(resolve, 50));
242+
}
243+
return false;
244+
};
245+
246+
try {
247+
await timedConnector.connect(postgresTest.connectionString, undefined, {
248+
queryTimeoutSeconds: 1,
249+
});
250+
await observer.connect(postgresTest.connectionString);
251+
252+
const query = timedConnector.executeSQL(
253+
`SELECT pg_sleep(10), '${probe}'`,
254+
{ readonly: true }
255+
);
256+
const settled = query.then(
257+
() => null,
258+
(error) => error as NodeJS.ErrnoException
259+
);
260+
261+
expect(await waitFor(async () => (await runningProbeCount()) === 1, 5_000)).toBe(true);
262+
263+
const error = await settled;
264+
expect(error).toBeInstanceOf(Error);
265+
expect(error?.code).toBe('57014');
266+
expect(await waitFor(async () => (await runningProbeCount()) === 0, 2_000)).toBe(true);
267+
268+
const after = await timedConnector.executeSQL('SELECT 1 AS ok', { readonly: true });
269+
expect(after.resultSets[0].rows[0].ok).toBe(1);
270+
} finally {
271+
await timedConnector.disconnect();
272+
await observer.disconnect();
273+
}
274+
}, 20_000);
275+
217276
it('should execute multiple statements with transaction support', async () => {
218277
const result = await postgresTest.connector.executeSQL(`
219278
INSERT INTO users (name, email, age) VALUES ('Multi User 1', 'multi1@example.com', 30);
@@ -756,4 +815,4 @@ describe('PostgreSQL Connector Integration Tests', () => {
756815
}
757816
});
758817
});
759-
});
818+
});

src/connectors/postgres/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import { splitSQLStatements } from "../../utils/sql-parser.js";
2626
import { FailedToReadCertificate } from "./failed-to-read-certificate.js";
2727
import { closeQuietly } from "../../utils/resource-cleanup.js";
2828

29+
const POSTGRES_CLIENT_QUERY_TIMEOUT_GRACE_MS = 5_000;
30+
2931
/**
3032
* PostgreSQL DSN Parser
3133
* Handles DSN strings like: postgres://user:password@localhost:5432/dbname?sslmode=disable
@@ -113,10 +115,14 @@ class PostgresDSNParser implements DSNParser {
113115
poolConfig.connectionTimeoutMillis = connectionTimeoutSeconds * 1000;
114116
}
115117

116-
// Apply query timeout if specified (client-side timeout)
118+
// Apply the configured limit on the server so a timed-out statement does
119+
// not keep running after DBHub stops waiting for it. Retain the client-side
120+
// timeout as a fallback, with enough grace for PostgreSQL's cancellation
121+
// response to arrive first.
117122
if (queryTimeoutSeconds !== undefined) {
118-
// pg library expects query_timeout in milliseconds
119-
poolConfig.query_timeout = queryTimeoutSeconds * 1000;
123+
const queryTimeoutMs = queryTimeoutSeconds * 1000;
124+
poolConfig.statement_timeout = queryTimeoutMs;
125+
poolConfig.query_timeout = queryTimeoutMs + POSTGRES_CLIENT_QUERY_TIMEOUT_GRACE_MS;
120126
}
121127

122128
return poolConfig;

0 commit comments

Comments
 (0)