Concepts

Conflict resolution

When a lock is already held, the conflict strategy decides what happens to the duplicate — drop it, retry it, reject it, replace the original, or reschedule it.

What a conflict is#

A conflict happens when a job tries to acquire a lock that another copy already holds. The lock type decides when the lock is held; the conflict strategy decides what happens to the copy that arrives while it's held.

By default the duplicate is simply silently discarded — the second perform_async returns without enqueuing anything, and nothing is logged. That's the right behavior most of the time: a double-clicked button or a webhook that fires twice shouldn't produce two jobs. If you want the drop to be logged, set on_conflict: :log. And sometimes you want the duplicate to survive: retried later, replaced, or sent somewhere you can inspect it. That's what the strategy chooses.

A duplicate is only a conflict while the lock is held. Once the lock is released, the same arguments enqueue normally again — the strategy only fires during contention.

The five strategies#

Set with on_conflict: on the worker.

There are five built-in strategies. When you don't set on_conflict, the duplicate is silently discarded — :log is a strategy you opt into.

OptionTypeDefaultDescription
`:log`Symbolopt-inLog the conflict and discard the duplicate. Nothing is enqueued.
`:raise`SymbolRaise an error so Sidekiq retries the job later.
`:reject`SymbolPush the duplicate to the Dead set for later inspection.
`:replace`SymbolDelete the existing job and lock, then enqueue the new one.
`:reschedule`SymbolRe-enqueue the duplicate to run later, after the lock clears.
class ExportReportJob
  include Sidekiq::Job

  sidekiq_options lock: :until_executed, on_conflict: :log

  def perform(account_id)
    # A second export for the same account_id, while the first is
    # still locked, is logged and dropped.
  end
end

:reschedule and :reject are the two you reach for most often once the default no longer fits — see Retry conflicts later and Only the latest matters for the full patterns.

Client conflicts vs server conflicts#

Where the conflict happens decides which strategy runs.

A lock can be contended in two different places, and each has its own strategy:

  • Client conflicts happen at enqueue time, in the client middleware. Client-side locks (:until_executing, :until_executed, :until_expired) run their strategy here.
  • Server conflicts happen at execution time, in the server middleware. :while_executing locks conflict on the server, because a while_executing lock is only about preventing concurrent execution — it never blocks the enqueue.

A single symbol applies to whichever side the lock uses. But you can split it, giving the client and the server different behavior:

class SyncInventoryJob
  include Sidekiq::Job

  # Drop duplicate enqueues quietly, but if two copies somehow reach
  # execution at once, reschedule the loser instead of dropping it.
  sidekiq_options lock: :until_and_while_executing,
                  on_conflict: { client: :log, server: :reschedule }

  def perform(warehouse_id)
    # ...
  end
end

This split form only matters for locks that have both a client and a server phase (like :until_and_while_executing). For a purely client-side or purely server-side lock, a single symbol is enough.

The while_executing_reject lock is exactly a while_executing lock with the server strategy forced to :reject — a shorthand for the common case.

Setting a global default#

You can set a default strategy for every worker that doesn't specify its own. It's nil by default, which means "fall back to per-worker configuration" — and with no per-worker value either, the duplicate is silently discarded (nothing is logged).

SidekiqUniqueJobs.configure do |config|
  config.on_conflict = :reschedule
end

A worker's own on_conflict: always wins over the global default, so you can set a sensible default and override it only where it matters.

Two worked examples#

Reschedule an overlapping cron job

A job that runs on a schedule can tick again before the previous run has finished. :reschedule re-enqueues the overlapping copy so it runs once the lock clears, instead of silently dropping it.

class NightlyBillingJob
  include Sidekiq::Job

  sidekiq_options lock: :until_executed, on_conflict: :reschedule

  def perform
    # If the previous nightly run is still going, this tick is
    # re-enqueued to run once the lock is released.
  end
end

Reject a duplicate webhook

A provider that retries a webhook can deliver the same event twice. With :reject, the duplicate goes to the Dead set — nothing runs twice, and you keep a record you can inspect or replay.

class ProcessWebhookJob
  include Sidekiq::Job

  sidekiq_options lock: :until_executed, on_conflict: :reject

  def self.lock_args(args)
    [args.first["event_id"]]
  end

  def perform(payload)
    # Duplicate deliveries of the same event_id are rejected to the
    # Dead set instead of processed twice.
  end
end

Where to go next#

  • Retry conflicts later — the full :reschedule and :raise patterns for work that must eventually run.
  • Only the latest matters — when :replace is the right call for superseding an in-flight job.
  • Observability — the reflections that fire on conflicts (duplicate, rescheduled, reschedule_failed, timeout), so you can measure how often they happen.