Calls and transactions
Advanced concept
You do not need a database session to build your first endpoint. Read this when several database operations must share a connection or succeed together.
flowchart LR
begin["Begin transaction"] --> work["Run related database operations"]
work --> outcome{"All succeeded?"}
outcome -->|Yes| commit["Commit once"]
outcome -->|No| rollback["Roll back"]Not every operation needs a context or a database. A pure in-memory operation needs neither. The framework gives you small types that you reach for only as an operation's needs grow.
| The operation needs to... | Reach for |
|---|---|
| Enforce input rules | Validate — no context, no session |
| Know who is calling, or gate access | CallContext + operation::check |
| Run several storage calls on one session | Session + CallScope |
| Make several database statements succeed or fail together | TransactionScope |
This is a reference, not a required path: an operation uses only the rows it needs. The document walks down them in order, then covers the transaction failure rules.
Code below is shown as fragments assuming their enclosing module, except the block marked "complete", which was compiled and run against
iet-corealone.
1. Input rules: no caller, no storage
use ;
That is a complete contract for a public action, and it is the same CreateNote
used in examples/handbook-notes. Nothing here knows about a request, a user, or
a database.
2. Knowing the caller: CallContext
When the operation must be authenticated or authorized, it needs a CallContext:
/// Trusted invocation metadata; constructing it does not verify credentials.
CallContext is claimed trust, not verified trust. It compiles for anyone, so
you authenticate at the edge first and only then build the context. Nothing
downstream re-checks it. The adapter is the place that does the expensive
credential check once; everything after it carries a cheap, trusted value.
Access is enforced by operation::check, which applies the access rule before
input.validate():
.await?;
The ordering matters: a denied caller never reaches validation, so validation
cannot leak information or run side effects for someone who is not allowed to be
there. crates/core/iet-core/tests/contracts.rs locks this in with an input whose
validate panics if a denied caller ever reaches it
(authorization_precedes_validation_and_future_is_send).
check consults the Authorizer only for Access::Permission. For
Access::Authenticated it just needs a nonempty principal.subject. A
deny-by-default authorizer is therefore a safe choice when every operation is
authenticated-only: if someone later adds a permission-gated operation, it fails
closed instead of silently succeeding.
3. Sharing a session: Session and CallScope
When the operation must run several storage calls, they should share one session.
A Session is whatever the storage feature hands you; CallScope borrows it for
the duration of the operation and carries the CallContext alongside:
let mut session = self.database.open.await?;
let mut scope = new;
self.repository.insert.await?;
Two properties come straight from the borrow:
- One exclusive borrow.
CallScopeholds&mut S, so two overlapping scopes over the same session cannot exist. Concurrency comes from a session per call, not a shared one. - Resource identity is checked, not assumed.
scope.require_resource(&expected)returnssession.resource_mismatchif the session points at a different database than the caller expects. This is what stops a repository from accidentally joining a session that belongs to another resource.
The context inside the scope is immutable, so ports can read the principal but cannot rewrite it mid-call.
4. Atomic DB work: TransactionScope
A write needs a transaction only when several database statements must succeed or fail together. A single statement is already atomic in the database, so a single insert does not require one. Multi-step invariants do: read a value, decide, then write based on it. Choose appropriate isolation or row locking for that invariant: merely starting a transaction does not turn PostgreSQL's default read-committed isolation into a fixed snapshot or prevent concurrent updates.
The starter wraps every mutation in an explicit scope anyway, including
single-statement ones (examples/htmx-starter/crates/project-impl-timescale/src/service.rs).
That is a consistency choice: one call shape, one place that decides commit
versus rollback, and room to add a statement later without changing the
lifecycle.
A complete, runnable example
This small in-memory session demonstrates ownership transitions; it is not a
database driver and does not persist or roll back data. It was compiled and run
against the workspace iet-core.
use ;
async
Reproduce it by saving the block as example.rs and running this from the
repository root, against the workspace iet-core:
cargo build -p iet-core --locked
rustc --edition 2024 -L dependency=target/refactor/debug/deps \
--extern iet_core="$(ls target/refactor/debug/deps/libiet_core-*.rlib | head -1)" \
example.rs -o example
./example
The rules behind it
- One owner commits.
enterreturns a token that knows whether it is the outermost call. A joined call'sfinishdoes not commit; it returns its own result and leaves the decision to the owner.finishconsumes the token, so the same one cannot commit twice (crates/core/iet-core/tests/contracts.rs::outer_owner_commits_once). - An error poisons the transaction. Any error handed to
finishmarks the session rollback-only. Even a later successful ownerfinishthen rolls back and reportstransaction.rollback_only(ErrorKind::Conflict). So never swallow an error inside a transaction and continue as if it succeeded (caught_nested_error_prevents_commit). - Unfinished work is marked rollback-only.
TransactionScopeimplementsDrop: dropping it while work is unfinished marks the session rollback-only. That is a mark, not anawait. The realROLLBACKis the database driver's job — see theTransactionSessiondoc comment, "Cancellation cleanup remains a driver obligation." If a request is cancelled and its future dropped, the framework guarantees only that the transaction will not be committed. - Commit failure is not success and is never retried. At the moment
COMMITfails, the server may or may not have applied it, and the error does not say which. Retrying can duplicate a write that actually succeeded. The Timescale driver maps this todatabase.commit_unknown("Commit outcome may be unknown; do not retry automatically"). Surface it and reconcile instead (commit_failure_is_not_success_or_automatic_retry).
Reads and when to skip the transaction
A simple independent read may not need a transaction. Multiple reads requiring a consistent snapshot can still need one. If a read sits behind a cache, it must tell the cache whether a transaction is already active, because an active transaction bypasses the shared cache:
let mut session = self.database.session;
let transaction_active = session.state != Idle;
self.cache
.get_or_load
.await
Contract reference
The behavior above comes from these files; this is where to look when you need the exact shape, rather than a summary:
crates/core/iet-core/src/lib.rs—Principal,CallContext,ResourceId,Session,CallScope,TransactionState,TransactionSession,TransactionScope, and the low-levelenter_transaction/finish_transactionhelpers used for adapter and driver code.crates/core/iet-core/src/operation.rs—Access,Operation,check;validate_operationis exported fromlib.rs.crates/core/iet-core/tests/contracts.rsandtests/operation.rs— the transaction and access-ordering rules as executable tests.crates/features/iet-db-timescale/src/session.rs— a realSession/TransactionSessionimplementation, including the non-retryable commit error.