Background jobs with Oban: worker design, return-value semantics (:ok / :cancel / :discard / {:error}), idempotency, unique jobs, queues, cron, and testing.
How this skill is triggered — by the user, by Claude, or both
Slash command
/elixir-phoenix:obanWhen to use
Use when writing or reviewing Oban workers and their `perform/1` return values, enqueuing background jobs, unique jobs, or cron, and when reasoning about job error/retry/backoff behavior or testing with `Oban.Testing`.
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Pairs with `elixir-conventions`. The worker's **return value is its control flow**. Getting it right is the difference between a self-healing queue and a retry-storm that pages you.
Pairs with elixir-conventions. The worker's return value is its control flow. Getting it right is the difference between a self-healing queue and a retry-storm that pages you.
:ok / {:ok, _} → success.{:error, reason} → transient failure worth retrying (timeout, network, rate-limit, 5xx). Counts an attempt, backs off, and (with error reporting on) pages.{:cancel, reason} → expected, non-retryable condition the job can't fix (record gone, invalid state, 4xx). Stops retrying; not an error.{:discard, reason} → drop this job without retrying (e.g. now irrelevant).{:error, _} for things retrying won't fix. "User not found" is :cancel/:discard, not :error; otherwise you retry six times and report each one (this is a top source of noisy, traceless job issues).perform/1 idempotent. Jobs run at least once; a retry must not double-charge/double-send. Guard with a unique key or an upsert.unique: [period: ..., fields: ..., keys: ...]) to dedupe enqueues.max_attempts and a sane backoff/1 per worker; don't leave a poison job retrying forever.Oban.Testing (perform_job/2 / assert_enqueued), not by sleeping.defmodule MyApp.Workers.SyncCustomer do
use Oban.Worker, queue: :default, max_attempts: 6
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => id}}) do
case Billing.sync(id) do
:ok -> :ok
{:error, :not_found} -> {:cancel, :not_found} # expected, won't fix on retry
{:error, :rate_limited} -> {:error, :rate_limited} # transient: retry with backoff
{:error, reason} -> {:error, reason}
end
# an unexpected crash in Billing.sync raises → stacktrace + reporter, no manual rewrap
end
@impl Oban.Worker
def backoff(%Oban.Job{attempt: attempt}), do: trunc(:math.pow(2, attempt))
end
# unique enqueue (dedupe at insert time)
%{id: id}
|> MyApp.Workers.SyncCustomer.new(unique: [period: 60, keys: [:id]])
|> Oban.insert()
# idempotent effect (safe under at-least-once delivery)
Repo.insert(%Receipt{job_id: job.id}, on_conflict: :nothing, conflict_target: :job_id)
test "cancels when the customer is gone" do
assert {:cancel, :not_found} = perform_job(SyncCustomer, %{"id" => "missing"})
end
test "enqueues on signup" do
Accounts.create_user(attrs)
assert_enqueued worker: SyncCustomer
end
{:error, inspect(e)} → erases the stacktrace and retries bugs.max_attempts ceiling / no backoff → retry storms.npx claudepluginhub ariesclark/skills --plugin elixir-phoenixBlocks Edit/Write/Bash actions until Claude investigates importers, data schemas, and user instructions. Improves output quality by forcing concrete facts before edits.