Skip to content

Transactions

Run several SQL operations atomically in a compiler-managed transaction block.

Updated View as Markdown

transaction { ... } runs every query in its body against one database transaction. Falling out of the block commits; leaving through an error rolls back.

schema bank

model Account {
    @id
    id: int
    balance: int
}

fn set_balances(from_id: int, to_id: int, left: int, gained: int): void ! QueryError {
    transaction {
        _ := UPDATE Account SET balance = left WHERE id == from_id?
        _ := UPDATE Account SET balance = gained WHERE id == to_id?
    }
}

fn main(): void {
    match set_balances(1, 2, 40, 160) {
        Ok(_) => println("committed")
        Err(e) => println("rolled back")
    }
}

Either both updates land or neither does. There is no handle to pass around, no begin() to remember, and no commit() to forget — the block owns the transaction from { to }.

It is a statement, not an expression

The block evaluates to void. You cannot bind it, and you cannot catch it:

schema bank

model Account {
    @id
    id: int
    balance: int
}

fn broken(): int ! QueryError {
    n := transaction {
        _ := UPDATE Account SET balance = 0 WHERE id == 1?
    }
    return n
}

To produce a value, return from inside the block — the commit happens first, then the function returns. The block must not be the function’s last expression, though, or it is checked against the return type and reports its own void:

schema bank

model Account {
    @id
    id: int
    balance: int
}

// The block is the tail expression, so it is checked against `int`.
fn broken(): int ! QueryError {
    transaction {
        rows := FROM Account SELECT { id }?
        return rows.len()
    }
}

That is ATOLL2002: type mismatch: expected int, got void. The inner return does not satisfy the tail. Put a statement after the block and it compiles:

schema bank

model Account {
    @id
    id: int
    balance: int
}

fn count_accounts(): int ! QueryError {
    transaction {
        rows := FROM Account SELECT { id }?
        return rows.len()
    }
    return 0
}

fn main(): void {
    match count_accounts() {
        Ok(n) => println("${n} accounts")
        Err(e) => println("read failed")
    }
}

The trailing return 0 does double duty: it moves the block out of tail position, and it covers the fall-through path the compiler still considers reachable. Anything written after the block runs only on that path.

The enclosing function must be fallible

Beginning and committing are themselves database operations, so a transaction block propagates QueryError even when its body contains no query at all:

schema bank

model Account {
    @id
    id: int
    balance: int
}

fn broken(): void {
    transaction {
        println("nothing to do")
    }
}

That reports ATOLL2002: a transaction block propagates QueryError (its begin/commit are fallible) — the enclosing function must be fallible. Declare void ! QueryError, or convert with catch at the caller.

Reads see the block’s own writes

Inside the block, a read observes rows written earlier in the same transaction — that is the whole point of grouping them:

schema bank

model Account {
    @id
    id: int
    balance: int
    owner: string
}

fn seed(): int ! QueryError {
    transaction {
        _ := SAVE Account { id: 1, balance: 100, owner: "ada" }?
        _ := SAVE Account { id: 2, balance: 50, owner: "bob" }?
        rows := FROM Account SELECT { id, balance }?
        return rows.len()
    }
    return 0
}

fn main(): void {
    match seed() {
        Ok(n) => println("seeded; ${n} accounts visible inside the transaction")
        Err(e) => println("seed rolled back")
    }
}

Outside the block, nothing is visible until the commit succeeds.

Rolling back

Any error exit rolls back: a propagated ?, an error statement, or a returned Err. The rollback runs before the error reaches the caller.

schema bank

model Account {
    @id
    id: int
    balance: int
}

error TransferError {
    NoSuchAccount { id: int }
    Insufficient { id: int }
    Unavailable
}

fn withdraw(from_id: int, amount: int): void ! TransferError {
    transaction {
        row := (ONE FROM Account WHERE id == from_id SELECT { id, balance }) catch {
            _ => error Unavailable
        }
        match row {
            Some(account) => {
                if account.balance < amount {
                    // Leaves the block through the error path: rollback.
                    error Insufficient { id: from_id }
                }
                left := account.balance - amount
                (UPDATE Account SET balance = left WHERE id == from_id) catch {
                    _ => error Unavailable
                }
            }
            None => error NoSuchAccount { id: from_id }
        }
    }
}

fn main(): void {
    match withdraw(1, 25) {
        Ok(_) => println("withdrawn")
        Err(NoSuchAccount { id }) => println("no account ${id}")
        Err(Insufficient { id }) => println("account ${id} is short")
        Err(Unavailable) => println("bank unavailable")
    }
}

Note that the error Insufficient arm never reaches the update, and the read that already happened is discarded by the rollback.

Read-then-write, correctly

The most common use of a transaction is a read whose result decides a write. Because Atoll’s SET clause takes a bound value rather than an expression over the old row, this shape is the normal way to increment or decrement:

schema bank

model Account {
    @id
    id: int
    balance: int
}

fn transfer(from_id: int, to_id: int, amount: int): void ! QueryError {
    transaction {
        src := ONE FROM Account WHERE id == from_id SELECT { id, balance }?
        dst := ONE FROM Account WHERE id == to_id SELECT { id, balance }?

        match src {
            Some(a) => {
                left := a.balance - amount
                _ := UPDATE Account SET balance = left WHERE id == from_id?
            }
            None => return
        }
        match dst {
            Some(b) => {
                gained := b.balance + amount
                _ := UPDATE Account SET balance = gained WHERE id == to_id?
            }
            None => return
        }
    }
}

fn main(): void {
    match transfer(1, 2, 25) {
        Ok(_) => println("transferred")
        Err(e) => println("transfer rolled back")
    }
}

The transaction is what makes the read-modify-write safe: another writer cannot slip between the read and the update without the engine’s isolation rules noticing.

What the block will not let you do

Blocks cannot nest, and there is no savepoint syntax:

schema bank

model Account {
    @id
    id: int
    balance: int
}

fn broken(): void ! QueryError {
    transaction {
        transaction {
            _ := SAVE Account { id: 1, balance: 0 }?
        }
    }
}

That is ATOLL3235. Factor the inner work into an ordinary helper function called from one outer transaction, and let its errors propagate to that boundary — the helper’s queries join the caller’s transaction automatically:

schema bank

model Account {
    @id
    id: int
    balance: int
}

fn credit(account_id: int, next: int): void ! QueryError {
    _ := UPDATE Account SET balance = next WHERE id == account_id?
}

fn settle(a: int, b: int): void ! QueryError {
    transaction {
        credit(a, 10)?
        credit(b, 20)?
    }
}

fn main(): void {
    match settle(1, 2) {
        Ok(_) => println("both credits committed together")
        Err(e) => println("rolled back")
    }
}

Two further rules:

  • Every query in the block must route to the same datasource. Routing is by the model’s schema, so a block touching two schemas that map to different databases is rejected. This is what stops a transaction from looking atomic while it spans two servers.
  • DataFusion routes are rejected, because the dataframe executor cannot offer this contract.

The transaction handle is compiler-managed. Application code cannot commit it by hand, store it, or hand it to another task; a spawned task does not inherit it.

Cleanup and suspension

defer works normally inside the block, and runs on both the commit and the rollback path:

schema bank

model Account {
    @id
    id: int
    balance: int
}

fn audited(): void ! QueryError {
    transaction {
        defer { println("transaction finished") }
        _ := SAVE Account { id: 1, balance: 0 }?
    }
}

fn main(): void {
    match audited() {
        Ok(_) => println("committed")
        Err(e) => println("rolled back")
    }
}

Keep deferred work idempotent, and do not try to commit or roll back manually from it.

Suspension is legal in the block — query execution itself suspends. But holding a transaction open across unrelated external work extends locks and invites conflicts. Fetch what you need before entering the block unless the consistency contract actually requires the wait.

Retrying

Serialization failures, deadlocks, and connection loss can be transient. Retry the whole function containing the transaction, never the last statement: each attempt must begin a fresh transaction so it reads a fresh snapshot.

schema bank

model Account {
    @id
    id: int
    balance: int
}

fn attempt(account_id: int, next: int): void ! QueryError {
    transaction {
        _ := UPDATE Account SET balance = next WHERE id == account_id?
    }
}

/// Returns the number of attempts it took.
fn with_retry(account_id: int, next: int): int ! QueryError {
    mut tries := 0
    for tries < 3 {
        match attempt(account_id, next) {
            Ok(_) => return tries + 1
            Err(e) => {
                if !e.is_retryable() {
                    return Err(e)
                }
                tries = tries + 1
            }
        }
    }
    return tries
}

fn main(): void {
    match with_retry(1, 500) {
        Ok(tries) => println("committed after ${tries} attempt(s)")
        Err(e) => println("gave up: not retryable")
    }
}

Before adding a retry loop, check all of these:

  1. each attempt starts a fresh transaction;
  2. the writes are safe to repeat, or protected by a stable request key;
  3. non-database side effects sit outside the attempt, or are deduplicated;
  4. an ambiguous commit — the server committed but the response was lost — can be reconciled without applying the operation twice;
  5. the loop is bounded, backs off, is cancellable, and reports its final failure.

Point 4 is the one people skip. A commit failure is not proof that nothing was written; depending on how the connection died, the caller may not know. Retry policy has to survive that ambiguity, not just statement idempotency.

Constraint, permission, read-only, and schema errors are not made correct by retrying — is_retryable() returns false for all of them.

Keep non-database effects out

Database rollback cannot un-send an email or un-publish a message:

schema shop

model Outbox {
    @id
    id: int
    topic: string
    payload: string
}

model Order {
    @id
    id: int
    total: float
    status: string
}

/// The order and the "please publish this" record commit together. A separate
/// worker drains `Outbox` and does the actual delivery.
fn place_and_announce(new_id: int, amount: float): void ! QueryError {
    transaction {
        _ := SAVE Order { id: new_id, total: amount, status: "open" }?
        _ := SAVE Outbox {
            id: new_id,
            topic: "order.placed",
            payload: "${new_id}",
        }?
    }
}

fn main(): void {
    match place_and_announce(1, 19.5) {
        Ok(_) => println("order and outbox row committed together")
        Err(e) => println("nothing was written")
    }
}

That is the transactional-outbox pattern: record the intent in the same commit as the data, and deliver it separately. Cancellation is handled too — if a task is cancelled while a transaction is active, the runtime requests a rollback and releases the handle.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close