Skip to content

perf(pg-pool): cut per-query allocations in the acquire/release path#3680

Open
kylecannon wants to merge 1 commit into
brianc:masterfrom
kylecannon:pr/pg-pool-alloc-cuts
Open

perf(pg-pool): cut per-query allocations in the acquire/release path#3680
kylecannon wants to merge 1 commit into
brianc:masterfrom
kylecannon:pr/pg-pool-alloc-cuts

Conversation

@kylecannon
Copy link
Copy Markdown

What

Two small allocation reductions in the pool's hot acquire/release path, with no behavior or API change:

  1. Register the per-query error listener with client.on('error', onError) instead of client.once(...). The query result callback already removes the listener on completion, and onError self-guards via the clientReleased flag, so once's per-query wrapper allocation was unnecessary.
  2. Bind _pulseQueue once in the constructor (this._pulseQueueBound) and reuse that reference for the connect-path process.nextTick and the three _remove callbacks, instead of allocating a fresh bound function on every acquire/release.

Why

pool.query() is on the hot path for most applications. These changes remove one closure allocation per query (the once wrapper) and one bound-function allocation per acquire/release, lowering per-query GC pressure with no change to behavior.

Correctness

The once to on switch cannot double-fire or leak a listener:

  • On success, the query callback calls client.removeListener('error', onError) before resolving.
  • onError flips clientReleased to true on its first call and returns early on any later call, so it can release and call back at most once.
  • On the error path the client is released with the error and removed from the pool, so no listener accumulates on a reused client.

No public API or observable behavior changes. waitingCount and all emitted events are unchanged.

Benchmark

Measured with a mock-client microbenchmark (no network, so it isolates the pool's own per-query overhead), alternating between this branch and base each round to cancel thermal drift. At concurrency 50 this change is about +2 to +3% queries/sec, with all three alternating rounds favoring it.

This is the allocation portion only. A separate change (O(1) pending-queue dequeue) adds more at high concurrency.

Testing

The full pg-pool suite passes (85 passing). The one failure in my environment is the unrelated pg-native bindings test, which requires a local libpq build.

Two allocation reductions, no behavior change:
- client.on('error', onError) instead of once: the query callback always removes
  the listener and onError self-guards via clientReleased, so once's per-query
  onceWrapper allocation was unnecessary.
- Bind _pulseQueue once in the constructor (_pulseQueueBound) and reuse it for the
  connect-path nextTick and the three _remove callbacks, instead of allocating a
  fresh bound function per acquire/release.

Benchmark (mock-client pool-micro, alternating-sampled vs base, conc=50):
  ~+2-3% qps (all 3 rounds favored this change). The O(1) pending-queue dequeue
  (separate PR) adds more at high concurrency.

Full pg-pool suite passes (85 passing; the one failure is the unrelated
pg-native bindings test, which requires libpq).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@charmander
Copy link
Copy Markdown
Collaborator

I’m not for merging this. Seems like a microoptimization that doesn’t match the rest of the code.

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