Skip to content

fix(postgres): roll back when begin() is cancelled after queuing BEGIN - #4365

Open
jiaming-cao-1111 wants to merge 1 commit into
transact-rs:mainfrom
jiaming-cao-1111:jiaming/pg-begin-cancel-rollback-main
Open

fix(postgres): roll back when begin() is cancelled after queuing BEGIN#4365
jiaming-cao-1111 wants to merge 1 commit into
transact-rs:mainfrom
jiaming-cao-1111:jiaming/pg-begin-cancel-rollback-main

Conversation

@jiaming-cao-1111

Copy link
Copy Markdown

Fixes #2805.

Problem

PgTransactionManager::begin queues BEGIN, then awaits wait_until_ready before incrementing transaction_depth. If the future is dropped at that await point (client disconnect, tokio::time::timeout, select!), the server has already executed BEGIN but the client-side depth is still 0, so the Rollback drop guard's start_rollback is a no-op. The connection is then returned to the pool with an untracked open transaction: the backend sits in idle in transaction, every subsequent checkout runs inside the leaked transaction (producing the there is already a transaction in progress warnings reported in #2805), rows it wrote stay locked, and the writes are silently discarded when the connection is eventually closed.

#2054 reported this class of bug; #2057 fixed it for SQLite only. #3980 constructs the Transaction before begin so its Drop always runs, but for Postgres the drop path is still gated on transaction_depth > 0, which is exactly the state a cancelled outermost BEGIN leaves behind — verified against 0.9.0.

Fix

In the begin drop guard, when transaction_depth == 0, queue an explicit ROLLBACK. Reaching the guard with depth 0 can only mean the outermost BEGIN was queued but its response never read. If the BEGIN reached the server, the ROLLBACK closes the leaked transaction before the connection is reused; if it never left the client buffer, both statements flush together and the pair is a no-op (plus a harmless warning). The depth counter is left untouched: the cancelled level was never recorded, so unlike start_rollback there is nothing to decrement.

Reproduction (deterministic, real Postgres)

// pool: max_connections = 1, warmed
let mut fut = Box::pin(pool.begin());
loop {
    let _ = futures::poll!(fut.as_mut());
    sleep(Duration::from_millis(3)).await;
    if backend_state().await == "idle in transaction" { break } // via 2nd conn
}
drop(fut); // cancellation

Before this patch, 500ms after the drop the backend is still idle in transaction, and clock_timestamp() - xact_start measured from the next checkout shows it running inside the leaked transaction; a subsequent begin() on the same connection logs WARNING: there is already a transaction in progress. With the patch the backend returns to idle and the next checkout is clean.

Related observation (not addressed here)

The depth > 0 path has an adjacent issue: a cancelled savepoint-begin at depth 1 issues a full ROLLBACK, killing the outer transaction, after which commit() on the outer Transaction silently no-ops (depth is 0) and returns Ok — the caller believes the outer transaction committed. Left out of scope to keep this change minimal; happy to file separately.

…response

A future dropped between queueing BEGIN and reading its response left the
server session inside a transaction that the client no longer tracked
(transaction_depth is only incremented after the response is read, so the
Rollback drop guard was a no-op at depth 0). The connection then returned to
the pool and subsequent checkouts silently ran inside the leaked transaction.

Queue an explicit ROLLBACK in the guard for the depth-0 case; if the BEGIN
never reached the server the paired ROLLBACK is a harmless warning.
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.

transaction statements out of order

1 participant