Skip to main content

atomic

Function atomic 

Source
pub async fn atomic<F, T>(pool: &Pool, f: F) -> Result<T, ExecError>
where F: for<'tx> FnOnce(&'tx AtomicTx) -> Pin<Box<dyn Future<Output = Result<T, ExecError>> + Send + 'tx>>,
Expand description

Closure-scoped transaction with after-commit hooks. Auto-commits when f returns Ok, auto-rolls-back when f returns Err. Callbacks queued via on_commit inside f fire only on the commit path — never on rollback.

Without this guarantee, side effects like “send the welcome email” after an INSERT can leak: the email goes out, the transaction rolls back, the user record never lands, the email references a phantom user.

use rustango::sql::{atomic, insert_tx, on_commit};

// The closures capture owned values: they must not borrow the caller.
let (p, q1, q2) = (pool.clone(), q.clone(), q.clone());
atomic(pool, move |tx| Box::pin(async move {
    insert_tx(&mut *tx.lock().await?, &q1).await?;
    // Nested: a savepoint on the same connection.
    let _ = atomic(&p, move |sp| Box::pin(async move {
        insert_tx(&mut *sp.lock().await?, &q2).await
    }))
    .await; // its failure keeps the first row
    on_commit(|| println!("committed"));
    Ok(())
}))
.await

The atomic! macro hides the Box::pin ceremony.

Inside the closure tx is an AtomicTx: lock it per statement and pass &mut *guard to the _tx helpers, or match the guard’s PoolTx variant for raw sqlx.

Nesting is per pool object: atomic on the same Pool (or a clone) inside the block runs in a savepoint on the same connection. Pass the request’s pool down rather than looking it up again. Its Err rolls back only its own writes; its callbacks wait for the outermost commit. A different pool gets its own transaction. A dropped (cancelled) or panicking nested block is rolled back. Two nested blocks on one transaction at once (join!), or a TxGuard held across a nested call, return ExecError::NestedAtomic. If a savepoint statement fails, the whole transaction rolls back and ExecError::AtomicAborted is returned.

Server-ended transactions: after a statement error that ends the whole transaction (MySQL deadlock 1213 / timeout 1205, SQLite automatic rollback, a lost connection) every later ORM statement in the block returns ExecError::AtomicAborted, and so does atomic. Before the outermost COMMIT the block also checks the transaction is still open (one round trip on PG, two on MySQL): a PG error the closure ignored returns AtomicAborted instead of a silent rollback. Raw sqlx on the guard bypasses the per-statement check.

MySQL implicit commits: DDL, TRUNCATE and LOCK TABLES commit the transaction. atomic then returns ExecError::AtomicEndedEarly with some writes already committed; do not blindly retry.

Failed statements differ by backend: PG aborts the transaction, but MySQL and SQLite undo only the failed statement (duplicate key 1062, SQLITE_BUSY). If the closure ignores that error and returns Ok, the other writes commit. A MySQL lock-wait timeout (1205) always aborts the block.

Costs: past 64 open savepoints in one transaction PostgreSQL spills its subtransaction cache, so avoid nesting in hot loops. On MySQL a deadlock inside a nested block rolls back the whole outer transaction.

Spawned tasks do not inherit the block: atomic inside tokio::spawn opens a separate transaction (a real, independent commit), and on_commit there panics.

Callbacks fire in registration order, serially, after the COMMIT returns OK. A panicking callback aborts the chain.

§Errors

Returns the first ExecError produced by f, or a driver error from BEGIN / SAVEPOINT / COMMIT / ROLLBACK.