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 rulesValidate — no context, no session
Know who is calling, or gate accessCallContext + operation::check
Run several storage calls on one sessionSession + CallScope
Make several database statements succeed or fail togetherTransactionScope

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-core alone.

1. Input rules: no caller, no storage

rust
use iet_core::{AppError, AppResult, ErrorKind, Validate};

pub struct CreateNote {
    pub text: String,
}

impl Validate for CreateNote {
    fn validate(&self) -> AppResult<()> {
        if self.text.trim().is_empty() {
            return Err(AppError::new(
                ErrorKind::Validation,
                "note.text_required",
                "A note needs some text",
            ));
        }
        Ok(())
    }
}

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:

rust
pub struct Principal {
    pub subject: String,
    pub profile: Option<String>,
}

/// Trusted invocation metadata; constructing it does not verify credentials.
#[derive(Debug, Clone, Default)]
pub struct CallContext {
    pub request_id: Option<String>,
    pub principal: Option<Principal>,
    pub tenant: Option<String>,
}

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():

rust
iet_core::operation::check::<CreateNote, _>(&authorizer, &context, &input).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:

rust
pub trait Session: Send {
    fn resource_id(&self) -> &ResourceId;
}
rust
let mut session = self.database.open().await?;
let mut scope = CallScope::new(&context, &mut session);
self.repository.insert(&actor.subject, input.text, &mut scope).await?;

Two properties come straight from the borrow:

  • One exclusive borrow. CallScope holds &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) returns session.resource_mismatch if 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.

rust
use iet_core::{
    AppResult, CallScope, CallContext, Principal, ResourceId, Session, TransactionScope,
    TransactionSession, TransactionState,
};

struct DemoSession {
    resource: ResourceId,
    state: TransactionState,
}

impl Session for DemoSession {
    fn resource_id(&self) -> &ResourceId {
        &self.resource
    }
}

impl TransactionSession for DemoSession {
    fn state(&self) -> TransactionState {
        self.state
    }
    fn mark_rollback_only(&mut self) {
        self.state = TransactionState::RollbackOnly;
    }
    async fn begin(&mut self) -> AppResult<()> {
        self.state = TransactionState::Active;
        Ok(())
    }
    async fn commit(&mut self) -> AppResult<()> {
        self.state = TransactionState::Idle;
        Ok(())
    }
    async fn rollback(&mut self) -> AppResult<()> {
        self.state = TransactionState::Idle;
        Ok(())
    }
}

fn block_on<F: std::future::Future>(future: F) -> F::Output {
    use std::task::{Context, Poll, Waker};
    let mut future = std::pin::pin!(future);
    let mut cx = Context::from_waker(Waker::noop());
    loop {
        match future.as_mut().poll(&mut cx) {
            Poll::Ready(value) => return value,
            Poll::Pending => std::thread::yield_now(),
        }
    }
}

async fn save_note(session: &mut DemoSession) -> AppResult<()> {
    let context = CallContext {
        principal: Some(Principal {
            subject: "alice".into(),
            profile: None,
        }),
        ..Default::default()
    };
    let mut scope = CallScope::new(&context, session);
    let transaction = TransactionScope::enter(scope.session()).await?;
    // Real work (repository reads/writes) goes here, using transaction.session().
    transaction.finish(Ok(())).await?;
    Ok(())
}

fn main() {
    let mut session = DemoSession {
        resource: ResourceId("primary".into()),
        state: TransactionState::Idle,
    };
    block_on(save_note(&mut session)).unwrap();
    assert_eq!(session.state, TransactionState::Idle);
    println!("ok: committed and back to idle");
}

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. enter returns a token that knows whether it is the outermost call. A joined call's finish does not commit; it returns its own result and leaves the decision to the owner. finish consumes 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 finish marks the session rollback-only. Even a later successful owner finish then rolls back and reports transaction.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. TransactionScope implements Drop: dropping it while work is unfinished marks the session rollback-only. That is a mark, not an await. The real ROLLBACK is the database driver's job — see the TransactionSession doc 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 COMMIT fails, 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 to database.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:

rust
let mut session = self.database.session();
let transaction_active = session.state() != TransactionState::Idle;
self.cache
    .get_or_load(&key, self.ttl, transaction_active, || async {
        let mut scope = CallScope::new(&context, &mut session);
        self.repository.list(&actor.subject, &mut scope).await
    })
    .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-level enter_transaction / finish_transaction helpers used for adapter and driver code.
  • crates/core/iet-core/src/operation.rs — Access, Operation, check; validate_operation is exported from lib.rs.
  • crates/core/iet-core/tests/contracts.rs and tests/operation.rs — the transaction and access-ordering rules as executable tests.
  • crates/features/iet-db-timescale/src/session.rs — a real Session / TransactionSession implementation, including the non-retryable commit error.
IET Framework documentation — built with the IET Framework.