Skip to content

Commit fe4075a

Browse files
authored
Merge pull request #947 from supabase/fix/only-add-statement-timeout-on-direct-connection
fix: use query level statement_timeout
2 parents 70c38c0 + f183588 commit fe4075a

File tree

7 files changed

+102
-39
lines changed

7 files changed

+102
-39
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@
3030
"test": "run-s db:clean db:run test:run db:clean",
3131
"db:clean": "cd test/db && docker compose down",
3232
"db:run": "cd test/db && docker compose up --detach --wait",
33-
"test:run": "PG_META_MAX_RESULT_SIZE_MB=20 PG_QUERY_TIMEOUT_SECS=3 PG_CONN_TIMEOUT_SECS=30 vitest run --coverage",
34-
"test:update": "run-s db:clean db:run && PG_META_MAX_RESULT_SIZE_MB=20 PG_QUERY_TIMEOUT_SECS=3 PG_CONN_TIMEOUT_SECS=30 vitest run --update && run-s db:clean"
33+
"test:run": "PG_META_MAX_RESULT_SIZE_MB=20 PG_QUERY_TIMEOUT_SECS=5 PG_CONN_TIMEOUT_SECS=30 vitest run --coverage",
34+
"test:update": "run-s db:clean db:run && PG_META_MAX_RESULT_SIZE_MB=20 PG_QUERY_TIMEOUT_SECS=5 PG_CONN_TIMEOUT_SECS=30 vitest run --update && run-s db:clean"
3535
},
3636
"engines": {
3737
"node": ">=20",

src/lib/PostgresMeta.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ import { init } from './db.js'
2222
import { PostgresMetaResult, PoolConfig } from './types.js'
2323

2424
export default class PostgresMeta {
25-
query: (sql: string, trackQueryInSentry?: boolean) => Promise<PostgresMetaResult<any>>
25+
query: (
26+
sql: string,
27+
opts?: { statementQueryTimeout?: number; trackQueryInSentry?: boolean }
28+
) => Promise<PostgresMetaResult<any>>
2629
end: () => Promise<void>
2730
columnPrivileges: PostgresMetaColumnPrivileges
2831
columns: PostgresMetaColumns

src/lib/db.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ const poolerQueryHandleError = (pgpool: pg.Pool, sql: string): Promise<pg.QueryR
6262
}
6363

6464
export const init: (config: PoolConfig) => {
65-
query: (sql: string, trackQueryInSentry?: boolean) => Promise<PostgresMetaResult<any>>
65+
query: (
66+
sql: string,
67+
opts?: { statementQueryTimeout?: number; trackQueryInSentry?: boolean }
68+
) => Promise<PostgresMetaResult<any>>
6669
end: () => Promise<void>
6770
} = (config) => {
6871
return Sentry.startSpan({ op: 'db', name: 'db.init' }, () => {
@@ -103,7 +106,10 @@ export const init: (config: PoolConfig) => {
103106
let pool: pg.Pool | null = new pg.Pool(config)
104107

105108
return {
106-
async query(sql, trackQueryInSentry = true) {
109+
async query(
110+
sql,
111+
{ statementQueryTimeout, trackQueryInSentry } = { trackQueryInSentry: true }
112+
) {
107113
return Sentry.startSpan(
108114
// For metrics purposes, log the query that will be run if it's not an user provided query (with possibly sentitives infos)
109115
{
@@ -112,18 +118,26 @@ export const init: (config: PoolConfig) => {
112118
attributes: { sql: trackQueryInSentry ? sql : 'custom' },
113119
},
114120
async () => {
121+
const statementTimeoutQueryPrefix = statementQueryTimeout
122+
? `SET statement_timeout='${statementQueryTimeout}s';`
123+
: ''
124+
// node-postgres need a statement_timeout to kill the connection when timeout is reached
125+
// otherwise the query will keep running on the database even if query timeout was reached
126+
// This need to be added at query and not connection level because poolers (pgbouncer) doesn't
127+
// allow to set this parameter at connection time
128+
const sqlWithStatementTimeout = `${statementTimeoutQueryPrefix}${sql}`
115129
try {
116130
if (!pool) {
117131
const pool = new pg.Pool(config)
118-
let res = await poolerQueryHandleError(pool, sql)
132+
let res = await poolerQueryHandleError(pool, sqlWithStatementTimeout)
119133
if (Array.isArray(res)) {
120134
res = res.reverse().find((x) => x.rows.length !== 0) ?? { rows: [] }
121135
}
122136
await pool.end()
123137
return { data: res.rows, error: null }
124138
}
125139

126-
let res = await poolerQueryHandleError(pool, sql)
140+
let res = await poolerQueryHandleError(pool, sqlWithStatementTimeout)
127141
if (Array.isArray(res)) {
128142
res = res.reverse().find((x) => x.rows.length !== 0) ?? { rows: [] }
129143
}
@@ -147,7 +161,10 @@ export const init: (config: PoolConfig) => {
147161
formattedError += '\n'
148162
if (error.position) {
149163
// error.position is 1-based
150-
const position = Number(error.position) - 1
164+
// we also remove our `SET statement_timeout = 'XXs';\n` from the position
165+
const position = Number(error.position) - 1 - statementTimeoutQueryPrefix.length
166+
// we set the new error position
167+
error.position = `${position + 1}`
151168

152169
let line = ''
153170
let lineNumber = 0

src/lib/secrets.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
// Use dynamic import to support module mock
2-
const fs = await import('node:fs/promises')
3-
41
export const getSecret = async (key: string) => {
52
if (!key) {
63
return ''
@@ -15,6 +12,8 @@ export const getSecret = async (key: string) => {
1512
if (!file) {
1613
return ''
1714
}
15+
// Use dynamic import to support module mock
16+
const fs = await import('node:fs/promises')
1817

1918
return await fs.readFile(file, { encoding: 'utf8' }).catch((e) => {
2019
if (e.code == 'ENOENT') {

src/server/constants.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,6 @@ export const PG_META_MAX_RESULT_SIZE = process.env.PG_META_MAX_RESULT_SIZE_MB
5959
export const DEFAULT_POOL_CONFIG: PoolConfig = {
6060
max: 1,
6161
connectionTimeoutMillis: PG_CONN_TIMEOUT_SECS * 1000,
62-
// node-postgrest need a statement_timeout to kill the connection when timeout is reached
63-
// otherwise the query will keep running on the database even if query timeout was reached
64-
statement_timeout: (PG_QUERY_TIMEOUT_SECS + 1) * 1000,
6562
query_timeout: PG_QUERY_TIMEOUT_SECS * 1000,
6663
ssl: PG_META_DB_SSL_ROOT_CERT ? { ca: PG_META_DB_SSL_ROOT_CERT } : undefined,
6764
application_name: `postgres-meta ${pkg.version}`,

src/server/routes/query.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,18 @@ export default async (fastify: FastifyInstance) => {
1919
Body: {
2020
query: string
2121
}
22+
Querystring: {
23+
statementTimeoutSecs?: number
24+
}
2225
}>('/', async (request, reply) => {
26+
const statementTimeoutSecs = request.query.statementTimeoutSecs
2327
errorOnEmptyQuery(request)
2428
const config = createConnectionConfig(request)
2529
const pgMeta = new PostgresMeta(config)
26-
const { data, error } = await pgMeta.query(request.body.query, false)
30+
const { data, error } = await pgMeta.query(request.body.query, {
31+
trackQueryInSentry: true,
32+
statementQueryTimeout: statementTimeoutSecs,
33+
})
2734
await pgMeta.end()
2835
if (error) {
2936
request.log.error({ error, request: extractRequestForLogging(request) })

test/server/query-timeout.ts

Lines changed: 64 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,72 @@ 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
7+
58
describe('test query timeout', () => {
6-
test('query timeout after 3s and connection cleanup', async () => {
7-
const query = `SELECT pg_sleep(10);`
8-
// Execute a query that will sleep for 10 seconds
9-
const res = await app.inject({
10-
method: 'POST',
11-
path: '/query',
12-
payload: {
13-
query,
14-
},
15-
})
16-
17-
// Check that we get the proper timeout error response
18-
expect(res.statusCode).toBe(408) // Request Timeout
19-
expect(res.json()).toMatchObject({
20-
error: expect.stringContaining('Query read timeout'),
21-
})
22-
// wait one second for the statement timeout to take effect
23-
await new Promise((resolve) => setTimeout(resolve, 1000))
24-
25-
// Verify that the connection has been cleaned up by checking active connections
26-
const connectionsRes = await pgMeta.query(`
9+
test(
10+
`query timeout after ${TIMEOUT}s and connection cleanup`,
11+
async () => {
12+
const query = `SELECT pg_sleep(${TIMEOUT + 10});`
13+
// Execute a query that will sleep for 10 seconds
14+
const res = await app.inject({
15+
method: 'POST',
16+
path: '/query',
17+
query: `statementTimeoutSecs=${STATEMENT_TIMEOUT}`,
18+
payload: {
19+
query,
20+
},
21+
})
22+
23+
// Check that we get the proper timeout error response
24+
expect(res.statusCode).toBe(408) // Request Timeout
25+
expect(res.json()).toMatchObject({
26+
error: expect.stringContaining('Query read timeout'),
27+
})
28+
// wait one second for the statement timeout to take effect
29+
await new Promise((resolve) => setTimeout(resolve, 1000))
30+
31+
// Verify that the connection has been cleaned up by checking active connections
32+
const connectionsRes = await pgMeta.query(`
33+
SELECT * FROM pg_stat_activity where application_name = 'postgres-meta 0.0.0-automated' and query ILIKE '%${query}%';
34+
`)
35+
36+
// Should have no active connections except for our current query
37+
expect(connectionsRes.data).toHaveLength(0)
38+
},
39+
TIMEOUT * 1000
40+
)
41+
42+
test(
43+
'query without timeout parameter should not have timeout',
44+
async () => {
45+
const query = `SELECT pg_sleep(${TIMEOUT + 10});`
46+
// Execute a query that will sleep for 10 seconds without specifying timeout
47+
const res = await app.inject({
48+
method: 'POST',
49+
path: '/query',
50+
payload: {
51+
query,
52+
},
53+
})
54+
55+
// Check that we get the proper timeout error response
56+
expect(res.statusCode).toBe(408) // Request Timeout
57+
expect(res.json()).toMatchObject({
58+
error: expect.stringContaining('Query read timeout'),
59+
})
60+
// wait one second
61+
await new Promise((resolve) => setTimeout(resolve, 1000))
62+
63+
// Verify that the connection has not been cleaned up sinice there is no statementTimetout
64+
const connectionsRes = await pgMeta.query(`
2765
SELECT * FROM pg_stat_activity where application_name = 'postgres-meta 0.0.0-automated' and query ILIKE '%${query}%';
2866
`)
2967

30-
// Should have no active connections except for our current query
31-
expect(connectionsRes.data).toHaveLength(0)
32-
}, 5000)
68+
// Should have no active connections except for our current query
69+
expect(connectionsRes.data).toHaveLength(1)
70+
},
71+
TIMEOUT * 1000
72+
)
3373
})

0 commit comments

Comments
 (0)