igmarin/elixir-phoenix-skills

oban-essentials

MANDATORY for ALL Oban work. Invoke before writing workers or enqueuing jobs. Covers worker definition, enqueuing, return values, queue configuration, idempotency, unique jobs, scheduled/recurring jobs, pruning, testing with Oban.Testing, and arg best practices. Trigger words: Oban, worker, job, queue, enqueue, perform, cron, idempotent, background job.

First seen Jun 20, 2026

Installation

$ npx skills add igmarin/elixir-phoenix-skills --skill oban-essentials

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from igmarin/elixir-phoenix-skills · top by installs.

npx skills add igmarin/elixir-phoenix-skills

Browse all from igmarin/elixir-phoenix-skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 2
License LICENSE
Default branch main
Open issues 0
Status Active

Skill metadata

Parsed from SKILL.md frontmatter.

Version1.0.0
LicenseMIT
More metadata
version
1.0.0
user-invocable
true

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 10,139 B
  • docs SUMMARY.md 378 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 24 installs

SKILL.md

Oban Essentials

Use this skill before writing ANY Oban worker or enqueuing jobs.

Canonical FP bar: [docs/fcis-engineering-rules.md](../../docs/fcis-engineering-rules.md) — Functional Core, Imperative Shell: pure domain modules; side effects at edges. Workers and pipelines are edges: fetch IDs, call pure core, return tagged tuples.

End-to-End Workflow

When setting up a new Oban worker, follow these steps in order:

  1. Add queue to config — define the queue name and concurrency in config/config.exs
  2. Define worker module — use Oban.Worker with explicit queue, max_attempts, and unique options
  3. Enqueue from context — call Oban.insert/1 inside a context function, not a LiveView
  4. Write tests — use Oban.Testing with assertenqueued and performjob, covering all return paths

RULES — Follow these with no exceptions

1. Use Oban.Worker with explicit queue and maxattempts — never rely on defaults — see [Worker Definition](#worker-definition) 2. Make workers idempotent — the same job may execute more than once, so guard side effects — see [Idempotency](#idempotency) 3. Never put large data in job args — store IDs and fetch fresh data in the worker — see [Job Args Best Practices](#job-args-best-practices) 4. Use Oban.insert/1 (not Oban.insert!/1) — handle the error tuple instead of raising — see [Enqueuing Jobs](#enqueuing-jobs) 5. Enqueue from contexts, not LiveViews — keep the web layer thin — see [Enqueuing from Contexts](#enqueuing-from-contexts) 6. Return one of {:ok, }, {:error, }, {:cancel, }, {:snooze, _} from perform/1 — never raise for expected failures — see [Return Values](#return-values)

FCIS at this boundary

perform/1 is an edge runner: load by ID, call pure/context functions, return Oban-tagged results. Do not bury domain rules only inside the worker.

❌ Bad: worker owns complex domain state

def perform(%Oban.Job{args: %{"order_id" => id}}) do
  order = Repo.get!(Order, id)
  total = Enum.reduce(order.lines, 0, &(&1.amount + &2))
  # ad-hoc retries / partial updates mixed here...
  Repo.update!(Ecto.Changeset.change(order, total: total, shipped: true))
  :ok
end

✅ Good: fetch → domain → tuple

@impl Oban.Worker
def perform(%Oban.Job{args: %{"order_id" => id}}) do
  with {:ok, order} <- Orders.fetch(id),
       {:ok, order} <- Orders.mark_shipped(order) do
    {:ok, order.id}
  else
    {:error, :not_found} -> {:cancel, "order missing"}
    {:error, reason} -> {:error, reason}
  end
end

Worker Definition

See [assets/obanjobtemplate.ex](assets/obanjobtemplate.ex) for a copy-paste worker skeleton (perform/1, max_attempts, unique:, backoff/1, idempotency guard).

defmodule MyApp.Workers.SendWelcomeEmail do
  use Oban.Worker,
    queue: :mailers,
    max_attempts: 3,
    unique: [period: 300, fields: [:args], keys: [:user_id]]

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"user_id" => user_id}}) do
    with {:ok, user} <- MyApp.Accounts.fetch_user(user_id),
         {:ok, result} <- MyApp.Accounts.send_welcome_if_needed(user) do
      {:ok, result}
    else
      {:error, :not_found} -> {:cancel, "user #{user_id} not found"}
      {:error, reason} -> {:error, reason}
    end
  end
end

Enqueuing Jobs

# Basic insert — always handle the result
case MyApp.Workers.SendWelcomeEmail.new(%{user_id: user.id}) |> Oban.insert() do
  {:ok, job} -> {:ok, job}
  {:error, changeset} -> {:error, changeset}
end

# Schedule for later
%{user_id: user.id}
|> MyApp.Workers.SendWelcomeEmail.new(schedule_in: 3600)
|> Oban.insert()

❌ Bad — raises on failure:

MyApp.Workers.SendWelcomeEmail.new(%{user_id: user.id}) |> Oban.insert!()

Enqueuing from Contexts

✅ Good — context handles the job:

defmodule MyApp.Accounts do
  def register_user(attrs) do
    with {:ok, user} <- create_user(attrs) do
      MyApp.Workers.SendWelcomeEmail.new(%{user_id: user.id})
      |> Oban.insert()

      {:ok, user}
    end
  end
end

❌ Bad — LiveView enqueues directly:

def handle_event("register", params, socket) do
  MyApp.Workers.SendWelcomeEmail.new(%{user_id: user.id}) |> Oban.insert()
end

Return Values

Return exactly one of these from perform/1. Use {:error, reason} for retryable failures; never raise.

@impl Oban.Worker
def perform(%Oban.Job{args: args}) do
  # Success — job completed, marked as completed
  {:ok, result}

  # Retryable failure — will retry up to max_attempts
  {:error, reason}

  # Permanent failure — will NOT retry, marked as cancelled
  {:cancel, reason}

  # Snooze — reschedule for later (in seconds)
  {:snooze, 60}
end

Queue Configuration

# config/config.exs
config :my_app, Oban,
  repo: MyApp.Repo,
  queues: [
    default: 10,      # 10 concurrent jobs
    mailers: 5,       # 5 concurrent email jobs
    imports: 2         # 2 concurrent import jobs (resource-heavy)
  ]

# config/test.exs — use testing mode
config :my_app, Oban,
  testing: :inline    # Jobs execute immediately in the test process

Idempotency

❌ Bad — bang + if in the worker; duplicates on retry:

@impl Oban.Worker
def perform(%Oban.Job{args: %{"user_id" => user_id}}) do
  user = MyApp.Accounts.get_user!(user_id)
  MyApp.Mailer.send_welcome(user)
  {:ok, :sent}
end

✅ Good — worker fetches; core decides; shell delivers:

# lib/my_app/accounts/welcome.ex
defmodule MyApp.Accounts.Welcome do
  def already_sent?(%{welcome_email_sent_at: nil}), do: false
  def already_sent?(%{welcome_email_sent_at: _}), do: true
end

# lib/my_app/accounts.ex
def send_welcome_if_needed(user), do: deliver_welcome(user, Welcome.already_sent?(user))

defp deliver_welcome(_user, true), do: {:ok, :already_sent}

defp deliver_welcome(user, false) do
  with {:ok, _} <- MyApp.Mailer.send_welcome(user),
       {:ok, _} <- mark_welcome_sent(user) do
    {:ok, :sent}
  end
end

Unique Jobs

use Oban.Worker,
  queue: :default,
  unique: [
    period: 300,              # 5-minute uniqueness window
    fields: [:args, :queue],  # match on these fields
    keys: [:user_id],         # only compare these arg keys
    states: [:available, :scheduled, :executing]
  ]

Scheduled and Recurring Jobs

# Schedule a job for later
%{report_id: report.id}
|> MyApp.Workers.GenerateReport.new(schedule_in: {1, :hour})
|> Oban.insert()

# Cron-based recurring jobs (in config)
config :my_app, Oban,
  repo: MyApp.Repo,
  queues: [default: 10],
  plugins: [
    {Oban.Plugins.Cron, crontab: [
      {"0 2 * * *", MyApp.Workers.NightlyCleanup},
      {"*/15 * * * *", MyApp.Workers.SyncData, args: %{source: "api"}}
    ]}
  ]

Testing

See [assets/obantestingchecklist.md](assets/obantestingchecklist.md) for a copy-paste checklist covering enqueue assertions, perform_job/2, and all return paths.

  • Use performjob/2 — not perform/1. performjob validates args and simulates the Oban runtime.
  • Use assert_enqueued/1 — verify jobs were enqueued with correct args.
  • Use Oban.Testing inline mode in test config — jobs run synchronously in the test process.
  • Test all return paths — success, retryable error, and cancel.
defmodule MyApp.Workers.SendWelcomeEmailTest do
  use MyApp.DataCase, async: true
  use Oban.Testing, repo: MyApp.Repo

  alias MyApp.Workers.SendWelcomeEmail

  test "enqueuing a welcome email job" do
    user = user_fixture()

    SendWelcomeEmail.new(%{user_id: user.id})
    |> Oban.insert()

    assert_enqueued(worker: SendWelcomeEmail, args: %{user_id: user.id})
  end

  test "performing the job sends the email" do
    user = user_fixture()

    assert {:ok, :sent} =
      perform_job(SendWelcomeEmail, %{user_id: user.id})
  end

  test "cancels if user not found" do
    assert {:cancel, _reason} =
      perform_job(SendWelcomeEmail, %{user_id: -1})
  end
end

Job Args Best Practices

❌ Bad — large data in args (stored as JSON in database):

SendReport.new(%{
  user_id: user.id,
  report_data: large_data_structure  # Don't do this!
})

✅ Good — store IDs, fetch fresh data in worker:

SendReport.new(%{user_id: user.id, report_id: report.id})

Common Pitfalls

❌ Don't ✅ Do
Oban.insert!/1 that raises on failure Oban.insert/1 and match {:ok, job} / {:error, changeset}
Non-idempotent perform/1 (re-sends on retry) Core predicate (Welcome.already_sent?/1) + thin perform/1
Store large payloads in args Store IDs; fetch fresh data inside the worker
Enqueue directly from a LiveView/controller Enqueue from a context function
raise for an expected failure Return {:error, reason} (retry) or {:cancel, reason} (stop)
Omit unique: on jobs that can double-enqueue Set unique: [period: ..., keys: [...]] to dedupe

Integration

Predecessor This Skill Successor
ecto-essentials oban-essentials testing-essentials
elixir-essentials oban-essentials telemetry-essentials

Companion skills:

  • background-job — playbook that orchestrates the full worker workflow
  • broadway-data-pipelines — high-throughput pipelines fed by external message queues
  • telemetry-essentials — observe job execution and failures