pub async fn atomic<F, T>(pool: &Pool, f: F) -> Result<T, ExecError>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(())
}))
.awaitThe 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.