Skip to content

Commit 9d831d7

Browse files
committed
feat(server): introduce maxResultSize limit and fix pg errors handling
1 parent a08764d commit 9d831d7

12 files changed

+2989
-5460
lines changed

package-lock.json

+2,566-5,415
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+4-4
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": "vitest run --coverage",
34-
"test:update": "run-s db:clean db:run && vitest run --update && run-s db:clean"
33+
"test:run": "PG_META_MAX_RESULT_SIZE=20971520 vitest run --coverage",
34+
"test:update": "run-s db:clean db:run && PG_META_MAX_RESULT_SIZE=20971520 vitest run --update && run-s db:clean"
3535
},
3636
"engines": {
3737
"node": ">=20",
@@ -46,10 +46,10 @@
4646
"crypto-js": "^4.0.0",
4747
"fastify": "^4.24.3",
4848
"fastify-metrics": "^10.0.0",
49-
"pg": "^8.13.1",
49+
"pg": "npm:@supabase/[email protected]",
5050
"pg-connection-string": "^2.7.0",
5151
"pg-format": "^1.0.4",
52-
"pg-protocol": "^1.7.0",
52+
"pg-protocol": "npm:@supabase/[email protected]",
5353
"pgsql-parser": "^13.16.0",
5454
"pino": "^9.5.0",
5555
"postgres-array": "^3.0.1",

src/lib/PostgresMeta.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { PoolConfig } from 'pg'
21
import * as Parser from './Parser.js'
32
import PostgresMetaColumnPrivileges from './PostgresMetaColumnPrivileges.js'
43
import PostgresMetaColumns from './PostgresMetaColumns.js'
@@ -20,7 +19,7 @@ import PostgresMetaTypes from './PostgresMetaTypes.js'
2019
import PostgresMetaVersion from './PostgresMetaVersion.js'
2120
import PostgresMetaViews from './PostgresMetaViews.js'
2221
import { init } from './db.js'
23-
import { PostgresMetaResult } from './types.js'
22+
import { PostgresMetaResult, PoolConfig } from './types.js'
2423

2524
export default class PostgresMeta {
2625
query: (sql: string) => Promise<PostgresMetaResult<any>>

src/lib/db.ts

+74-13
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import pg, { PoolConfig } from 'pg'
2-
import { DatabaseError } from 'pg-protocol'
1+
import pg from 'pg'
32
import { parse as parseArray } from 'postgres-array'
4-
import { PostgresMetaResult } from './types.js'
3+
import { PostgresMetaResult, PoolConfig } from './types.js'
54

65
pg.types.setTypeParser(pg.types.builtins.INT8, (x) => {
76
const asNumber = Number(x)
@@ -21,6 +20,42 @@ pg.types.setTypeParser(1185, parseArray) // _timestamptz
2120
pg.types.setTypeParser(600, (x) => x) // point
2221
pg.types.setTypeParser(1017, (x) => x) // _point
2322

23+
// Ensure any query will have an appropriate error handler on the pool to prevent connections errors
24+
// to bubble up all the stack eventually killing the server
25+
const poolerQueryHandleError = (pgpool: pg.Pool, sql: string): Promise<pg.QueryResult<any>> => {
26+
return new Promise((resolve, reject) => {
27+
let rejected = false
28+
const connectionErrorHandler = (err: any) => {
29+
// If the error hasn't already be propagated to the catch
30+
if (!rejected) {
31+
// This is a trick to wait for the next tick, leaving a chance for handled errors such as
32+
// RESULT_SIZE_LIMIT to take over other stream errors such as `unexpected commandComplete message`
33+
setTimeout(() => {
34+
rejected = true
35+
return reject(err)
36+
})
37+
}
38+
}
39+
// This listened avoid getting uncaught exceptions for errors happening at connection level within the stream
40+
// such as parse or RESULT_SIZE_EXCEEDED errors instead, handle the error gracefully by bubbling in up to the caller
41+
pgpool.once('error', connectionErrorHandler)
42+
pgpool
43+
.query(sql)
44+
.then((results: pg.QueryResult<any>) => {
45+
if (!rejected) {
46+
return resolve(results)
47+
}
48+
})
49+
.catch((err: any) => {
50+
// If the error hasn't already be handled within the error listener
51+
if (!rejected) {
52+
rejected = true
53+
return reject(err)
54+
}
55+
})
56+
})
57+
}
58+
2459
export const init: (config: PoolConfig) => {
2560
query: (sql: string) => Promise<PostgresMetaResult<any>>
2661
end: () => Promise<void>
@@ -60,26 +95,27 @@ export const init: (config: PoolConfig) => {
6095
// compromise: if we run `query` after `pool.end()` is called (i.e. pool is
6196
// `null`), we temporarily create a pool and close it right after.
6297
let pool: pg.Pool | null = new pg.Pool(config)
98+
6399
return {
64100
async query(sql) {
65101
try {
66102
if (!pool) {
67103
const pool = new pg.Pool(config)
68-
let res = await pool.query(sql)
104+
let res = await poolerQueryHandleError(pool, sql)
69105
if (Array.isArray(res)) {
70106
res = res.reverse().find((x) => x.rows.length !== 0) ?? { rows: [] }
71107
}
72108
await pool.end()
73109
return { data: res.rows, error: null }
74110
}
75111

76-
let res = await pool.query(sql)
112+
let res = await poolerQueryHandleError(pool, sql)
77113
if (Array.isArray(res)) {
78114
res = res.reverse().find((x) => x.rows.length !== 0) ?? { rows: [] }
79115
}
80116
return { data: res.rows, error: null }
81117
} catch (error: any) {
82-
if (error instanceof DatabaseError) {
118+
if (error.constructor.name === 'DatabaseError') {
83119
// Roughly based on:
84120
// - https://github.com/postgres/postgres/blob/fc4089f3c65a5f1b413a3299ba02b66a8e5e37d0/src/interfaces/libpq/fe-protocol3.c#L1018
85121
// - https://github.com/brianc/node-postgres/blob/b1a8947738ce0af004cb926f79829bb2abc64aa6/packages/pg/lib/native/query.js#L33
@@ -146,17 +182,42 @@ ${' '.repeat(5 + lineNumber.toString().length + 2 + lineOffset)}^
146182
},
147183
}
148184
}
149-
150-
return { data: null, error: { message: error.message } }
185+
try {
186+
// Handle stream errors and result size exceeded errors
187+
if (error.code === 'RESULT_SIZE_EXCEEDED') {
188+
// Force kill the connection without waiting for graceful shutdown
189+
return {
190+
data: null,
191+
error: {
192+
message: `Query result size (${error.resultSize} bytes) exceeded the configured limit (${error.maxResultSize} bytes)`,
193+
code: error.code,
194+
resultSize: error.resultSize,
195+
maxResultSize: error.maxResultSize,
196+
},
197+
}
198+
}
199+
return { data: null, error: { code: error.code, message: error.message } }
200+
} finally {
201+
// If the error isn't a "DatabaseError" assume it's a connection related we kill the connection
202+
// To attempt a clean reconnect on next try
203+
await this.end()
204+
}
151205
}
152206
},
153207

154208
async end() {
155-
const _pool = pool
156-
pool = null
157-
// Gracefully wait for active connections to be idle, then close all
158-
// connections in the pool.
159-
if (_pool) await _pool.end()
209+
try {
210+
const _pool = pool
211+
pool = null
212+
// Gracefully wait for active connections to be idle, then close all
213+
// connections in the pool.
214+
if (_pool) {
215+
await _pool.end()
216+
}
217+
} catch (endError) {
218+
// Ignore any errors during cleanup just log them
219+
console.error('Failed ending connection pool', endError)
220+
}
160221
},
161222
}
162223
}

src/lib/types.ts

+7-13
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Static, Type } from '@sinclair/typebox'
22
import { DatabaseError } from 'pg-protocol'
33
import type { Options as PrettierOptions } from 'prettier'
4+
import { PoolConfig as PgPoolConfig } from 'pg'
45

56
export interface FormatterOptions extends PrettierOptions {}
67

@@ -251,13 +252,7 @@ export const postgresPublicationSchema = Type.Object({
251252
publish_delete: Type.Boolean(),
252253
publish_truncate: Type.Boolean(),
253254
tables: Type.Union([
254-
Type.Array(
255-
Type.Object({
256-
id: Type.Integer(),
257-
name: Type.String(),
258-
schema: Type.String(),
259-
})
260-
),
255+
Type.Array(Type.Object({ id: Type.Integer(), name: Type.String(), schema: Type.String() })),
261256
Type.Null(),
262257
]),
263258
})
@@ -445,12 +440,7 @@ export const postgresTypeSchema = Type.Object({
445440
schema: Type.String(),
446441
format: Type.String(),
447442
enums: Type.Array(Type.String()),
448-
attributes: Type.Array(
449-
Type.Object({
450-
name: Type.String(),
451-
type_id: Type.Integer(),
452-
})
453-
),
443+
attributes: Type.Array(Type.Object({ name: Type.String(), type_id: Type.Integer() })),
454444
comment: Type.Union([Type.String(), Type.Null()]),
455445
})
456446
export type PostgresType = Static<typeof postgresTypeSchema>
@@ -596,3 +586,7 @@ export const postgresColumnPrivilegesRevokeSchema = Type.Object({
596586
]),
597587
})
598588
export type PostgresColumnPrivilegesRevoke = Static<typeof postgresColumnPrivilegesRevokeSchema>
589+
590+
export interface PoolConfig extends PgPoolConfig {
591+
maxResultSize?: number
592+
}

src/server/app.ts

+2-6
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,10 @@ import { PG_META_REQ_HEADER } from './constants.js'
55
import routes from './routes/index.js'
66
import { extractRequestForLogging } from './utils.js'
77
// Pseudo package declared only for this module
8-
import pkg from '#package.json' assert { type: 'json' }
8+
import pkg from '#package.json' with { type: 'json' }
99

1010
export const build = (opts: FastifyServerOptions = {}): FastifyInstance => {
11-
const app = fastify({
12-
disableRequestLogging: true,
13-
requestIdHeader: PG_META_REQ_HEADER,
14-
...opts,
15-
})
11+
const app = fastify({ disableRequestLogging: true, requestIdHeader: PG_META_REQ_HEADER, ...opts })
1612

1713
app.setErrorHandler((error, request, reply) => {
1814
app.log.error({ error: error.toString(), request: extractRequestForLogging(request) })

src/server/constants.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import crypto from 'crypto'
2-
import { PoolConfig } from 'pg'
2+
import { PoolConfig } from '../lib/types.js'
33
import { getSecret } from '../lib/secrets.js'
44
import { AccessControl } from './templates/swift.js'
5-
import pkg from '#package.json' assert { type: 'json' }
5+
import pkg from '#package.json' with { type: 'json' }
66

77
export const PG_META_HOST = process.env.PG_META_HOST || '0.0.0.0'
88
export const PG_META_PORT = Number(process.env.PG_META_PORT || 1337)
@@ -49,11 +49,16 @@ export const GENERATE_TYPES_SWIFT_ACCESS_CONTROL = process.env
4949
? (process.env.PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL as AccessControl)
5050
: 'internal'
5151

52+
export const PG_META_MAX_RESULT_SIZE = process.env.PG_META_MAX_RESULT_SIZE
53+
? parseInt(process.env.PG_META_MAX_RESULT_SIZE, 10)
54+
: 2 * 1024 * 1024 * 1024 // default to 2GB max query size result
55+
5256
export const DEFAULT_POOL_CONFIG: PoolConfig = {
5357
max: 1,
5458
connectionTimeoutMillis: PG_CONN_TIMEOUT_SECS * 1000,
5559
ssl: PG_META_DB_SSL_ROOT_CERT ? { ca: PG_META_DB_SSL_ROOT_CERT } : undefined,
5660
application_name: `postgres-meta ${pkg.version}`,
61+
maxResultSize: PG_META_MAX_RESULT_SIZE,
5762
}
5863

5964
export const PG_META_REQ_HEADER = process.env.PG_META_REQ_HEADER || 'request-id'

test/index.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ import './server/query'
2222
import './server/ssl'
2323
import './server/table-privileges'
2424
import './server/typegen'
25+
import './server/result-size-limit'

test/lib/secrets.ts

+2-5
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@ vi.mock('node:fs/promises', async (): Promise<typeof import('node:fs/promises')>
66
const originalModule =
77
await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises')
88
const readFile = vi.fn()
9-
return {
10-
...originalModule,
11-
readFile,
12-
}
9+
return { ...originalModule, readFile }
1310
})
1411

1512
describe('getSecret', () => {
@@ -57,6 +54,6 @@ describe('getSecret', () => {
5754
const e: NodeJS.ErrnoException = new Error('permission denied')
5855
e.code = 'EACCES'
5956
vi.mocked(readFile).mockRejectedValueOnce(e)
60-
expect(getSecret('SECRET')).rejects.toThrow()
57+
await expect(getSecret('SECRET')).rejects.toThrow()
6158
})
6259
})

0 commit comments

Comments
 (0)