---
name: oban-worker
description: Use this skill when creating, modifying, debugging, or reviewing ANY Oban worker. Use PROACTIVELY when the user mentions "worker", "Oban", "background job", "job", "cron", "queue", "perform", "enqueue", "schedule", "async", "snooze", "cancel", "discard", or asks about running work in the background. Also use when adding Oban plugins, changing queue configuration, or writing worker tests.
---

# Oban Worker Development

Project-specific patterns for Oban background job workers. Every worker follows
consistent conventions for structure, error handling, queue assignment, and testing.

## Checklist Before Creating or Modifying a Worker

1. **Does the worker delegate to a context module?** `perform/1` must be a thin wrapper.
   No business logic inline.

2. **Is the queue correct?** Rate-limited APIs get dedicated queues with concurrency 1.
   DB-intensive operations go to `heavy_writes`. General tasks go to `default`.

3. **Does error handling use `ErrorHandler`?** Workers calling APIs route HTTP errors
   through `MusicLibrary.Worker.ErrorHandler.to_oban_result/1`.

4. **Is the worker registered under the correct Oban instance?** Production uses
   `MusicLibrary.BackgroundRepo` as the Oban instance.

## Worker Structure

```elixir
defmodule MusicLibrary.Worker.FetchArtistInfo do
  use Oban.Worker,
    queue: :default,           # Pick the right queue (see below)
    max_attempts: 3,            # Oban default, override if needed
    unique: [period: 60]        # Optional: prevent duplicate jobs

  @impl true
  def perform(%Oban.Job{args: %{"musicbrainz_id" => mbid}}) do
    # Delegate to context — no business logic here
    case Artists.fetch_info(mbid) do
      {:ok, _artist} ->
        :ok

      # App-layer permanent failures matched FIRST
      {:error, :no_english_wikipedia} ->
        {:cancel, :no_english_wikipedia}

      {:error, :cover_not_available} ->
        {:cancel, :cover_not_available}

      # Everything else routed through ErrorHandler
      {:error, reason} ->
        MusicLibrary.Worker.ErrorHandler.to_oban_result(reason)
    end
  end
end
```

## Queues

| Queue | Concurrency | Use For |
|-------|-------------|---------|
| `default` | 10 | General async tasks, non-API operations |
| `heavy_writes` | 1 | DB-intensive or serialized operations |
| `music_brainz` | 1 | MusicBrainz API calls |
| `discogs` | 1 | Discogs API calls |
| `wikipedia` | 1 | Wikipedia API calls |
| `last_fm` | 1 | Last.fm API calls |

**Rule**: Any worker that calls an external API goes on that API's dedicated queue.
The concurrency of 1 combined with `Req.RateLimiter` ensures the API rate limit is
never exceeded.

## Return Values

| Return | Meaning | Oban Behaviour |
|--------|---------|----------------|
| `:ok` | Success | Job marked complete |
| `{:ok, result}` | Success with result | Job marked complete |
| `{:error, reason}` | Transient failure | Oban retries with backoff |
| `{:snooze, seconds}` | Retryable with delay | Oban retries after `seconds` |
| `{:cancel, reason}` | Permanent failure | Job cancelled, never retried |

**CRITICAL**: Never use `{:discard, reason}` — it's deprecated since Oban 2.14.
Always use `{:cancel, reason}`.

## Error Handling Flow

```
perform/1
  ├── App-layer permanent failures (match FIRST)
  │   ├── :no_english_wikipedia → {:cancel, :no_english_wikipedia}
  │   ├── :cover_not_available → {:cancel, :cover_not_available}
  │   └── ...other domain-specific permanent failures
  │
  └── Everything else → MusicLibrary.Worker.ErrorHandler.to_oban_result(reason)
      ├── %ErrorResponse{retryable?: true}  → {:snooze, seconds}
      ├── %ErrorResponse{retryable?: false} → {:cancel, reason}
      └── Other errors                      → {:error, reason}
```

The order matters: **app-layer atoms first**, then fall through to `ErrorHandler`.
This ensures domain-specific permanent failures (e.g., no Wikipedia article exists)
are handled correctly before generic HTTP error classification kicks in.

## Cron Workers

Scheduled via `Oban.Plugins.Cron` in production config:

```elixir
# config/prod.exs
config :music_library, Oban,
  plugins: [
    {Oban.Plugins.Cron,
     crontab: [
       {"@daily", MusicLibrary.Worker.SendRecordsOnThisDayEmail},
       {"*/5 * * * *", MusicLibrary.Worker.RefreshScrobbles}
     ]}
  ]
```

Cron workers follow the same thin-wrapper pattern. The cron expression is the only
difference from on-demand workers.

### All Cron Workers

| Schedule | Worker | Queue |
|----------|--------|-------|
| Every 12h | `ApplyScrobbleRules` | heavy_writes |
| Every 12h | `PruneAssetCache` | default |
| Daily 2 AM | `PruneAssets` | default |
| Daily 3 AM | `RepoVacuum` | heavy_writes |
| Daily 4 AM | `RepoOptimize` | heavy_writes |
| Monthly 1st, 6 AM | `RecordRefreshAllMusicBrainzData` | music_brainz |
| Monthly 1st, 7 AM | `RecordGenerateAllEmbeddings` | heavy_writes |
| Monthly 1st, 8 AM | `ArtistRefreshAllMusicBrainzData` | music_brainz |
| Monthly 1st, 9 AM | `ArtistRefreshAllDiscogsData` | discogs |
| Monthly 1st, 10 AM | `ArtistRefreshAllWikipediaData` | wikipedia |
| Daily 7 AM | `SendRecordsOnThisDayEmail` | default |
| Every 5 min | `RefreshScrobbles` | last_fm |

## Oban Plugins (Production)

| Plugin | Config | Purpose |
|--------|--------|---------|
| `Pruner` | `max_age: 604800` (7 days) | Clean up completed jobs |
| `Reindexer` | `schedule: "@weekly"` | Reindex for query performance |
| `Cron` | timezone: `"Europe/London"` | Schedule recurring jobs |

## Batch Workers (Self-Chaining)

Some workers process data in batches, enqueuing their next batch on completion:

```elixir
def perform(%{args: %{"page" => page}}) do
  case process_page(page) do
    {:ok, []} ->
      :ok  # No more data, done

    {:ok, _results} ->
      # Enqueue next page
      %{page: page + 1}
      |> __MODULE__.new()
      |> Oban.insert()

      :ok
  end
end
```

The `Records.Batch` and `Artists.Batch` modules provide shared batch infrastructure
(`stream + transaction + error accumulation`). Bulk workers should use these rather
than implementing their own batching logic.

## Testing Workers

### Manual mode

Oban runs in manual testing mode — jobs don't auto-execute. Use `perform_job/2`:

```elixir
import Oban.Testing, only: [perform_job: 2]

test "fetches artist info" do
  job = MyWorker.new(%{musicbrainz_id: "abc-123"})
  assert :ok = perform_job(MyWorker, job.args)
end
```

### Assert enqueued downstream jobs

Workers that enqueue other jobs MUST verify with `assert_enqueued`:

```elixir
assert_enqueued(worker: FetchArtistImage, args: %{musicbrainz_id: "abc-123"})
```

`perform_job` returning `{:ok, []}` is NOT sufficient — verify the expected downstream
workers were enqueued with correct args.

### Testing all return states

```elixir
# Success
assert :ok = perform_job(MyWorker, %{id: valid_id})

# Transient (Oban retries)
assert {:error, :rate_limit} = perform_job(MyWorker, %{id: rate_limited_id})

# Permanent (job cancelled)
assert {:cancel, :no_english_wikipedia} = perform_job(MyWorker, %{id: missing_id})
```

## Non-Fatal Enrichment

Context functions that enrich records (colors, embeddings) use a private
`best_effort_*` helper pattern — log a warning and return unchanged data rather
than surfacing `{:error, reason}`. Workers calling these functions don't need
error handling for enrichment failures.
