Background AI Jobs with Oban

The page request finishes in 30ms. The 4-second LLM call happens in an Oban worker, with retries, deduplication, and a cron schedule. The user gets a notification when it’s done.

This is how petal.build runs its daily Elixir news bot: a cron-scheduled Oban worker hits Perplexity once a day, parses the JSON response, and inserts deduped news items. Total work in your controller: zero. Total downtime if Perplexity flakes: zero (Oban retries with exponential backoff).

defmodule MyApp.Workers.SummarisePostWorker do
@moduledoc "On-demand: summarise a post after it's published."
use Oban.Worker, queue: :ai, max_attempts: 3, unique: [period: 60, fields: [:args]]
alias MyApp.{Posts, Notifications}
@model "openai:gpt-4o-mini"
@impl Oban.Worker
def perform(%Oban.Job{args: %{"post_id" => post_id}}) do
post = Posts.get_post!(post_id)
ReqLLM.put_key(:openai_api_key, System.get_env("OPENAI_API_KEY"))
messages = [
%{role: "system", content: "Summarise the following in 2 sentences. Plain text only."},
%{role: "user", content: post.body}
]
with {:ok, response} <- ReqLLM.generate_text(@model, messages),
summary <- ReqLLM.Response.text(response),
{:ok, post} <- Posts.update_post(post, %{summary: summary}) do
Notifications.notify(post.author_id, :post_summary_ready, %{post_id: post.id})
{:ok, post}
end
end
end
defmodule MyApp.Workers.DailyDigestWorker do
@moduledoc "Cron: fetch fresh industry news daily and insert NewsItems."
use Oban.Worker, queue: :ai, max_attempts: 3
alias MyApp.{News, EctoHelpers, Repo}
@model "perplexity:sonar-pro"
@impl Oban.Worker
def perform(%Oban.Job{}) do
ReqLLM.put_key(:perplexity_api_key, System.get_env("PERPLEXITY_API_KEY"))
schema = [
items: [
type: {:list, {:map, [
title: [type: :string, required: true],
url: [type: :string, required: true],
summary: [type: :string, required: true]
]}},
required: true
]
]
prompt = """
Find 5 fresh Elixir/Phoenix news items from the last 24 hours.
Return JSON only. Include title, url, summary for each.
"""
case ReqLLM.generate_object(@model, prompt, schema) do
{:ok, response} ->
%{items: items} = ReqLLM.Response.object(response)
Enum.each(items, fn item ->
News.create_or_skip_news_item(%{
title: item.title,
url: EctoHelpers.clean_link(item.url),
summary: item.summary
})
end)
:ok
{:error, reason} ->
{:error, "News fetch failed: #{inspect(reason)}"}
end
end
end
config :my_app, Oban,
repo: MyApp.Repo,
queues: [default: 10, ai: 3],
plugins: [
{Oban.Plugins.Pruner, max_age: 60 * 60 * 24},
{Oban.Plugins.Cron,
crontab: [
# Daily at 07:30 UTC
{"30 7 * * *", MyApp.Workers.DailyDigestWorker}
]}
]
defmodule MyAppWeb.PostLive.Show do
use MyAppWeb, :live_view
def handle_event("publish", _, socket) do
{:ok, post} = Posts.publish(socket.assigns.post)
# Fire and forget. The summary will appear via PubSub when ready.
%{post_id: post.id}
|> MyApp.Workers.SummarisePostWorker.new()
|> Oban.insert()
{:noreply, put_flash(socket, :info, "Published. Summary is being generated.")}
end
end

Why Oban for AI work?

  • Retries with exponential backoff handle provider flakes for free, and most LLM 500s are transient.
  • unique: [period: 60, fields: [:args]] prevents the same prompt from being dispatched twice in a window.
  • Dedicated :ai queue with a low concurrency cap keeps your provider rate-limit budget separate from regular jobs.
  • Cron plugin replaces Quantum entirely for daily LLM tasks. See the cost-tracking recipe to enforce a daily spend cap inside perform/1.