Skip to content
Development
Skill

/oban-essentials

Use when writing or enqueuing Oban background jobs — worker options, return values, idempotency, uniqueness, transactional enqueue, cron/pruner/lifeline config, Oban.Testing.

BOOST
From plugin
elixir-phoenix-guide
16924 skills3 hooks
Install
$ npx -y skills add j-morgan6/elixir-phoenix-guide --skill oban-essentials --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/oban-essentials

Context preview

The summary Claude sees to decide when to auto-load this skill.

Use when writing or enqueuing Oban background jobs — worker options, return values, idempotency, uniqueness, transactional enqueue, cron/pruner/lifeline config, Oban.Testing.

SKILL.md

oban-essentials.SKILL.md
name: oban-essentials
description: "Use when writing or enqueuing Oban background jobs — worker options, return values, idempotency, uniqueness, transactional enqueue, cron/pruner/lifeline config, Oban.Testing."

Oban Essentials

> Verified against Elixir 1.20, Ecto 3.14, Oban 2.24, Req 0.7 — October 2026

RULES — Follow these with no exceptions

1. **`use Oban.Worker` with explicit `queue:` and `max_attempts:`**, and match string-keyed args in `perform/1` 2. **Return `:ok`/`{:ok, value}` for success, `{:error, reason}` to retry, `{:cancel, reason}` to stop for good, `{:snooze, period}` to re-run later** — prefer tuples over raising 3. **Enqueue in the same transaction as the data the job needs** — `Oban.insert/2` inside `Repo.transact/2`, or `Oban.insert/3` in an `Ecto.Multi`; handle the result (no `Oban.insert!` in app code) 4. **Make workers idempotent** — a job can run more than once (retries, Lifeline rescues); record completion and send an idempotency key to external APIs 5. **Uniqueness keeps `:worker` in `fields`** — omit `fields` (default `[:args, :queue, :worker]`) or use `[:worker, :args]`; narrow with `keys:`, pick a state group such as `:incomplete`. `Oban.insert_all/2` ignores `unique` in OSS 6. **One `config :my_app, Oban` block with the 2.24 service keys** — `cron:`, `pruner:`, `lifeline:`; always enable `pruner` and `lifeline` 7. **Args hold IDs and small JSON values only** — fetch fresh data in `perform/1` 8. **Bound every job** — `timeout/1` for long work; outbound HTTP uses `retry: false` and a host allowlist; cap snoozes with `meta["snoozed"]` 9. **Test with `Oban.Testing`** — `testing: :manual`, `perform_job/3`, `assert_enqueued`, `drain_queue`; never call `perform/1` directly 10. **Workflows, Batches, global and rate limits, DynamicCron and bulk unique inserts are Oban Pro** — don't write OSS code that assumes them

---

Worker definition

defmodule MyApp.Workers.SendWelcomeEmail do
  use Oban.Worker,
    queue: :mailers,
    max_attempts: 5,
    unique: [period: {5, :minutes}, keys: [:user_id]]

  alias MyApp.Accounts

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"user_id" => user_id}}) do
    case Accounts.get_user(user_id) do
      nil -> {:cancel, :user_not_found}
      %{welcome_sent_at: %DateTime{}} -> :ok
      user -> Accounts.deliver_welcome(user)
    end
  end
end
  • `Accounts.get_user/1` is `Repo.get(User, id)`, which you add; the generator only has `get_user!/1`, which would raise (and retry) for a deleted user instead of cancelling.
  • `max_attempts` counts the first run (5 = 1 + 4 retries). Retries back off exponentially (15 s minimum, with jitter) unless you define `backoff/1`.
  • `args` are stored as JSON: keys come back as strings, structs and tuples don't survive. Enqueue `%{user_id: user.id}`, never `%{user: user}`.
  • `{:cancel, reason}` replaces the deprecated `{:discard, reason}`.

Enqueuing

Enqueue from the context, in the transaction that writes the data. If the transaction rolls back, the job is never inserted; if it commits, the job can't run before the row exists:

# The generated register_user/1 plus the job. If you added a registration_changeset/3
# (the phoenix-auth-customization skill), insert with that instead of email_changeset/2.
def register_user(attrs) do
  Repo.transact(fn ->
    with {:ok, user} <- %User{} |> User.email_changeset(attrs) |> Repo.insert(),
         {:ok, _job} <- Oban.insert(SendWelcomeEmail.new(%{user_id: user.id})) do
      {:ok, user}
    end
  end)
end

The `Ecto.Multi` form, when steps are built dynamically:

Ecto.Multi.new()
|> Ecto.Multi.insert(:user, User.email_changeset(%User{}, attrs))
|> Oban.insert(:welcome_job, fn %{user: user} -> SendWelcomeEmail.new(%{user_id: user.id}) end)
|> Repo.transact()
  • **Scheduling:** `SendReminder.new(args, scheduled_in: {1, :hour})` (`scheduled_in` is the documented name since 2.24; `schedule_in` still works) or `scheduled_at: datetime`.
  • **Unique conflicts are not errors:** `Oban.insert/2` returns `{:ok, %Oban.Job{conflict?: true}}` for a duplicate.
  • **Bulk:** `Oban.insert_all/2` (or `Oban.insert_all(multi, :jobs, changesets)`) inserts many jobs in one query and returns a list. It raises on an invalid changeset and, in OSS, **skips uniqueness checks**; use `Oban.insert/2` per job when uniqueness matters.

Configuration (Oban 2.24)

# config/config.exs
config :my_app, Oban,
  repo: MyApp.Repo,
  queues: [default: 10, mailers: 5, imports: 2],
  cron: [
    crontab: [
      {"0 2 * * *", MyApp.Workers.NightlyCleanup},
      {"*/15 * * * *", MyApp.Workers.SyncData, args: %{source: "api"}}
    ]
  ],
  pruner: [max_age: {7, :days}],
  lifeline: [rescue_after: {30, :minutes}]

# config/test.exs: no queues or plugins run; jobs stay in the table for assertions
config :my_app, Oban, testing: :manual
  • `cron:`, `pruner:`, `lifeline:` and `reindexer:` are top-level keys since 2.24 (`Oban.Plugins.Cron` is now `Oban.Cron`, and so on). The old `plugins: [...]` form still works.
  • Keep the Oban config in one `config` call. Repeated calls deep-merge keyword lists, but a `plugins:` list containing one bare module (`Oban.Plugins.Lifeline`) isn't a keyword list, so a later `plugins:` replaces it and cron or pruning silently disappears.
  • **Lifeline is off unless configured.** Without it, jobs orphaned in `executing` by a node restart stay there forever. It rescues purely by time, so `rescue_after` must exceed your longest job's `timeout/1`, or it re-runs jobs that are still executing.
  • **Pruner** keeps `oban_jobs` from growing without bound; `max_age` accepts `{7, :days}`.
  • Start Oban in `application.ex` with `{Oban, Application.fetch_env!(:my_app, Oban)}`, before the Endpoint.

Timeouts and backoff

@impl Oban.Worker
def timeout(_job), do: to_timeout(minute: 2)

@impl Oban.Worker
def backoff(%Oban.Job{attempt: attempt}), do: trunc(:math.pow(2, attempt) * 10)
  • `timeout/1` returns *
Read more
Ships withelixir-phoenix-guide

· Changelog A Claude Code plugin (with Codex support for the skills) that keeps Claude's Elixir and Phoenix code idiomatic and current.

Get the whole plugin

Other skills on elixir-phoenix-guide.