Scaling Applications

Copy Markdown View Source

Change Notifiers

Oban uses PubSub notifications for communication between nodes, like job inserts, pausing queues, resuming queues, and metrics for Web. The default notifier is Oban.Notifiers.Postgres, which sends all messages through the database. This provides transactional consistency, but Postgres' notifications adds up at scale because each one requires a separate query.

If you're clustered, consider switching to an alternative notifier like Oban.Notifiers.PG. That keeps notifications out of the db, reduces total queries, and allows larger messages, with the tradeoff that notifications from within a database transaction may be sent even if the transaction is rolled back. As long as you have a functional Distributed Erlang cluster, switching is a single line change to your Oban config.

 config :my_app, Oban,
+  notifier: Oban.Notifiers.PG,

If you're not clustered, consider using Oban.Notifiers.Phoenix to send notifications through an alternative service like Redis.

Disable Triggers

Inserting jobs emits a trigger notification to let queues know there are jobs to process immediately, without waiting up to 1s for the next polling interval. Triggers may create many notifications for active queues.

Evaluate if you need sub-second job dispatch. Without it, jobs may wait up to 1s before running, but that’s not a concern for busy queues since they’re constantly fetching and dispatching.

Disable triggers in your Oban configuration:

 config :my_app, Oban,
+  insert_trigger: false,

Tuning Autovacuum

Default autovacuum settings are sized for general purpose tables and may fall behind on oban_jobs, where state transitions update a row and pruning deletes in bulk. Dead tuples accumulate between vacuums and planner statistics go stale, which effects critical operations like job fetching.

Tune autovacuum for the oban_jobs table alone rather than the whole database:

ALTER TABLE oban_jobs SET (
  -- Vacuum earlier on large tables
  autovacuum_vacuum_scale_factor = 0.02,
  autovacuum_vacuum_threshold = 50,

  -- Keep stats fresh for the planner
  autovacuum_analyze_scale_factor = 0.02,
  autovacuum_analyze_threshold = 100,

  -- Handle insert-heavy spikes
  autovacuum_vacuum_insert_scale_factor = 0.02,
  autovacuum_vacuum_insert_threshold = 1000,

  -- Make autovacuum push harder with little sleeping
  autovacuum_vacuum_cost_limit = 2000,
  autovacuum_vacuum_cost_delay = 1,

  -- Leave headroom on pages for locality and fewer page splits
  fillfactor = 85
);

Check n_dead_tup and last_autovacuum in pg_stat_user_tables to confirm vacuum is keeping up, and adjust the scale factors to match table size and write load.

Limit Uniqueness

Frequently, people set uniqueness for jobs that don’t really need it. Not you, of course. Before setting uniqueness, ensure the following, in a very checklist type fashion:

  1. Evaluate whether it’s necessary for your workload
  2. Always set a keys option so that uniqueness isn’t based on the full args or meta
  3. Avoid setting a period at all if possible, use period: :infinity instead

If you're still committed to setting uniquness for your jobs, consider tweaking your configuration as follows:

use Oban.Worker, unique: [
-   period: {1, :hour},
+   period: :infinity,
+   keys: [:some_key]

🌟 Pro Uniqueness

Oban Pro uses an alternative mechanism for unique jobs that works for bulk inserts, and is designed for speed, correctness, scalability, and simplicity. Uniqueness is enforced and makes insertion entirely safe between processes and nodes, without the load added by multiple queries.

Apply Reindexing

To stop oban_jobs indexes from taking up so much space on disk, use the Oban.Reindexer plugin to rebuild indexes periodically. The Postgres transactional model applies to indexes as well as tables. That leaves bloat from inserting, updating, and deleting jobs that auto-vacuuming won’t always fix.

The reindexer rebuilds key indexes on a fixed schedule, concurrently. Concurrent rebuilds are low impact, they don’t lock the table, and they free up space while optimizing indexes.

The Oban.Reindexer plugin is part of OSS Oban. It runs every day at midnight by default, but it accepts a cron-style schedule and you can tweak it to run less frequently.

config :my_app, Oban,
+  reindexer: [schedule: "@weekly"],
   …

Optimize Staging

Applications with a large number of scheduled or retryable jobs may benefit from an index tailored to staging. The index isn't necessary for most applications, and maintaining it adds overhead to inserts and updates, so verify that staging queries are a bottleneck before adding it.

Create the index concurrently to avoid locking the oban_jobs table. Concurrent index operations must run outside of a transaction and without Ecto's migration lock:

defmodule MyApp.Repo.Migrations.AddObanJobsStagingIndex do
  use Ecto.Migration

  @disable_ddl_transaction true
  @disable_migration_lock true

  def change do
    create_if_not_exists index(
      :oban_jobs,
      [:scheduled_at],
      concurrently: true,
      where: "state IN ('scheduled', 'retryable')"
    )
  end
end

Increase Pruning

Ensuring you are using the Pruner plugin, and that you prune aggressively. Pruning periodically deletes completed, cancelled, and discarded jobs. Your application and database will benefit from keeping the jobs table small. Aim to retain as few jobs as necessary for uniqueness and historic introspection.

For example, to limit historic jobs to 1 day:

 config :my_app, Oban,
+  pruner: [max_age: {1, :day}],
   …

Consider Pooling

Oban uses connections from your application Repo’s pool to talk to the database. When that pool is busy, it can starve Oban of connections and you’ll see timeout errors. Likewise, if Oban is extremely busy (as it should be), it can starve your application of connections. A good solution for this is to set up another pool that’s exclusively for Oban’s internal use. The dedicated pool isolates Oban’s queries from the rest of the application.

Start by defining a new ObanRepo:

defmodule MyApp.ObanRepo do
   use Ecto.Repo,
     adapter: Ecto.Adapters.Postgres,
     otp_app: :my_app
end

Then add a function to your main Repo

defmodule MyApp.Repo do
  use Ecto.Repo,
     adapter: Ecto.Adapters.Postgres,
     otp_app: :my_app
  
+ def oban_repo do
+   if in_transaction?() do
+     MyApp.Repo
+   else
+     MyApp.ObanRepo
+   end
+ end
end

Then switch the configured repo, and use the repo's dynamic_repo option to ensure the same repo is used within a transaction:

 config :my_app, Oban,
-  repo: MyApp.Repo,
+  repo: {MyApp.ObanRepo, dynamic_repo: {MyApp.Repo, :oban_repo, []}},
   ...

Scale Concurrency

In a busy system with high concurrency all of the record keeping after jobs run causes pool contention, despite the individual queries being very quick. Fetching jobs uses a single query per queue. However, acking when a job finishes takes a single connection for each job.

Improve the ratio between executing jobs and available connections by scaling up your Ecto pool_size and minimizing concurrency between all queues.

config :my_app, Repo,
-  pool_size: 10,
+  pool_size: 50,
   …

 config :my_app, Oban,
   queues: [
-    events: 200,
+    events: 50,
-    emails: 100,
+    emails: 25,
   …

Using a dedicated pool with a known number of constant connections can also help the ratio. It’s not necessary for most applications, but a dedicated database can help maintain predictable performance.