Skip to content

Commit 5b6537d

Browse files
authored
feat(query): add queryTimeoutSecs param for pool timeout override (#1045)
* feat(query): add queryTimeoutSecs param for pool timeout override Allow callers to set or disable pool-level query_timeout via ?queryTimeoutSecs=0|N. Absent keeps default (backwards compatible). Enables long-running migrations to avoid 408 timeouts. * fix: review * fix: setStatementTimeout=0
1 parent be1e9de commit 5b6537d

4 files changed

Lines changed: 87 additions & 23 deletions

File tree

src/lib/db.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,10 @@ export const init: (config: PoolConfig) => {
124124
async () => {
125125
// Use statement_timeout AND idle_session_timeout to ensure the connection will be killed even if idle after
126126
// timeout time.
127-
const statementTimeoutQueryPrefix = statementQueryTimeout
128-
? `SET statement_timeout='${statementQueryTimeout}s'; SET idle_session_timeout='${statementQueryTimeout}s';`
129-
: ''
127+
const statementTimeoutQueryPrefix =
128+
statementQueryTimeout !== undefined
129+
? `SET statement_timeout='${statementQueryTimeout}s'; SET idle_session_timeout='${statementQueryTimeout}s';`
130+
: ''
130131
// node-postgres need a statement_timeout to kill the connection when timeout is reached
131132
// otherwise the query will keep running on the database even if query timeout was reached
132133
// This need to be added at query and not connection level because poolers (pgbouncer) doesn't

src/server/routes/query.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ export default async (fastify: FastifyInstance) => {
1717
fastify.post<{
1818
Headers: { pg: string; 'x-pg-application-name'?: string }
1919
Body: { query: string; parameters?: unknown[] }
20-
Querystring: { statementTimeoutSecs?: number }
20+
Querystring: { statementTimeoutSecs?: number; queryTimeoutSecs?: number }
2121
}>('/', async (request, reply) => {
2222
const statementTimeoutSecs = request.query.statementTimeoutSecs
2323
errorOnEmptyQuery(request)
24-
const config = createConnectionConfig(request)
24+
const config = createConnectionConfig(request, request.query.queryTimeoutSecs)
2525
const pgMeta = new PostgresMeta(config)
2626
const { data, error } = await pgMeta.query(request.body.query, {
2727
trackQueryInSentry: true,

src/server/utils.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,19 @@ export const extractRequestForLogging = (request: FastifyRequest) => {
2323
}
2424
}
2525

26-
export function createConnectionConfig(request: FastifyRequest): PoolConfig {
26+
export function createConnectionConfig(
27+
request: FastifyRequest,
28+
queryTimeoutSecs?: number | string
29+
): PoolConfig {
2730
const connectionString = request.headers.pg as string
28-
const config = { ...DEFAULT_POOL_CONFIG, connectionString }
31+
const timeout = queryTimeoutSecs !== undefined ? Number(queryTimeoutSecs) : undefined
32+
const config = {
33+
...DEFAULT_POOL_CONFIG,
34+
connectionString,
35+
...(timeout !== undefined && {
36+
query_timeout: timeout === 0 ? undefined : timeout * 1000,
37+
}),
38+
}
2939

3040
// Override application_name if custom one provided in header
3141
if (request.headers['x-pg-application-name']) {

test/server/query-timeout.ts

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@ import { expect, test, describe } from 'vitest'
22
import { app } from './utils'
33
import { pgMeta } from '../lib/utils'
44

5-
const TIMEOUT = (Number(process.env.PG_QUERY_TIMEOUT_SECS) ?? 10) + 2
6-
const STATEMENT_TIMEOUT = (Number(process.env.PG_QUERY_TIMEOUT_SECS) ?? 10) + 1
5+
const PG_QUERY_TIMEOUT = Number(process.env.PG_QUERY_TIMEOUT_SECS) ?? 10
6+
const TIMEOUT = PG_QUERY_TIMEOUT + 2
7+
const STATEMENT_TIMEOUT = PG_QUERY_TIMEOUT + 1
8+
const CUSTOM_QUERY_TIMEOUT = 2
79

810
describe('test query timeout', () => {
911
test(
10-
`query timeout after ${TIMEOUT}s and connection cleanup`,
12+
`pool timeout after ${TIMEOUT}s with statementTimeoutSecs and connection cleanup`,
1113
async () => {
1214
const query = `SELECT pg_sleep(${TIMEOUT + 10});`
13-
// Execute a query that will sleep for 10 seconds
1415
const res = await app.inject({
1516
method: 'POST',
1617
path: '/query',
@@ -20,30 +21,25 @@ describe('test query timeout', () => {
2021
},
2122
})
2223

23-
// Check that we get the proper timeout error response
24-
expect(res.statusCode).toBe(408) // Request Timeout
24+
expect(res.statusCode).toBe(408)
2525
expect(res.json()).toMatchObject({
2626
error: expect.stringContaining('Query read timeout'),
2727
})
28-
// wait one second for the statement timeout to take effect
2928
await new Promise((resolve) => setTimeout(resolve, 1000))
3029

31-
// Verify that the connection has been cleaned up by checking active connections
3230
const connectionsRes = await pgMeta.query(`
3331
SELECT * FROM pg_stat_activity where application_name = 'postgres-meta 0.0.0-automated' and query ILIKE '%${query}%';
3432
`)
3533

36-
// Should have no active connections except for our current query
3734
expect(connectionsRes.data).toHaveLength(0)
3835
},
3936
TIMEOUT * 1000
4037
)
4138

4239
test(
43-
'query without timeout parameter should not have timeout',
40+
'absent queryTimeoutSecs uses default pool timeout',
4441
async () => {
4542
const query = `SELECT pg_sleep(${TIMEOUT + 10});`
46-
// Execute a query that will sleep for 10 seconds without specifying timeout
4743
const res = await app.inject({
4844
method: 'POST',
4945
path: '/query',
@@ -52,22 +48,79 @@ describe('test query timeout', () => {
5248
},
5349
})
5450

55-
// Check that we get the proper timeout error response
56-
expect(res.statusCode).toBe(408) // Request Timeout
51+
expect(res.statusCode).toBe(408)
5752
expect(res.json()).toMatchObject({
5853
error: expect.stringContaining('Query read timeout'),
5954
})
60-
// wait one second
6155
await new Promise((resolve) => setTimeout(resolve, 1000))
6256

63-
// Verify that the connection has not been cleaned up sinice there is no statementTimetout
57+
// No statementTimeout was set, so the PG-side query is still running
6458
const connectionsRes = await pgMeta.query(`
6559
SELECT * FROM pg_stat_activity where application_name = 'postgres-meta 0.0.0-automated' and query ILIKE '%${query}%';
6660
`)
6761

68-
// Should have no active connections except for our current query
6962
expect(connectionsRes.data).toHaveLength(1)
7063
},
7164
TIMEOUT * 1000
7265
)
66+
67+
test(
68+
'queryTimeoutSecs=0 disables pool-level timeout',
69+
async () => {
70+
const sleepSecs = PG_QUERY_TIMEOUT + 1
71+
const res = await app.inject({
72+
method: 'POST',
73+
path: '/query',
74+
query: 'queryTimeoutSecs=0',
75+
payload: {
76+
query: `SELECT pg_sleep(${sleepSecs});`,
77+
},
78+
})
79+
80+
expect(res.statusCode).toBe(200)
81+
expect(res.json()).toEqual([{ pg_sleep: '' }])
82+
},
83+
(PG_QUERY_TIMEOUT + 5) * 1000
84+
)
85+
86+
test(
87+
'custom queryTimeoutSecs overrides default pool timeout',
88+
async () => {
89+
const res = await app.inject({
90+
method: 'POST',
91+
path: '/query',
92+
query: `queryTimeoutSecs=${CUSTOM_QUERY_TIMEOUT}`,
93+
payload: {
94+
query: `SELECT pg_sleep(${PG_QUERY_TIMEOUT + 1});`,
95+
},
96+
})
97+
98+
expect(res.statusCode).toBe(408)
99+
expect(res.json()).toMatchObject({
100+
error: expect.stringContaining('Query read timeout'),
101+
})
102+
},
103+
(CUSTOM_QUERY_TIMEOUT + 5) * 1000
104+
)
105+
106+
test(
107+
'queryTimeoutSecs=0 with statementTimeoutSecs still enforces statement timeout',
108+
async () => {
109+
const res = await app.inject({
110+
method: 'POST',
111+
path: '/query',
112+
query: `queryTimeoutSecs=0&statementTimeoutSecs=${STATEMENT_TIMEOUT}`,
113+
payload: {
114+
query: `SELECT pg_sleep(${STATEMENT_TIMEOUT + 5});`,
115+
},
116+
})
117+
118+
// Statement timeout fires (not pool timeout), producing a DatabaseError
119+
expect(res.statusCode).toBe(400)
120+
expect(res.json()).toMatchObject({
121+
error: expect.stringContaining('canceling statement due to statement timeout'),
122+
})
123+
},
124+
(STATEMENT_TIMEOUT + 5) * 1000
125+
)
73126
})

0 commit comments

Comments
 (0)