sidekiq-locking 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +225 -0
- data/lib/sidekiq/locking/acquirer.rb +26 -0
- data/lib/sidekiq/locking/key.rb +68 -0
- data/lib/sidekiq/locking/middleware/client.rb +61 -0
- data/lib/sidekiq/locking/middleware/server.rb +41 -0
- data/lib/sidekiq/locking/releaser.rb +27 -0
- data/lib/sidekiq/locking/version.rb +7 -0
- data/lib/sidekiq/locking.rb +80 -0
- data/lib/sidekiq-locking.rb +13 -0
- metadata +79 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 920668894edb18696853798031f13f8192595b6f7c82f1e2902a6f7219bef21e
|
|
4
|
+
data.tar.gz: f7173b85a3f6b4d9524c312244f2dc90eb91869203a3b0049029e5d919d37262
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a89b55c2dac277700ec9e59b9fd90faba733117ebad38a63ee1fd00f3030c449756bb226c11483196dcf1d557fb5c26eabc7cbf56036e893dd4a25987fc2693c
|
|
7
|
+
data.tar.gz: 86be7fb1fe072579f1682eec4b11d9d17d27e106755de55a2b583f752b3b516b810282997c73405c47315e47707c328248e8f669407c3a28fdfb43cbe8310534
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BigBinary Technologies Pvt. Ltd.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# sidekiq-locking
|
|
2
|
+
|
|
3
|
+
Best-effort, **TTL-bounded enqueue deduplication** for Sidekiq jobs — drop
|
|
4
|
+
duplicate enqueues of the same job while a short-lived lock is held, **without a
|
|
5
|
+
database or a runtime mutex**.
|
|
6
|
+
|
|
7
|
+
If application code enqueues the same work repeatedly — a webhook storm, a
|
|
8
|
+
fan-out that re-triggers the same sync, a retry loop upstream — only the first
|
|
9
|
+
copy is kept in Redis while the lock is active. A duplicate is skipped when it
|
|
10
|
+
matches the same lock context: `(class, queue, args)` by default.
|
|
11
|
+
|
|
12
|
+
This is intentionally **best-effort deduplication, not a correctness primitive.**
|
|
13
|
+
It is not a distributed lock and not a runtime mutex: two copies can still run
|
|
14
|
+
concurrently if the TTL expires before the first finishes. Jobs must stay
|
|
15
|
+
idempotent, and business-critical uniqueness should be protected with database
|
|
16
|
+
constraints, row locks, or advisory locks.
|
|
17
|
+
|
|
18
|
+
> **Naming.** The gem is `sidekiq-locking` and the namespace is `Sidekiq::Locking`,
|
|
19
|
+
> with a `lock_for` DSL. The word "lock" somewhat overclaims — this is
|
|
20
|
+
> best-effort, TTL-bounded enqueue *deduplication*, not a true mutex. The name
|
|
21
|
+
> and DSL are kept stable for now and may be revisited.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
gem "sidekiq-locking"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
bundle install
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Requires Ruby >= 3.1 and Sidekiq >= 7.0. The gem depends only on `sidekiq` — no
|
|
34
|
+
Rails or ActiveSupport required.
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
Install the middleware once, in the host application's Sidekiq initializer:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
# config/initializers/sidekiq.rb
|
|
42
|
+
require "sidekiq-locking"
|
|
43
|
+
|
|
44
|
+
Sidekiq::Locking.install! unless Rails.env.test?
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`install!` registers the client middleware (acquires the lock and skips
|
|
48
|
+
duplicate pushes), the server middleware (releases the lock around `perform`),
|
|
49
|
+
and a death handler (releases the lock when a job is discarded). Disabling it in
|
|
50
|
+
test keeps tests deterministic and avoids locks leaking between examples.
|
|
51
|
+
|
|
52
|
+
Then opt a job in with a lock window:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
class SyncAddressJob
|
|
56
|
+
include Sidekiq::Job
|
|
57
|
+
|
|
58
|
+
sidekiq_options lock_for: 5.minutes
|
|
59
|
+
|
|
60
|
+
def perform(user_id)
|
|
61
|
+
# sync user address
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A second `SyncAddressJob` with the same queue and args cannot be enqueued while
|
|
67
|
+
the first lock is active. The lock is released when the job succeeds, or when
|
|
68
|
+
`lock_for` expires — whichever happens first. Keep `lock_for` short; a window
|
|
69
|
+
longer than a few minutes usually means the job wants stronger application-level
|
|
70
|
+
concurrency control.
|
|
71
|
+
|
|
72
|
+
## Lock context
|
|
73
|
+
|
|
74
|
+
By default jobs are deduplicated by `[class, queue, args]`, so the same job
|
|
75
|
+
class with the same args can still be enqueued on different queues:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
SyncAddressJob.set(queue: "default").perform_async(1)
|
|
79
|
+
SyncAddressJob.set(queue: "low").perform_async(1) # different lock — queue differs
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Custom lock args
|
|
83
|
+
|
|
84
|
+
Use a `lock_args(job)` class method when only part of the args should
|
|
85
|
+
participate in the lock key. It must return an `Array`; if it is absent, the
|
|
86
|
+
full args array is used.
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
class RefreshAccountCacheJob
|
|
90
|
+
include Sidekiq::Job
|
|
91
|
+
|
|
92
|
+
sidekiq_options lock_for: 5.minutes
|
|
93
|
+
|
|
94
|
+
# Only account_id participates in the lock; the reason arg is ignored.
|
|
95
|
+
def self.lock_args(job)
|
|
96
|
+
[job["args"].first]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def perform(account_id, reason = nil)
|
|
100
|
+
# refresh account cache
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# These two share one lock:
|
|
105
|
+
RefreshAccountCacheJob.perform_async(42, "user_updated")
|
|
106
|
+
RefreshAccountCacheJob.perform_async(42, "manual_refresh")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### ActiveJob
|
|
110
|
+
|
|
111
|
+
`Sidekiq::Locking` unwraps Sidekiq's ActiveJob wrapper and keys on the wrapped job
|
|
112
|
+
class plus the real ActiveJob arguments. A wrapped job that defines
|
|
113
|
+
`lock_args(job)` receives the unwrapped args.
|
|
114
|
+
|
|
115
|
+
## Scheduled jobs
|
|
116
|
+
|
|
117
|
+
Scheduled jobs include the delay in the lock TTL. With `lock_for: 10.minutes`:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
SyncAddressJob.set(wait: 1.hour).perform_async(1)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
the lock lasts ~70 minutes total (the one-hour delay plus the ten-minute
|
|
124
|
+
window), so a duplicate cannot be enqueued for that full period unless the
|
|
125
|
+
original runs and releases the lock earlier.
|
|
126
|
+
|
|
127
|
+
## Retries and the unlock policy
|
|
128
|
+
|
|
129
|
+
`release_lock` controls when the lock is released.
|
|
130
|
+
|
|
131
|
+
- **`:after_perform` (default)** — release only after `perform` returns
|
|
132
|
+
successfully. A failed job keeps its lock while it waits to retry, so a
|
|
133
|
+
transient failure does not let a duplicate slip in. The lock clears on
|
|
134
|
+
success, on death (via the death handler), or on TTL expiry.
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
sidekiq_options lock_for: 5.minutes, release_lock: :after_perform
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
- **`:before_perform`** — release immediately before the job starts running.
|
|
141
|
+
Use this only when it is acceptable for another copy to be enqueued while the
|
|
142
|
+
current job is still executing.
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
sidekiq_options lock_for: 5.minutes, release_lock: :before_perform
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
If the TTL expires while a job is still retrying, a duplicate can be enqueued.
|
|
149
|
+
Design jobs with that best-effort behavior in mind.
|
|
150
|
+
|
|
151
|
+
## Per-enqueue overrides
|
|
152
|
+
|
|
153
|
+
Use Sidekiq's `set` API to disable or override the lock for one enqueue:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
SyncAddressJob.set(lock_for: false).perform_async(1) # no lock this time
|
|
157
|
+
SyncAddressJob.set(lock_for: 30.seconds).perform_async(1) # shorter window
|
|
158
|
+
SyncAddressJob.set(lock_for: 10.minutes).perform_async(1) # longer window
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Return value on a duplicate enqueue
|
|
162
|
+
|
|
163
|
+
A duplicate enqueue does not return a JID because the job was never pushed.
|
|
164
|
+
Through Sidekiq's public `perform_async` API this is observed as `nil`:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
SyncAddressJob.perform_async(1) # => "abc123..."
|
|
168
|
+
SyncAddressJob.perform_async(1) # => nil (duplicate, lock held)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Do not rely on `perform_async` always returning a JID for lockable jobs. Each
|
|
172
|
+
skipped duplicate is logged at info level —
|
|
173
|
+
`Skipping enqueue for <Class>, lock held by JID <jid>`.
|
|
174
|
+
|
|
175
|
+
## Redis key prefix
|
|
176
|
+
|
|
177
|
+
Each active lock is one Redis string at `sidekiq:locking:<digest>`, whose value is
|
|
178
|
+
the owning job's JID (releases are JID-guarded so a late finisher cannot delete
|
|
179
|
+
a newer lock). The prefix is configurable:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
Sidekiq::Locking.redis_key_prefix = "my-app:sidekiq:locking:"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## How it works
|
|
186
|
+
|
|
187
|
+
```text
|
|
188
|
+
perform_async
|
|
189
|
+
→ client middleware builds a lock key from (class, queue, args)
|
|
190
|
+
→ Redis: SET sidekiq:locking:<digest> <jid> GET NX PX <ttl_ms>
|
|
191
|
+
|
|
192
|
+
Acquired (key was new):
|
|
193
|
+
→ stamp job["lock_token"] = digest, push the job
|
|
194
|
+
→ server middleware releases the lock on success (per release_lock)
|
|
195
|
+
→ death handler releases the lock if the job is discarded
|
|
196
|
+
|
|
197
|
+
Already held:
|
|
198
|
+
→ do not push; perform_async returns nil; log the skip
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
If a *downstream* client middleware aborts the push by returning `false` (for
|
|
202
|
+
example a blackhole route from
|
|
203
|
+
[sidekiq-routing](https://github.com/neetozone/sidekiq-routing)), the lock
|
|
204
|
+
middleware releases the lock it just acquired so the next identical enqueue is
|
|
205
|
+
not blocked until the TTL. When composing with other client middleware, the
|
|
206
|
+
lock middleware should run **before** any middleware that may abort the push.
|
|
207
|
+
|
|
208
|
+
## Caveats
|
|
209
|
+
|
|
210
|
+
- Locks are best-effort and TTL-bounded; jobs must remain idempotent.
|
|
211
|
+
- This is enqueue deduplication, not execution-time mutual exclusion — if the
|
|
212
|
+
TTL expires while a job is still running or retrying, another copy can be
|
|
213
|
+
enqueued.
|
|
214
|
+
- Duplicate jobs are dropped, not rescheduled or replaced.
|
|
215
|
+
- There is no Web UI, lock browser, or stale-lock reaper — the TTL is the safety
|
|
216
|
+
mechanism.
|
|
217
|
+
- `release_lock: :before_perform` allows duplicates while the job is running.
|
|
218
|
+
- Manually deleting jobs from Sidekiq queues may leave the lock in Redis until
|
|
219
|
+
the TTL expires.
|
|
220
|
+
- The lock adds one Redis round-trip before each push; if Redis is unavailable,
|
|
221
|
+
enqueue can fail with a Redis/network error.
|
|
222
|
+
|
|
223
|
+
## License
|
|
224
|
+
|
|
225
|
+
Released under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sidekiq
|
|
4
|
+
module Locking
|
|
5
|
+
# Claims a lock with `SET key <jid> NX PX <ttl>` plus the GET option — the
|
|
6
|
+
# check-and-set lock recipe from the Redis SET docs
|
|
7
|
+
# (https://redis.io/commands/set/). The acquiring job's JID is stored as the
|
|
8
|
+
# value so the release can later be guarded by ownership.
|
|
9
|
+
module Acquirer
|
|
10
|
+
Held = Struct.new(:jid) do
|
|
11
|
+
def held?
|
|
12
|
+
true
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def try_acquire(key, jid, ttl_ms, redis_pool = nil)
|
|
18
|
+
setter = ->(conn) { conn.set(key.redis_key, jid, "get", "nx", "px", ttl_ms) }
|
|
19
|
+
holder = redis_pool ? redis_pool.with(&setter) : Sidekiq.redis(&setter)
|
|
20
|
+
|
|
21
|
+
holder ? Held.new(holder) : :acquired
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha2"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Sidekiq
|
|
7
|
+
module Locking
|
|
8
|
+
# Value object for a lock key. Builds the uniqueness tuple (job class, queue,
|
|
9
|
+
# args) from the Sidekiq payload and hashes it into the Redis key shared by
|
|
10
|
+
# the acquirer and releaser. The hash encoding (SHA-256 hex over a JSON dump
|
|
11
|
+
# of the tuple) is an internal implementation detail — it only has to be
|
|
12
|
+
# stable and collision-resistant for a given tuple.
|
|
13
|
+
class Key
|
|
14
|
+
ACTIVE_JOB_WRAPPERS = [
|
|
15
|
+
"Sidekiq::ActiveJob::Wrapper",
|
|
16
|
+
"ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper"
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :job, :klass, :queue, :args, :context, :digest
|
|
20
|
+
|
|
21
|
+
def initialize(job)
|
|
22
|
+
@job = job
|
|
23
|
+
@klass = job["wrapped"] || job["class"]
|
|
24
|
+
@queue = job["queue"]
|
|
25
|
+
@args = resolved_args
|
|
26
|
+
@context = [klass, queue, args]
|
|
27
|
+
@digest = Digest::SHA256.hexdigest(JSON.generate(context))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def redis_key
|
|
31
|
+
"#{Locking.redis_key_prefix}#{digest}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def resolved_args
|
|
37
|
+
job_class = constantized_job_class
|
|
38
|
+
return default_args unless job_class&.respond_to?(:lock_args)
|
|
39
|
+
|
|
40
|
+
scratch = job.merge("class" => klass, "args" => copy_args(default_args))
|
|
41
|
+
result = job_class.lock_args(scratch)
|
|
42
|
+
unless result.is_a?(Array)
|
|
43
|
+
raise TypeError, "#{job_class.name}.lock_args must return an Array (got #{result.class})"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
result
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def default_args
|
|
50
|
+
if ACTIVE_JOB_WRAPPERS.include?(job["class"])
|
|
51
|
+
job.dig("args", 0, "arguments") || []
|
|
52
|
+
else
|
|
53
|
+
job["args"] || []
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def constantized_job_class
|
|
58
|
+
Object.const_get(klass)
|
|
59
|
+
rescue NameError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def copy_args(value)
|
|
64
|
+
value.respond_to?(:dup) ? value.dup : value
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sidekiq
|
|
4
|
+
module Locking
|
|
5
|
+
module Middleware
|
|
6
|
+
# Acquires the lock before push. Skips the push (returns false) if a lock
|
|
7
|
+
# is already held for the same (class, queue, lock_args) tuple.
|
|
8
|
+
#
|
|
9
|
+
# Scheduled jobs (`perform_in`) include their delay in the TTL: a job
|
|
10
|
+
# scheduled an hour out with `lock_for: 10.minutes` holds the lock for 70
|
|
11
|
+
# minutes total.
|
|
12
|
+
class Client
|
|
13
|
+
def call(_worker_class, job, _queue, redis_pool = nil)
|
|
14
|
+
return yield unless lockable?(job)
|
|
15
|
+
|
|
16
|
+
expiry_ms = expiry_for(job)
|
|
17
|
+
if expiry_ms <= 0
|
|
18
|
+
Sidekiq.logger.info("Skipping lock for #{job["class"]}/#{job["jid"]}: lock window ends in the past")
|
|
19
|
+
return yield
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
key = Key.new(job)
|
|
23
|
+
result = Acquirer.try_acquire(key, job["jid"], expiry_ms, redis_pool)
|
|
24
|
+
|
|
25
|
+
if result == :acquired
|
|
26
|
+
job[TOKEN_KEY] = key.digest
|
|
27
|
+
pushed = yield
|
|
28
|
+
# If a downstream middleware aborted the push (e.g. Sidekiq::Routing's
|
|
29
|
+
# blackhole returns false) the job never reaches Redis but our lock
|
|
30
|
+
# is held. Release it so the next identical enqueue isn't blocked
|
|
31
|
+
# until the TTL expires.
|
|
32
|
+
Releaser.release(key.digest, job["jid"]) if pushed == false
|
|
33
|
+
pushed
|
|
34
|
+
else
|
|
35
|
+
klass = job["wrapped"] || job["class"]
|
|
36
|
+
Sidekiq.logger.info { "Skipping enqueue for #{klass}, lock held by JID #{result.jid}" }
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def lockable?(job)
|
|
44
|
+
job[LOCK_FOR_KEY] && !job.key?(TOKEN_KEY) && job[LOCK_FOR_KEY].to_i >= 0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# How long the lock should live, in milliseconds. The base window is
|
|
48
|
+
# `lock_for` seconds; a scheduled job ("at" in the future) stays locked
|
|
49
|
+
# until it actually runs, so the remaining delay is added on top.
|
|
50
|
+
# Immediate jobs carry no delay.
|
|
51
|
+
def expiry_for(job)
|
|
52
|
+
window_seconds = job[LOCK_FOR_KEY].to_i
|
|
53
|
+
run_at = job["at"]
|
|
54
|
+
delay_seconds = run_at ? run_at - Time.now.to_f : 0.0
|
|
55
|
+
|
|
56
|
+
((window_seconds + delay_seconds) * 1000).to_i
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sidekiq
|
|
4
|
+
module Locking
|
|
5
|
+
module Middleware
|
|
6
|
+
# Releases the lock around perform.
|
|
7
|
+
#
|
|
8
|
+
# release_lock: :after_perform (default) — release after successful
|
|
9
|
+
# perform. Errored jobs keep the lock through retries until success or
|
|
10
|
+
# death.
|
|
11
|
+
# release_lock: :before_perform — release just before perform begins.
|
|
12
|
+
class Server
|
|
13
|
+
def call(_worker_class, job, _queue)
|
|
14
|
+
return yield unless locked?(job)
|
|
15
|
+
|
|
16
|
+
if release_before_perform?(job)
|
|
17
|
+
release(job)
|
|
18
|
+
yield
|
|
19
|
+
else
|
|
20
|
+
yield
|
|
21
|
+
release(job)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def locked?(job)
|
|
28
|
+
job[LOCK_FOR_KEY] && job[LOCK_FOR_KEY].to_i >= 0 && job[TOKEN_KEY]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def release_before_perform?(job)
|
|
32
|
+
job.fetch(RELEASE_LOCK_KEY, RELEASE_AFTER_PERFORM).to_s == RELEASE_BEFORE_PERFORM
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def release(job)
|
|
36
|
+
Releaser.release(job[TOKEN_KEY], job["jid"])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sidekiq
|
|
4
|
+
module Locking
|
|
5
|
+
# Deletes a lock only when the Redis value still equals the releasing job's
|
|
6
|
+
# JID, so a late finisher cannot delete a newer lock acquired after the
|
|
7
|
+
# original TTL expired. This compare-and-delete is the standard safe-release
|
|
8
|
+
# pattern from the Redis SET docs (https://redis.io/commands/set/).
|
|
9
|
+
module Releaser
|
|
10
|
+
UNLOCK_SCRIPT = <<~LUA
|
|
11
|
+
if redis.call('get', KEYS[1]) == ARGV[1] then
|
|
12
|
+
redis.call('del', KEYS[1])
|
|
13
|
+
end
|
|
14
|
+
LUA
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def release(digest, jid)
|
|
18
|
+
return unless digest && jid
|
|
19
|
+
|
|
20
|
+
Sidekiq.redis do |conn|
|
|
21
|
+
conn.call("EVAL", UNLOCK_SCRIPT, 1, "#{Locking.redis_key_prefix}#{digest}", jid)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sidekiq"
|
|
4
|
+
|
|
5
|
+
# Sidekiq::Locking — best-effort, TTL-bounded deduplication of Sidekiq enqueues.
|
|
6
|
+
#
|
|
7
|
+
# While a lock is held for a job, a second enqueue of the same job is dropped.
|
|
8
|
+
# Treat it as a pruning convenience, never a guarantee — the lock expires on a
|
|
9
|
+
# TTL and the check is inherently racy, so keep jobs idempotent and enforce real
|
|
10
|
+
# uniqueness in your datastore.
|
|
11
|
+
#
|
|
12
|
+
# The Redis building blocks used here — a token-valued `SET ... NX PX` to claim
|
|
13
|
+
# the lock and a compare-and-delete to release it — are the standard recipes from
|
|
14
|
+
# the Redis SET documentation (https://redis.io/commands/set/).
|
|
15
|
+
#
|
|
16
|
+
# Opt a job in with a lock window, and optionally narrow the dedup scope:
|
|
17
|
+
#
|
|
18
|
+
# # config/initializers/sidekiq.rb
|
|
19
|
+
# Sidekiq::Locking.install! unless Rails.env.test?
|
|
20
|
+
#
|
|
21
|
+
# class MyJob
|
|
22
|
+
# include Sidekiq::Job
|
|
23
|
+
# sidekiq_options lock_for: 5.minutes
|
|
24
|
+
#
|
|
25
|
+
# def self.lock_args(job)
|
|
26
|
+
# [job["args"].first]
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
module Sidekiq
|
|
30
|
+
module Locking
|
|
31
|
+
LOCK_FOR_KEY = "lock_for"
|
|
32
|
+
RELEASE_LOCK_KEY = "release_lock"
|
|
33
|
+
TOKEN_KEY = "lock_token"
|
|
34
|
+
|
|
35
|
+
RELEASE_AFTER_PERFORM = "after_perform"
|
|
36
|
+
RELEASE_BEFORE_PERFORM = "before_perform"
|
|
37
|
+
|
|
38
|
+
DEFAULT_REDIS_KEY_PREFIX = "sidekiq:locking:"
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
attr_writer :redis_key_prefix
|
|
42
|
+
|
|
43
|
+
def redis_key_prefix
|
|
44
|
+
@redis_key_prefix || DEFAULT_REDIS_KEY_PREFIX
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Registers the client + server middleware on both client and server
|
|
48
|
+
# configurations, plus a death handler so dead jobs release their lock.
|
|
49
|
+
def install!
|
|
50
|
+
Sidekiq.configure_client do |config|
|
|
51
|
+
config.client_middleware do |chain|
|
|
52
|
+
chain.add Middleware::Client
|
|
53
|
+
end
|
|
54
|
+
# ensure `perform_inline` releases locks too
|
|
55
|
+
config.server_middleware do |chain|
|
|
56
|
+
chain.add Middleware::Server
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
Sidekiq.configure_server do |config|
|
|
61
|
+
config.client_middleware do |chain|
|
|
62
|
+
chain.add Middleware::Client
|
|
63
|
+
end
|
|
64
|
+
config.server_middleware do |chain|
|
|
65
|
+
chain.add Middleware::Server
|
|
66
|
+
end
|
|
67
|
+
config.death_handlers << ->(job, _ex) do
|
|
68
|
+
Releaser.release(job[TOKEN_KEY], job["jid"]) if job[TOKEN_KEY]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
require "sidekiq/locking/key"
|
|
77
|
+
require "sidekiq/locking/acquirer"
|
|
78
|
+
require "sidekiq/locking/releaser"
|
|
79
|
+
require "sidekiq/locking/middleware/client"
|
|
80
|
+
require "sidekiq/locking/middleware/server"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sidekiq/locking/version"
|
|
4
|
+
|
|
5
|
+
# Sidekiq::Locking: TTL-bounded, best-effort enqueue deduplication for Sidekiq jobs.
|
|
6
|
+
#
|
|
7
|
+
# Defines Sidekiq::Locking and its public API (install!, redis_key_prefix). The
|
|
8
|
+
# module file requires its own collaborators (Key, Acquirer, Releaser, and the
|
|
9
|
+
# client/server middleware), so requiring it here loads the whole subsystem.
|
|
10
|
+
#
|
|
11
|
+
# The per-job DSL (`lock_for`, `release_lock`, `lock_args`) is unchanged. "Lock"
|
|
12
|
+
# somewhat overclaims: this is best-effort dedup, not a runtime mutex (see README).
|
|
13
|
+
require "sidekiq/locking"
|
metadata
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sidekiq-locking
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Vishnu M
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-17 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: sidekiq
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '7.0'
|
|
27
|
+
description: |
|
|
28
|
+
sidekiq-locking keeps only the first copy of a job in Redis while a
|
|
29
|
+
short-lived lock is active. A duplicate enqueue for the same lock context
|
|
30
|
+
(class, queue, args by default) is skipped until the original job succeeds
|
|
31
|
+
or the lock_for TTL expires — whichever comes first. It is deliberately
|
|
32
|
+
best-effort enqueue coalescing, not a correctness primitive or a runtime
|
|
33
|
+
mutex: jobs must still be idempotent and protect true uniqueness with
|
|
34
|
+
database constraints/locks where required. Opt a job in with
|
|
35
|
+
`sidekiq_options lock_for: 5.minutes`; customize the dedup scope with a
|
|
36
|
+
`lock_args` class method. No Rails or ActiveSupport required.
|
|
37
|
+
email:
|
|
38
|
+
- vishnu.m@bigbinary.com
|
|
39
|
+
executables: []
|
|
40
|
+
extensions: []
|
|
41
|
+
extra_rdoc_files: []
|
|
42
|
+
files:
|
|
43
|
+
- LICENSE
|
|
44
|
+
- README.md
|
|
45
|
+
- lib/sidekiq-locking.rb
|
|
46
|
+
- lib/sidekiq/locking.rb
|
|
47
|
+
- lib/sidekiq/locking/acquirer.rb
|
|
48
|
+
- lib/sidekiq/locking/key.rb
|
|
49
|
+
- lib/sidekiq/locking/middleware/client.rb
|
|
50
|
+
- lib/sidekiq/locking/middleware/server.rb
|
|
51
|
+
- lib/sidekiq/locking/releaser.rb
|
|
52
|
+
- lib/sidekiq/locking/version.rb
|
|
53
|
+
homepage: https://github.com/neetozone/sidekiq-locking
|
|
54
|
+
licenses:
|
|
55
|
+
- MIT
|
|
56
|
+
metadata:
|
|
57
|
+
source_code_uri: https://github.com/neetozone/sidekiq-locking
|
|
58
|
+
rubygems_mfa_required: 'true'
|
|
59
|
+
post_install_message:
|
|
60
|
+
rdoc_options: []
|
|
61
|
+
require_paths:
|
|
62
|
+
- lib
|
|
63
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.1'
|
|
68
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '0'
|
|
73
|
+
requirements: []
|
|
74
|
+
rubygems_version: 3.5.22
|
|
75
|
+
signing_key:
|
|
76
|
+
specification_version: 4
|
|
77
|
+
summary: 'Best-effort, TTL-bounded enqueue deduplication for Sidekiq jobs: drop duplicate
|
|
78
|
+
enqueues of the same job while a lock is held.'
|
|
79
|
+
test_files: []
|