Skip to content

fix(core): don't roll back the enclosing level when a nested begin fails or is cancelled - #4446

Closed
CalebFenton wants to merge 2 commits into
transact-rs:mainfrom
CalebFenton:fix/nested-begin-rolls-back-outer
Closed

CalebFenton wants to merge 2 commits into
transact-rs:mainfrom
CalebFenton:fix/nested-begin-rolls-back-outer

Conversation

@CalebFenton

Copy link
Copy Markdown

Does your PR solve an issue?

fixes #4445

What changes

Transaction::begin built the Transaction with open: true before it awaited TransactionManager::begin, so a failed or dropped begin also ran Transaction::drop's start_rollback. Every in-tree driver already cleans up its own failed begin:

The extra start_rollback therefore ran one level too many. At depth 0 that was a no-op. Inside a transaction, it rolled back the caller's own level.

The fix arms the guard only after begin succeeds and documents that contract on TransactionManager::begin. The commits are split so that the test commit fails on its own.

Tests

Two tests in tests/any/any.rs, so they run in every driver's lane:

  • it_keeps_the_outer_transaction_when_a_nested_begin_fails uses begin_with("BEGIN") at depth 1, which returns InvalidSavePointStatement. It is deterministic and involves no cancellation.
  • it_keeps_the_outer_transaction_when_a_nested_begin_is_cancelled polls begin() once and drops it. A fast server can answer within that poll; MySQL did about once in 300 runs locally. In that case the test rolls back the completed savepoint and tries again, so it cannot pass without cancelling.

Both tests use one connection and a temporary table. Inside the outer transaction, the row count must be 2 after the nested begin. After outer.rollback(), it must be 0.

Is this a breaking change?

No for the in-tree drivers. A third-party TransactionManager whose begin relied on Transaction::drop to clean up after it would now need to do that itself. The new rustdoc states this contract.

One thing this PR does not change: a cancelled top-level begin() on MySQL still leaves the transaction open on the connection, as described in #4445. That was already true before this change, because start_rollback at depth 0 is a no-op.

Verification

Run locally with Rust 1.94.1 on Linux:

  • Each new test fails on main (8b65c2f) against Postgres 17, MySQL 8, and SQLite, and passes with the fix.
  • With the fix, the two new tests passed 500 runs in a row on each of the three databases.
  • cargo test --no-default-features --features any,<db>,macros,migrate,_unstable-all-types,runtime-tokio,tls-none passes in full against the postgres_17 and mysql_8 compose services and against SQLite (--test-threads=1, with sqlite-preupdate-hook).
  • cargo fmt --all -- --check passes. cargo clippy -p sqlx-core -- -D warnings is clean. Clippy on --test any reports only warnings that already exist in sqlx-test and in older parts of tests/any/any.rs.
  • Not run: the async-std and smol runtimes, and the TLS variants. The change does not depend on either.

This PR was written with an AI coding agent (Claude Code) on behalf of @CalebFenton. The agent reproduced the bug, wrote the fix and the tests, and ran the checks listed above.

…nsaction

Both tests fail on every driver: the nested begin rolls back the
enclosing level, so the outer transaction's earlier write is lost and
later writes escape its rollback.
`Transaction::begin` armed its rollback-on-drop guard before awaiting
`TransactionManager::begin`. Every in-tree driver already undoes its
own failed or cancelled `begin`, so the guard's `start_rollback` ran
one level too many: at depth 0 it was a no-op, but inside a
transaction it rolled back the caller's enclosing transaction or
savepoint and lowered the depth.

Arm the guard only once `begin` has succeeded, and document that
contract on `TransactionManager::begin`.

Fixes transact-rs#4445
@CalebFenton CalebFenton closed this Oct 9, 2026
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.

A failed or cancelled nested begin() rolls back the enclosing transaction

1 participant