Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

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

Aperta
#4,445 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 3 giorni

@chenty2333 ci sta già lavorando.

Dal 18/10/2026.

  • #4446 di @CalebFenton — chiusa senza merge
  • #4468 di @chenty2333 — aperta

Valutazione

Difficoltà
4/5
Tempo stimato
3-5 giorni
Idoneità per principianti
25/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Ferma
Stack tecnologico
mysql, postgresql, rust, sqlite
Ambito
databases

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 Transaction run outside any transaction, so they commit as they go;
  • outer.commit() and outer.rollback() send nothing and return Ok(()).

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 Rollback guard in PgTransactionManager::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 begin leaves 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 SAVEPOINT itself fails (for example, the outer transaction is already aborted), the #4394 guard queues ROLLBACK TO SAVEPOINT _sqlx_savepoint_<d> for a savepoint that was never created. The caller's next statement then fails with 3B001 savepoint "_sqlx_savepoint_1" does not exist instead of 25P02. 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() is false and an INSERT stays 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

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di transact-rs/sqlx

Tutte le issue di transact-rs/sqlx

Issue simili

Altre issue su Rust

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.