github oban-bg/oban v2.24.0

4 hours ago

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_000

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, and reindexer. 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 module or {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 :repo key instead of at the top level:

    repo: {MyApp.Repo, log: false, dynamic_repo: fn -> MyApp.Repo end}
    

    The top-level log and get_dynamic_repo keys 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 :queues option 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 :plugins setting in either form.

    Setting plugins: false now disables plugins configured through top-level servic keys, e.g. :cron or :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.Reindexer
    

    The old modules are deprecated and delegate to the new ones, and legacy module names in :plugins translate 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_interval option, which is inconsistent with other services like cron, pruner, and lifeline. The stager now accepts options directly, e.g. stager: [interval: 5000], false to disable it, or {module, opts} tuple for alternative implementations.

    The stage_interval option is soft-deprecated but still accepted, along with the legacy poll_interval and older plugin style.

  • [Queues] Rename Oban.Midwife to Oban.Queues

    Queue supervision moved from the internal Midwife to the public Oban.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 :timezone option, 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 attempt and count snoozes on snooze

    Snoozing incremented max_attempts inflated retry timing and skewed backoff with each snooze. Now snoozing matches Oban Pro and the attempt is rolled back, so a snooze never consumes an attempt and backoff stays accurate. Each snooze also increments a snoozed count in the job's meta.

  • [Job] Add scheduled_in for jobs and testing

    Introduce scheduled_in as the documented way to schedule a job for a relative time, replacing the awkward scheduled_at/schedule_in naming split that frequently confused people.

    The legacy schedule_in option is still accepted and rewritten transparently, so existing code continues to work.

    Testing helpers gain a matching scheduled_in so assertions can use a relative offset instead of computing an absolute DateTime:

    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 optional delta for 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 :suspended are 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/3 call when provided.

  • [Lifeline] Accept period durations for rescue_after

    The Lifeline plugin's rescue_after now accepts an Oban.Period tuple like {60, :minutes} in addition to a raw millisecond integer, matching the duration format already used by the pruner's max_age.

  • [Pruner] Accept period durations for max_age

    The pruner's :max_age now accepts a period tuple such as {1, :day} in addition to an integer count of seconds, normalized internally via Oban.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?/1 clauses conditionally

    The MySQL clause of expected_error?/1 raised 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 listen and unlisten specs with error tuples

    Without a running notifier process both listen/2 and unlisten/2 can 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 nil return, 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 :error on plugin:stop events, and the :error value is the error itself rather than an {:error, reason} tuple.

Don't miss a new oban release

NewReleases is sending notifications on new releases.