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).
@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")
}
}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:
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.