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:
- each attempt starts a fresh transaction;
- the writes are safe to repeat, or protected by a stable request key;
- non-database side effects sit outside the attempt, or are deduplicated;
- an ambiguous commit — the server committed but the response was lost — can be reconciled without applying the operation twice;
- 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.