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:
| Capability | PostgreSQL | MySQL and MariaDB | SQLite |
|---|---|---|---|
| Read uncommitted | Yes | Yes | No |
| Read committed | Yes | Yes | No |
| Repeatable read | Yes | Yes | No |
| Serializable | Yes | Yes | Immediate transaction |
| Read only | Yes | Yes | No |
| Deferrable | Yes | No | No |
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.
