Transactions
atomic()
Wrap multiple operations in a transaction. Automatically commits on success, rolls back on error.
#![allow(unused)] fn main() { atomic(&pool, async |tx| { let user = insert(&mut **tx, new_user).await?; let profile = insert(&mut **tx, Profile { user_id: ForeignKey::new(user.id.0), ..profile }).await?; Ok((user, profile)) }) .await?; }
The body is an async closure (async |tx| { ... }, Rust 1.85+). The future it returns is Send, so tokio::spawn(async move { atomic(&pool, async |tx| { ... }).await }) works.
If any operation returns Err, the entire transaction is rolled back. To abort on a business rule, return Err(Error::abort("insufficient funds")).
Retrying on conflict
Use atomic_retry() for SERIALIZABLE / REPEATABLE READ workloads and deadlocks. It retries only Error::Conflict (Postgres: serialization failure 40001, deadlock 40P01; MySQL/MariaDB: deadlock 1213 and lock wait timeout 1205); other errors are returned immediately.
#![allow(unused)] fn main() { atomic_retry(&pool, 3, async |tx| { raw_execute(&mut **tx, "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE", vec![]).await?; // ... Ok(()) }) .await?; }
The future of atomic_retry is Send too, so tokio::spawn(async move { atomic_retry(&pool, 3, async |tx| { ... }).await }) works. For that, the closure must be Clone (it is cloned for each attempt): an async closure is Clone when everything it captures is, so capture shared state by reference (&AtomicU32, &Arc<..>), not as a &mut or by moving a non-Clone value.
The closure can run several times, so keep it free of side effects outside the transaction (no emails, no HTTP calls). Postgres and MySQL/MariaDB (atomic() works on every backend, SQLite has no equivalent). On MySQL a lock wait timeout is a Conflict (so it is retried), while on Postgres lock_timeout stays Error::Timeout.
Manual transactions
For more control, use pool.begin() directly:
#![allow(unused)] fn main() { let mut tx = pool.begin().await?; insert(&mut *tx, user).await?; insert(&mut *tx, profile).await?; tx.commit().await?; // or tx.rollback().await? to cancel }
Queries, M2M and bulk inside a transaction
filter, the M2M operations and the bulk operations take a pool or a transaction, on every backend. Inside atomic() pass &mut **tx:
#![allow(unused)] fn main() { atomic(&pool, async |tx| { bulk_create(&mut **tx, &users).await?; let n = User::filter(&mut **tx).eq("active", true).count().await?; // sees the writes above M2M::<Article, Tag>::add(&mut **tx, &article, &tag).await?; Ok(n) }) .await?; }
If the closure returns Err, none of it is kept.
Nesting
Transactions can be nested — inner failures roll back to the outer savepoint. This is handled automatically by the database driver.
Hooks inside transactions
Model hooks run inside the same transaction as the operation.
If a hook returns Err, the whole transaction rolls back — including any operations that ran before the hook.
get_or_create is atomic
get_or_create uses INSERT ... ON CONFLICT DO NOTHING RETURNING * (SQLite, MySQL: the equivalent for the engine) — it's atomic without wrapping in atomic(). Safe against race conditions. It takes a pool or a transaction.