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