rent
Learn by example

6. Transactions and rollback

The closure receives a generated client bound to one database transaction:

let post = client
    .transaction(async |tx| {
        let user = tx.create_user().id(1).name("Ada").save().await?;
        tx.create_post()
            .id(10)
            .title("Transactions")
            .body("Together.")
            .published(true)
            .author_id(user.id)
            .save()
            .await
    })
    .await?;

Returning Ok commits. Returning any EntityError rolls back. The transaction client carries the same policies, interceptors, hooks, and field codecs as its parent.

Recovering from one failed write

Each generated mutation inside an existing transaction has a savepoint. If a write fails, Rent rolls back that mutation before returning its error. This includes compound relationship writes and errors from after-write hooks. You can handle a recoverable error and keep unrelated work in the outer transaction:

client
    .transaction(async |tx| {
        let optional_comment = tx
            .create_comment()
            .id(101)
            .body("Optional contribution")
            .post_id(post.id)
            .author_id(999)
            .save()
            .await;

        if let Err(error) = optional_comment {
            tracing::warn!(%error, "Optional comment was not saved");
        }

        tx.create_user().id(3).name("Grace").save().await?;
        Ok(())
    })
    .await?;

The executable chapter uses a missing author to exercise this recovery path. The comment is absent and Grace's row commits. Returning the error with ? instead would still roll back the entire outer transaction.

Sibling operations sharing a transaction wait while a mutation's savepoint is active. Always use the supplied transaction executor inside a nested runtime transaction callback, not its captured parent executor. Cancellation or failure to finalize a savepoint closes the outer transaction; start a fresh transaction rather than attempting to commit uncertain work. Savepoint recovery adds database commands to transactional writes; it does not add savepoints to ordinary pool-backed single-statement writes.

On a server database, choose an explicit isolation level and a database-enforced read-only boundary when the workflow requires them. The chapter's SQLite run uses IsolationLevel::Serializable instead, because that is the only explicit level SQLite accepts:

use rent::driver::{IsolationLevel, TransactionOptions};

let options = TransactionOptions::new()
    .isolation(IsolationLevel::RepeatableRead)
    .read_only();

let posts = client
    .transaction_with(options, async |tx| {
        tx.post()
            .published_eq(true)
            .all()
            .await
    })
    .await?;

PostgreSQL, MySQL, and MariaDB support all four isolation levels. PostgreSQL additionally supports deferrable transactions. SQLite exposes its portable default and immediate serializable transaction.

Use transaction_app when business logic has its own error type. Rent preserves that application error and still reports a rollback failure separately if rollback itself fails:

let result = client
    .transaction_app(async |tx| {
        tx.create_user().id(2).name("Temporary").save().await?;

        Err::<(), _>(anyhow::anyhow!("application validation failed"))
    })
    .await;

The transaction fixture uses only the three participating models in crates/rent/examples/tutorial_05_transactions/schema.rsl. Run cargo run -p rent --example tutorial_05_transactions. The same commit and rollback workflow runs under nextest.

On this page