Concepts

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:

  1. Acquire — the lock is created in Redis and starts blocking duplicates.
  2. Hold — the lock exists; any second copy with the same digest is a conflict.
  3. 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 typeLocks atUnlocks atPrevents
:until_executingEnqueue (client)Just before perform starts (server)Duplicate enqueues while a copy is still queued
:until_executedEnqueue (client)After perform completes (server)Duplicates for the whole push → done lifecycle
:until_expiredEnqueue (client)When the TTL expires (never on completion)Duplicates within a fixed time window
:while_executingJust before perform (server)After perform (server)Concurrent execution only — not enqueues
:until_and_while_executingEnqueue and before performBefore perform and after performDuplicate 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
end

Server-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
end
Uniqueness is decided by the digest — worker class, queue, and arguments. To make only some arguments count, use lock_args_method.

Release — 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
end

After 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
end
lock_ttl bounds how long the lock lives; lock_timeout bounds how long a client waits to acquire a held lock. They're independent — see TTL and timeouts.

The 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
after_unlock never fires for :until_expired. That lock is released by Redis expiring the key, not by your code running, so there is no release event to hook.