Changelog for Oban v2.24
View Source🌟 Looking for changes to Oban Pro? Check the Oban.Pro Changelog 🌟
This release unifies configuration for queues, repos, and services, swaps opaque timing integers for readable durations, and backports per-entry cron timezones and attempt-preserving snoozes from Oban Pro.
⚙️ Unified Service Configuration
Configuration for queues, repos, and all services (formerly "plugins") is now entirely unified. This is a massive syntactic change, but it isn't all sugar. There's purpose behind the unification and the configuration hoisting.
Functionality like pruning jobs and rescuing orphaned jobs is essential to running Oban, and it
shouldn't be an optional afterthought that's demoted as a "plugin" and buried in a guide. Now
services are top level configuration just like the engine, notifier, and peer:
config :my_app, Oban,
cron: [crontab: [{"0 2 * * *", MyApp.Nightly}]],
pruner: [max_age: {7, :days}],
lifeline: [rescue_after: {30, :minutes}],
reindexer: Oban.Reindexer,
...This configuration style should look familiar to anybody using oban-py. Building it is
where we realized that these services are core functionality (in fact, it doesn't even have
plugins).
Service module names are flatter as well. Since they're not considered plugins anymore, the
Plugin namespace was a confusing misnomer—so Oban.Plugins.Cron is simply Oban.Cron,
Oban.Plugins.Pruner is now Oban.Pruner, and so on.
Along with keyword options, the unified syntax supports bare modules, {module, opts} tuples, and
disabling functionality altogether by passing false. The tuple variant makes it especially easy
to swap core services out for alternatives (particularly useful for Pro 😉):
config :my_app, Oban,
+ engine: Oban.Pro.Engine,
- cron: {Oban.Cron, crontab: [...]},
+ cron: {Oban.Pro.Cron, crontab: [...]},
- lifeline: Oban.Lifeline,
+ lifeline: Oban.Pro.Lifeline,
- pruner: {Oban.Pruner, ...},
+ pruner: {Oban.Pro.Pruner, ...},
- queues: [...]
+ queues: {Oban.Pro.Queues, queues: [...]}You'll see more about that in the Pro v1.8 release as well.
Finally, the repo option got the same treatment. Both log and get_dynamic_repo were really
repo options, and the stand-alone log option was genuinely confusing. Now you can use the tuple
format to pass those options through :repo directly:
repo: {MyApp.Repo, log: false, dynamic_repo: fn -> MyApp.Repo end}Don't worry, these changes are fully backward compatible. Oban transparently rewrites older
configuration formats into the correct format, all of the old plugin modules have backward
compatible shims, and you can still provide plugins beyond the standard services.
🎁 Backports from Oban Pro
Two long-standing Pro features are now built into Oban. They're small quality-of-life fixes that most people run into eventually, which makes them a better fit for core.
First, individual crontab entries may override the scheduler's timezone, so a single Cron
service can handle schedules in multiple zones:
[
{"0 7 * * *", MyApp.Strictly, timezone: "America/Chicago"},
{"0 9 * * *", MyApp.Business, timezone: "Europe/London"}
]Second, snoozing no longer consumes a job attempt. The attempt count is rolled back on snooze,
so backoff stays accurate across snoozes and the max_attempts value remains stable. Snoozing
increments a snoozed count in job meta, which helps distinguish real attempts from snoozes and
react accordingly:
def perform(%Job{meta: %{"snoozed" => snoozed}}) when snoozed > 5 do
{:cancel, :snoozed_too_many_times}
end⏱️ Readable Durations
Over time, all of the Oban functions that accept durations have started to accept periods in the
{value, unit} tuple format as well. That convention now extends to timing options for services
like Oban.Pruner, so the numbers in your config are readable without mental math:
config :my_app, Oban,
pruner: [max_age: {7, :days}, interval: {1, :minute}],
lifeline: [rescue_after: {30, :minutes}]The format is public, centralized, and documented as Oban.Period now, so you can use it in
plugins and application code as well. It sports units from seconds through months, along with
helpers to convert into either seconds or milliseconds (whereas Elixir's newer to_timeout always
generates milliseconds, and only exists on v1.17+):
Oban.Period.to_seconds({2, :hours})
#=> 7200
Oban.Period.to_milliseconds({5, :minutes})
#=> 300_000v2.24.1 - 2026-09-03
Bug Fixes
[Engine] Prevent newer execution from overwriting
Acking now verifies that the database hasn't been updated by another execution. Writes from a new executor will no longer transition an acked job, e.g. erroring or snoozing a job back out of
completed.This applies to ack operations in
Basic,Dolphin, andLiteengines.[Notifier] Track notify listeners in isolated registry
Listeners were tracked within each notifier process, which was lost if the notifier crashed. Registration now lives in a dedicated registry, owne by the Oban application, which is immune to process crashes.
[Notifier] Normalize notifier exits into error tuples
Notifier callbacks through the notifier process could exit if it crashed or timed out, leaving callers to guard themselves with a try/catch.
Now, exits are caught in the Notifier itself, and returned as an error tuple. Postgres and PG notifiers also no longer raise when their registered state is gone.
[Queues] Allow dispatch_cooldown when starting queues
The
dispatch_cooldownoption was silently ignored for static queues, and loudly failed validation for dynamic queues. The option was always threaded through to the producer, and now they pass validation.
v2.24.0 - 2026-08-25
Changes
[Oban] Top-level config for maintenance plugins
Promote the common maintenance plugins to first-class configuration keys:
cron,pruner,lifeline, andreindexer. Each desugars into a standard plugin entry and accepts the same forms used elsewhere in Oban:config :my_app, Oban, cron: [crontab: [{"0 2 * * *", MyApp.Nightly}]], pruner: [max_age: 60 * 60 * 24 * 7]A keyword list configures the default plugin, and a
moduleor{module, opts}tuple can configure an alternative (making it an easy switch for Oban Pro, e.g.lifeline: Oban.Pro.Lifeline).[Oban] Accept repo options through {repo, opts} tuple
Configure repo-level options like logging and dynamic repo directly on the
:repokey instead of at the top level:repo: {MyApp.Repo, log: false, dynamic_repo: fn -> MyApp.Repo end}The top-level
logandget_dynamic_repokeys are soft-deprecated. They continue to work for backward compatibility, but the tuple form is now preferred and documented, keeping repo concerns grouped with the repo.[Oban] Accept a module for the top-level :queues option
The
:queuesoption now accepts a{module, options}tuple in addition to a static keyword list, which hands queue management to an alternative implementation such as Oban Pro's Queues:queues: {Oban.Pro.Queues, queues: [default: 10]}The module is started as a plugin and controls which queues run, while a static keyword list keeps the built-in behavior of starting the listed queues on init. Queues run regardless of the
:pluginssetting in either form.Setting
plugins: falsenow disables plugins configured through top-level servic keys, e.g.:cronor:pruner, rather than crashing during normalization.[Oban] Rename maintenance plugins to top-level modules
Plugins configured through top-level service keys now live directly in the Oban namespace:
Oban.Plugins.Cron -> Oban.Cron Oban.Plugins.Lifeline -> Oban.Lifeline Oban.Plugins.Pruner -> Oban.Pruner Oban.Plugins.Reindexer -> Oban.ReindexerThe old modules are deprecated and delegate to the new ones, and legacy module names in
:pluginstranslate to the renamed version automatically. Because the renamed module is what runs, plugin telemetry metadata and registry keys report the new names.[Oban] Expose stager as a top-level service option
Previously staging could only be configured through the
stage_intervaloption, which is inconsistent with other services like cron, pruner, and lifeline. The stager now accepts options directly, e.g.stager: [interval: 5000],falseto disable it, or{module, opts}tuple for alternative implementations.The
stage_intervaloption is soft-deprecated but still accepted, along with the legacypoll_intervaland older plugin style.[Queues] Rename
Oban.MidwifetoOban.QueuesQueue supervision moved from the internal
Midwifeto the publicOban.Queues. It takes the queues it starts as an option rather than reading them from the config.All internal queue modules moved to the
Oban.Queues.*namespace to sit under the service that owns them, matching the naming style of other modules.
Enhancements
[Cron] Support per-entry timezones in the crontab
Individual crontab entries may now override the plugin's timezone with a
:timezoneoption, e.g.{"0 9 * * *", MyApp.Worker, timezone: "America/Chicago"}. Previously every entry was evaluated in a single configured timezone, which forced a separate Cron instance for each zone.[Worker] Roll back
attemptand count snoozes on snoozeSnoozing incremented
max_attemptsinflated retry timing and skewed backoff with each snooze. Now snoozing matches Oban Pro and theattemptis rolled back, so a snooze never consumes an attempt and backoff stays accurate. Each snooze also increments asnoozedcount in the job'smeta.[Job] Add
scheduled_infor jobs and testingIntroduce
scheduled_inas the documented way to schedule a job for a relative time, replacing the awkwardscheduled_at/schedule_innaming split that frequently confused people.The legacy
schedule_inoption is still accepted and rewritten transparently, so existing code continues to work.Testing helpers gain a matching
scheduled_inso assertions can use a relative offset instead of computing an absoluteDateTime:assert_enqueued worker: MyApp.Worker, scheduled_in: 3600 assert_enqueued worker: MyApp.Worker, scheduled_in: {1, :hour} assert_enqueued worker: MyApp.Worker, scheduled_in: {1, :hour, delta: 10}The value accepts seconds, a
{amount, unit}period tuple, and an optionaldeltafor the timestamp comparison window.[Job] Restrict unique warnings to insertion states
Workers with custom unique :states no longer warn when they omit incomplete states such as
:executing. Uniqueness is only checked at insertion, so combinations of:available,:scheduled, and:suspendedare valid.A warning is now emitted only when the configuration omits every insertion state, such as [:completed], which allows duplicates to go undetected. Unique period validation is also consolidated through
Oban.Period.[Basic] Avoid starting a transaction on non-unique insert
Refactor the engine's insert path to avoid a pointless transaction when inserting jobs, and pass extra options through to the
Repo.transaction/3call when provided.[Lifeline] Accept period durations for
rescue_afterThe Lifeline plugin's
rescue_afternow accepts anOban.Periodtuple like{60, :minutes}in addition to a raw millisecond integer, matching the duration format already used by the pruner'smax_age.[Pruner] Accept period durations for
max_ageThe pruner's
:max_agenow accepts a period tuple such as{1, :day}in addition to an integer count of seconds, normalized internally viaOban.Period.to_seconds/1.[Pruner] Accept period durations for plugin timing options
Pruner and Lifeline intervals, along with Reindexer timeouts, now accept Oban.Period tuples such as
{30, :seconds}in addition to millisecond integers.[Installer] Configure pruner and lifeline defaults
Generated config now enables pruning and orphan rescue out of the box using the new feature keys, with conservative values: prune jobs after one day and rescue jobs only after two hours.
[Period] Publicize duration conversion helpers
Oban.Period is now a public API for expressing and converting durations. It provides guards for validating periods and functions for converting values to seconds or milliseconds:
Oban.Period.to_seconds({2, :hours}) Oban.Period.to_milliseconds({5, :minutes})Periods accept raw integers or
{value, unit}tuples, with singular and plural units ranging from seconds through months. Months use a generic, non-portable 30-day window.
Bug Fixes
[Repo] Compile
expected_error?/1clauses conditionallyThe MySQL clause of
expected_error?/1raised an "unused clause" warning in environments without MyXQL, because the retryable error type narrows to only the loaded adapters and the MyXQL struct could never match.This moves the function into
Oban.Errors, where the Postgres and MySQL clauses are now guarded alongside the existing optional error list. Each clause only compiles when its adapter is available, so absent adapters no longer produce a never-matched clause.[Notifier] Correct
listenandunlistenspecs with error tuplesWithout a running notifier process both
listen/2andunlisten/2can return an{:error, Exception.t()}tuple rather than:ok.[Reindexer] Return :ok from checks without leadership
Previously, on non-leader nodes the reindexer check fell through with a
nilreturn, which telemetry reported as an error. Now the non-leader path returns:ok, matching all other plugins.[Telemetry] Normalize plugin telemetry metadata errors
Plugin runs that fail, e.g. because the database is unavailable, emit a
[:oban, :plugin, :stop]event with the plugin's usual metadata keys zeroed out and the underlying error added as :error. Previously the keys were omitted entirely, which crashed handlers that matched on them. That included the default logger, which telemetry then detached, silencing all Oban logging on the node until restart.The default logger now reports
:erroronplugin:stopevents, and the:errorvalue is the error itself rather than an{:error, reason}tuple.