Skip to content
ReferenceRust API

Transaction API

The closure API is the safest default. It commits when the closure returns Ok and rolls back on every error:

let post = client
    .transaction(async |tx| {
        let post = tx
            .create_post()
            .title("Atomic work")
            .author_id(user.id)
            .save()
            .await?;

        tx.create_comment()
            .body("Created with the post")
            .post_id(post.id)
            .author_id(user.id)
            .save()
            .await?;

        Ok(post)
    })
    .await?;

The helper manages BEGIN, your operations, and COMMIT, with automatic rollback and typed error preservation. Generated mutations retain their normal savepoint behavior inside that transaction.

Application errors

Use transaction_app(async |tx| ...), or transaction_app_with(options, ...), when the closure returns your own error type. The result is a TransactionError<E>. Rent distinguishes database setup/commit failures, the application failure, and the rare case where both the operation and rollback fail.

Explicit lifecycle

let tx = client.tx().await?;

tx.client()
    .create_post()
    .title("Manual transaction")
    .author_id(user.id)
    .save()
    .await?;

tx.commit().await?;

Call rollback() instead of commit() to discard the transaction. Every clone of a transaction client becomes closed after either operation.

Tests that leave nothing behind

test_transaction runs its closure in a transaction that is always rolled back, so a test cleans up after itself whether it passes or fails:

#[tokio::test]
async fn duplicate_keys_conflict() -> anyhow::Result<()> {
    client
        .test_transaction(async |tx| -> anyhow::Result<()> {
            tx.create_post().title("One").author_id(1).save().await?;
            let second =
                tx.create_post().title("One").author_id(1).save().await;
            assert!(second.is_err());
            Ok(())
        })
        .await
}

The closure's error type only needs From<EntityError>, so ? works with anyhow or your application error. When both the closure and the rollback fail, the closure's error is returned. Code under test has to use the client the closure receives. A separate client runs outside the transaction and its writes persist. Code that opens its own transaction becomes a nested transaction and is rolled back with the rest. Tests that need several concurrent connections, such as race tests, need ordinary cleanup instead.

Nested transactions

A transaction opened inside another, through transaction, transaction_app, test_transaction, or tx(), runs as a savepoint. Its failure rolls back only its own writes, and the enclosing transaction decides the outcome of everything else:

let receipt_saved = client
    .transaction(async |tx| {
        let order = tx.create_order().total(40).save().await?;
        let receipt = tx
            .transaction(async |inner| {
                inner.create_receipt().order_id(order.id).save().await
            })
            .await;

        // A failed receipt rolled back alone; the order commits.
        Ok(receipt.is_ok())
    })
    .await?;

This lets a function that needs atomicity open a transaction without knowing whether its caller already has one. Inside the nested closure, use the client it receives. The enclosing client waits until the savepoint finishes.

Isolation and access mode belong to the outermost transaction, so transaction_with inside another transaction rejects non-default options with TransactionAlreadyActive rather than silently ignoring them. Retries belong to the outermost transaction too: a deadlock or serialization failure dooms the whole enclosing transaction (MySQL rolls it back entirely), so a nested transaction_retry runs once and lets the failure reach the retry around the whole unit. A nested tx() that is dropped without commit() or rollback() closes the enclosing transaction, so partial work can never be committed.

Isolation and read-only work

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

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

let tx = client.tx_with(options).await?;

transaction_with(options, async |tx| ...) runs a closure under the same options. Support varies by database. PostgreSQL additionally supports deferrable read-only serializable transactions through .deferrable().

Retry transient conflicts

Place the complete database operation inside the closure. Here, post_id identifies the post to publish. Each attempt loads it through that attempt's transaction before saving:

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?;

Each attempt receives an owned transaction client. Use |tx| async move { ... } to keep that handle inside the attempt. Its root or scoped authority stays the same as the client you called. The handle is closed when the attempt finishes.

If the operation needs an owned request value on every attempt, clone that value before creating the async block:

let updated = client.transaction_retry(3, |tx| {
    let title = title.clone();
    async move {
        let post = tx.post().id_eq(post_id).only().await?;
        tx.update_post_one(&post).title(title).save_one().await
    }
}).await?;

The number is a total-attempt budget: 3 means one initial attempt and at most two retries. For compatibility, zero is normalized to one attempt. Only serialization failures, deadlocks, and lock-contention errors are retried. Rent classifies failures from transaction setup, the operation, and commit. Every retry opens a fresh transaction. A rollback failure is never retried because Rent can no longer prove that the failed attempt ended cleanly.

EntityError::OptimisticLock is not a transient database error and is not retried by this helper. Use application-level conflict handling to decide how to resolve a stale edit. Keep reads and calculations inside the retry closure when they determine a write. Capturing a previously loaded row outside the closure would reuse that stale row on every attempt.

The closure may run more than once, so keep external side effects outside it or make them idempotent.

TransactionOptions and retry policy are deliberately separate. The former controls database semantics such as isolation and read-only mode. Use transaction_retry_with to pair those settings with the default retry policy, or configure both explicitly:

use std::time::Duration;

use rent::driver::{IsolationLevel, TransactionOptions};
use rent::runtime::{RetryJitter, TransactionRetryPolicy};

let transaction_options = TransactionOptions::new()
    .isolation(IsolationLevel::Serializable);

let retry_policy = TransactionRetryPolicy::new(5)
    .initial_delay(Duration::from_millis(25))
    .max_delay(Duration::from_millis(500))
    .backoff_factor(2)
    .jitter(RetryJitter::Full);

let result = client
    .transaction_retry_with_policy(
        transaction_options,
        retry_policy,
        |tx| async move { update_inventory(&tx).await },
    )
    .await?;

The convenience methods use a 10 ms initial delay, a factor of two, a one-second ceiling, and full jitter (RetryJitter::None disables it). Those are defaults rather than generated constants. TransactionRetryPolicy can replace each setting.

Savepoints provide partial rollback inside a transaction. See the savepoint tutorial.

Transaction helpers preserve the root or scoped client kind. Configuration and explicit system access remain on RootClient. Viewer/context binding returns a ScopedClient. See root and scoped clients.