Repository navigation
perf(pool): skip the release ping when the driver proves the connection is idle - #4470
Open
SebastianThiebaud wants to merge 2 commits into
Open
SebastianThiebaud wants to merge 2 commits into
SebastianThiebaud wants to merge 2 commits into
Conversation
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
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.
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_poolpings every connection that comes back to the pool. For Postgres that is aSyncand a wait forReadyForQuery: one network round trip per release. The ping went in with #1025 as a band-aid for cancellation. If anExecutorfuture 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-statementpool.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 toshould_flush(). It defaults tofalse, so MySQL, SQLite and third-party drivers keep pinging exactly as today.AnyConnectionforwards it through a providedAnyConnectionBackendmethod.Floating::return_to_poolreleases the connection without the ping when it returnstrue. The pool-closed, max-lifetime andafter_releasesteps run first, unchanged.PgConnectionreturnstrueonly when noReadyForQueryis owed, the write buffer is empty,transaction_depthis zero, and the lastReadyForQueryreportedIdle.Why that condition is enough for Postgres
Every frontend message sequence that expects a reply ends in
SyncorQuery.write_sync()andqueue_simple_query()count the owedReadyForQuerybefore the first.await, andrecv_unchecked()never consumes a partial message. So a future dropped at any await leaves bytes in the write buffer or a non-zeropending_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
ReadyForQueryis still unread), a droppedTransaction(queuedROLLBACK), a droppedPgAdvisoryLockGuard(queued unlock), a droppedPgCopyIn(queuedCopyFail), 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
Querywithout counting theReadyForQuery. #4454 fixesCOPY TO STDOUT, and this PR is stacked on it. It does the same forCOPY FROM STDIN:begincounts the reply,finishandabortconsume it withrecv_ready_for_query(), and the error paths drain withwait_until_ready(). Without that, afinish()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'sReadyForQueryas its own reply and leaves the connection one reply behind, so the next statement fails withexpected ParseComplete, got ReadyForQuery.PgListenernow handlesReadyForQuerythroughhandle_ready_for_query(). It keepstransaction_statuscurrent 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
ROLLBACKor 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 turnedtest_before_acquireoff already accept unchecked idle connections. The only window that changes is the time between a caller's last statement and the drop of itsPoolConnection.PoolConnectionMetadata::idle_forstill counts from the release.Relationship to other open PRs
return_to_poolwith a timeout. That is orthogonal and complementary: the skip path does no I/O, so a clean release cannot hang. A connection released mid-query still pings and still needs the bound. The textual conflict is a few lines inreturn_to_pool.sqlx::Poolarchitecture #3582:return_to_poolthere also pings unconditionally. The same check applies, and the driver method does not depend on the pool's internals.Is this a breaking change?
No API break. It adds one
#[doc(hidden)]provided method toConnectionand one toAnyConnectionBackend, 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, andtest_before_acquireis the documented one. If you consider this significant, I can gate the skip behind aPoolOptionsflag.Tests
tests/postgres/pool_release.rs, a new target that needs noDATABASE_URL. An in-process server counts the client'sSyncmessages, 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_flightwalks a real connection through each state listed above. It assertsis_protocol_idle()staysfalseuntil the leftover state is flushed.it_flushes_a_pooled_copy_in_cancelled_during_finish: a statement trigger makesfinish()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 withexpected ParseComplete, got ReadyForQuery.Mutation checks, each run against the tests above:
pool_releasetests (3Syncs instead of 0)ReadyForQuerypool_releasetest, state walkCOPY FROM STDINaccountingRun 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, skippingtest_pg_copy_chunkedandrustsec_2024_0363as CI does, andtest_pg_copy_chunkedpasses on its own.cargo test -p sqlx-core --all-featurespasses. CI'scargo clippycommand with-D warningsandcargo fmt --checkare 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
COPY FROM STDINaccounting is a bug fix on its own: its test fails on main today. I can send it as a separate PR next to fix(postgres): track the ReadyForQuery owed by COPY TO STDOUT #4454 and stack this one on both.is_protocol_idleis a placeholder; happy to rename it.should_flush()? It is unused today and was the release check before fix(pool): always test connection on release to see if it's still viable #1025. But it is a required method, so third-party drivers already implement it, often asfalse. Giving it pool semantics would silently drop their release ping. A new provided method with a safe default avoids that.PoolOptionsflag could keep today's behavior by default. I'd argue against it: the condition is exactly the absence of the state the ping exists to clean up.waitingqueue does not track. SQLite's ping is an in-process channel hop rather than a network round trip, so there is little to gain there.