7.3 KiB
name, description
| name | description |
|---|---|
| oban-worker | 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
-
Does the worker delegate to a context module?
perform/1must be a thin wrapper. No business logic inline. -
Is the queue correct? Rate-limited APIs get dedicated queues with concurrency 1. DB-intensive operations go to
heavy_writes. General tasks go todefault. -
Does error handling use
ErrorHandler? Workers calling APIs route HTTP errors throughMusicLibrary.Worker.ErrorHandler.to_oban_result/1. -
Is the worker registered under the correct Oban instance? Production uses
MusicLibrary.BackgroundRepoas the Oban instance.
Worker Structure
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:
# 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:
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:
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:
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
# 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.