A failed or cancelled nested begin() rolls back the enclosing transaction
I maintainer di solito rispondono entro 3 giorni
@chenty2333 ci sta già lavorando.
Dal 18/10/2026.
Valutazione
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Idoneità per principianti
- 25/100
Direzione di ricerca
Start with sqlx-core/src/transaction.rs and trace how Transaction::begin and Transaction::drop interact with each driver's TransactionManager::begin. Review the reproduction and tests in linked PR #4446; verify failed and cancelled nested begins preserve the enclosing transaction across PostgreSQL, MySQL, and SQLite, including through Any. Done means the outer transaction can still commit or roll back its writes correctly after a nested begin fails or is cancelled.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
I have found these related issues/pull requests
#4393 and #4394 (a cancelled top-level BEGIN on Postgres), #4423 and #4425 (the deterministic version of that test). Those cover depth 0. This report is about depth ≥ 1.
Description
When begin() on a connection that is already in a transaction fails or is cancelled, sqlx rolls back the caller's enclosing transaction (or savepoint). The caller still holds the outer Transaction, and nothing tells it what happened:
- the outer level's earlier writes are gone;
- later statements on the outer
Transactionrun outside any transaction, so they commit as they go; outer.commit()andouter.rollback()send nothing and returnOk(()).
So a caller that does outer.rollback() after a failed nested begin() gets Ok, but its writes are kept.
Cause. Transaction::begin (sqlx-core/src/transaction.rs) creates the Transaction with open: true before it awaits TransactionManager::begin. When that call fails or is dropped, Transaction::drop sees open: true and calls start_rollback. But every in-tree driver has already cleaned up its own failed begin:
- Postgres: the
Rollbackguard inPgTransactionManager::begin(since #4394 it raises the depth first, so it rolls back the right savepoint); - SQLite: the worker rolls back when the begin's acknowledgement is not received;
- MySQL: the depth is only raised after the round trip, so a failed
beginleaves the depth where it was.
The extra start_rollback then runs at the caller's depth. At depth 0 it does nothing, which is why the top-level case looks fine. At depth d ≥ 1 it rolls back level d, the caller's own level, and lowers the depth to d - 1.
Reproduction steps
Deterministic, no cancellation needed. A custom BEGIN is refused inside a transaction (InvalidSavePointStatement), and that refusal is enough:
use sqlx::{Connection, PgConnection};
#[tokio::main]
async fn main() -> sqlx::Result<()> {
let url = std::env::var("DATABASE_URL").unwrap();
let mut observer = PgConnection::connect(&url).await?;
sqlx::raw_sql("DROP TABLE IF EXISTS t; CREATE TABLE t (tag text)").execute(&mut observer).await?;
let mut conn = PgConnection::connect(&url).await?;
let mut outer = conn.begin().await?;
sqlx::query("INSERT INTO t VALUES ('w1')").execute(&mut *outer).await?;
let nested = (&mut *outer).begin_with("BEGIN").await;
println!("nested: {:?}", nested.map(|_| ())); // Err(InvalidSavePointStatement)
sqlx::query("INSERT INTO t VALUES ('w2')").execute(&mut *outer).await?;
println!("rollback: {:?}", outer.rollback().await); // Ok(())
let rows: Vec<String> = sqlx::query_scalar("SELECT tag FROM t").fetch_all(&mut observer).await?;
println!("committed: {rows:?}"); // ["w2"]; expected []
Ok(())
}
Cancellation gives the same result. Polling the nested begin once and then dropping it cancels it partway through the SAVEPOINT round trip:
use futures::FutureExt;
let mut outer = conn.begin().await?;
insert(&mut outer, "w1").await?;
assert!((&mut *outer).begin().now_or_never().is_none()); // dropped partway through
insert(&mut outer, "w2").await?;
outer.commit().await?; // Ok(())
// committed: ["w2"]; expected ["w1", "w2"]
Results on 0.9.0 from crates.io (a different connection reads the committed rows):
| Case | Expected | Postgres | MySQL | SQLite |
|---|---|---|---|---|
begin_with("BEGIN") at depth 1, then rollback |
[] |
["w2"] |
w1 lost (PR test) |
w1 lost (PR test) |
nested begin cancelled, then commit |
["w1", "w2"] |
["w2"] |
["w2"] |
["w2"] |
nested begin cancelled, then rollback |
[] |
["w2"] |
["w2"] |
["w2"] |
outer aborted by SELECT 1/0, nested begin fails with 25P02, write, then rollback |
[] |
["w2"] |
n/a | n/a |
On 0.9.0 without #4394, Postgres is worse at depth 2: the Rollback guard also rolls back at the old depth, so two levels are lost.
The tests in the linked PR reproduce this on main (8b65c2f) for all three drivers through Any.
Smaller things seen while reproducing (not changed by the PR):
- Postgres: if the
SAVEPOINTitself fails (for example, the outer transaction is already aborted), the #4394 guard queuesROLLBACK TO SAVEPOINT _sqlx_savepoint_<d>for a savepoint that was never created. The caller's next statement then fails with3B001 savepoint "_sqlx_savepoint_1" does not existinstead of25P02. It fails safely, but the error is misleading. - MySQL: a cancelled top-level
begin()leaves the transaction open on the connection, which is the MySQL version of #4393. After the cancel,is_in_transaction()isfalseand anINSERTstays invisible to other connections.
SQLx version
0.9.0, and main at 8b65c2f
Enabled SQLx features
postgres, mysql, sqlite, runtime-tokio (each driver checked on its own and through any)
Database server and version
PostgreSQL 16 and 17, MySQL 8.4, SQLite (bundled)
Operating system
Linux 6.14 (x86_64)
Rust version
rustc 1.94.1 and 1.95.0
This report was investigated and written with an AI coding agent (Claude Code) on behalf of @CalebFenton. Every result above comes from running the code shown.
- Lingua principale
- Rust
- Stelle
- 17.5k
- Fork
- 1.7k
- Merge medio
- 4g 2m
- PR unite (30g)
- 8
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di transact-rs/sqlx
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 80/100
transact-rs/sqlx#4482 ·
I maintainer di solito rispondono entro 3 giorni
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 90/100
transact-rs/sqlx#4466 ·
I maintainer di solito rispondono entro 3 giorni
-
MySQL: ENUM and SET columns with a binary collation are named BINARY or CHARForse già presa @CodeMasterXZT l’ha presa 1 giorno fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
transact-rs/sqlx#4453 ·
I maintainer di solito rispondono entro 3 giorni
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
transact-rs/sqlx#4452 ·
I maintainer di solito rispondono entro 3 giorni
-
Fetch stream ends after first error using query macrosForse già presa @barry3406 l’ha presa 198 giorni fa. Apertabug
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
transact-rs/sqlx#4126 · 1 commento ·
I maintainer di solito rispondono entro 3 giorni
Tutte le issue di transact-rs/sqlx
Issue simili
-
bug easy good first issue
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
lacs-project/sysknife#659 ·
I maintainer di solito rispondono entro 2 giorni
-
Document multi-value claims with rowBoundaries operator:in (and their per-record application)Apertadocumentation
Difficoltà 2/5 1-3 ore Idoneità per principianti 70/100
registrystack/registry-stack#2431 ·
I maintainer di solito rispondono entro 1 giorno
-
contribution
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
farion1231/cc-switch#8863 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
dioxus-desktop on macOS: Finder file drops never fire `ondrop` — `dragenter` is not cancelledAperta
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 85/100
DioxusLabs/dioxus#6006 ·
I maintainer di solito rispondono entro 1 giorno