OxL Reference

Sagas

Transactions across service boundaries are impossible, so a long process is written as a saga (§11.3): the steps run one after another (->), and a step may have an undo, a compensation that runs if a later step fails. Compensations run in reverse order, then the saga returns the original error. The state of a saga is journaled, so if a node fails another one takes over; steps and compensations must be idempotent (@idempotent, §11.4).

oxl✓ checked in CI
@error data Declined { why: str }

service Ledger {
  @public @idempotent rpc openAcct(id: u64) -> u64
  @public @idempotent rpc closeAcct(id: u64) -> ()
  @public @idempotent rpc charge(id: u64) -> u64 | Declined
}

impl Ledger {
  rpc openAcct(id) {
    print("open {id}")
    id
  }
  rpc closeAcct(id) { print("close {id}") }
  rpc charge(id) {
    if id > 1 { fail Declined{ why: "limit" } }
    id
  }
}

saga flow(a: u64, b: u64) -> u64 {
  Ledger.openAcct(a)   undo x => Ledger.closeAcct(x)
  -> Ledger.openAcct(b)   undo y => Ledger.closeAcct(y)
  -> Ledger.charge(b)
}

fn main() io, net, time, db {
  match flow(1, 2) {
    u64 v => print("done {v}")
    Declined d => print("declined {d.why}")
    CompensationFailed c => print("stuck")
    NetError e => print("net error")
  }
}
Output
open 1
open 2
close 2
close 1
declined limit

If a compensation itself keeps failing, the saga becomes "stuck" and reports CompensationFailed; such sagas are visible in the inspector (oxl inspect). A complete example with an external warehouse and payments is checkout in shop.

Steps with unknown outcome#

A step whose outcome is not known when the saga is compensated (the caller's deadline passed, or the node running the saga lost its lease during a rolling restart and another node took it over) counts as failed, and a failed step is not compensated: its undo x => … needs the result x, and there is none. The call may still be on its way, though, and its effect can arrive after the compensations of the earlier steps ran: a charge that is never refunded. The Standard therefore requires (§11.3) that the called service can cancel or neutralize such a late effect by the idempotency key of the step (ctx.idempotencyKey).

The pattern (the fake services of the Jepsen workload, tests/jepsen/app.oxl): each effect is recorded under its key, and a cancellation that arrives first leaves a tombstone under the same key, so the late effect is void. The undo of the first step cancels the later steps too:

oxl
actor Bank sharded by id: u64 {
  var seen: Map<str, i64> = {:}
  msg charge(order: str, amount: i64) -> str {
    let prev = seen.get("{order}#c") ?? 0
    if prev < 0 { return "declined" }          // cancelled before it arrived: the tombstone
    if prev > 0 { return "ok" }                // a repeated call: answered from the record
    balance -= amount
    seen.insert("{order}#c", amount)
    "ok"
  }
  msg refund(order: str) -> str {              // undoes the charge, or leaves the tombstone for a late one
    let prev = seen.get("{order}#c") ?? 0
    if prev > 0 { balance += prev }
    seen.insert("{order}#c", -1)
    "ok"
  }
}

saga pay(order: str, acct: u64, item: u64, amount: i64) -> str {
  Ledger.reserve(order, item) undo r => Ledger.cancel(order, item, acct)   // cancel = release + refund (tombstone)
  -> Ledger.charge(order, acct, amount)
}

Ledger.cancel releases the reservation and calls Bank.refund for the order: when the charge was applied it is undone, otherwise the tombstone makes the late charge void. Without it, a saga that is compensated while charge is in flight can end as "charged, reservation released, never refunded". The same holds for any step of your own sagas: give the service of every step that has side effects a cancel that works by the key, and call it from the undo of an earlier step (or from a dedicated cancel rpc). A form of undo that runs for unknown-outcome steps by their key is planned after 1.0.

See also

OxL 1.0.0-beta · Documentation version 1.0.0-beta© OxwaldLink · generated from the Standard, the std sources and the guides