Transactions
A mutating request runs inside a transaction with no boilerplate. The
DbContext interceptor — auto-installed by DatabaseModule — opens it on
the way in, hands the handler the transactional executor, and decides
commit or rollback based on the response. The hard cases — serialization
conflicts, deadlocks — get their own primitive, with a defined boundary
between what the interceptor handles and what the service owns.
The conflict classifier matches against
sqlx’s typed DatabaseError::code()
(SQLSTATE) — never a formatted error string — so a digit substring in a
message can’t trigger a false retry. SeaORM
provides the TransactionTrait the programmatic boundary builds on.
How the auto-commit works
Section titled “How the auto-commit works”DbContext classifies the HTTP method and branches:
GET / HEAD / OPTIONS / TRACE → Executor::Pool, no transactionPOST / PUT / PATCH / DELETE → Executor::Txn, commit on 2xx/3xx rollback otherwiseInside the handler, Repo::<E>::conn() returns whichever variant the
interceptor installed — same code, different mode. The decision matrix:
| Outcome | Decision |
|---|---|
Ok(2xx) or Ok(3xx) | commit |
Ok(4xx) or Ok(5xx) | rollback |
Err(_) from the handler | rollback |
| Commit error | response becomes 500, rollback is implicit |
A failed mutation never half-persists, even when the failure surfaces as
a typed 4xx body from a thiserror-derived error. See
Repo and executor for the full sequence,
including the escape-on-spawn case.
Why retrying belongs to the service, not the interceptor
Section titled “Why retrying belongs to the service, not the interceptor”A poem Request is consumed by next.run — the body bytes are gone past
the handler call. The interceptor cannot replay the same handler on a
fresh transaction; the DatabaseTransaction handle is already aborted by
the time a conflict surfaces. Two consequences:
- The interceptor observes the conflict, it does not retry it. The failed commit has already consumed its handle, and the handler’s non-transactional side effects — a queued job, an emitted event, an object written to storage — would replay with it.
- Retrying is the service’s job. Only the service knows which boundary is replayable, so the replayable closure owns that boundary itself.
The framework exposes two pieces, deliberately split:
The observability flag
Section titled “The observability flag”NESTRS_DATABASE__OBSERVE_SERIALIZATION_CONFLICTS=trueDatabaseModule::for_root(DatabaseConfig { observe_serialization_conflicts: true, ..Default::default()})With the flag on, the DbContext interceptor matches a commit-time
conflict against the SQLSTATE markers below and logs it at warn with
the conflict tag (target nest_rs::orm) so ops sees the failure mode
distinctly from a generic commit error.
The working retry primitive
Section titled “The working retry primitive”For an actually-retried operation, wrap a replayable closure in
retry_on_conflict inside a service method that owns its programmatic
transaction boundary:
use std::sync::Arc;use std::time::Duration;use nest_rs_seaorm::retry::{ retry_on_conflict, DEFAULT_RETRY_ATTEMPTS, DEFAULT_INITIAL_BACKOFF,};use sea_orm::{DatabaseConnection, TransactionTrait};
impl PostsService { pub async fn publish(&self, id: Uuid) -> Result<post::Model, ServiceError> { let db = self.db.clone(); retry_on_conflict(DEFAULT_RETRY_ATTEMPTS, DEFAULT_INITIAL_BACKOFF, || { let db = db.clone(); async move { let txn = db.begin().await?; // read + update + insert against `txn` txn.commit().await?; Ok::<_, sea_orm::DbErr>(/* the published row */) } }) .await .map_err(ServiceError::from) }}retry_on_conflict runs the closure up to attempts times, sleeping
initial_backoff << attempt between tries (5 ms → 10 ms → 20 ms with the
defaults). A non-retryable error returns immediately; a retryable one is
logged at warn and the closure re-runs from scratch — that’s what makes
the retry honest, the body re-opens a fresh transaction against a fresh
snapshot.
The defaults are conservative on purpose:
| Constant | Value |
|---|---|
DEFAULT_RETRY_ATTEMPTS | 3 |
DEFAULT_INITIAL_BACKOFF | 5 ms |
MAX_RETRY_ATTEMPTS | 32 (hard ceiling) |
| Per-sleep cap | 30 s |
The hard ceiling and per-sleep cap exist because 1u32 << attempt
overflows at attempt >= 32 (UB in debug, wraps to 0 in release —
which would hot-spin). Pass usize::MAX and the function still terminates
in bounded time.
What is_retryable_conflict catches
Section titled “What is_retryable_conflict catches”| SQLSTATE | Backend(s) | What it means |
|---|---|---|
40001 | Postgres, MySQL | Serialization failure |
40P01 | Postgres | Deadlock detected |
1213 | MySQL | Deadlock found |
1205 | SQL Server | Transaction was deadlock victim |
Anything else — 23505 (unique violation), RecordNotFound, a generic
Internal error whose message contains “40001” — returns immediately.
A unique-violation retry would loop forever; a substring match on the
message would falsely retry a connection log mentioning port 40001. The
classifier reads the typed code only, never the display string.
When retries don’t help
Section titled “When retries don’t help”The failure modes a retry cannot fix:
- A unique constraint violation. Retrying inserts the same row again, same failure. The classifier rejects these.
- A row that doesn’t exist.
RecordNotFoundis terminal — no amount of retries materialize the row. - Application-side validation.
ServiceError::Validationis not a conflict; the closure re-runs from scratch but the input is the same. - A handler-time conflict on the auto-commit transaction. The
interceptor cannot retry the body; the conflict surfaces as a
DbErrfrom the service, which gets mapped to the appropriate HTTP status. Useretry_on_conflictat the service level instead.
The split is deliberate. The interceptor handles the common case transparently (open / commit / rollback); the service handles the hard case explicitly (replay the work against a fresh snapshot). One way to do each.
Going further
Section titled “Going further”- Database — the slice that imports
DatabaseModule. - Repo and executor — what the interceptor installs, the escape-on-spawn fail-closed.
- Pagination — the other knob the entity carries.
- Configuration — the full
NESTRS_DATABASE__*scheme.
Built by YV17labs