Conversation
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 porsager#747 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BMNuLV7ceCupEDQmQFNADT
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 porsager#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 porsager#747 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BMNuLV7ceCupEDQmQFNADT
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #747
Queue.shift()only resets its backing array when the queue drains completely. The pool'sopenqueue never drains while two or more connections sit idle in it: a connection is taken withshift()and pushed back when its query finishes, so every query leaves one dead slot behind, andremove()scanned all of them from index 0. Connection hand-off therefore gets slower with every query served sinceopenlast drained — the "gets slower until restart" in #747, and why it only shows up once a burst has opened more than one connection. The same pattern growsbusyunder load, the pendingqueriesbacklog, and each connection'ssentqueue.The fix is in
src/queue.jsonly:shift()compacts once the dead prefix is at least as long as the live entries (and longer than 64, so small queues aren't reallocated on every call). Each compaction copies no more elements than were shifted since the last one, soshift()stays O(1) amortized.remove()searches from the cursor. Slots below it are alwaysundefined, so for any defined value the result is unchanged; only the scan gets shorter.Keeping
c.queuein sync inindex.jswould stopremove()from missing, but not the growth —sentis neverremove()d, and the backlog grows the same way — so the callers are untouched.'3,,b,a,1,c,0,d,e,true' != '3,,b,a,1,c,0,d,e,10003'. The backing array has no public observable and the slowdown only becomes measurable past ~10^5 queries, so it's aQueueunit test rather than a database test: it passes anArraysubclass asinitial, which.slice()keeps viaSymbol.species(asResultalready relies on), so itspushsees the backing array's size. It also checks aremove()miss, a non-head hit, and a hit at the cursor after a drain. No timing assertions; it runs in a few ms.eslint src testsis clean.Queuewas differentially fuzzed against 3.4.9 over ~124M operations, biased toward the compaction boundary, with no divergence in anypush,shift,removeorlengthresult.End to end,
max: 10, 500kselect 1after a warm-up burst:openbacking array🤖 Generated with Claude Code
https://claude.ai/code/session_01BMNuLV7ceCupEDQmQFNADT