Skip to content
Tutorials

Transactions and rollback

Maya signs up and writes her first post. Save the user and post together so a failed post does not leave an account that this workflow meant to create as one unit. The closure receives a client for that transaction. Use it for both writes:

let post = client
    .transaction(async |tx| {
        let user = tx
            .create_user()
            .id(1)
            .name("Maya")
            .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("Ken")
            .save()
            .await?;

        Ok(())
    })
    .await?;

If a comment insert fails because its author is missing, the comment is absent and Ken'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;

Complete example

Download this project, or copy the files below into an empty directory.

Replace RENT_REPOSITORY_URL in Cargo.toml with the URL supplied with your preview access. Use the matching Rent CLI release.

rent generate
cargo run

The project includes its database setup. Each lesson uses a small, self-contained schema. You can run it without applying the earlier lessons.

Cargo.toml
[package]
name = "rent-lesson-05-transactions"
version = "0.1.0"
edition = "2024"

[workspace]

[[bin]]
name = "rent-lesson-05-transactions"
path = "main.rs"

[dependencies]
rent = { git = "RENT_REPOSITORY_URL", tag = "v0.8.6", default-features = false, features = ["sqlite", "migrate", "extensions"] }
anyhow = "1.0.104"
tokio = { version = "1.53.1", features = ["full"] }
rent.toml
schema = "schema.rsl"
generated = "generated"
migrations = "migrations"

[database]
dialect = "sqlite"
backends = ["sqlite"]
schema.rsl
datasource db {
  provider = "sqlite"
}

model User {
  id       BigInt     @id
  name     String
  posts    Post[]
  comments Comment[]  @relation("CommentAuthor")

  @@map("users")
}

model Post {
  id        BigInt     @id
  title     String
  body      String
  published Boolean
  author_id BigInt
  author    User       @relation(fields: [author_id], references: [id])
  comments  Comment[]

  @@map("posts")
}

model Comment {
  id        BigInt  @id
  body      String
  post_id   BigInt
  author_id BigInt
  post      Post    @relation(fields: [post_id], references: [id], onDelete: Cascade)
  author    User    @relation("CommentAuthor", fields: [author_id], references: [id])

  @@map("comments")
}
main.rs
use anyhow::{Result, ensure};
use rent::driver::{DatabasePool, IsolationLevel, TransactionOptions};
use rent::sql::Dialect;

#[allow(dead_code, unused_imports)]
#[rustfmt::skip]
#[path = "generated/mod.rs"]
mod generated;

async fn exercise() -> Result<()> {
    let pool = DatabasePool::connect(Dialect::Sqlite, "sqlite::memory:").await?;
    pool.execute("PRAGMA foreign_keys = ON").await?;
    for statement in [
        "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)",
        "CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL, published INTEGER NOT NULL, author_id INTEGER NOT NULL REFERENCES users(id))",
        "CREATE TABLE comments (id INTEGER PRIMARY KEY, body TEXT NOT NULL, post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, author_id INTEGER NOT NULL REFERENCES users(id))",
    ] {
        pool.execute(statement).await?;
    }
    let client = generated::client::Client::new(pool);

    let post = client
        .transaction(async |tx| {
            let user = tx.create_user().id(1).name("Maya").save().await?;
            tx.create_post()
                .id(10)
                .title("Transactions")
                .body("Both rows commit together.")
                .published(true)
                .author_id(user.id)
                .save()
                .await
        })
        .await?;
    ensure!(client.post().id_eq(post.id).exist().await?);

    let failed = client
        .transaction(async |tx| {
            tx.create_comment()
                .id(100)
                .body("This will be rolled back")
                .post_id(post.id)
                .author_id(999)
                .save()
                .await?;
            Ok(())
        })
        .await;
    ensure!(failed.is_err());
    ensure!(client.comment().count().await? == 0);

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

            tx.create_user().id(3).name("Ken").save().await?;
            Ok(optional_comment.is_err())
        })
        .await?;
    ensure!(recovered);
    ensure!(client.user().id_eq(3).exist().await?);
    ensure!(!client.comment().id_eq(101).exist().await?);

    let application_failure = client
        .transaction_app(async |tx| {
            tx.create_user().id(2).name("Temporary").save().await?;
            Err::<(), _>(anyhow::anyhow!("application validation failed"))
        })
        .await;
    ensure!(application_failure.is_err());
    ensure!(!client.user().id_eq(2).exist().await?);

    let post_count = client
        .transaction_with(TransactionOptions::new().isolation(IsolationLevel::Serializable), async |tx| {
            tx.post().count().await
        })
        .await?;
    ensure!(post_count == 1);

    let retried_count = client.transaction_retry(3, |tx| async move { tx.post().count().await }).await?;
    ensure!(retried_count == 1);

    let post_id = post.id;
    let updated = client
        .transaction_retry(3, |tx| async move {
            let post = tx.post().id_eq(post_id).only().await?;
            tx.update_post_one(&post).published(true).save_one().await
        })
        .await?;
    ensure!(updated.id == post_id && updated.published);
    Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
    exercise().await
}

#[cfg(test)]
mod tests {
    #[tokio::test]
    async fn example_runs() -> anyhow::Result<()> {
        super::exercise().await
    }
}