Skip to content

perf(pool): skip the release ping when the driver proves the connection is idle - #4470

Open
SebastianThiebaud wants to merge 2 commits into
transact-rs:mainfrom
SebastianThiebaud:sebastian/pool-skip-release-ping
Open

SebastianThiebaud wants to merge 2 commits into
transact-rs:mainfrom
SebastianThiebaud:sebastian/pool-skip-release-ping

Conversation

@SebastianThiebaud

Copy link
Copy Markdown

Does your PR solve an issue?

Closes #4469.

Related: #1743 (the same question for the acquire-side ping, answered with before_acquire), #4454 (this PR is stacked on it), #4409, #4349.

What this does

Floating::return_to_pool pings every connection that comes back to the pool. For Postgres that is a Sync and a wait for ReadyForQuery: one network round trip per release. The ping went in with #1025 as a band-aid for cancellation. If an Executor future was dropped mid-operation, the ping flushes what it left behind before anyone reuses the connection.

Most releases follow an operation that ran to completion. There is nothing to flush, but they still pay the round trip. With the default test_before_acquire(true), a one-statement pool.fetch_one() costs three round trips: acquire ping, statement, release ping. On a service we run against a small PostgreSQL instance, a wire proxy counted 143 release pings in 335 round trips per minute, for 182 statements, on otherwise idle background pools.

This PR lets a driver prove that a connection has nothing to flush, and skips the ping in that case.

  • Connection::is_protocol_idle(): a new #[doc(hidden)] provided method, next to should_flush(). It defaults to false, so MySQL, SQLite and third-party drivers keep pinging exactly as today. AnyConnection forwards it through a provided AnyConnectionBackend method.
  • Floating::return_to_pool releases the connection without the ping when it returns true. The pool-closed, max-lifetime and after_release steps run first, unchanged.
  • PgConnection returns true only when no ReadyForQuery is owed, the write buffer is empty, transaction_depth is zero, and the last ReadyForQuery reported Idle.

Why that condition is enough for Postgres

Every frontend message sequence that expects a reply ends in Sync or Query. write_sync() and queue_simple_query() count the owed ReadyForQuery before the first .await, and recv_unchecked() never consumes a partial message. So a future dropped at any await leaves bytes in the write buffer or a non-zero pending_ready_for_query_count. Either one makes the connection non-idle, and it gets pinged as today.

That covers a result stream dropped mid-rows, a failed statement (its ReadyForQuery is still unread), a dropped Transaction (queued ROLLBACK), a dropped PgAdvisoryLockGuard (queued unlock), a dropped PgCopyIn (queued CopyFail), and a future cancelled anywhere in between.

The transaction check keeps the #4393, #4423 and #4409 cases on the ping path. A session inside a transaction block the client did not open is not idle. If #4409 lands, its rollback in ping() still applies to it.

One gap remained: COPY sent its Query without counting the ReadyForQuery. #4454 fixes COPY TO STDOUT, and this PR is stacked on it. It does the same for COPY FROM STDIN: begin counts the reply, finish and abort consume it with recv_ready_for_query(), and the error paths drain with wait_until_ready(). Without that, a finish() cancelled while the server was still processing the copy would look idle. That case is broken on main anyway: the release ping takes the copy's ReadyForQuery as its own reply and leaves the connection one reply behind, so the next statement fails with expected ParseComplete, got ReadyForQuery.

PgListener now handles ReadyForQuery through handle_ready_for_query(). It keeps transaction_status current and returns a protocol error instead of underflowing the counter.

What still pings, and what changes

The ping still runs whenever anything is queued or owed. Time-sensitive work like a queued ROLLBACK or advisory unlock still reaches the server when the connection is released, not when someone next uses it.

What a clean connection loses is the liveness check at release. Its last completed exchange already proved it was live moments earlier, and test_before_acquire (on by default) checks it again before anyone uses it. Users who turned test_before_acquire off already accept unchecked idle connections. The only window that changes is the time between a caller's last statement and the drop of its PoolConnection.

PoolConnectionMetadata::idle_for still counts from the release.

Relationship to other open PRs

Is this a breaking change?

No API break. It adds one #[doc(hidden)] provided method to Connection and one to AnyConnectionBackend, both defaulting to today's behavior.

The observable behavior change, with Hyrum's Law in mind: a Postgres connection with nothing in flight is no longer pinged when it is released. A backend that dies between the caller's last statement and the release now waits in the idle queue. With test_before_acquire(true), the default, the next acquire catches and replaces it. With it off, the next statement on that connection fails instead. Nothing in the docs promises a liveness check on release, and test_before_acquire is the documented one. If you consider this significant, I can gate the skip behind a PoolOptions flag.

Tests

  • tests/postgres/pool_release.rs, a new target that needs no DATABASE_URL. An in-process server counts the client's Sync messages, like the fake server in fix(pool): bound return_to_pool so a dead connection can't leak its permit #4350. Three clean releases send none. A query cancelled mid-flight gets exactly one, and the connection is reused afterwards.
  • it_is_protocol_idle_only_with_nothing_in_flight walks a real connection through each state listed above. It asserts is_protocol_idle() stays false until the leftover state is flushed.
  • it_flushes_a_pooled_copy_in_cancelled_during_finish: a statement trigger makes finish() wait, and cancelling it must not poison the pooled connection. On main, and on main with fix(postgres): track the ReadyForQuery owed by COPY TO STDOUT #4454, it fails with expected ParseComplete, got ReadyForQuery.

Mutation checks, each run against the tests above:

Mutation Fails
Always ping (today's behavior) both pool_release tests (3 Syncs instead of 0)
Ignore the owed ReadyForQuery cancelled-query pool_release test, state walk
Ignore the server's transaction status state walk
Drop the COPY FROM STDIN accounting cancelled-COPY test, state walk

Run locally against PostgreSQL 18.6 with --no-default-features --features any,postgres,macros,migrate,_unstable-all-types,runtime-tokio,tls-none. Every Postgres target passes, skipping test_pg_copy_chunked and rustsec_2024_0363 as CI does, and test_pg_copy_chunked passes on its own. cargo test -p sqlx-core --all-features passes. CI's cargo clippy command with -D warnings and cargo fmt --check are clean on the pinned 1.94 toolchain.

Not run locally: the MySQL and SQLite suites (they keep the default, so their path is unchanged), the async-std and smol runtimes, the TLS matrix, and PostgreSQL versions other than 18.

Open questions

TannerRogalsky and others added 2 commits October 18, 2026 10:27
A copy_out_raw cancelled mid-exchange returned its connection to the pool
with an untracked ReadyForQuery still pending. The release ping consumed it
as its own reply, leaving the connection one reply behind, so its next query
failed with "expected ParseComplete, got ReadyForQuery".
…on is idle

`Floating::return_to_pool` pings every connection that comes back to the pool. The ping exists to flush protocol state that a cancelled or failed operation left behind. Most releases follow an operation that ran to completion, so they pay a round trip with nothing to flush.

Add `Connection::is_protocol_idle()`, a hidden provided method that defaults to `false`, and skip the ping when it returns `true`. Postgres returns `true` only when no `ReadyForQuery` is owed, the write buffer is empty, and neither the client nor the server's last `ReadyForQuery` reports a transaction. MySQL, SQLite and third-party drivers keep the default and ping as before.

`COPY FROM STDIN` now counts the `ReadyForQuery` it is owed, like `COPY TO STDOUT` does after transact-rs#4454. Without it, a `finish()` cancelled mid-copy looked idle. `PgListener` handles `ReadyForQuery` through `handle_ready_for_query()`, so it keeps `transaction_status` current.

Closes transact-rs#4469
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.

Pool: skip the release ping when the driver can prove the connection is idle

2 participants