Enqueue once, run serialized
Guarantee both at once — no duplicate sits in the queue, and two copies never run at the same time for the same arguments.
The scenario#
Sometimes one guarantee isn't enough. Debouncing enqueues keeps the queue clean, but says nothing once a job is running — a fresh copy can slip in the moment execution begins. Serializing execution stops overlap, but does nothing to keep duplicates out of the queue while the first copy waits.
A report rebuild wants both. If RebuildReportJob.perform_async(42) is already queued, a second request for report 42 should be dropped — one pending rebuild is enough. And once a rebuild is actually running, no other rebuild for report 42 should run alongside it — they'd race on the same rows. That is exactly what :until_and_while_executing provides: it is the full-lifecycle lock, and the most protective of the five.
A complete worker#
One lock type, one argument that identifies the resource. Uniqueness is keyed on the worker class, the queue, and the arguments — so report 42 and report 43 are independent, but a second perform_async(42) while the first is still in flight is a duplicate.
class RebuildReportJob
include Sidekiq::Job
sidekiq_options lock: :until_and_while_executing,
on_conflict: { client: :log, server: :reschedule }
def perform(report_id)
report = Report.find(report_id)
report.recompute_rollups!
report.regenerate_pdf!
end
endRebuildReportJob.perform_async(42) acquires the lock at enqueue. A second perform_async(42) before the first runs finds the queue lock held and is logged and discarded. When the first job reaches the worker, it re-acquires the lock for execution — so if two rebuilds ever do run in overlapping windows, the later one is rescheduled to run after the first releases.
The full lifecycle#
Both phases of a single lock, in order.
:until_and_while_executing is two locks in one, chained across the job's life. Follow a single job from perform_async to done:
- Locked at enqueue (client). The queue lock is acquired the moment the job is pushed. While it's held, another
perform_asyncwith the same arguments is a conflict — withclient: :log, it's logged and discarded, so only one copy ever sits in the queue. - Released just before perform (server). When the job is picked up and is about to run, the queue lock is released. The queue is now free to accept the next rebuild for this report — you're no longer debouncing, you're serializing.
- Re-locked for execution (server). Immediately before
performruns, an execution lock is acquired. If another copy of this job is already executing, the new one can't get it — withserver: :reschedule, it's re-enqueued to try again later, guaranteeing the two never overlap. - Released after perform (server). When
performreturns, the execution lock is released and the next serialized rebuild may run.
The handoff between step 2 and step 3 is what makes this lock distinct: the queue lock ends exactly where the execution lock begins, so there is no window where the same arguments are either duplicated in the queue or running twice.
Choosing conflict strategies for each phase#
Because there are two phases, on_conflict can carry two strategies — one for the queue lock (client) and one for the execution lock (server):
sidekiq_options lock: :until_and_while_executing,
on_conflict: { client: :log, server: :reschedule }client:decides what happens to a duplicate enqueue.:logdiscards it quietly;:rejectsends it to the Dead set;:replacedeletes the existing lock and enqueues the new job.server:decides what happens when a copy tries to run while another is executing.:reschedulere-enqueues it to run later (the natural choice here — you want the work to happen, just not concurrently);:raiselets Sidekiq retry it;:logdrops it.
A single symbol applies to both phases, e.g. on_conflict: :reschedule. The default is effectively :log for both. See Conflict resolution for the full strategy list.
When you only need one guarantee#
:until_and_while_executing is the combination. If you only need one half of it, pick the single-phase lock instead — it's simpler and holds a lock for less time:
- Serialize per resource uses
:while_executing— it prevents concurrent execution only, and does nothing to keep duplicates out of the queue. - Debounce duplicate enqueues uses
:until_executing— it collapses duplicate enqueues, then releases the lock as soon as the job starts running, allowing a fresh copy to queue.
Choose the combined lock only when you genuinely need both at once. If you're unsure which fits, start from Choosing a lock type.