Coming From Oban
Durable runs workflows: a run is a series of steps with retries, waits and compensations, and it can pause for days. Oban runs jobs. Many Oban setups are pipelines built from jobs that enqueue the next job, and in Durable one workflow replaces the chain. This guide maps what you use in Oban to what does the same thing here.
At a Glance
| In Oban | In Durable |
|---|---|
A worker’s perform/1 |
A workflow’s steps |
Oban.insert/2 |
Durable.start/3 |
Oban.insert_all/2 |
Durable.start_many/2 (keys are still checked) |
unique: |
A workflow key: key: "order:42" (Workflow keys) |
unique: on a cron job |
@schedule cron: "...", key: "cleanup" |
unique: with a period: that includes finished jobs (“at most once an hour”) |
A time bucket in the key, with reuse: :never on the workflow: key: "report:2026-11-09T14" |
job.conflict? |
return: :details on start: {:ok, %{id: id, created: false}} |
| Finish, check for new work, snooze to go again | on_duplicate: :run_again |
max_attempts, backoff/1 |
retry: [max_attempts: 5, backoff: :exponential] on a step |
job.attempt |
attempt/0, max_attempts/0, last_attempt?/0 in Durable.Context |
{:snooze, seconds} |
{:snooze, ms} or {:snooze, ms, reason} |
{:cancel, reason} |
{:cancel, reason} |
{:discard, reason} |
{:fail, reason} |
tags |
tags: ["account:7"] on start |
Oban.cancel_job/1 |
Durable.cancel/2. A running step is killed at its next heartbeat; it can check cancelled?/0 to stop cleanly first |
Oban.cancel_all_jobs/1 |
Durable.cancel_all([tag: "account:7"], reason) |
assert_enqueued, refute_enqueued, all_enqueued |
assert_started, refute_started, all_started in Durable.Testing |
Oban.drain_queue/1 |
Durable.Testing.drain/2 |
A queue with a limit |
A queue with a concurrency |
| A queue per pipeline stage | queue: on each step of one workflow |
| The notifier that wakes queues | pubsub: :start, or your own Phoenix.PubSub |
Differences That Bite
- Priority runs the other way. Higher
priority:numbers run first (default0). In Oban,0is the highest priority. - Times are in milliseconds.
{:snooze, 15}waits 15 ms, not 15 s. Use the helpers:{:snooze, seconds(15)}. - One retry budget per step, not per job. Each step has its own
retry:. A step that completed isn’t run again when a later step fails. - Retries wait off the worker. Between attempts the run goes back on its
queue until the backoff has passed, as in Oban, so a long backoff holds no
slot.
max_backoffdefaults to 1 hour. - A snooze doesn’t use an attempt, and there’s no limit on snoozes. The
workflow’s
timeout:is what stops a step that snoozes forever. - Stopping for good is a choice between two returns. Use
{:fail, reason}when giving up is a failure: the run shows as failed and its compensations run. Use{:cancel, reason}when there’s nothing left to do: the run shows as cancelled, not failed, and compensations don’t run. {:cancel, reason}in a parallel step cancels the whole workflow, without compensations. Passscope: :runto cancel only that branch.- Keys are checked on every start,
start_many/2included. Oban’sinsert_allon the basic engine skips unique checks. - Queue concurrency is per node.
concurrency: 20on three nodes runs up to 60 at once, as Oban’slimitdoes without Oban Pro’s global limits. That goes for a step’squeue:too. - Nothing to change behind PgBouncer. Durable uses no
LISTEN/NOTIFYor other session features, so transaction mode works as it is. Oban’s Postgres notifier needs replacing there.
Production Settings
Pruning is off by default, and every log line a step writes is stored. See Running in production before you go live.