oban-essentials
Skill4 starsChanged 4 months agoMITView on GitHub
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.
In this repository34 other files it gives its agents.
- AGENTS.mdAGENTS.md
- apply-phoenix-controller-conventionsSkill
- ash-frameworkSkill
- benchee-profilingSkill
- broadway-data-pipelinesSkill
- cachex-cachingSkill
- code-qualitySkill
- code-reviewSkill
- deployment-gotchasSkill
- ecto-changeset-patternsSkill
- ecto-essentialsSkill
- ecto-migrationSkill
- ecto-nested-associationsSkill
- elixir-essentialsSkill
- gettext-i18nSkill
- liveview-streamsSkill
- mix-tasks-generatorsSkill
- otp-essentialsSkill
- phoenix-authenticationSkill
- phoenix-authorization-patternsSkill
- phoenix-channels-essentialsSkill
- phoenix-json-apiSkill
- phoenix-liveview-essentialsSkill
- phoenix-pubsub-patternsSkill
- phoenix-uploadsSkill
- property-based-testingSkill
- refactor-codeSkill
- req-http-clientSkill
- respond-to-reviewSkill
- security-essentialsSkill
- swoosh-emailsSkill
- telemetry-essentialsSkill
- testing-essentialsSkill
- typespec-dialyzerSkill
The file
We split this skill into the rules it gives an agent
2 rules. About 0 are common, 2 are distinctive, and 0 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
0
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 18
Use this skill before writing ANY Oban worker or enqueuing jobs.
- Distinctive: line 23
When setting up a new Oban worker, follow these steps in order:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Oban Essentials
Use this skill before writing ANY Oban worker or enqueuing jobs.
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.Workerwith explicitqueue,max_attempts, anduniqueoptions - Enqueue from context — call
Oban.insert/1inside a context function, not a LiveView - Write tests — use
Oban.Testingwithassert_enqueuedandperform_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
endWorker 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
with {:ok, user} <- MyApp.Accounts.fetch_user(user_id),
{:ok, result} <- MyApp.Accounts.send_welcome_if_needed(user) do
{:ok, result}
else
{:error, :not_found} -> {:cancel, "user #{user_id} not found"}
{:error, reason} -> {:error, reason}
end
end
endEnqueuing 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()
endReturn 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}
endQueue 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 processIdempotency
❌ Bad — bang + if in the worker; duplicates 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 — worker fetches; core decides; shell delivers:
# lib/my_app/accounts/welcome.ex
defmodule MyApp.Accounts.Welcome do
def already_sent?(%{welcome_email_sent_at: nil}), do: false
def already_sent?(%{welcome_email_sent_at: _}), do: true
end
# lib/my_app/accounts.ex
def send_welcome_if_needed(user), do: deliver_welcome(user, Welcome.already_sent?(user))
defp deliver_welcome(_user, true), do: {:ok, :already_sent}
defp deliver_welcome(user, false) do
with {:ok, _} <- MyApp.Mailer.send_welcome(user),
{:ok, _} <- mark_welcome_sent(user) do
{:ok, :sent}
end
endUnique 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— notperform/1.perform_jobvalidates args and simulates the Oban runtime. - Use
assert_enqueued/1— verify jobs were enqueued with correct args. - Use
Oban.Testinginline 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
endJob 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) | Core predicate (Welcome.already_sent?/1) + thin perform/1 |
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 workflowbroadway-data-pipelines— high-throughput pipelines fed by external message queuestelemetry-essentials— observe job execution and failures
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

