# Changelog for Oban v2.24

_🌟 Looking for changes to [Oban Pro][pro]? Check the [Oban.Pro Changelog][opc] 🌟_

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`:

```elixir
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`][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 😉):

```diff
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:

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

[py]: https://github.com/oban-bg/oban-py

## 🎁 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:

```elixir
[
  {"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:

```elixir
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:

```elixir
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+):

```elixir
Oban.Period.to_seconds({2, :hours})
#=> 7200

Oban.Period.to_milliseconds({5, :minutes})
#=> 300_000
```

## v2.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`, and `Lite` engines.

- [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_cooldown` option 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`, 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.

[pro]: https://oban.pro
[opc]: https://oban.pro/docs/pro/changelog.html
