| name | oban-essentials |
| type | atomic |
| tags | ["atomic"] |
| license | MIT |
| description | MANDATORY for ALL Oban work. Invoke before writing workers or enqueuing jobs. Covers worker definition, enqueuing, return values, queue configuration, idempotency, unique jobs, scheduled/recurring jobs, pruning, testing with Oban.Testing, and arg best practices. Trigger words: Oban, worker, job, queue, enqueue, perform, cron, idempotent, background job.
|
| metadata | {"version":"1.0.0","user-invocable":"true"} |
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:
- Add queue to config — define the queue name and concurrency in
config/config.exs
- Define worker module —
use Oban.Worker with explicit queue, max_attempts, and unique options
- Enqueue from context — call
Oban.insert/1 inside a context function, not a LiveView
- 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
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
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 — 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
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) | Guard with a "already processed" check before side effects |
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