Skip to content

Commit 529fb59

Browse files
committed
wip
1 parent 477f812 commit 529fb59

File tree

6 files changed

+206
-6
lines changed

6 files changed

+206
-6
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,6 @@
3636
"arrowParens": "always",
3737
"trailingComma": "es5",
3838
"singleQuote": true
39-
}
39+
},
40+
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
4041
}

packages/pg/README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,45 @@ $ npm install pg
2121
- Pure JavaScript client and native libpq bindings share _the same API_
2222
- Connection pooling
2323
- Extensible JS ↔ PostgreSQL data-type coercion
24+
- Memory safety with configurable result size limits
2425
- Supported PostgreSQL features
2526
- Parameterized queries
2627
- Named statements with query plan caching
2728
- Async notifications with `LISTEN/NOTIFY`
2829
- Bulk import & export with `COPY TO/COPY FROM`
2930

31+
### Memory Safety with Result Size Limits
32+
33+
To prevent out-of-memory errors when dealing with unexpectedly large query results, you can set a maximum result size:
34+
35+
```js
36+
const { Client, Pool } = require('pg')
37+
38+
// For a single client
39+
const client = new Client({
40+
// other connection options
41+
maxResultSize: 50 * 1024 * 1024 // 50MB limit
42+
})
43+
44+
// Or with a pool
45+
const pool = new Pool({
46+
// other connection options
47+
maxResultSize: 10 * 1024 * 1024 // 10MB limit
48+
})
49+
50+
// If a query result exceeds the limit, it will emit an error with code 'RESULT_SIZE_EXCEEDED'
51+
client.query('SELECT * FROM large_table').catch(err => {
52+
if (err.code === 'RESULT_SIZE_EXCEEDED') {
53+
console.error(`Query result exceeded size limit of ${err.maxResultSize} bytes`)
54+
// Handle gracefully - perhaps use a cursor or add a LIMIT clause
55+
} else {
56+
// Handle other errors
57+
}
58+
})
59+
```
60+
61+
For large datasets, consider using [pg-cursor](https://github.com/brianc/node-postgres/tree/master/packages/pg-cursor) to process rows in batches.
62+
3063
### Extras
3164

3265
node-postgres is by design pretty light on abstractions. These are some handy modules we've been using over the years to complete the picture.

packages/pg/lib/client.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class Client extends EventEmitter {
5252
keepAlive: c.keepAlive || false,
5353
keepAliveInitialDelayMillis: c.keepAliveInitialDelayMillis || 0,
5454
encoding: this.connectionParameters.client_encoding || 'utf8',
55+
maxResultSize: c.maxResultSize
5556
})
5657
this.queryQueue = []
5758
this.binary = c.binary || defaults.binary
@@ -243,7 +244,7 @@ class Client extends EventEmitter {
243244
}
244245
}
245246

246-
_handleAuthCleartextPassword(msg) {
247+
_handleAuthCleartextPassword(_msg) {
247248
this._checkPgPass(() => {
248249
this.connection.password(this.password)
249250
})
@@ -299,7 +300,7 @@ class Client extends EventEmitter {
299300
this.secretKey = msg.secretKey
300301
}
301302

302-
_handleReadyForQuery(msg) {
303+
_handleReadyForQuery(_msg) {
303304
if (this._connecting) {
304305
this._connecting = false
305306
this._connected = true
@@ -376,12 +377,12 @@ class Client extends EventEmitter {
376377
this.activeQuery.handleDataRow(msg)
377378
}
378379

379-
_handlePortalSuspended(msg) {
380+
_handlePortalSuspended(_msg) {
380381
// delegate portalSuspended to active query
381382
this.activeQuery.handlePortalSuspended(this.connection)
382383
}
383384

384-
_handleEmptyQuery(msg) {
385+
_handleEmptyQuery(_msg) {
385386
// delegate emptyQuery to active query
386387
this.activeQuery.handleEmptyQuery(this.connection)
387388
}
@@ -410,7 +411,7 @@ class Client extends EventEmitter {
410411
}
411412
}
412413

413-
_handleCopyInResponse(msg) {
414+
_handleCopyInResponse(_msg) {
414415
this.activeQuery.handleCopyInResponse(this.connection)
415416
}
416417

packages/pg/lib/connection.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ class Connection extends EventEmitter {
2727
this.ssl = config.ssl || false
2828
this._ending = false
2929
this._emitMessage = false
30+
this._maxResultSize = config.maxResultSize
31+
this._currentResultSize = 0
3032
var self = this
3133
this.on('newListener', function (eventName) {
3234
if (eventName === 'message') {
@@ -108,6 +110,16 @@ class Connection extends EventEmitter {
108110
}
109111

110112
attachListeners(stream) {
113+
// Use the appropriate implementation based on whether maxResultSize is enabled
114+
if (this._maxResultSize && this._maxResultSize > 0) {
115+
this._attachListenersWithSizeLimit(stream)
116+
} else {
117+
this._attachListenersStandard(stream)
118+
}
119+
}
120+
121+
// Original implementation with no overhead
122+
_attachListenersStandard(stream) {
111123
parse(stream, (msg) => {
112124
var eventName = msg.name === 'error' ? 'errorMessage' : msg.name
113125
if (this._emitMessage) {
@@ -117,6 +129,59 @@ class Connection extends EventEmitter {
117129
})
118130
}
119131

132+
// Implementation with size limiting logic
133+
_attachListenersWithSizeLimit(stream) {
134+
parse(stream, (msg) => {
135+
var eventName = msg.name === 'error' ? 'errorMessage' : msg.name
136+
137+
// Only track data row messages for result size
138+
if (msg.name === 'dataRow') {
139+
// Approximate size by using message length
140+
const msgSize = msg.length || this._getApproximateMessageSize(msg)
141+
this._currentResultSize += msgSize
142+
143+
// Check if we've exceeded the max result size
144+
if (this._currentResultSize > this._maxResultSize) {
145+
const error = new Error('Query result size exceeded the configured limit')
146+
error.code = 'RESULT_SIZE_EXCEEDED'
147+
error.resultSize = this._currentResultSize
148+
error.maxResultSize = this._maxResultSize
149+
this.emit('error', error)
150+
this.end() // Terminate the connection
151+
return
152+
}
153+
}
154+
155+
// Reset counter on query completion
156+
if (msg.name === 'readyForQuery') {
157+
this._currentResultSize = 0
158+
}
159+
160+
if (this._emitMessage) {
161+
this.emit('message', msg)
162+
}
163+
this.emit(eventName, msg)
164+
})
165+
}
166+
167+
// Helper method to approximate message size when length is not available
168+
_getApproximateMessageSize(msg) {
169+
let size = 0
170+
if (msg.fields) {
171+
// Sum up the sizes of field values
172+
msg.fields.forEach(field => {
173+
if (field && typeof field === 'string') {
174+
size += field.length;
175+
} else if (field && typeof field === 'object') {
176+
size += JSON.stringify(field).length;
177+
} else if (field !== null && field !== undefined) {
178+
size += String(field).length;
179+
}
180+
});
181+
}
182+
return size > 0 ? size : 1024; // Default to 1KB if we can't determine size
183+
}
184+
120185
requestSsl() {
121186
this.stream.write(serialize.requestSsl())
122187
}

packages/pg/lib/defaults.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ module.exports = {
7070
keepalives: 1,
7171

7272
keepalives_idle: 0,
73+
// maxResultSize limit of a request before erroring out
74+
maxResultSize: undefined,
7375
}
7476

7577
var pgTypes = require('pg-types')
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
'use strict'
2+
var helper = require('./test-helper')
3+
var assert = require('assert')
4+
const { Client } = helper.pg
5+
const suite = new helper.Suite()
6+
7+
// Just verify the test infrastructure works
8+
suite.test('sanity check', function (done) {
9+
var client = new Client(helper.args)
10+
client.connect(assert.success(function () {
11+
client.query('SELECT 1 as num', assert.success(function(result) {
12+
assert.equal(result.rows.length, 1)
13+
client.end(done)
14+
}))
15+
}))
16+
})
17+
18+
// Basic test to check if the _maxResultSize property is passed to Connection
19+
suite.test('client passes maxResultSize to connection', function (done) {
20+
var client = new Client({
21+
...helper.args,
22+
maxResultSize: 1024 * 1024 // 1MB limit
23+
})
24+
25+
client.connect(assert.success(function () {
26+
assert.equal(client.connection._maxResultSize, 1024 * 1024,
27+
'maxResultSize should be passed to the connection')
28+
client.end(done)
29+
}))
30+
})
31+
32+
// Check if the correct attachListeners method is being used based on maxResultSize
33+
suite.test('connection uses correct listener implementation', function (done) {
34+
var client = new Client({
35+
...helper.args,
36+
maxResultSize: 1024 * 1024 // 1MB limit
37+
})
38+
39+
client.connect(assert.success(function () {
40+
// Just a simple check to see if our methods exist on the connection object
41+
assert(typeof client.connection._attachListenersStandard === 'function',
42+
'Standard listener method should exist')
43+
assert(typeof client.connection._attachListenersWithSizeLimit === 'function',
44+
'Size-limiting listener method should exist')
45+
client.end(done)
46+
}))
47+
})
48+
49+
// Test that small result sets complete successfully with maxResultSize set
50+
suite.test('small result with maxResultSize', function (done) {
51+
var client = new Client({
52+
...helper.args,
53+
maxResultSize: 1024 * 1024 // 1MB limit
54+
})
55+
56+
client.connect(assert.success(function () {
57+
client.query('SELECT generate_series(1, 10) as num', assert.success(function(result) {
58+
assert.equal(result.rows.length, 10)
59+
client.end(done)
60+
}))
61+
}))
62+
})
63+
64+
// Test for large result size
65+
// Using a separate test to avoid issue with callback being called twice
66+
suite.testAsync('large result triggers error', async () => {
67+
const client = new Client({
68+
...helper.args,
69+
maxResultSize: 500 // Very small limit
70+
})
71+
72+
// Setup error handler
73+
const errorPromise = new Promise(resolve => {
74+
client.on('error', err => {
75+
assert.equal(err.message, 'Query result size exceeded the configured limit')
76+
assert.equal(err.code, 'RESULT_SIZE_EXCEEDED')
77+
resolve()
78+
})
79+
})
80+
81+
await client.connect()
82+
83+
// Start the query but don't await it (it will error)
84+
const queryPromise = client.query('SELECT repeat(\'x\', 1000) as data FROM generate_series(1, 100)')
85+
.catch(err => {
86+
// We expect this to error out, silence the rejection
87+
return null
88+
})
89+
90+
// Wait for error event
91+
await errorPromise
92+
93+
// Make sure the query is done before we end
94+
await queryPromise
95+
96+
// Clean up
97+
await client.end().catch(() => {}) // Ignore errors during cleanup
98+
})

0 commit comments

Comments
 (0)