agentleFS
igmarinigmarin/elixir-phoenix-skills/skills/oban-essentials/SKILL.md

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.

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
  1. Distinctive:

    Use this skill before writing ANY Oban worker or enqueuing jobs.

    line 18
  2. Distinctive:

    When setting up a new Oban worker, follow these steps in order:

    line 23

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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:

  1. Add queue to config — define the queue name and concurrency in config/config.exs
  2. Define worker module — use Oban.Worker with explicit queue, max_attempts, and unique options
  3. Enqueue from context — call Oban.insert/1 inside a context function, not a LiveView
  4. 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
    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
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 — 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
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 failureOban.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 argsStore IDs; fetch fresh data inside the worker
Enqueue directly from a LiveView/controllerEnqueue from a context function
raise for an expected failureReturn {:error, reason} (retry) or {:cancel, reason} (stop)
Omit unique: on jobs that can double-enqueueSet unique: [period: ..., keys: [...]] to dedupe

Integration

PredecessorThis SkillSuccessor
ecto-essentialsoban-essentialstesting-essentials
elixir-essentialsoban-essentialstelemetry-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

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.