SuperAPI AI Directory

oban-essentials

Coding & development

View repository

Updated 10-15 22:16

About this skill

Oban Essentials

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

Canonical FP bar: 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 assert_enqueued and perform_job, covering all return paths

RULES — Follow these with no exceptions

1. Use Oban.Worker with explicit queue and max_attempts — never rely on defaults — see Worker Definition 2. Make workers idempotent — the same job may execute more than once, so guard side effects — see Idempotency 3. Never put large data in job args — store IDs and fetch fresh data in the worker — see Job Args Best Practices 4. Use Oban.insert/1 (not Oban.insert!/1) — handle the error tuple instead of raising — see Enqueuing Jobs 5. Enqueue from contexts, not LiveViews — keep the web layer thin — see Enqueuing from Contexts 6. Return one of {:ok, _}, {:error, _}, {:cancel, _}, {:snooze, _} from perform/1 — never raise for expected failures — see 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/oban_job_template.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/oban_testing_checklist.md for a copy-paste checklist covering enqueue assertions, perform_job/2, and all return paths.

  • Use perform_job/2 — not perform/1. perform_job 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