OxL Reference

module std.saga

std.saga — sagas and durable pipelines (§10.6, §11.3, D-5100..D-5189).

The compiler turns every saga and pipeline declaration into ordinary functions that call this module (compiler/src/sema/SagaSema.cpp); nothing here is meant to be called by hand except status, resolve and list.

A saga is a list of steps; after a step undo r => … names its compensation. The generated function nests the steps:

oxl
enter(0, …) -> S1 -> guard(0, it, r => U1, x => ( done(0, x) -> enter(1, …) -> S2 -> done(1, it) ))

so the compensations run by themselves, innermost first, when a later step fails (guard). Nothing is kept in memory between steps except the call stack: a node that takes the saga over RE-EXECUTES the steps from the first one. That is sound because every step carries the key sagaId#step as idempotency-key and the rpcs of a step are @idempotent (the analyzer insists on it): a repeated step answers with its first result instead of running again.

Journal (std.kv store oxl.saga, all keys of one saga carry the tag {sagaId} and so live on ONE shard, so a transaction may read and write them together):

oxl
s/{id}/h  head: name, encoded arguments, caller, creation time          (written once)
s/{id}/o  lease of the executor: node, expiry, epoch                    (compare-and-set; epoch fences a stale executor)
s/{id}/t  state: running | compensating | done | failed | cancelled | stuck, step cursor, result
s/{id}/c  cancel marker (the caller's deadline passed)
s/{id}/uK compensation of step K was done
a/<saga>/{id}   index of unfinished sagas of a kind; x/{id} index of stuck ones

Who runs a saga: the node that was called when it has a role with runs sagas, otherwise any such node; its recovery loop (serve) claims sagas without an executor and takes over those whose lease expired or whose node SWIM declared dead. Deadline (customer answer 9): when the caller's deadline passes the driver writes the cancel marker (one transaction) and returns Timeout at once; the executor sees the marker before the next step and compensates the finished ones in the background. The marker and the commit of the last step are decided in one transaction: a saga either commits or is cancelled.

Example:

oxl✓ checked in CI
service Ledger {
  @public @idempotent rpc openAcct(id: u64) -> u64
}

impl Ledger {
  rpc openAcct(id) => id + 100
}

saga register(id: u64) -> u64 {
  Ledger.openAcct(id)
}

fn main() io, net, time, db {
  match register(5) {
    u64 v => print("registered {v}")
    CompensationFailed _ => print("stuck")
    NetError _ => print("net error")
  }
  print("stuck sagas: {std.saga.list(.Stuck).len()}")
}
Output
registered 105
stuck sagas: 0

Types

data DlqEntryA dead letter: a tail step failed (step, the error as text).
data HandoffNot an error: the tail of the pipeline was handed to a node with the role (phop); the rest of the chain is skipped.
enum SagaFilterWhich sagas list returns
enum SagaResolutionHow an operator closes a stuck saga by hand
data SagaStatusThe state of a saga as std.saga.status shows it.

Functions

dbgStep k (0-based) of the saga / pipeline name starts
decodeLikeDecodes b as the result type of like (used to decode the value of a durable step when a pipeline resumes on another node).
dlqThe dead-letter queue.
dlqListThe entries of the dead-letter queue of a pipeline (qualified name), oldest first.
doneEnd of step k. The last step (last) commits
driveStarts the saga name with the encoded arguments (or joins the one the idempotency key of the call names) and waits for its result.
enterStart of step k (0-based) with the value v of the previous one
guardRuns rest (the steps after step k) and, when it fails, the compensation comp of step k with v (the step's result).
hoppedThe tail was handed over by phop in this task (the error handler and the DLQ must not see it).
isErrWhether a value of a union type is one of its error alternatives.
listSagas by state: .Stuck (needing manual work), .Active (not finished) or .All known to the index of unfinished ones.
pdurableA durable step: the value that enters step k is written to the journal first.
pfinishThe pipeline (or its tail) is over
phopon role in the tail of a pipeline (D-5110)
pipeScopeRuns the front of a pipeline.
pmarkStep k of a pipeline starts (the label names it in on_error(e, step)); the value passes through.
ponon role where nothing can hop (before respond, over a stream)
pstepThe label of the pipeline step that is running in this task ("" outside a pipeline).
resolveManual resolution of a stuck saga
respondThenAnswers the caller with v and runs tail(v) in the background scope of the node.
sagaRolesCluster roles with runs sagas of this program (empty without a cluster declaration).
serveRegisters the recovery loop of a saga kind; called once at node start by the code the compiler generates.
servePipeRegisters the recovery loop of a pipeline with durable steps
statusThe state of a saga, or none when no such saga exists.
valueOfType witness: the value type of a chain, without evaluating it (the generated code never calls it).

See also