saga/execution

Runs a saga.Workflow: run configuration and its validation, starting a run, waiting for or inspecting its outcome, and cancellation.

Use this module to execute a workflow defined with saga in memory. run is the ordinary path: it starts a run and blocks until it ends. start and start_reporting run without blocking; await, progress and cancel act on the returned Execution. saga/durable drives the same workflow with saved checkpoints and returns the same Outcome, and saga/testing polls progress for tests.

import gleam/time/duration
import saga/execution

// The caller's own failure type keeps both reports intact.
type Failure(o, e, u) {
  NotStarted(execution.RunError)
  Unfinished(execution.Outcome(o, e, u))
}

let config =
  execution.config()
  |> execution.with_max_concurrency(4)
  |> execution.with_deadline(execution.After(duration.seconds(5)))
case execution.run(workflow, "order-1", config) {
  Ok(execution.Completed(receipt)) -> Ok(receipt)
  Ok(outcome) -> Error(Unfinished(outcome))
  Error(run_error) -> Error(NotStarted(run_error))
}

NotStarted keeps the RunError that refused the run. Unfinished keeps the Outcome, so the caller still reads the failure and its settlement, and execution.unknown_effects(outcome) names the actions whose effect is unknown when the caller needs them.

Defaults. config() gives one concurrent task per scheduler, no run deadline, a 60 second per-attempt timeout, a 5 second settle window, a 5 second bound on each compensation decision and undo, and a 300 second cap on RetryAfter delays; see config for the setter of each. start waits up to 5 seconds for the run’s coordinator to start. run waits until the run ends.

Who learns the outcome. run returns it; start delivers it to the starting process, which alone may await it; start_reporting delivers it as one message to a caller-supplied Subject, which a surviving process can hold even after the starting process exits, or which the starting process can add to its own Selector. In every case the starting process owns the run: its exit cancels the run, which still settles and rolls back before ending.

Resource bounds. Once a run stops admitting new work (on a step failure, the deadline, or a cancellation), in-flight attempts and compensations get up to settle_timeout to finish on their own before they are killed, and each compensation decision or undo action is bounded by cleanup_timeout. With a deadline, worst-case run time is therefore deadline + settle_timeout + (undone entries + compensations) * cleanup_timeout. Without one, a run lasts as long as its steps: each attempt is bounded by its timeout, and each step by its attempt budget and its RetryAfter delays, each at most max_retry_delay. A step’s worst case is therefore max_attempts * (attempt timeout + cleanup_timeout + max_retry_delay).

Cancellation never reverses an unknown effect. A step whose attempt or compensation is killed — by its own timeout, or by the settle window closing — is reported interrupted: its effect is unknown and is never journaled or undone. Only steps that are known to have completed are undone.

Known results and unknown effects. Every action a run performs — a step attempt, a compensation decision, an undo — ends either with a known result (it returned Ok or a typed error) or with an unknown effect (it crashed or its process exited, it was killed at its time bound, or it was killed when the settle window closed). unknown_effects(outcome) names every action of the second kind, for every outcome kind; it is [] exactly when every action returned.

Types

Which of a step’s actions ended with an unknown effect.

pub type Action {
  StepAttempt(attempt: Int)
  StepCompensation(attempt: Int)
  StepUndo
}

Constructors

  • StepAttempt(attempt: Int)

    The step’s attempt with this number (the first is 1).

  • StepCompensation(attempt: Int)

    The saga.compensate decider, deciding about the attempt with this number.

  • StepUndo

    The step’s saga.undo, during rollback.

Why await did not return an Outcome.

AlreadyAwaited is reported for the ordinary case (a previous await on this Execution already consumed its outcome) and also for a second await after a previous one already reported Lost — both leave nothing further to observe, since the coordinator is gone either way and no fresh Down will ever arrive again for a monitor that already fired. A caller that needs the crash detail should keep the first await’s own Lost result instead of relying on a second call to repeat it.

Timing. If await is issued immediately after a previous await already succeeded (returned Ok), it may still report Error(AwaitTimedOut) rather than Error(AlreadyAwaited): succeeding only means the coordinator has sent its outcome, not that the coordinator process has actually exited yet, and AlreadyAwaited is only guaranteed once it has. A caller relying on AlreadyAwaited to detect a repeated await should either retry past a timeout or give a long enough one, rather than treating a single short-timeout call right after success as conclusive.

pub type AwaitError {
  AwaitTimedOut
  NotOwner
  AlreadyAwaited
  Lost(crash: saga.Crash)
}

Constructors

  • AwaitTimedOut
  • NotOwner

    The calling process does not receive this run’s outcome: it is not the process that called start, or the run was started with start_reporting and delivers its outcome to a report subject.

  • AlreadyAwaited
  • Lost(crash: saga.Crash)

Why a run was cancelled: an explicit cancel call, or the owning process exiting.

pub type CancelReason {
  CancelRequested
  OwnerExited
}

Constructors

  • CancelRequested
  • OwnerExited

The run-ending cause behind a Failed or a sibling failure recorded in a Settlement. Keeps an application failure (StepFailed), an execution failure (StepCrashed/StepTimedOut), retry exhaustion, and an output-transform crash as distinct variants, so no report can conflate them.

A cause names the last failure of a step, not every attempt before it. Whether any attempt, compensation decision or undo left an effect of unknown status is reported by unknown_effects, never by the cause’s variant.

pub type Cause(e) {
  StepFailed(step: saga.StepAddress, error: e)
  StepCrashed(step: saga.StepAddress, crash: saga.Crash)
  StepTimedOut(step: saga.StepAddress)
  RetryLimitReached(
    step: saga.StepAddress,
    last: saga.AttemptFailure(e),
  )
  RetrySuperseded(
    step: saga.StepAddress,
    last: saga.AttemptFailure(e),
  )
  OutputCrashed(crash: saga.Crash)
  DeadlineExceeded
}

Constructors

  • StepFailed(step: saga.StepAddress, error: e)

    The step’s attempt returned error and the step has no compensate decider, or its decider chose Abort(error) or AbortAfterCleanupFailure(error, _).

    A StepFailed may follow a crash. A decider is asked about a crashed or timed-out attempt too (saga.Crashed/saga.TimedOut), and an Abort it returns is reported as StepFailed with the decider’s error, exactly like an aborted typed error. The crashed or timed-out attempt is then named in Settlement.unknown_effects: a decider author who aborts after a crash should choose an error that says so, and a consumer should consult unknown_effects rather than infer a known result from StepFailed.

  • StepCrashed(step: saga.StepAddress, crash: saga.Crash)
  • StepTimedOut(step: saga.StepAddress)
  • RetryLimitReached(
      step: saga.StepAddress,
      last: saga.AttemptFailure(e),
    )
  • RetrySuperseded(
      step: saga.StepAddress,
      last: saga.AttemptFailure(e),
    )

    A Retry/RetryAfter decision was refused because settling had already begun for a different, unrelated trigger, not because this step’s own attempt budget was exhausted — distinct from RetryLimitReached for that reason.

  • OutputCrashed(crash: saga.Crash)
  • DeadlineExceeded

A compensation decision itself failed to produce a clean outcome.

pub type CompensationFailure(u) {
  CleanupFailed(step: saga.StepAddress, error: u)
  CompensationCrashed(step: saga.StepAddress, crash: saga.Crash)
  CompensationTimedOut(step: saga.StepAddress)
}

Constructors

The bounds, pacing and labels of a run. Build one with config and the with_* setters; run, start, start_reporting and durable.drive check it and return InvalidConfig with every violation.

pub opaque type Config

A single violated Config bound. InvalidConfig lists every one that applies, not just the first.

pub type ConfigError {
  MaxConcurrencyNotPositive(value: Int)
  DeadlineNotPositive(value: duration.Duration)
  StepTimeoutNotPositive(value: duration.Duration)
  SettleTimeoutNegative(value: duration.Duration)
  CleanupTimeoutNotPositive(value: duration.Duration)
  MaxRetryDelayNegative(value: duration.Duration)
}

Constructors

A started run. Only the process that called start may await it; a run started with start_reporting delivers its outcome to its report subject instead, and cannot be awaited at all. Either way, cancel, progress, pid and run_id work from any process.

Deliberately stateless on the owner’s side: no process-dictionary flag or other bookkeeping outside this immutable value survives between await calls, so starting and awaiting any number of Executions never grows the owner’s process dictionary. ToOwner.monitor is the original monitor set up once, in start — an ordinary value held on Execution, not owner-process state, so keeping it costs nothing once the Execution itself is dropped. It is what makes a coordinator that dies abnormally before ever being awaited reliably reported as Lost: a monitor set up while the coordinator was still alive is the only way to see its real exit reason, since a monitor set up afterwards (once the coordinator is already dead) always reports the synthetic reason noproc instead. See await’s doc comment for how a second await (after monitor has already fired and been torn down) is still detected — without extra owner-side state — using a second, freshly created monitor for that call only.

Lifetime. Every await that actually consumes a signal (an outcome, or a Down) demonitors/drains as it returns, so nothing is left in the owner’s mailbox once that call happens — but a call that never happens cannot clean up after itself. An Execution you stop awaiting (an await that timed out, followed by cancel, with no await afterwards) can still leave a monitor Down or an unclaimed outcome message sitting in the owner’s mailbox once the run eventually settles. Always await again after cancel (even with a short timeout) so the run’s terminal outcome is consumed before the Execution is dropped. A start_reporting run sets up no monitor in the starting process and sends it nothing, so it leaves nothing behind there.

pub opaque type Execution(o, e, u)

A run’s terminal result: success, a failure with its settlement, a cancellation, or an unresolved Hold that left completed effects untouched. Cancelled’s settlement follows the same rules as Failed’s: completed steps are undone, and interrupted or not-undoable effects are listed rather than claimed reversed.

CompletedWithUnknownEffects is Completed’s counterpart for a run that reached its output although a step attempt crashed (or its process exited) or was killed at its timeout, and the step’s recovery decider then chose Retry/RetryAfter/Continue. That attempt’s own effect is still unknown and was never journaled or undone — only the replacement attempt (the retry, or Continue’s supplied output) is known. Kept as its own variant (never an always-present field on Completed) so a case matching only Completed cannot silently drop the uncertainty. unknown_effects is never empty on this variant, and a plain Completed means every action of the run returned a result.

pub type Outcome(o, e, u) {
  Completed(output: o)
  CompletedWithUnknownEffects(
    output: o,
    unknown_effects: List(UnknownEffect),
  )
  Failed(cause: Cause(e), settlement: Settlement(e, u))
  Cancelled(reason: CancelReason, settlement: Settlement(e, u))
  Unresolved(
    step: saga.StepAddress,
    evidence: e,
    settlement: Settlement(e, u),
  )
}

Constructors

The run’s current admission phase: accepting new work, letting active siblings settle after a terminal trigger, or undoing completed steps. The coordinator reports its outcome and exits in the same step that finishes rollback (or reaches Unresolved/Completed), so there is no observable window for progress to report a “finishing” phase.

pub type Phase {
  Running
  Settling
  RollingBack
}

Constructors

  • Running
  • Settling
  • RollingBack

A read-only snapshot of a run: its phase and every step’s state. Never exposes values or executable closures.

pub type Progress {
  Progress(run_id: Int, phase: Phase, steps: List(StepProgress))
}

Constructors

pub type ProgressError {
  ProgressTimedOut
  ExecutionEnded
}

Constructors

  • ProgressTimedOut
  • ExecutionEnded

Why run/start could not return an execution or report. InvalidConfig starts no work. ExecutionLost does not prove that no work started: a coordinator may act before its startup handshake is lost.

pub type RunError {
  InvalidConfig(errors: List(ConfigError))
  ExecutionLost(crash: saga.Crash)
}

Constructors

What happened to every step once a run stopped admitting new work: which were undone (in reverse completion order), which undo actions failed (all retained, not just the first), which had no undo configured, which were held by an unresolved Hold, which attempts or compensations were killed by their step’s own timeout or in flight when the settle window closed (interrupted), and which sibling failures settled after the primary cause.

unknown_effects is the complete record: every action of the whole run — attempt, compensation decision or undo, whether before or after the run stopped admitting work — that ended with an unknown effect, in the order they ended. It includes each interrupted entry, a crashed attempt whatever its decider then chose, a crashed or timed-out compensation, and a crashed or timed-out undo. It is [] exactly when every action of the run returned a result.

pub type Settlement(e, u) {
  Settlement(
    undone: List(saga.StepAddress),
    undo_failures: List(UndoFailure(u)),
    not_undoable: List(saga.StepAddress),
    held: List(saga.StepAddress),
    interrupted: List(saga.StepAddress),
    compensation_failures: List(CompensationFailure(u)),
    sibling_failures: List(Cause(e)),
    unknown_effects: List(UnknownEffect),
  )
}

Constructors

pub type StepProgress {
  StepProgress(address: saga.StepAddress, state: StepState)
}

Constructors

One step’s current state, as reported by progress.

pub type StepState {
  Waiting
  Attempting(attempt: Int)
  Compensating(attempt: Int)
  RetryScheduled(next_attempt: Int)
  Succeeded
  FailedStep
  Interrupted
  Undoing
  Undone
  UndoFailedStep
  Skipped
}

Constructors

  • Waiting
  • Attempting(attempt: Int)
  • Compensating(attempt: Int)
  • RetryScheduled(next_attempt: Int)
  • Succeeded
  • FailedStep
  • Interrupted
  • Undoing
  • Undone
  • UndoFailedStep
  • Skipped

A time bound that may be lifted. Infinity lifts it and must be chosen explicitly.

pub type Timeout {
  After(duration.Duration)
  Infinity
}

Constructors

One completed step’s undo did not succeed, during rollback.

pub type UndoFailure(u) {
  UndoFailed(step: saga.StepAddress, error: u)
  UndoCrashed(step: saga.StepAddress, crash: saga.Crash)
  UndoTimedOut(step: saga.StepAddress)
}

Constructors

One action of a run that ended without a result, so its effect is unknown: it may or may not have happened, and saga never journaled or undid it. Recorded when the action ends, whatever is decided afterwards: a crashed attempt retried to success, continued, aborted or held is still named.

pub type UnknownEffect {
  UnknownEffect(
    step: saga.StepAddress,
    action: Action,
    ending: UnknownEnding,
  )
}

Constructors

How an action ended without a result.

pub type UnknownEnding {
  ActionCrashed(crash: saga.Crash)
  ActionTimedOut
  ActionInterrupted
  ActionReturnedUnknown
}

Constructors

  • ActionCrashed(crash: saga.Crash)

    It raised, or its process exited (for example, killed from outside).

  • ActionTimedOut

    It was killed at its time bound: the step’s timeout (or Config.step_timeout) for an attempt, Config.cleanup_timeout for a compensation decision or an undo.

  • ActionInterrupted

    It was still running when the settle window closed, and was killed.

  • ActionReturnedUnknown

    The attempt returned an error that saga.unknown_when classified as a possible effect, such as a timeout after the request may have been sent.

Values

pub fn await(
  execution: Execution(o, e, u),
  timeout timeout: duration.Duration,
) -> Result(Outcome(o, e, u), AwaitError)

Waits up to timeout for the run’s outcome. Returns Error(AwaitTimedOut) on timeout — the run continues, and await may be called again. Returns Error(AlreadyAwaited) if a previous await on this same Execution already consumed the outcome, or if a previous await already reported Lost for it (see AwaitError’s doc comment). Returns Error(Lost(crash)) if the coordinator was killed externally before it could report an outcome, whether that happened before this call started or during it (execution.pid lets applications monitor it themselves independently, for comparison).

Timing. An await issued immediately after a previous await already returned Ok may still return Error(AwaitTimedOut) rather than Error(AlreadyAwaited): a successful outcome only means the coordinator has sent it, not that the coordinator process has exited yet. AlreadyAwaited is only guaranteed once the coordinator has actually exited — see AwaitError’s doc comment.

Stateless on the owner’s side: no process-dictionary flag or other owner-process bookkeeping is used to detect a repeated await — see Execution/await_selector’s doc comments for how a fresh, per-call monitor takes its place instead, so starting and awaiting any number of runs never grows the owner’s process dictionary.

pub fn cancel(execution: Execution(o, e, u)) -> Nil

Requests cancellation. Returns immediately; the request is idempotent and a no-op once settling or later has already begun. Cancellation stops admission, lets active siblings settle within settle_timeout, then kills whatever remains (reported interrupted) and rolls back known completed effects. It never reverses an interrupted or not-undoable effect.

The settle window is the run’s Config.settle_timeout, fixed at start; a cancellation cannot shorten it, because the owner’s exit cancels a run with no call to carry a value. It is an upper bound, not a delay: settling ends as soon as nothing is in flight. A zero settle timeout kills in-flight work at once, reporting it interrupted; a longer window lets it finish, so that it is known and undone.

pub fn config() -> Config

The default configuration:

SettingDefaultSetter
concurrent attempts and compensation decisionsschedulers onlinewith_max_concurrency
run deadlineInfinitywith_deadline
per-attempt timeout60 secondswith_step_timeout
settle window5 secondswith_settle_timeout
each compensation decision and undo5 secondswith_cleanup_timeout
RetryAfter delay cap5 minuteswith_max_retry_delay
correlationunique() (local), from_key(id) (durable)with_correlation

Why the run has no default deadline. A step’s attempts are bounded by the per-attempt timeout, its attempt budget (saga.compensate’s max_attempts) and the capped RetryAfter delay, so every run already has a finite worst case without a run-wide deadline (see the module doc). A deadline also cuts healthy long workflows, so it is opt-in.

pub fn describe_cause(
  cause: Cause(e),
  error describe_error: fn(e) -> String,
) -> String

Describes a cause for logs, naming the step and rendering the step’s error e with describe_error, for example "step publish returned an error: HTTP 500".

pub fn describe_config_error(error: ConfigError) -> String

Describes a configuration error for logs.

pub fn kind(outcome: Outcome(o, e, u)) -> telemetry.OutcomeKind

The closed classification of an outcome, the same value that [saga, run, stop] reports. CompletedWithUnknownEffects has its own kind, so a match on the kind cannot mistake it for a plain completion.

pub fn pid(execution: Execution(o, e, u)) -> process.Pid

The coordinator’s pid, for monitoring.

pub fn progress(
  execution: Execution(o, e, u),
  timeout timeout: duration.Duration,
) -> Result(Progress, ProgressError)

Synchronously inspects the run’s current phase and per-step states. Once the run has already ended, the coordinator process is gone and control is a dead subject: sending a request to it is a silent no-op, so without a monitor this would always time out rather than report ExecutionEnded promptly. A short-lived monitor (armed only for this one call, always demonitored with [flush] before returning) races the reply against the coordinator’s exit instead.

pub fn run(
  workflow: saga.Workflow(i, o, e, u),
  input: i,
  config: Config,
) -> Result(Outcome(o, e, u), RunError)

Runs workflow with input to completion, validating config first. This is the ordinary path: it blocks until the run finishes.

pub fn run_id(execution: Execution(o, e, u)) -> Int

The run’s id, the run field of its saga/telemetry events.

pub fn start(
  workflow: saga.Workflow(i, o, e, u),
  input: i,
  config: Config,
) -> Result(Execution(o, e, u), RunError)

Starts a run without blocking, returning an Execution handle. Only the calling process may await it. Returns Error(InvalidConfig(_)) with every violated bound of config, or Error(ExecutionLost(_)) if the coordinator process fails to complete its startup handshake within 5 seconds (it should not normally take anywhere near that long; this guards against a wedged or unschedulable coordinator rather than reporting it as a caller configuration error).

pub fn start_reporting(
  workflow: saga.Workflow(i, o, e, u),
  input: i,
  config: Config,
  to report: process.Subject(Outcome(o, e, u)),
) -> Result(Execution(o, e, u), RunError)

Starts a run like start, but delivers its outcome to report as one message instead of to the calling process. report may belong to any process — the caller itself, to receive the outcome in its own Selector next to its other messages, or another process, which then learns the outcome even if the caller is gone — or be a process.named_subject, resolved when the outcome is sent.

Ownership. The calling process is still the run’s owner: if it exits before the run ends, the run is cancelled with OwnerExited, settles and rolls back exactly as for cancel, and the resulting Cancelled(OwnerExited, settlement) is still delivered to report. The settlement names every step undone, every undo or compensation that failed or timed out, and every step interrupted with an unknown effect.

Delivery. report receives at most one message per run, sent by the run’s coordinator when the run ends, never before. It receives exactly one unless the coordinator itself is killed (then nothing is sent) or, for a named subject, no process holds the name when the outcome is sent (then the outcome is dropped and the coordinator still exits normally). To detect a lost run, the receiver monitors pid(execution): the coordinator’s Down always arrives after its outcome, so a Down with no outcome before it means the run was lost. A monitor set up after the coordinator exited still fires (with reason noproc) and still arrives after the outcome, if one was sent.

await on the returned Execution reports Error(NotOwner), from any process; cancel, progress, pid and run_id work as for start. The starting process gets no monitor and no message from the run.

pub fn unknown_effects(
  outcome: Outcome(o, e, u),
) -> List(UnknownEffect)

Every action of the run that ended with an unknown effect, in the order they ended: [] for Completed, the variant’s own list for CompletedWithUnknownEffects, and settlement.unknown_effects for Failed, Cancelled and Unresolved. [] means every step attempt, compensation decision and undo that ran returned a result, so no effect of the run is of unknown status; effects a result left in place (an undo that returned an error, a step with no undo, a held step) are reported by the settlement, not here.

pub fn with_cleanup_timeout(
  config: Config,
  timeout: duration.Duration,
) -> Config

Bounds each compensation decision and each undo action. Must be at least 1 millisecond.

pub fn with_correlation(
  config: Config,
  correlation: correlation.Correlation,
) -> Config

Carries correlation in every saga/telemetry event of the run, so the run can be followed with the caller’s other work. The run’s steps read it as correlation of the saga.EffectKey that saga.effect, saga.undo and saga.compensate receive, and pass it to the clients they call.

Every run has a correlation. A local run without one gets a fresh correlation.unique() at start. A durable execution without one carries correlation.from_key of its execution id; its first drive saves the correlation it uses, and later drives read it back (see durable.with_correlation).

pub fn with_deadline(config: Config, deadline: Timeout) -> Config

Ends the run with DeadlineExceeded after deadline, settling and rolling back like any failure. Infinity (the default) sets no deadline. A duration must be at least 1 millisecond.

pub fn with_max_concurrency(config: Config, limit: Int) -> Config

Bounds how many step attempts and compensation decisions of one run execute at once. Must be at least 1.

pub fn with_max_retry_delay(
  config: Config,
  delay: duration.Duration,
) -> Config

Caps the delay of a RetryAfter decision. A longer requested delay is shortened to the cap, and the decision’s telemetry.CompensationMetadata reports retry_delay_capped: True. Must not be negative.

pub fn with_settle_timeout(
  config: Config,
  timeout: duration.Duration,
) -> Config

How long in-flight attempts and compensation decisions may finish on their own once the run stops admitting work, before they are killed and reported interrupted. A zero duration kills them at once. Must not be negative.

pub fn with_step_timeout(
  config: Config,
  timeout: Timeout,
) -> Config

Bounds each attempt of a step that has no saga.timeout of its own. A step’s own saga.timeout always wins, shorter or longer. Infinity leaves such attempts unbounded: the explicit opt-out of the 60 second default. A duration must be at least 1 millisecond.

✨ Search Document