A durable FSM is a module with a step/2 clause per step name. The engine's one guarantee:
on step completion, the new state is committed to the database before execution proceeds.
On a crash before commit, the step re-executes from scratch (at-least-once). The unit of
re-execution is the whole step — make each step idempotent, and keep steps small.
defmodule Checkout do
use GenDurable.FSM, version: 1, queue: "checkout"
defmodule State do
use GenDurable.State
embedded_schema do
field :order, :integer
field :n, :integer, default: 0
end
end
@impl true
def step("start", %{state: s}), do: {:next, "ship", %{s | n: s.n + 1}}
def step("ship", %{state: s}), do: {:done, %{"order" => s.order}}
end
{:ok, id} = GenDurable.insert(Checkout, state: %{order: 42})The step text column maps to a function-clause head; the instance walks from clause to clause
until a terminal outcome.
State
ctx.state is the instance's state, loaded before every step and committed after it.
- Nested
Stateschema (recommended). Declare adefmodule Statewithuse GenDurable.Stateinside the FSM module; it is adopted by convention (no option needed) andctx.stateis that typed struct. Return a struct of the same type from your outcome. - Explicit
:state. Point at a schema module elsewhere withuse GenDurable.FSM, state: MyState. - Plain map. Omit both;
ctx.stateis a string-keyed map. Supported but untyped.
Outcomes
A step (and the error handler handle/2) returns one of:
| Outcome | Effect |
|---|---|
{:next, step, state} | transition to step, runnable, attempt := 0 |
{:next, step, state, opts} | …with per-transition rate_limit:/weight:, concurrency_key:, or inline_execution: (below) |
{:retry, state, delay_ms} | re-run the same step after delay_ms, attempt += 1 |
{:await, names, next_step, state} | park until a signal arrives |
{:await, names, next_step, state, timeout: ms} | …but wake after ms even without one |
{:schedule_childs, next_step, children, state} | fan out and wait for the children |
{:done, result} | terminal, done, result recorded |
{:stop, reason} | terminal, failed, reason recorded |
:next resets the attempt counter (a fresh step); :retry keeps re-running the current step
with attempt += 1 (the poll/backoff primitive) — the delay and the retry policy are yours,
computed from ctx.attempt.
Inline execution (run-ahead)
By default a :next commits the row back to runnable and the picker claims it again for the
next step — durable, but a full re-pick per step. use GenDurable.FSM, inline_execution: true
runs the next step in the same worker, on the claim already held, when its rate/concurrency
tokens can be secured:
defmodule Pipeline do
use GenDurable.FSM, inline_execution: true
def step("fetch", ctx), do: {:next, "parse", fetch(ctx.state)}
def step("parse", ctx), do: {:next, "call_api", parse(ctx.state), concurrency_key: {:vendor, ctx.state.vendor}}
def step("call_api", ctx), do: {:next, "persist", call(ctx.state), rate_limit: :vendor}
def step("persist", ctx), do: {:done, persist(ctx.state)}
endEach step still declares its own rate_limit: and
concurrency_key:; the engine secures those tokens out-of-band before
running the next step inline. If a token is denied (rate exhausted, concurrency slot full,
or an unconfigured key is held elsewhere), that step is not run inline — the row requeues and
the picker admits it exactly as without inlining. So inline is a throughput optimization, never
a change in what runs: the same limits hold. Durability is unchanged too — the next step's
state is committed before it runs, and a crash re-runs it.
Override the FSM default for a single transition with inline_execution: in the opts — force a
boundary step to requeue (so the picker re-checks priority) even in an inline FSM:
def step("charge", ctx), do: {:next, "settle", ctx.state, inline_execution: false}A caveat worth knowing: an inline chain holds one executor slot and doesn't let the picker
re-evaluate priority until it yields (a terminal/await/retry outcome, a denied token, or a
step with inline_execution: false). Reach for it on throughput-bound pipelines; drop a
boundary inline_execution: false where a higher-priority backlog must be able to cut in.
Errors
A raised exception routes to handle(reason, ctx), which returns any outcome above (it
defaults to {:stop, reason}, and is overridable):
@impl true
def handle(reason, ctx) do
if ctx.attempt < 5, do: {:retry, ctx.state, 1_000 * ctx.attempt}, else: {:stop, reason}
endAn uncaught throw routes there too, as {:throw, value} — it is a controlled non-local
return, not a crash. A worker crash (a bare exit, a kill — no return at all) is
different: the reaper recovers it and re-runs the step from
scratch — handle/2 is not called.
Options
use GenDurable.FSM, ...:
:name— thefsmcolumn value (defaultinspect(module)).:version— old versions coexist as separately-registered modules; instances finish on the version they started.:queue— default queue for instances.:initial— the first step (default"start").:state— theGenDurable.Stateschema module (usually unnecessary; see above).:inline_execution— run:nextsteps inline in the same worker (defaultfalse); see Inline execution.