Use cases

One per period

Send a job at most once per key per fixed time window — one digest email per user per day — with :until_expired and a lock_ttl that matches the period.

The scenario#

You send a daily digest email. The job that enqueues it can fire more than once a day — a cron tick overlaps, a backfill re-runs, a retry loops — but each user must receive at most one digest per calendar day, no matter how many times the job is scheduled.

This is time-boxed uniqueness: the same arguments are a duplicate for a fixed window, then the window resets. The :until_expired lock does exactly that. The lock is created when the job is enqueued and lives for lock_ttl seconds — a full day — regardless of when, or whether, the job actually runs. Any second enqueue for the same user inside that window is a duplicate.

The worker#

One user, one digest, one day.

class DailyDigestJob
  include Sidekiq::Job

  sidekiq_options lock: :until_expired, lock_ttl: 86_400

  def perform(user_id)
    user = User.find(user_id)
    DigestMailer.daily(user).deliver_now
  end
end

DailyDigestJob.perform_async(42) acquires a lock keyed on the worker class, the queue, and the argument 42. For the next 86_400 seconds — one day — every further DailyDigestJob.perform_async(42) sees that lock and is discarded (the default conflict behavior is to log and drop the duplicate). A different user, perform_async(43), has different arguments and its own independent lock.

How the window works#

:until_expired is unusual among the lock types: it is the only one that never unlocks on the job's behalf. The lifecycle is entirely driven by the TTL.

  1. Enqueue. The client middleware creates the lock and stamps it with a TTL of lock_ttl seconds. This happens on the push, before any worker picks the job up.
  2. Execution. The job runs (or fails, or never runs at all). Either way, the lock is not released when the job finishes — running the job does not shorten or extend the window.
  3. Expiry. When lock_ttl seconds have elapsed from the moment the lock was created, Redis expires the lock. Only now can the next enqueue for the same user acquire a fresh lock and send tomorrow's digest.

So the guarantee is "one per window", not "one at a time": within a single lock_ttl window, exactly one enqueue wins and the rest are duplicates.

The TTL counts from lock creation, not from job completion. A digest scheduled at 09:00 unlocks 24 hours after 09:00 — not 24 hours after the mail was sent. If you need the clock to start when the job finishes, this is the wrong lock type; see TTL and timeouts.

Picking the TTL#

The lock_ttl is the period. Choose it to match the window you want to deduplicate, in seconds:

OptionTypeDefaultDescription
Once per hourInteger3_600Rolling one-hour window from first enqueue
Once per dayInteger86_400The daily digest above
Once per weekInteger604_800Weekly summary
Match the TTL to the period, and enqueue on a schedule aligned to that period. Because the window starts at the first enqueue rather than at a calendar boundary, a job enqueued at 23:59 unlocks at 23:59 the next day — pick a stable enqueue time (a single daily cron) so the window lines up with your intended cadence.

No after_unlock callback#

Because the lock is released by Redis expiry and not by your code, an :until_expired lock never invokes the after_unlock callback. There is no moment in your worker where the release happens, so there is nothing to hook. If you need cleanup to run when a job's lock is released, use a lock that unlocks on completion — :until_executed — instead.

For the full map of when each lock acquires and releases, see Choosing a lock type.