<< All versions
Skill v1.0.0
currentAutomated scan100/100j-morgan6/elixir-phoenix-guide/oban-essentials
──Details
PublishedApril 27, 2026 at 10:14 PM
Content Hashsha256:45587443542097d4...
Git SHA32a9ec7036cf
──Files
Files (1 file, 7.8 KB)
SKILL.md7.8 KBactive
SKILL.md · 318 lines · 7.8 KB
version: "1.0.0" name: oban-essentials description: MANDATORY for ALL Oban work. Invoke before writing workers or enqueuing jobs. file_patterns:
- "/workers//*.ex"
- "**/*_worker.ex"
auto_suggest: true
Oban Essentials
RULES — Follow these with no exceptions
- Always `use Oban.Worker` with explicit
queueandmax_attemptsoptions - Return `{:ok, result}` for success, `{:error, reason}` for retryable failures, `{:cancel, reason}` for permanent failures — never return bare
:okor raise - Make workers idempotent — the same job may run more than once due to retries or node restarts
- Use `unique` option to prevent duplicate jobs — specify
period,fields, andkeys - Test with `Oban.Testing` — use
assert_enqueuedandperform_job, never callperform/1directly - Never put large data in job args — store IDs and fetch fresh data in the worker
- Use `Oban.insert/1` (not
Oban.insert!/1) and handle the error tuple
Worker Definition
elixir
defmodule MyApp.Workers.SendWelcomeEmail douse Oban.Worker,queue: :mailers,max_attempts: 3,unique: [period: 300, fields: [:args], keys: [:user_id]]@impl Oban.Workerdef perform(%Oban.Job{args: %{"user_id" => user_id}}) docase MyApp.Accounts.get_user(user_id) donil ->{:cancel, "user #{user_id} not found"}user ->MyApp.Mailer.send_welcome(user){:ok, :sent}endendend
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;periodin seconds,keysspecifies 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 resultcase 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 handlingMyApp.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 jobdefmodule MyApp.Accounts dodef register_user(attrs) dowith {:ok, user} <- create_user(attrs) doMyApp.Workers.SendWelcomeEmail.new(%{user_id: user.id})|> Oban.insert(){:ok, user}endendend# Bad — LiveView enqueues directlydef handle_event("register", params, socket) doMyApp.Workers.SendWelcomeEmail.new(%{user_id: user.id}) |> Oban.insert()end
Return Values
elixir
@impl Oban.Workerdef 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
Never raise in workers. An unhandled exception counts as a retryable failure but produces noisy logs and stack traces. Use explicit {:error, reason} instead.
Queue Configuration
elixir
# config/config.exsconfig :my_app, Oban,repo: MyApp.Repo,queues: [default: 10, # 10 concurrent jobsmailers: 5, # 5 concurrent email jobsimports: 2 # 2 concurrent import jobs (resource-heavy)]# config/test.exs — use testing modeconfig :my_app, Oban,testing: :inline # Jobs execute immediately in the test process
Idempotency
Workers must be safe to run multiple times with the same args.
elixir
# Bad — sends duplicate emails on retry@impl Oban.Workerdef perform(%Oban.Job{args: %{"user_id" => user_id}}) douser = MyApp.Accounts.get_user!(user_id)MyApp.Mailer.send_welcome(user){:ok, :sent}end# Good — check if already processed@impl Oban.Workerdef perform(%Oban.Job{args: %{"user_id" => user_id}}) douser = MyApp.Accounts.get_user!(user_id)if user.welcome_email_sent_at do{:ok, :already_sent}elsewith {:ok, _} <- MyApp.Mailer.send_welcome(user),{:ok, _} <- MyApp.Accounts.mark_welcome_sent(user) do{:ok, :sent}endendend
Unique Jobs
Prevent duplicate jobs from being enqueued:
elixir
use Oban.Worker,queue: :default,unique: [period: 300, # 5-minute uniqueness windowfields: [:args, :queue], # match on these fieldskeys: [:user_id], # only compare these arg keysstates: [: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.exsdefmodule MyApp.Workers.SendWelcomeEmailTest douse MyApp.DataCase, async: trueuse Oban.Testing, repo: MyApp.Repoalias MyApp.Workers.SendWelcomeEmailtest "enqueuing a welcome email job" douser = user_fixture()SendWelcomeEmail.new(%{user_id: user.id})|> Oban.insert()assert_enqueued(worker: SendWelcomeEmail, args: %{user_id: user.id})endtest "performing the job sends the email" douser = user_fixture()assert {:ok, :sent} =perform_job(SendWelcomeEmail, %{user_id: user.id})endtest "cancels if user not found" doassert {:cancel, _reason} =perform_job(SendWelcomeEmail, %{user_id: -1})endend
Testing Rules
- Use `perform_job/2` — not
perform/1.perform_jobvalidates 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.
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 workerSendReport.new(%{user_id: user.id, report_id: report.id})# Bad — non-JSON-serializable argsSendEmail.new(%{user: user}) # Structs don't serialize to JSON# Good — pass IDs, fetch in workerSendEmail.new(%{user_id: user.id})
Error Handling
elixir
@impl Oban.Workerdef perform(%Oban.Job{args: %{"url" => url}, attempt: attempt}) docase 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_attemptsendenddefp retry_delay(attempt), do: attempt * 60 # Exponential-ish backoff
See testing-essentials skill for comprehensive testing patterns.