Use cases

# Debounce duplicate enqueues

Collapse a burst of identical enqueues down to a single queued job, while still letting a fresh request in once the job starts running.

## The scenario

A product changes, and something fires a "reindex this product" trigger. Then it fires again. And again — an admin saves the form three times, a webhook retries, a bulk import touches the same record twice. You want **one** reindex sitting in the queue, not five identical copies.

But there's a wrinkle: reindexing takes a while, and the product might change *again* while a reindex is already running. Once the job has started, a new trigger should be allowed to enqueue a fresh reindex — the one in flight is working from stale data.

That's exactly what [`:until_executing`](https://sidekiq-unique-jobs.zoolutions.llc/docs/choosing-a-lock-type) gives you. The lock is held from the moment the job is enqueued and released the instant it starts executing — so duplicates collapse while the job waits in the queue, but the door reopens as soon as it runs.

## A complete worker

One reindex per product in the queue at a time.

```ruby
class ReindexProductJob
  include Sidekiq::Job

  sidekiq_options queue: "search", lock: :until_executing

  def perform(product_id)
    product = Product.find(product_id)
    SearchIndex.reindex(product)
  end
end
```

The digest is built from the worker class, the queue, and the arguments, so each `product_id` is tracked independently: `ReindexProductJob.perform_async(1)` and `perform_async(2)` never collide. A second `perform_async(1)` fired while the first is still waiting in the queue is the duplicate — and it's dropped.

> **Note:** The middleware is not loaded automatically. Wire it up once in your Sidekiq initializer — see [Installation](https://sidekiq-unique-jobs.zoolutions.llc/docs/installation).

## What happens when a duplicate arrives

Walk a burst of triggers through the lock's lifecycle:

1. **First trigger enqueues.** `ReindexProductJob.perform_async(42)` is pushed. The client middleware acquires the `:until_executing` lock for the digest of `ReindexProductJob` + `search` + `[42]`. The job sits in the queue, locked.
2. **Duplicates arrive while it waits.** Two more triggers fire `perform_async(42)` before a worker picks the job up. Each makes a single attempt to acquire the same lock, finds it held, and is **silently discarded** — by default nothing is logged; the duplicate is simply dropped. (Set `on_conflict: :log` if you want each dropped duplicate written to the log.) The queue still holds exactly one reindex for product 42.
3. **A worker picks it up.** Just **before** `perform` runs, the server middleware releases the lock. The digest is now free.
4. **A fresh trigger mid-run is allowed.** While `SearchIndex.reindex` is still churning, a new `perform_async(42)` comes in. Because the lock was already released in step 3, this one acquires the lock cleanly and enqueues a fresh reindex — which is what you want, since the product may have changed since the running job read it.

The key moment is step 3: the lock unlocks **just before** the job starts, not after it finishes. That single choice is the entire difference between `:until_executing` and [`:until_executed`](https://sidekiq-unique-jobs.zoolutions.llc/docs/choosing-a-lock-type), which holds the lock across the whole run.

## Tuning knobs

Two options adjust how the debounce behaves under contention. Neither is required — the worker above works as-is.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `lock_ttl` | Integer (seconds) | nil | A safety expiry for the lock. Measured from when the lock is created (at enqueue), not from job completion. Set this so a crash between enqueue and pickup can't wedge the digest forever. |
| `on_conflict` | Symbol | nil | What to do with a duplicate. Unset, the duplicate is silently discarded — nothing is logged. Set :log to have each dropped duplicate logged, :reschedule to re-enqueue it to run later, or :replace to swap the queued job for the newer one. |

For example, to drop stale triggers but never let a lock outlive a lost job:

```ruby
class ReindexProductJob
  include Sidekiq::Job

  sidekiq_options queue: "search",
                  lock: :until_executing,
                  lock_ttl: 300,
                  on_conflict: :log

  def perform(product_id)
    product = Product.find(product_id)
    SearchIndex.reindex(product)
  end
end
```

See [Conflict resolution](https://sidekiq-unique-jobs.zoolutions.llc/docs/conflict-resolution) for every strategy. Note that `lock_ttl` bounds the lock's *lifetime* in Redis. Lock acquisition itself is non-blocking in v9 — a contended enqueue makes one attempt and, on failure, the conflict strategy fires immediately. A job never waits for a held lock to free up.

## It does not prevent concurrent execution

This is the pitfall to internalize. `:until_executing` releases the lock **before** `perform` runs — so nothing stops two copies of `ReindexProductJob` from executing at the same time. In fact the mid-run enqueue in step 4 above is designed to allow exactly that.

If your goal is that only one reindex for a given product **runs** at a time — never two overlapping — that's a different job. Reach for [Serialize per resource](https://sidekiq-unique-jobs.zoolutions.llc/docs/serialize-per-resource), which holds the lock across execution instead of releasing it beforehand.

> **Tip:** Rule of thumb: use :until_executing to keep the queue clean, and a while-executing lock to keep execution serialized. Combine both with :until_and_while_executing when you need each — see [Choosing a lock type](https://sidekiq-unique-jobs.zoolutions.llc/docs/choosing-a-lock-type).