Lock lifecycle
Every lock is born at a moment, held for a while, and released at a moment — and each lock type picks a different pair of moments.
The arc of a lock#
A lock has exactly three moments in its life:
- Acquire — the lock is created in Redis and starts blocking duplicates.
- Hold — the lock exists; any second copy with the same digest is a conflict.
- Release — the lock is removed and duplicates are allowed again.
What separates the five lock types is when acquire and release happen. Some acquire at enqueue (in the client middleware), some acquire on the server just before perform. Some release before the job runs, some after it finishes, some only when a TTL expires. Everything else — timeouts, conflict strategies, argument filtering — is tuning around these two moments.
The five lock types, at a glance#
Where each lock is born and where it dies.
| Lock type | Locks at | Unlocks at | Prevents |
|---|---|---|---|
:until_executing | Enqueue (client) | Just before perform starts (server) | Duplicate enqueues while a copy is still queued |
:until_executed | Enqueue (client) | After perform completes (server) | Duplicates for the whole push → done lifecycle |
:until_expired | Enqueue (client) | When the TTL expires (never on completion) | Duplicates within a fixed time window |
:while_executing | Just before perform (server) | After perform (server) | Concurrent execution only — not enqueues |
:until_and_while_executing | Enqueue and before perform | Before perform and after perform | Duplicate enqueues and concurrent execution |
See Choosing a lock type for the "I want to prevent X" decision table. The rest of this page walks through each phase.
Acquire — where a lock is born#
A lock is acquired in one of two places, depending on its type.
Client-side acquire (at enqueue)
:until_executing, :until_executed, and :until_expired acquire the lock in the client middleware, the moment the job is pushed. If the lock is already held, the push is a duplicate and the conflict strategy decides its fate — see Conflict resolution.
class GenerateReportJob
include Sidekiq::Job
sidekiq_options lock: :until_executed
def perform(account_id)
# The lock was acquired when this job was enqueued, and is
# held until this method returns.
end
endServer-side acquire (just before perform)
:while_executing does not touch enqueueing at all. Duplicates pile up in the queue freely; the lock is acquired on the server, right before perform runs, so only one copy executes at a time. A worker that waits for the lock is bounded by lock_timeout — see TTL and timeouts.
class RebuildSearchIndexJob
include Sidekiq::Job
sidekiq_options lock: :while_executing, lock_timeout: 5
def perform
# Many copies may sit in the queue; only one runs this body
# at any given moment.
end
end:until_and_while_executing does both: it acquires at enqueue (like :until_executed) and acquires a second, execution-scoped lock just before perform — so the job is unique in the queue and serialized while running.
Hold — while the lock exists#
While a lock is held, it lives as two Redis structures (see How locking works):
<digest>:LOCKED— a hash of the job IDs holding this digest, with metadata.uniquejobs:digests— a global sorted set indexing every active lock.
Any second job whose class, queue, and arguments hash to the same digest is a conflict for as long as the lock is held. By default a digest allows a single holder; lock_limit: raises that ceiling so up to N copies can hold the same digest at once.
class SyncContactsJob
include Sidekiq::Job
# Allow up to 3 concurrent syncs for the same arguments.
sidekiq_options lock: :while_executing, lock_limit: 3
def perform(list_id)
# Up to three copies may hold this lock simultaneously.
end
endRelease — where a lock dies#
A lock is released in one of three ways, and the lock type chooses which.
Before perform starts
:until_executing releases the moment the server picks the job up, just before perform runs. Its whole purpose is to debounce a burst of enqueues: once a copy actually starts running, the queue is clear and a fresh copy may enqueue again.
class DeliverWebhookJob
include Sidekiq::Job
sidekiq_options lock: :until_executing
def perform(endpoint_id, payload)
# The lock was already released before this line ran, so a new
# webhook can queue up while this one delivers.
end
endAfter perform completes
:until_executed (and the execution phase of :until_and_while_executing) releases after perform returns — the lock spans the entire push → done lifecycle. :while_executing also releases here, since it only ever existed for the duration of perform.
When the TTL expires
:until_expired never releases on completion. It lives exactly as long as lock_ttl, measured from when the lock was created at enqueue — not from when the job finishes. This is the lock for time-boxed uniqueness, like a "once per day" job.
class SendDailyDigestJob
include Sidekiq::Job
# One digest per user per day: the lock outlives the job and
# expires 24 hours after it was enqueued.
sidekiq_options lock: :until_expired, lock_ttl: 86_400
def perform(user_id)
# Enqueue this again within the TTL window and it's a duplicate,
# whether or not this run has finished.
end
endThe after_unlock callback#
Right after a lock is released, the gem can call back into your worker. Define after_unlock as an instance or class method and it runs on release — useful for cleanup, metrics, or chaining follow-up work.
class ImportOrdersJob
include Sidekiq::Job
sidekiq_options lock: :until_executed
def perform(batch_id)
# ... import work ...
end
def after_unlock
# Runs after the lock is released.
Rails.logger.info("import lock released")
end
end