Runic.Workflow.Saga (Runic v0.1.0-alpha.7)

Copy Markdown View Source

A sequential transaction pipeline with compensating actions for failure recovery.

A Saga orchestrates a series of steps that must all succeed or be rolled back. Each transaction step has a paired compensation that undoes its effect. If any step fails, previously completed steps are compensated in reverse order. Think of it as a more powerful with statement where each clause has a paired undo.

Unlike the Aggregate component (CQRS/ES semantics) or ProcessManager (event-driven orchestration), a Saga is an explicit forward-then-compensate pipeline with sequential execution semantics.

How It Works

At compile time a Saga is lowered into standard Runic primitives:

  • An Accumulator that tracks the saga's execution state as a map with keys:
    • :status — one of :running, :completed, or :aborted
    • :current_step — the name of the step currently being executed
    • :results — map of step name to result for completed steps
    • :failure_reason — the error reason if a step failed
    • :compensated — map of compensation results
    • :step_order — list of step names in declaration order
  • One Rule per transaction step (forward execution). Each rule gates on the accumulator's state via state_of() meta-references and executes when it is the current step's turn.
  • Rules for compensation triggers and compensation steps that fire when a failure is detected, executing compensations in reverse declaration order.
  • Optional rules for on_complete and on_abort terminal handlers.

Compile-time validation ensures every transaction has a corresponding compensate with the same name.

Each transaction rule is named :"<saga_name>_<step_name>".

DSL Syntax

Sagas are created with the Runic.saga/2 macro using a block DSL:

Runic.saga name: :name do
  transaction :step_name do
    fn input -> {:ok, result} | {:error, reason} end
  end
  compensate :step_name do
    fn results_map -> compensation_result end
  end

  on_complete fn results -> value end       # optional
  on_abort fn reason, compensated -> value end  # optional
end

Directives

  • transaction :name do fn -> ... end end — declares a forward step. The function receives the accumulated results map and must return {:ok, result} on success or {:error, reason} on failure.
  • compensate :name do fn -> ... end end — declares the compensation for a transaction. Receives the results map and returns a compensation result. Every transaction must have a matching compensate (validated at compile time).
  • on_complete fn results -> value end — optional handler called when all steps complete successfully. Receives the final results map.
  • on_abort fn reason, compensated -> value end — optional handler called when the saga is aborted after compensations. Receives the failure reason and a map of compensation results.

Steps execute in declaration order. On failure, compensations run in reverse declaration order for all previously completed steps.

Examples

require Runic

# Order fulfillment saga
saga = Runic.saga name: :fulfillment do
  transaction :reserve_inventory do
    fn input -> {:ok, :reserved} end
  end
  compensate :reserve_inventory do
    fn %{reserve_inventory: _reservation} -> :released end
  end

  transaction :charge_payment do
    fn %{reserve_inventory: _} -> {:ok, :charged} end
  end
  compensate :charge_payment do
    fn %{charge_payment: _charge} -> :refunded end
  end

  on_complete fn results -> {:saga_completed, results} end
  on_abort fn reason, compensated -> {:saga_aborted, reason, compensated} end
end

# Add to workflow and execute
alias Runic.Workflow

wrk = Workflow.new() |> Workflow.add(saga)
wrk = Workflow.react(wrk, :start)

Sub-Component Access

After adding a Saga to a workflow, its internal primitives can be retrieved via Workflow.get_component/2 using a {name, kind} tuple:

alias Runic.Workflow

wrk = Workflow.new() |> Workflow.add(saga)

# Get the underlying accumulator (tracks saga execution state)
[accumulator] = Workflow.get_component(wrk, {:fulfillment, :accumulator})

# Get all transaction (forward) rules
transactions = Workflow.get_component(wrk, {:fulfillment, :transaction})