Skip to content

perf: Optimize queue removal searchs - #1263

Open
eavidy wants to merge 1 commit into
porsager:masterfrom
eavidy:master
Open

eavidy wants to merge 1 commit into
porsager:masterfrom
eavidy:master

Conversation

@eavidy

@eavidy eavidy commented Oct 20, 2026

Copy link
Copy Markdown

Fix queue removal scanning already-consumed entries

Summary

Start Queue.remove() searches at the queue's read index instead of the beginning of its backing array:

- const i = xs.indexOf(x)
+ const i = xs.indexOf(x, index)

shift() leaves cleared slots behind until the queue drains. During connection-pool scheduling, move() can call remove() for a connection already taken by shift(), causing a failed search through the entire backing array. As the consumed prefix grows, searches become more expensive even when few connections remain active.

Searching from the read index skips that prefix, reducing failed searches from O(backing-array length) to O(active-queue length). The public API and FIFO order are unchanged. This does not compact the array or eliminate redundant removal calls.

Benchmark

Postgres.js 3.4.9 on Deno 2.9.7, using 4 connections, prepare: false, default max_pipeline (100), and select 1 as x. Concurrency increases through the stages below, each lasting 8 seconds, with no warmup.

postgres-queue-before-after
Concurrency QPS before QPS after QPS after / before CPU before CPU after
50 33,560 76,502 2.28x 105.63% 101.51%
100 16,384 83,290 5.08x 100.44% 104.25%
200 13,396 130,097 9.71x 102.31% 128.17%
300 11,537 145,745 12.63x 102.37% 137.21%
400 161,715 158,736 0.98x 145.02% 145.67%
500 164,965 158,978 0.96x 146.24% 145.07%

Throughput improves by 2.28–12.63x at concurrency 50–300 in this run. At concurrency 400 and 500, QPS is 1.84% and 3.63% lower, respectively. Both runs reported zero failed or dropped queries.

CPU is client-process user + system CPU time, including the load generator; 100% represents one logical core. This is not an across-the-board CPU reduction.

These results are one before/after pair with no warmup. Stages share a process and pool, so they are not independent steady-state measurements. Repeated runs are needed to establish reproducibility.

@deval-code

Copy link
Copy Markdown

We ran into the same problem independently and can confirm this fix. One thing it leaves, which you may want to add: the backing array still grows by one slot per dispatched query for as long as the pool stays saturated.

Here's a rotation that models dispatch with every connection busy (busy.shift(), then remove() + push()), with 20 connections over 200,000 dispatches:

src/queue.js backing array slots scanned time
as released (3.4.7; 3.4.9 is identical) 200,019 20.0 billion 4,305 ms
this PR (indexOf(x, index)) 200,019 3.8 million 11 ms
compaction in shift() 83 10.2 million 11 ms

With this PR alone, a pool that stays saturated for hours keeps growing its busy array until it drains. Compacting in shift() once the consumed prefix is most of the array bounds both the array and the scan, at amortized O(1):

       } else {
         xs[index - 1] = undefined
+        if (index > 64 && index * 2 > xs.length) {
+          xs = xs.slice(index)
+          index = 0
+        }
       }

It composes with your change. End to end (Node 24, PostgreSQL 14, max: 4, 64 concurrent select 1 loops for 60 s), the released queue falls from 37,728 to 6,541 queries/s by 55 s, while with compaction it holds at about 185,000/s. In a production-like service, the unpatched queue showed up as 16.6% of the process's CPU in handler. We're running the compaction as a local patch, and I'm happy to open a follow-up PR with it and a test if that helps.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants