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

ReturnResult
:okterminal — 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 exceptiontreated 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
end

ctx.attempt (0-based) is available in perform/2 and backoff/1.

Waiting for a result (sync-over-async)

GenDurable.await/3 holds the caller until the instance settles or a deadline passes — insert, then wait a beat, and either hand the client the answer or a retry token:

{:ok, id} = GenDurable.insert(Charge, args: %{amount: 100})

case GenDurable.await(id, 1_000) do
  {:done, result}  -> json(conn, 200, result)
  {:failed, error} -> json(conn, 422, %{error: error})
  {:busy, _snap}   -> conn |> put_resp_header("retry-after", "1") |> json(202, %{id: id})
  # {:awaiting, _} / :not_found — see the await/3 docs
end

Calling await(id, …) again with the same id is the retry protocol — a job that finished in the meantime answers immediately. A timeout is not a failure (the work continues), and a step in retry-backoff shows as {:busy, %{status: :runnable, attempt: n}}{:failed, _} means retries are exhausted. Same-node results answer in milliseconds (the executor nudges waiters directly); results committed on other nodes land within the watcher tick (await: [tick: 25] engine option). Retry tokens live as long as the row — terminal rows are GC-swept retention after finishing (a day by default).

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.