#9
Background Jobs

The Job That Won't Start Until You Say So

Oban 2.21.0 adds a suspended job state. It sounds minor. It isn't.

There are two ways a job can not be running. The first is temporal: it’s scheduled for later. The second is something else entirely — it’s waiting, indefinitely, for an external signal to say it’s allowed to proceed.

Oban has always had the first. Oban 2.21.0 shipped this week with the second.

The new :suspended state looks, from the outside, like a minor enum addition. You insert a job with state: :suspended and it stays there until something explicitly transitions it to :available. No time-based trigger. No backoff. No staging sweep will pick it up. It simply waits.

# Insert a job that won't run until you say so
{:ok, job} = MyApp.Worker.new(%{order_id: 123}, state: :suspended) |> Oban.insert()

# Later — when your external condition is satisfied — resume it
Oban.resume_job(job)

What makes this worth paying attention to is what it clarifies about the shape of deferred work. Scheduled jobs are deferred by time. Suspended jobs are deferred by decision. The distinction matters more than it first appears.

Consider the patterns you’ve probably built without this primitive: jobs inserted with a far-future scheduled_at that gets updated when a condition is met. Or jobs with custom logic in perform/1 that checks a flag in the database and returns :snooze if it’s not ready yet. Or, more elegantly, jobs that live in a separate holding queue and get re-enqueued when a trigger fires. All of these are workarounds for the absence of a proper “hold until told” state. They work, but they carry invisible cost — in Oban’s staging queries, in queue pollution, in reasoning overhead.

The new state earns its place precisely because Oban’s internal staging process no longer needs to consider these jobs at all. Suspended jobs aren’t in the :available pool. They don’t consume queue capacity. They don’t show up as pending in metrics. They exist in a conceptually distinct category: jobs that are waiting for permission, not time.

The pattern shows up most naturally in workflows with external dependencies. An order confirmation job that can’t run until payment clears. A provisioning job waiting for a third-party API to signal readiness. A notification job that’s staged but held pending a compliance check. In all of these cases, the right model isn’t “schedule it for whenever you think the dependency might be ready” — it’s “hold it explicitly until the dependency tells you it’s done.”

The AI case is worth pausing on specifically. Consider a system that generates outbound content — a customer reply, a newsletter draft, a moderation decision. The AI produces something. A human needs to approve it before it goes out. The naive implementation reaches for a polling loop or a custom state flag in the database. But what you actually want is a job in a coherent waiting state: work is staged, context is preserved, nothing runs until a human signs off.

defmodule MyApp.Workers.SendAIReply do
  use Oban.Worker, queue: :outbound

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"thread_id" => thread_id, "draft" => draft}}) do
    MyApp.Mailer.send_reply(thread_id, draft)
  end
end

# After the AI generates a draft — insert suspended, store the job id for later
{:ok, job} =
  MyApp.Workers.SendAIReply.new(%{thread_id: thread.id, draft: ai_draft},
    state: :suspended
  )
  |> Oban.insert()

# Persist the job id so the approval UI can resume it
MyApp.Threads.update(thread, %{pending_job_id: job.id})

When the human approves from the review UI:

# In your LiveView or controller
def handle_event("approve_draft", %{"thread_id" => thread_id}, socket) do
  thread = MyApp.Threads.get!(thread_id)
  job = Oban.Repo.get(Oban.Job, thread.pending_job_id)
  Oban.resume_job(job)

  {:noreply, assign(socket, :status, :approved)}
end

The job now exits the suspended state and joins the :available pool. No polling. No re-enqueueing. No risk of the job firing before approval. The draft sits in Postgres, inert, until a human says go.

Parker Selbert added this to Oban core primarily as infrastructure for Oban Pro’s workflow engine, which needed a way to defer jobs within multi-step pipelines without workarounds. But it’s a clean primitive on its own. The migration is a single ALTER TYPE adding the value to Postgres’s enum. The downgrade path is explicit: suspended jobs roll back to :scheduled, not cancelled. The state is part of the :incomplete group, so uniqueness constraints that target incomplete jobs will catch it. The implementation is smaller than you’d expect.

What I keep coming back to is the naming. Not :held, not :deferred, not :waiting. Suspended. As in: paused mid-flight, coherent state preserved, ready to resume exactly where it left off when the signal arrives. It’s a precise word for a precise idea. And in a library as carefully considered as Oban, that precision is usually a signal that the concept underneath is real.

The upgrade requires PostgreSQL 14+ — PG 12 and 13 are both past end-of-life, so if you’re still on them, that’s a more pressing conversation. For everyone else: it’s a single migration and a new optional state you can adopt at your own pace.