The simplest durable unit is a job: one function that runs to completion, with retries
for free. Define perform/1 or perform/2 (instead of step/2) and you get a one-shot
durable job — no step names, no outcome tuples.
defmodule Cleanup do
use GenDurable.FSM
@impl true
def perform(args, _ctx) do
File.rm_rf!(args["path"])
:ok
end
end
GenDurable.insert(Cleanup, args: %{"path" => "/tmp/x"})perform receives the instance args (the plain map passed as :args/:state, or the typed
struct if a State schema is declared) and, in the /2 form, the
GenDurable.Context.t/0.
Return values
| Return | Result |
|---|---|
:ok | terminal — done, empty result |
{:ok, map} | terminal — done, map recorded as the result |
{:error, reason} | retried with backoff/1 until :max_attempts, then failed |
{:cancel, reason} | failed immediately, no retry |
| a raised exception | treated as {:error, exception} |
Retries and backoff
{:error, _} (or a raised exception) is retried until :max_attempts (default 20), then
the instance is failed. The delay between attempts is backoff/1 — a capped exponential by
default (min(1000 * 2^attempt, 300_000) ms). Override either:
defmodule Charge do
use GenDurable.FSM, max_attempts: 10
@impl true
def perform(args, ctx) do
case Stripe.charge(args["amount"]) do
:ok -> :ok
{:error, :card_declined} -> {:cancel, :declined} # don't retry a hard decline
{:error, _transient} -> {:error, :try_again} # retried with backoff
end
end
@impl true
def backoff(attempt), do: 5_000 * (attempt + 1) # linear, 5s steps
endctx.attempt (0-based) is available in perform/2 and backoff/1.
When you outgrow a job
A job is a degenerate one-step machine. The moment you need to wait for an external event,
fan work out, or move through named phases, switch to step/2 — a module
defines either perform or step/2, never both.