From 2a2e195db538a7358771093e26ef0ff44a265fb9 Mon Sep 17 00:00:00 2001 From: Evil0ctal Date: Tue, 29 Sep 2026 16:49:15 -0700 Subject: [PATCH 1/2] Add test for Queue growth when it is never drained Queue.shift() only resets its backing array when the queue drains completely at that instant. A queue that is shifted and pushed without ever emptying keeps one dead slot per cycle, so the array grows without bound and remove() scans all of it. The pool's `open` queue is in that state whenever more than one connection is idle. The test observes the backing array through an Array subclass passed as `initial`: Queue copies it with .slice(), which keeps the subclass via Symbol.species. It rotates three entries so compaction has to preserve order across several live entries, then checks a remove() miss, a hit on a non-head entry, and a hit at the cursor after a full drain. No timing, no database. Fails on 3.4.9 with '3,,b,a,1,c,0,d,e,true' != '3,,b,a,1,c,0,d,e,10003'. Refs #747 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BMNuLV7ceCupEDQmQFNADT --- tests/index.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/index.js b/tests/index.js index 845c6ce..f7013c4 100644 --- a/tests/index.js +++ b/tests/index.js @@ -6,6 +6,7 @@ import fs from 'fs' import crypto from 'crypto' import postgres from '../src/index.js' +import Queue from '../src/queue.js' const delay = ms => new Promise(r => setTimeout(r, ms)) const rel = x => new URL(x, import.meta.url) @@ -2719,3 +2720,26 @@ t('query during copy error', async() => { await sql`drop table test` ] }) + +t('Queue does not grow when never drained', async() => { + let size = 0 + const q = Queue(new class extends Array { // Queue copies this with .slice(), which keeps the subclass + push(x) { + const length = super.push(x) + size = Math.max(size, length) + return length + } + }()) + + q.push('a') + q.push('b') + q.push('c') + for (let i = 0; i < 10000; i++) + q.push(q.shift()) + + const result = [q.length, q.remove('x'), q.shift(), q.remove('a'), q.length, q.shift(), q.length] + q.push('d') + q.push('e') + result.push(q.remove('d'), q.shift(), size > 0 && size < 1000 || size) + return ['3,,b,a,1,c,0,d,e,true', result.join()] +}) From 9f54c221961ba4dfc2215ff64903c4b6f211e2c1 Mon Sep 17 00:00:00 2001 From: Evil0ctal Date: Tue, 29 Sep 2026 16:49:15 -0700 Subject: [PATCH 2/2] Fix unbounded Queue growth when the queue never drains Queue.shift() advances a read cursor and only resets the backing array when the cursor reaches the end, i.e. when the queue drains completely. The pool's `open` queue never drains while two or more connections sit idle in it, which is the steady state with max > 1 once any burst has opened a second connection (the #747 repro: -c 10, then -c 5). A connection is taken with shift() and later pushed back, so every query leaves one dead slot behind. The same happens to `busy` under load, the pending `queries` backlog, and each connection's `sent` queue. remove() then scanned the whole array from index 0, so connection hand-off cost grew linearly with the number of queries served since `open` last drained. In production this reached 23.8M slots in three weeks and pinned a CPU core. - shift() compacts once the dead prefix is at least as long as the live entries (and longer than 64, so small queues are not reallocated on every call). Each compaction copies no more elements than were shifted since the last one, so shift() stays O(1) amortized. - remove() searches from the cursor. Slots below it are always undefined, so for any x other than undefined -- the pool only removes connections and queries -- the result is unchanged, and the scan covers only live entries. The fix belongs in Queue rather than the callers: keeping c.queue in sync would stop remove() from missing, but not the dead-slot growth, which also hits `sent` (never remove()d) and the `queries` backlog. Fixes #747 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BMNuLV7ceCupEDQmQFNADT --- src/queue.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/queue.js b/src/queue.js index c4ef971..d0f1564 100644 --- a/src/queue.js +++ b/src/queue.js @@ -9,10 +9,10 @@ function Queue(initial = []) { return xs.length - index }, remove: (x) => { - const index = xs.indexOf(x) - return index === -1 + const i = xs.indexOf(x, index) + return i === -1 ? null - : (xs.splice(index, 1), x) + : (xs.splice(i, 1), x) }, push: (x) => (xs.push(x), x), shift: () => { @@ -21,6 +21,9 @@ function Queue(initial = []) { if (index === xs.length) { index = 0 xs = [] + } else if (index > 64 && index * 2 >= xs.length) { + xs = xs.slice(index) + index = 0 } else { xs[index - 1] = undefined }