Skip to content
Concepts

Transactions and rollback

Ken joins the writing app. Creating his account also creates its profile. If the profile cannot be saved, you should not be left with half an account. Put both writes in a transaction: either they both become visible, or neither does.

Use the client passed to the closure for every database operation in that unit of work:

let user = client
    .transaction(async |tx| {
        let user = tx
            .create_user()
            .name("Ken")
            .save()
            .await?;

        tx.create_profile()
            .user_id(user.id)
            .bio("Writes about product design")
            .save()
            .await?;

        Ok(user)
    })
    .await?;

Rent commits when the closure returns Ok and rolls back when it returns Err. The transaction client keeps its parent's context, policies, interceptors, hooks, and field codecs. It also keeps the same root or scoped authority. See root and scoped clients.

Most workflows need only this closure. Continue below when you need row locks, recoverable writes, isolation options, retries, or an explicit commit boundary.

Row locks

A transaction alone does not stop another transaction from changing a row between your read and your write. Lock the rows you read when the write depends on what you read. For example, the app's billing account must have enough credit before a withdrawal:

client
    .transaction_app(async |tx| {
        let account = tx.account().id_eq(account_id).for_update().only().await?;
        if account.balance < amount {
            return Err(TransferError::InsufficientFunds);
        }
        tx.update_account_one(&account).decrement_balance(amount).save().await?;
        Ok(())
    })
    .await?;

for_update() adds FOR UPDATE, and for_share() adds FOR SHARE. skip_locked() skips rows another transaction holds, which is how several workers claim distinct jobs from one table, and no_wait() fails at once with EntityError::LockUnavailable instead of waiting. The same error reports a lock wait that timed out on MySQL or MariaDB. It is retryable, so a transaction retry helper waits and tries again. A locking query outside a transaction returns EntityError::LockOutsideTransaction, because its lock would end with the statement. SQLite has no row locks and serializes its writers instead, so these methods take no lock there.

Use for_share() when several transactions may read the same record but must keep it unchanged until they finish. For example, Maya and Ken can each add a comment while holding a shared lock on the same post. Use for_update() if the transaction will edit that post. Acquire the strongest lock you will need from the start, and lock multiple records in a consistent order.

On PostgreSQL, scoped relationship checks also use shared row locks to keep the referenced record stable. Independent writes can reference the same record together. Application rules such as a limit on the number of comments still need their own transaction protection. Do not rely on a relationship check to make other writes run one at a time.

Prefer an atomic update when the write only adds to a counter or a list, and optimistic locking when conflicts are rare and a retry is cheap.

For a state change with mandatory history, lock the current row, capture its previous value, and save both the replacement and audit through the transaction client. Propagate an audit failure so the whole change rolls back. The row-locking tutorial combines typed JSON, generated projections, full-text search, and history counts in that workflow.

Recover from one failed write

Generated writes inside an existing transaction use savepoints. A failed write rolls back its own database changes before returning an error, including nested relationship changes and after-write hook failures. Catch a recoverable error to continue the transaction, or propagate it with ? to roll back the whole transaction. The transactions tutorial demonstrates both choices.

Permission-controlled row locks

PostgreSQL supports for_update(), for_share(), no_wait() and skip_locked() through a scoped client inside an application transaction using repeatable-read or serializable isolation. The ordinary read rule still determines which rows are eligible. Taking a lock grants no update authority. Eager relationships are read with their own permissions and are not automatically locked with the root row.

Rent validates independent tenant and recursive-evaluation status before the resource-lock statement, retaining the same connection, clock and permission evidence. That validation can add a statement only to the explicit locking path. Recursive evaluation can already require more statements. to_sql() shows the final data statement, not an execution transcript of preparation. Empty results and zero limits still validate permission status.

MySQL and MariaDB scoped locking reads currently return PermissionError::ExecutionUnavailable. Ordinary unpermissioned row locks remain supported. Their locking reads can observe different versions from earlier snapshot reads. Rent does not change transaction isolation or lock the permission relationships to hide that distinction. SQLite retains its documented lack of row locks. Choose an immediate writer transaction when writer serialization is required.

Use row-preserving entity, field or tuple reads. Scoped locking aggregates, grouped queries, streams and reverse inspection are rejected. A PostgreSQL row lock does not freeze related grants, refresh a prior snapshot, enforce a sibling count, or remove the need to retry a transaction after serialization failure. SKIP LOCKED is suited to queue-like claiming, not a consistent general-purpose list. OFFSET can lock rows skipped by the window as part of the database's normal behavior.

Understand transaction boundaries

Each successful mutation scope adds SAVEPOINT and RELEASE SAVEPOINT commands. Rollback adds ROLLBACK TO SAVEPOINT. Compound writes can contain nested scopes. These boundaries add latency, especially over a network, but do not add savepoints to ordinary pool-backed single-statement writes. Permission-aware reads that need several statements and reverse inspection use an operation transaction, or a nested savepoint inside an existing transaction. A proven, bounded PostgreSQL permission existence check can instead use a single statement snapshot when the schema has no expiring permission evidence. Checks inside an existing transaction retain its isolation requirements. If the read fails and its rollback succeeds, earlier unrelated writes remain intact. Operations sharing a connection wait outside an active nested scope so one rollback cannot erase another operation's successful changes.

Permission streams borrow an existing transaction without opening a savepoint around it. A permission depth or work limit cleans up temporary decisions before returning the error and leaves that transaction usable. A database error can abort the borrowed transaction. The application must then roll it back. Completing or dropping a borrowed stream does not commit or roll back the application transaction.

If a nested operation is cancelled or its savepoint cannot be finalized, the enclosing transaction cannot commit. Start a new transaction. Use the executor supplied to a nested runtime callback, not a captured parent executor that is waiting for that callback to finish.

Return an application error

Application services often need to return their own error type. transaction_app keeps that error intact:

let order = client
    .transaction_app(async |tx| {
        let order = create_order(tx, input).await?;

        reserve_inventory(tx, order.id).await?;

        Ok(order)
    })
    .await?;

The result uses TransactionError<E> to distinguish a database failure, the application error E, and the rare case where both the application operation and rollback fail.

Choose isolation and read-only work

Choose explicit transaction characteristics when a workflow needs stronger consistency or a database-enforced read-only boundary:

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

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

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

The exact support is explicit:

CapabilityPostgreSQLMySQL and MariaDBSQLite
Read uncommittedYesYesNo
Read committedYesYesNo
Repeatable readYesYesNo
SerializableYesYesImmediate transaction
Read onlyYesYesNo
DeferrableYesNoNo

Rent applies MySQL and MariaDB isolation to the same pooled connection that begins the transaction. Unsupported combinations return a configuration error. Rent never silently weakens the requested boundary.

Retry a transient conflict

Retry a complete transaction after serialization failures or deadlocks:

let receipt = client
    .transaction_retry(3, |tx| async move {
        reserve_inventory(&tx, order_id).await
    })
    .await?;

The closure receives an owned transaction client on each attempt. Keep all reads and writes for that attempt on this handle. Its permissions and tenant scope come from the original client.

Rent rolls back before retrying, uses bounded exponential backoff, and retries only errors the database marks as safe for a whole-transaction retry. The attempt budget includes the first attempt, and each retry starts a new transaction. Failures reported by BEGIN, the operation, or COMMIT are eligible. A rollback failure stops immediately because a clean boundary cannot be guaranteed.

Database characteristics and retry behavior are separate controls. Use transaction_retry_with for explicit TransactionOptions with the default backoff, or transaction_retry_with_policy to provide a TransactionRetryPolicy with custom attempt, delay, multiplier, ceiling, and jitter settings. The convenience policy starts at 10 ms, doubles up to one second, and applies full jitter.

The callback can run more than once. Keep email, message delivery and other external effects outside it, or use a transactional outbox. A successful rollback only undoes database changes.

Permission-aware upserts can read an absent key inside a consistent snapshot. If another transaction creates that key first, the operation can return a unique-key conflict rather than a serialization error. The generic retry helper does not retry arbitrary constraint failures. If the application needs to retry this race, recognize only the expected constraint, roll back the whole transaction, and use a bounded retry with a fresh snapshot. Do not retry the write inside the old transaction or treat every unique-key error as transient.

Commit or roll back explicitly

Use the explicit API when the boundary itself is conditional:

let transaction = client.tx().await?;
let tx = transaction.client();
tx.create_audit_event()
    .message("reviewed")
    .save()
    .await?;

transaction.rollback().await?;

The lower-level driver transaction exposes named savepoints, rollback-to-savepoint, release, commit, and rollback.