Prevents duplicate Sidekiq jobs. Uses Redis locks to ensure only one job with the same arguments runs at a time.
Want to show me some love for the hard work I do on this gem? You can use the following PayPal link: https://paypal.me/mhenrixon2. Any amount is welcome and let me tell you it feels good to be appreciated. Even a dollar makes me super excited about all of this.
- Ruby >= 3.2
- Sidekiq >= 8.0
- Redis >= 6.2 (for LMOVE support)
gem "sidekiq-unique-jobs", "~> 9.0"# config/initializers/sidekiq.rb
Sidekiq.configure_client do |config|
config.client_middleware do |chain|
chain.add SidekiqUniqueJobs::Middleware::Client
end
end
Sidekiq.configure_server do |config|
config.client_middleware do |chain|
chain.add SidekiqUniqueJobs::Middleware::Client
end
config.server_middleware do |chain|
chain.add SidekiqUniqueJobs::Middleware::Server
end
SidekiqUniqueJobs::Server.configure(config)
endclass MyJob
include Sidekiq::Job
sidekiq_options lock: :until_executed
def perform(user_id)
# Only one job per user_id runs at a time
end
endThat's it. Duplicate jobs are silently dropped by default.
| Type | Locks at | Unlocks at | Use case |
|---|---|---|---|
:until_executing |
Enqueue | Before perform | Prevent duplicate enqueuing |
:until_executed |
Enqueue | After perform | Prevent duplicates until job completes |
:until_expired |
Enqueue | TTL expiry | Time-based uniqueness (e.g. daily jobs) |
:while_executing |
Perform | After perform | Prevent concurrent execution |
:until_and_while_executing |
Enqueue + Perform | Before + After perform | Full lifecycle protection |
When a duplicate is detected, the conflict strategy determines what happens:
| Strategy | Behavior |
|---|---|
:log (default) |
Log and discard the duplicate |
:raise |
Raise SidekiqUniqueJobs::OnConflict::Raise |
:reject |
Send to dead set |
:replace |
Delete the existing job and enqueue the new one |
:reschedule |
Schedule the duplicate to run later |
sidekiq_options lock: :until_executed,
on_conflict: :reject
# Or different strategies for client (enqueue) and server (execute):
sidekiq_options lock: :until_and_while_executing,
on_conflict: { client: :log, server: :reschedule }SidekiqUniqueJobs.configure do |config|
config.lock_ttl = nil # Lock expiration in seconds (nil = no expiry)
config.lock_timeout = 0 # How long to wait for a lock (0 = don't wait)
config.lock_prefix = "uniquejobs"
config.on_conflict = nil # Global default conflict strategy
config.lock_info = false # Store lock metadata (useful for debugging)
config.enabled = true # Disable uniqueness globally
config.reaper = :ruby # Orphaned lock cleanup (:ruby, :lua, true, :none, false)
config.reaper_count = 1000 # Max locks to reap per cycle
config.reaper_interval = 600 # Seconds between reaper runs
config.reaper_timeout = 10 # Max seconds per reaper run
config.digest_algorithm = :legacy # :legacy (MD5) or :modern (SHA3-256)
endBy default, uniqueness is based on worker class, queue, and all arguments. To customize:
class MyJob
include Sidekiq::Job
sidekiq_options lock: :until_executed,
lock_args_method: ->(args) { [args.first] }
def perform(user_id, timestamp)
# Only user_id determines uniqueness, timestamp is ignored
end
endsidekiq_options lock: :until_expired,
lock_ttl: 3600 # Lock expires after 1 hoursidekiq_options lock: :until_executed,
unique_across_queues: true # Same args on different queues = duplicatev9 includes an optional reliable fetch strategy that provides crash recovery and lock-aware job acknowledgment:
Sidekiq.configure_server do |config|
config[:fetch_class] = SidekiqUniqueJobs::Fetch::Reliable
endFeatures:
- Atomic LMOVE: Jobs move from queue to per-process working list atomically
- Crash recovery: On startup, recovers jobs from dead worker processes
- Lock-aware acknowledge: Confirms lock cleanup after job completion
- Lock-preserving requeue: During shutdown, locks persist for requeued jobs
Add to your routes:
require "sidekiq_unique_jobs/web"This adds a Locks tab to the Sidekiq Web UI where you can browse, filter, and delete locks.
Disable uniqueness in your tests:
SidekiqUniqueJobs.config.enabled = falseOr use Sidekiq::Testing modes:
Sidekiq::Testing.inline! do
# Jobs execute immediately, uniqueness still enforced
endv9 automatically migrates v8 lock data on first startup. No manual steps required.
Key changes:
- Redis keys: 2 per lock (down from 13). Only
digest:LOCKEDhash anduniquejobs:digestssorted set. - Sidekiq 8+ only: Dropped Sidekiq 7 support.
- Ruby 3.2+ only: Dropped older Ruby support.
- Changelog removed: Use the reflection system for lock event observability.
- Expiring locks unified: No separate
expiring_digestssorted set. TTL-based locks use the samedigestsZSET with expiry time as score.
Observe lock lifecycle events without modifying behavior:
SidekiqUniqueJobs.reflect do |on|
on.locked { |job| logger.info("Locked: #{job['class']}") }
on.unlocked { |job| logger.info("Unlocked: #{job['class']}") }
on.lock_failed { |job| logger.warn("Lock failed: #{job['class']}") }
on.execution_failed { |job| logger.error("Execution failed: #{job['class']}") }
end- Fork it
- Create your feature branch (
git checkout -b my-feature) - Run tests (
bundle exec rspec) - Run linter (
bundle exec rubocop) - Commit and push
- Create a Pull Request
MIT