---
name: oban-essentials
description: Use when writing background jobs with Oban — worker options, return contracts, idempotency, uniqueness, Oban.Testing.
file_patterns:
  - "**/workers/**/*.ex"
  - "**/*_worker.ex"
auto_suggest: true
---

# Oban Essentials

## RULES — Follow these with no exceptions

1. **Always `use Oban.Worker`** with explicit `queue` and `max_attempts` options
2. **Return `{:ok, result}` for success, `{:error, reason}` for retryable failures, `{:cancel, reason}` for permanent failures** — prefer `{:ok, result}` over bare `:ok` for explicit intent (bare `:ok` is supported), and prefer `{:error, reason}` over raising
3. **Make workers idempotent** — the same job may run more than once due to retries or node restarts
4. **Use `unique` option to prevent duplicate jobs** — specify `period`, `fields`, and `keys`
5. **Test with `Oban.Testing`** — use `assert_enqueued` and `perform_job`, never call `perform/1` directly
6. **Never put large data in job args** — store IDs and fetch fresh data in the worker
7. **Use `Oban.insert/1`** (not `Oban.insert!/1`) and handle the error tuple

---

## Worker Definition

```elixir
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
    case MyApp.Accounts.get_user(user_id) do
      nil ->
        {:cancel, "user #{user_id} not found"}

      user ->
        MyApp.Mailer.send_welcome(user)
        {:ok, :sent}
    end
  end
end
```

### Key Points

- `queue` — which queue runs this worker (must match config)
- `max_attempts` — total attempts including the first (3 = 1 original + 2 retries)
- `unique` — deduplication; `period` in seconds, `keys` specifies which args fields to compare
- Pattern match on `%Oban.Job{args: ...}` — args are always string-keyed maps (JSON serialized)

---

## Enqueuing Jobs

```elixir
# 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, no error handling
MyApp.Workers.SendWelcomeEmail.new(%{user_id: user.id}) |> Oban.insert!()
```

### Enqueuing from Contexts

Enqueue jobs from context modules, not LiveViews or controllers:

```elixir
# Good — context handles the job and its insert result
defmodule MyApp.Accounts do
  def register_user(attrs) do
    with {:ok, user} <- create_user(attrs) do
      case MyApp.Workers.SendWelcomeEmail.new(%{user_id: user.id}) |> Oban.insert() do
        {:ok, _job} -> {:ok, user}
        {:error, changeset} -> {:error, changeset}
      end
    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

```elixir
@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
```

**Prefer returning `{:error, reason}` over raising.** Both are recorded as retryable failures, but tuples make retry intent explicit and avoid the noisy logs and stack traces that come with an unhandled exception.

---

## Queue Configuration

```elixir
# 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
config :my_app, Oban, testing: :manual
# :manual keeps jobs in the queue so assert_enqueued/perform_job work.
# Use :inline only for tests that want jobs executed immediately in-process —
# assert_enqueued will always fail under :inline because jobs are never inserted.
```

---

## Idempotency

Workers must be safe to run multiple times with the same args.

```elixir
# Bad — sends duplicate emails 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 — check if already processed
@impl Oban.Worker
def perform(%Oban.Job{args: %{"user_id" => user_id}}) do
  user = MyApp.Accounts.get_user!(user_id)

  if user.welcome_email_sent_at do
    {:ok, :already_sent}
  else
    with {:ok, _} <- MyApp.Mailer.send_welcome(user),
         {:ok, _} <- MyApp.Accounts.mark_welcome_sent(user) do
      {:ok, :sent}
    end
  end
end
```

---

## Unique Jobs

Prevent duplicate jobs from being enqueued:

```elixir
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]  # check these states
  ]
```

### When to Use

- **Email sending** — don't send the same email twice within 5 minutes
- **Data syncing** — don't start a sync if one is already running
- **Webhook delivery** — deduplicate retry attempts

---

## Scheduled and Recurring Jobs

```elixir
# 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"}}
    ]}
  ]
```

---

## Pruning

Keep the jobs table from growing indefinitely:

```elixir
config :my_app, Oban,
  plugins: [
    {Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 7}  # 7 days
  ]
```

---

## Testing

```elixir
# test/my_app/workers/send_welcome_email_test.exs
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
```

### Testing Rules

- **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` with `testing: :manual`** in test config — jobs stay queued so `assert_enqueued` and `perform_job` work.
- **Test all return paths** — success, retryable error, and cancel.

---

## Job Args Best Practices

```elixir
# 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})

# Bad — non-JSON-serializable args
SendEmail.new(%{user: user})  # Structs don't serialize to JSON

# Good — pass IDs, fetch in worker
SendEmail.new(%{user_id: user.id})
```

---

## Error Handling

```elixir
@impl Oban.Worker
def perform(%Oban.Job{args: %{"url" => url}, attempt: attempt}) do
  case HTTPClient.get(url) do
    {:ok, %{status: 200, body: body}} ->
      {:ok, process(body)}

    {:ok, %{status: 404}} ->
      {:cancel, "resource not found at #{url}"}

    {:ok, %{status: 429}} ->
      {:snooze, retry_delay(attempt)}

    {:error, reason} ->
      {:error, reason}  # Will retry up to max_attempts
  end
end

defp retry_delay(attempt), do: attempt * 60  # Exponential-ish backoff
```

See `testing-essentials` skill for comprehensive testing patterns.
