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.
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.
| Option | Type | Default | Description |
|---|---|---|---|
`:log` | Symbol | opt-in | Log the conflict and discard the duplicate. Nothing is enqueued. |
`:raise` | Symbol | — | Raise an error so Sidekiq retries the job later. |
`:reject` | Symbol | — | Push the duplicate to the Dead set for later inspection. |
`:replace` | Symbol | — | Delete the existing job and lock, then enqueue the new one. |
`:reschedule` | Symbol | — | Re-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_executinglocks conflict on the server, because awhile_executinglock 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
endThis 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.
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
endA 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
endReject 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
endWhere to go next#
- Retry conflicts later — the full
:rescheduleand:raisepatterns for work that must eventually run. - Only the latest matters — when
:replaceis 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.