redlock 1.2.0 → 1.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +31 -0
- data/.gitignore +1 -0
- data/Gemfile.lock +31 -25
- data/Makefile +3 -0
- data/README.md +63 -2
- data/docker-compose.yml +6 -0
- data/lib/redlock/client.rb +103 -30
- data/lib/redlock/scripts.rb +34 -0
- data/lib/redlock/testing.rb +2 -0
- data/lib/redlock/version.rb +1 -1
- data/lib/redlock.rb +1 -0
- data/redlock.gemspec +14 -13
- data/spec/client_spec.rb +202 -9
- metadata +41 -20
- data/.travis.yml +0 -10
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 91ee96071b53b7710c8268b7146c1c44f77a50b1b38267ccc9c088ad2ce8ca0c
|
4
|
+
data.tar.gz: 3d5adc81e331d1f971b440c14c9c8418257c7c499c8cc40a515dbc80c564dda5
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5753c8c53fee8599cf22869200bed307d27eff4c0aeb8e5235e96dfd46fe201283cb122d9298f7cc113396a5fa141ecb6d69f2f90bb80a4e0aff4bd1ae89fa50
|
7
|
+
data.tar.gz: 2fe0eb860c936c84bf37e3f77a6e8f3b83d003e30121c964d2a1a816894b48a3ce69c874982bb8973d626cab95f508a752da157def815e69133cf0a17c57dc63
|
@@ -0,0 +1,31 @@
|
|
1
|
+
name: Ruby CI
|
2
|
+
|
3
|
+
on:
|
4
|
+
push:
|
5
|
+
branches: [ master ]
|
6
|
+
pull_request:
|
7
|
+
branches: [ master ]
|
8
|
+
|
9
|
+
jobs:
|
10
|
+
test:
|
11
|
+
|
12
|
+
runs-on: ubuntu-latest
|
13
|
+
|
14
|
+
strategy:
|
15
|
+
matrix:
|
16
|
+
ruby-version: [3.1, "3.0", "2.7", "2.6", "2.5", "ruby-head"]
|
17
|
+
|
18
|
+
steps:
|
19
|
+
- uses: actions/checkout@v2
|
20
|
+
- name: Set up Ruby ${{ matrix.ruby-version }}
|
21
|
+
uses: ruby/setup-ruby@v1
|
22
|
+
with:
|
23
|
+
ruby-version: ${{ matrix.ruby-version }}
|
24
|
+
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
25
|
+
- name: Start Redis
|
26
|
+
uses: supercharge/redis-github-action@1.2.0
|
27
|
+
with:
|
28
|
+
redis-version: 6
|
29
|
+
- name: Run tests
|
30
|
+
run: bundle exec rspec
|
31
|
+
|
data/.gitignore
CHANGED
data/Gemfile.lock
CHANGED
@@ -1,46 +1,51 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
redlock (1.2.
|
5
|
-
redis (>= 3.0.0, <
|
4
|
+
redlock (1.2.2)
|
5
|
+
redis (>= 3.0.0, < 6.0)
|
6
6
|
|
7
7
|
GEM
|
8
8
|
remote: https://rubygems.org/
|
9
9
|
specs:
|
10
|
-
connection_pool (2.2.
|
11
|
-
coveralls (0.8.
|
10
|
+
connection_pool (2.2.5)
|
11
|
+
coveralls (0.8.23)
|
12
12
|
json (>= 1.8, < 3)
|
13
13
|
simplecov (~> 0.16.1)
|
14
14
|
term-ansicolor (~> 1.3)
|
15
|
-
thor (
|
15
|
+
thor (>= 0.19.4, < 2.0)
|
16
16
|
tins (~> 1.6)
|
17
|
-
diff-lcs (1.
|
18
|
-
docile (1.
|
19
|
-
json (2.1
|
20
|
-
rake (13.0.
|
21
|
-
redis (
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
rspec-
|
28
|
-
|
17
|
+
diff-lcs (1.5.0)
|
18
|
+
docile (1.4.0)
|
19
|
+
json (2.3.1)
|
20
|
+
rake (13.0.6)
|
21
|
+
redis (5.0.1)
|
22
|
+
redis-client (~> 0.7)
|
23
|
+
redis-client (0.7.1)
|
24
|
+
connection_pool
|
25
|
+
rspec (3.11.0)
|
26
|
+
rspec-core (~> 3.11.0)
|
27
|
+
rspec-expectations (~> 3.11.0)
|
28
|
+
rspec-mocks (~> 3.11.0)
|
29
|
+
rspec-core (3.11.0)
|
30
|
+
rspec-support (~> 3.11.0)
|
31
|
+
rspec-expectations (3.11.0)
|
29
32
|
diff-lcs (>= 1.2.0, < 2.0)
|
30
|
-
rspec-support (~> 3.
|
31
|
-
rspec-mocks (3.
|
33
|
+
rspec-support (~> 3.11.0)
|
34
|
+
rspec-mocks (3.11.1)
|
32
35
|
diff-lcs (>= 1.2.0, < 2.0)
|
33
|
-
rspec-support (~> 3.
|
34
|
-
rspec-support (3.
|
36
|
+
rspec-support (~> 3.11.0)
|
37
|
+
rspec-support (3.11.0)
|
35
38
|
simplecov (0.16.1)
|
36
39
|
docile (~> 1.1)
|
37
40
|
json (>= 1.8, < 3)
|
38
41
|
simplecov-html (~> 0.10.0)
|
39
42
|
simplecov-html (0.10.2)
|
40
|
-
|
43
|
+
sync (0.5.0)
|
44
|
+
term-ansicolor (1.7.1)
|
41
45
|
tins (~> 1.0)
|
42
|
-
thor (
|
43
|
-
tins (1.
|
46
|
+
thor (1.2.1)
|
47
|
+
tins (1.31.1)
|
48
|
+
sync
|
44
49
|
|
45
50
|
PLATFORMS
|
46
51
|
ruby
|
@@ -48,9 +53,10 @@ PLATFORMS
|
|
48
53
|
DEPENDENCIES
|
49
54
|
connection_pool (~> 2.2)
|
50
55
|
coveralls (~> 0.8)
|
56
|
+
json (~> 2.3.1, >= 2.3.0)
|
51
57
|
rake (~> 13.0, >= 11.1.2)
|
52
58
|
redlock!
|
53
59
|
rspec (~> 3, >= 3.0.0)
|
54
60
|
|
55
61
|
BUNDLED WITH
|
56
|
-
|
62
|
+
2.3.7
|
data/Makefile
CHANGED
data/README.md
CHANGED
@@ -1,8 +1,7 @@
|
|
1
|
-
[![Build Status](https://
|
1
|
+
[![Build Status](https://github.com/leandromoreira/redlock-rb/actions/workflows/ci.yml/badge.svg)](https://github.com/leandromoreira/redlock-rb/actions/workflows/ci.yml)
|
2
2
|
[![Coverage Status](https://coveralls.io/repos/leandromoreira/redlock-rb/badge.svg?branch=master)](https://coveralls.io/r/leandromoreira/redlock-rb?branch=master)
|
3
3
|
[![Code Climate](https://codeclimate.com/github/leandromoreira/redlock-rb/badges/gpa.svg)](https://codeclimate.com/github/leandromoreira/redlock-rb)
|
4
4
|
[![Gem Version](https://badge.fury.io/rb/redlock.svg)](http://badge.fury.io/rb/redlock)
|
5
|
-
[![security](https://hakiri.io/github/leandromoreira/redlock-rb/master.svg)](https://hakiri.io/github/leandromoreira/redlock-rb/master)
|
6
5
|
[![Inline docs](http://inch-ci.org/github/leandromoreira/redlock-rb.svg?branch=master)](http://inch-ci.org/github/leandromoreira/redlock-rb)
|
7
6
|
|
8
7
|
|
@@ -112,6 +111,68 @@ The above code will also acquire the lock if the previous lock has expired and t
|
|
112
111
|
lock_manager.lock("resource key", 3000, extend: lock_info, extend_only_if_locked: true)
|
113
112
|
```
|
114
113
|
|
114
|
+
### Querying lock status
|
115
|
+
|
116
|
+
You can check if a resource is locked:
|
117
|
+
|
118
|
+
```ruby
|
119
|
+
resource = "resource_key"
|
120
|
+
lock_info = lock_manager.lock(resource, 2000)
|
121
|
+
lock_manager.locked?(resource)
|
122
|
+
#=> true
|
123
|
+
|
124
|
+
lock_manager.unlock(lock_info)
|
125
|
+
lock_manager.locked?(resource)
|
126
|
+
#=> false
|
127
|
+
```
|
128
|
+
|
129
|
+
Any caller can call the above method to query the status. If you hold a lock and would like to check if it is valid, you can use the `valid_lock?` method:
|
130
|
+
|
131
|
+
```ruby
|
132
|
+
lock_info = lock_manager.lock("resource_key", 2000)
|
133
|
+
lock_manager.valid_lock?(lock_info)
|
134
|
+
#=> true
|
135
|
+
|
136
|
+
lock_manager.unlock(lock_info)
|
137
|
+
lock_manager.valid_lock?(lock_info)
|
138
|
+
#=> false
|
139
|
+
```
|
140
|
+
|
141
|
+
The above methods **are not safe if you are using this to time critical code**, since they return true if the lock has not expired, even if there's only (for example) 1ms left on the lock. If you want to safely time the lock validity, you can use the `get_remaining_ttl_for_lock` and `get_remaining_ttl_for_resource` methods.
|
142
|
+
|
143
|
+
Use `get_remaining_ttl_for_lock` if you hold a lock and want to check the TTL specifically for your lock:
|
144
|
+
```ruby
|
145
|
+
resource = "resource_key"
|
146
|
+
lock_info = lock_manager.lock(resource, 2000)
|
147
|
+
sleep 1
|
148
|
+
|
149
|
+
lock_manager.get_remaining_ttl_for_lock(lock_info)
|
150
|
+
#=> 986
|
151
|
+
|
152
|
+
lock_manager.unlock(lock_info)
|
153
|
+
lock_manager.get_remaining_ttl_for_lock(lock_info)
|
154
|
+
#=> nil
|
155
|
+
```
|
156
|
+
|
157
|
+
Use `get_remaining_ttl_for_resource` if you do not hold a lock, but want to know the remaining TTL on a locked resource:
|
158
|
+
```ruby
|
159
|
+
# Some part of the code
|
160
|
+
resource = "resource_key"
|
161
|
+
lock_info = lock_manager.lock(resource, 2000)
|
162
|
+
|
163
|
+
# Some other part of the code
|
164
|
+
lock_manager.locked?(resource)
|
165
|
+
#=> true
|
166
|
+
lock_manager.get_remaining_ttl_for_resource(resource)
|
167
|
+
#=> 1975
|
168
|
+
|
169
|
+
# Sometime later
|
170
|
+
lock_manager.locked?(resource)
|
171
|
+
#=> false
|
172
|
+
lock_manager.get_remaining_ttl_for_resource(resource)
|
173
|
+
#=> nil
|
174
|
+
```
|
175
|
+
|
115
176
|
## Redis client configuration
|
116
177
|
|
117
178
|
`Redlock::Client` expects URLs or Redis objects on initialization. Redis objects should be used for configuring the connection in more detail, i.e. setting username and password.
|
data/docker-compose.yml
CHANGED
@@ -11,17 +11,23 @@ services:
|
|
11
11
|
- REDIS1_PORT=6379
|
12
12
|
- REDIS2_HOST=redis2.local.com
|
13
13
|
- REDIS2_PORT=6379
|
14
|
+
- REDIS3_HOST=redis3.local.com
|
15
|
+
- REDIS3_PORT=6379
|
14
16
|
- DEFAULT_REDIS_HOST=redis1.local.com
|
15
17
|
- DEFAULT_REDIS_PORT=6379
|
16
18
|
links:
|
17
19
|
- redis1:redis1.local.com
|
18
20
|
- redis2:redis2.local.com
|
21
|
+
- redis3:redis3.local.com
|
19
22
|
depends_on:
|
20
23
|
- redis1
|
21
24
|
- redis2
|
25
|
+
- redis3
|
22
26
|
|
23
27
|
redis1:
|
24
28
|
image: redis
|
25
29
|
redis2:
|
26
30
|
image: redis
|
31
|
+
redis3:
|
32
|
+
image: redis
|
27
33
|
|
data/lib/redlock/client.rb
CHANGED
@@ -2,6 +2,8 @@ require 'redis'
|
|
2
2
|
require 'securerandom'
|
3
3
|
|
4
4
|
module Redlock
|
5
|
+
include Scripts
|
6
|
+
|
5
7
|
class Client
|
6
8
|
DEFAULT_REDIS_HOST = ENV["DEFAULT_REDIS_HOST"] || "localhost"
|
7
9
|
DEFAULT_REDIS_PORT = ENV["DEFAULT_REDIS_PORT"] || "6379"
|
@@ -54,6 +56,9 @@ module Redlock
|
|
54
56
|
# +resource+:: the resource (or key) string to be locked.
|
55
57
|
# +ttl+:: The time-to-live in ms for the lock.
|
56
58
|
# +options+:: Hash of optional parameters
|
59
|
+
# * +retry_count+: see +initialize+
|
60
|
+
# * +retry_delay+: see +initialize+
|
61
|
+
# * +retry_jitter+: see +initialize+
|
57
62
|
# * +extend+: A lock ("lock_info") to extend.
|
58
63
|
# * +extend_only_if_locked+: Boolean, if +extend+ is given, only acquire lock if currently held
|
59
64
|
# * +extend_only_if_life+: Deprecated, same as +extend_only_if_locked+
|
@@ -102,26 +107,46 @@ module Redlock
|
|
102
107
|
end
|
103
108
|
end
|
104
109
|
|
110
|
+
# Gets remaining ttl of a resource. The ttl is returned if the holder
|
111
|
+
# currently holds the lock and it has not expired, otherwise the method
|
112
|
+
# returns nil.
|
113
|
+
# Params:
|
114
|
+
# +lock_info+:: the lock that has been acquired when you locked the resource
|
115
|
+
def get_remaining_ttl_for_lock(lock_info)
|
116
|
+
ttl_info = try_get_remaining_ttl(lock_info[:resource])
|
117
|
+
return nil if ttl_info.nil? || ttl_info[:value] != lock_info[:value]
|
118
|
+
ttl_info[:ttl]
|
119
|
+
end
|
120
|
+
|
121
|
+
# Gets remaining ttl of a resource. If there is no valid lock, the method
|
122
|
+
# returns nil.
|
123
|
+
# Params:
|
124
|
+
# +resource+:: the name of the resource (string) for which to check the ttl
|
125
|
+
def get_remaining_ttl_for_resource(resource)
|
126
|
+
ttl_info = try_get_remaining_ttl(resource)
|
127
|
+
return nil if ttl_info.nil?
|
128
|
+
ttl_info[:ttl]
|
129
|
+
end
|
130
|
+
|
131
|
+
# Checks if a resource is locked
|
132
|
+
# Params:
|
133
|
+
# +lock_info+:: the lock that has been acquired when you locked the resource
|
134
|
+
def locked?(resource)
|
135
|
+
ttl = get_remaining_ttl_for_resource(resource)
|
136
|
+
!(ttl.nil? || ttl.zero?)
|
137
|
+
end
|
138
|
+
|
139
|
+
# Checks if a lock is still valid
|
140
|
+
# Params:
|
141
|
+
# +lock_info+:: the lock that has been acquired when you locked the resource
|
142
|
+
def valid_lock?(lock_info)
|
143
|
+
ttl = get_remaining_ttl_for_lock(lock_info)
|
144
|
+
!(ttl.nil? || ttl.zero?)
|
145
|
+
end
|
146
|
+
|
105
147
|
private
|
106
148
|
|
107
149
|
class RedisInstance
|
108
|
-
UNLOCK_SCRIPT = <<-eos
|
109
|
-
if redis.call("get",KEYS[1]) == ARGV[1] then
|
110
|
-
return redis.call("del",KEYS[1])
|
111
|
-
else
|
112
|
-
return 0
|
113
|
-
end
|
114
|
-
eos
|
115
|
-
|
116
|
-
# thanks to https://github.com/sbertrang/redis-distlock/blob/master/lib/Redis/DistLock.pm
|
117
|
-
# also https://github.com/sbertrang/redis-distlock/issues/2 which proposes the value-checking
|
118
|
-
# and @maltoe for https://github.com/leandromoreira/redlock-rb/pull/20#discussion_r38903633
|
119
|
-
LOCK_SCRIPT = <<-eos
|
120
|
-
if (redis.call("exists", KEYS[1]) == 0 and ARGV[3] == "yes") or redis.call("get", KEYS[1]) == ARGV[1] then
|
121
|
-
return redis.call("set", KEYS[1], ARGV[1], "PX", ARGV[2])
|
122
|
-
end
|
123
|
-
eos
|
124
|
-
|
125
150
|
module ConnectionPoolLike
|
126
151
|
def with
|
127
152
|
yield self
|
@@ -139,13 +164,11 @@ module Redlock
|
|
139
164
|
end
|
140
165
|
@redis.extend(ConnectionPoolLike)
|
141
166
|
end
|
142
|
-
|
143
|
-
load_scripts
|
144
167
|
end
|
145
168
|
|
146
169
|
def lock(resource, val, ttl, allow_new_lock)
|
147
170
|
recover_from_script_flush do
|
148
|
-
@redis.with { |conn| conn.evalsha
|
171
|
+
@redis.with { |conn| conn.evalsha Scripts::LOCK_SCRIPT_SHA, keys: [resource], argv: [val, ttl, allow_new_lock] }
|
149
172
|
end
|
150
173
|
rescue Redis::BaseConnectionError
|
151
174
|
false
|
@@ -153,17 +176,32 @@ module Redlock
|
|
153
176
|
|
154
177
|
def unlock(resource, val)
|
155
178
|
recover_from_script_flush do
|
156
|
-
@redis.with { |conn| conn.evalsha
|
179
|
+
@redis.with { |conn| conn.evalsha Scripts::UNLOCK_SCRIPT_SHA, keys: [resource], argv: [val] }
|
157
180
|
end
|
158
181
|
rescue
|
159
182
|
# Nothing to do, unlocking is just a best-effort attempt.
|
160
183
|
end
|
161
184
|
|
185
|
+
def get_remaining_ttl(resource)
|
186
|
+
recover_from_script_flush do
|
187
|
+
@redis.with { |conn| conn.evalsha Scripts::PTTL_SCRIPT_SHA, keys: [resource] }
|
188
|
+
end
|
189
|
+
rescue Redis::BaseConnectionError
|
190
|
+
nil
|
191
|
+
end
|
192
|
+
|
162
193
|
private
|
163
194
|
|
164
195
|
def load_scripts
|
165
|
-
|
166
|
-
|
196
|
+
scripts = [
|
197
|
+
Scripts::UNLOCK_SCRIPT,
|
198
|
+
Scripts::LOCK_SCRIPT,
|
199
|
+
Scripts::PTTL_SCRIPT
|
200
|
+
]
|
201
|
+
|
202
|
+
scripts.each do |script|
|
203
|
+
@redis.with { |conn| conn.script(:load, script) }
|
204
|
+
end
|
167
205
|
end
|
168
206
|
|
169
207
|
def recover_from_script_flush
|
@@ -186,11 +224,12 @@ module Redlock
|
|
186
224
|
end
|
187
225
|
|
188
226
|
def try_lock_instances(resource, ttl, options)
|
189
|
-
|
227
|
+
retry_count = options[:retry_count] || @retry_count
|
228
|
+
tries = options[:extend] ? 1 : (retry_count + 1)
|
190
229
|
|
191
230
|
tries.times do |attempt_number|
|
192
231
|
# Wait a random delay before retrying.
|
193
|
-
sleep(attempt_retry_delay(attempt_number)) if attempt_number > 0
|
232
|
+
sleep(attempt_retry_delay(attempt_number, options)) if attempt_number > 0
|
194
233
|
|
195
234
|
lock_info = lock_instances(resource, ttl, options)
|
196
235
|
return lock_info if lock_info
|
@@ -199,15 +238,18 @@ module Redlock
|
|
199
238
|
false
|
200
239
|
end
|
201
240
|
|
202
|
-
def attempt_retry_delay(attempt_number)
|
241
|
+
def attempt_retry_delay(attempt_number, options)
|
242
|
+
retry_delay = options[:retry_delay] || @retry_delay
|
243
|
+
retry_jitter = options[:retry_jitter] || @retry_jitter
|
244
|
+
|
203
245
|
retry_delay =
|
204
|
-
if
|
205
|
-
|
246
|
+
if retry_delay.respond_to?(:call)
|
247
|
+
retry_delay.call(attempt_number)
|
206
248
|
else
|
207
|
-
|
249
|
+
retry_delay
|
208
250
|
end
|
209
251
|
|
210
|
-
(retry_delay + rand(
|
252
|
+
(retry_delay + rand(retry_jitter)).to_f / 1000
|
211
253
|
end
|
212
254
|
|
213
255
|
def lock_instances(resource, ttl, options)
|
@@ -228,6 +270,37 @@ module Redlock
|
|
228
270
|
end
|
229
271
|
end
|
230
272
|
|
273
|
+
def try_get_remaining_ttl(resource)
|
274
|
+
# Responses from the servers are a 2 tuple of format [lock_value, ttl].
|
275
|
+
# The lock_value is nil if it does not exist. Since servers may have
|
276
|
+
# different lock values, the responses are grouped by the lock_value and
|
277
|
+
# transofrmed into a hash: { lock_value1 => [ttl1, ttl2, ttl3],
|
278
|
+
# lock_value2 => [ttl4, tt5] }
|
279
|
+
ttls_by_value, time_elapsed = timed do
|
280
|
+
@servers.map { |s| s.get_remaining_ttl(resource) }
|
281
|
+
.select { |ttl_tuple| ttl_tuple&.first }
|
282
|
+
.group_by(&:first)
|
283
|
+
.transform_values { |ttl_tuples| ttl_tuples.map { |t| t.last } }
|
284
|
+
end
|
285
|
+
|
286
|
+
# Authoritative lock value is that which is returned by the majority of
|
287
|
+
# servers
|
288
|
+
authoritative_value, ttls =
|
289
|
+
ttls_by_value.max_by { |(lock_value, ttls)| ttls.length }
|
290
|
+
|
291
|
+
if ttls && ttls.size >= @quorum
|
292
|
+
# Return the minimum TTL of an N/2+1 selection. It will always be
|
293
|
+
# correct (it will guarantee that at least N/2+1 servers have a TTL that
|
294
|
+
# value or longer)
|
295
|
+
min_ttl = ttls.sort.last(@quorum).first
|
296
|
+
min_ttl = min_ttl - time_elapsed - drift(min_ttl)
|
297
|
+
{ value: authoritative_value, ttl: min_ttl }
|
298
|
+
else
|
299
|
+
# No lock_value is authoritatively held for the resource
|
300
|
+
nil
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
231
304
|
def drift(ttl)
|
232
305
|
# Add 2 milliseconds to the drift to account for Redis expires
|
233
306
|
# precision, which is 1 millisecond, plus 1 millisecond min drift
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'digest'
|
2
|
+
|
3
|
+
module Redlock
|
4
|
+
module Scripts
|
5
|
+
UNLOCK_SCRIPT = <<-eos
|
6
|
+
if redis.call("get",KEYS[1]) == ARGV[1] then
|
7
|
+
return redis.call("del",KEYS[1])
|
8
|
+
else
|
9
|
+
return 0
|
10
|
+
end
|
11
|
+
eos
|
12
|
+
|
13
|
+
# thanks to https://github.com/sbertrang/redis-distlock/blob/master/lib/Redis/DistLock.pm
|
14
|
+
# also https://github.com/sbertrang/redis-distlock/issues/2 which proposes the value-checking
|
15
|
+
# and @maltoe for https://github.com/leandromoreira/redlock-rb/pull/20#discussion_r38903633
|
16
|
+
LOCK_SCRIPT = <<-eos
|
17
|
+
if (redis.call("exists", KEYS[1]) == 0 and ARGV[3] == "yes") or redis.call("get", KEYS[1]) == ARGV[1] then
|
18
|
+
return redis.call("set", KEYS[1], ARGV[1], "PX", ARGV[2])
|
19
|
+
end
|
20
|
+
eos
|
21
|
+
|
22
|
+
PTTL_SCRIPT = <<-eos
|
23
|
+
return { redis.call("get", KEYS[1]), redis.call("pttl", KEYS[1]) }
|
24
|
+
eos
|
25
|
+
|
26
|
+
# We do not want to load the scripts on every Redlock::Client initialization.
|
27
|
+
# Hence, we rely on Redis handing out SHA1 hashes of the cached scripts and
|
28
|
+
# pre-calculate them instead of loading the scripts unconditionally. If the scripts
|
29
|
+
# have not been cached on Redis, `recover_from_script_flush` has our backs.
|
30
|
+
UNLOCK_SCRIPT_SHA = Digest::SHA1.hexdigest(UNLOCK_SCRIPT)
|
31
|
+
LOCK_SCRIPT_SHA = Digest::SHA1.hexdigest(LOCK_SCRIPT)
|
32
|
+
PTTL_SCRIPT_SHA = Digest::SHA1.hexdigest(PTTL_SCRIPT)
|
33
|
+
end
|
34
|
+
end
|
data/lib/redlock/testing.rb
CHANGED
data/lib/redlock/version.rb
CHANGED
data/lib/redlock.rb
CHANGED
data/redlock.gemspec
CHANGED
@@ -1,27 +1,28 @@
|
|
1
|
-
#
|
2
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
lib = File.expand_path('lib', __dir__)
|
3
4
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
5
|
require 'redlock/version'
|
5
6
|
|
6
7
|
Gem::Specification.new do |spec|
|
7
|
-
spec.name =
|
8
|
+
spec.name = 'redlock'
|
8
9
|
spec.version = Redlock::VERSION
|
9
|
-
spec.authors = [
|
10
|
-
spec.email = [
|
11
|
-
spec.summary =
|
12
|
-
spec.description =
|
13
|
-
spec.homepage =
|
10
|
+
spec.authors = ['Leandro Moreira']
|
11
|
+
spec.email = ['leandro.ribeiro.moreira@gmail.com']
|
12
|
+
spec.summary = 'Distributed lock using Redis written in Ruby.'
|
13
|
+
spec.description = 'Distributed lock using Redis written in Ruby. Highly inspired by https://github.com/antirez/redlock-rb.'
|
14
|
+
spec.homepage = 'https://github.com/leandromoreira/redlock-rb'
|
14
15
|
spec.license = 'BSD-2-Clause'
|
15
16
|
|
16
17
|
spec.files = `git ls-files -z`.split("\x0")
|
17
|
-
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
18
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
-
spec.require_paths = [
|
19
|
+
spec.require_paths = ['lib']
|
20
20
|
|
21
|
-
spec.add_dependency 'redis', '>= 3.0.0', '<
|
21
|
+
spec.add_dependency 'redis', '>= 3.0.0', '< 6.0'
|
22
22
|
|
23
|
-
spec.add_development_dependency
|
23
|
+
spec.add_development_dependency 'connection_pool', '~> 2.2'
|
24
|
+
spec.add_development_dependency 'coveralls', '~> 0.8'
|
25
|
+
spec.add_development_dependency 'json', '>= 2.3.0', '~> 2.3.1'
|
24
26
|
spec.add_development_dependency 'rake', '>= 11.1.2', '~> 13.0'
|
25
27
|
spec.add_development_dependency 'rspec', '~> 3', '>= 3.0.0'
|
26
|
-
spec.add_development_dependency 'connection_pool', '~> 2.2'
|
27
28
|
end
|
data/spec/client_spec.rb
CHANGED
@@ -7,13 +7,22 @@ RSpec.describe Redlock::Client do
|
|
7
7
|
# It is recommended to have at least 3 servers in production
|
8
8
|
let(:lock_manager_opts) { { retry_count: 3 } }
|
9
9
|
let(:lock_manager) { Redlock::Client.new(Redlock::Client::DEFAULT_REDIS_URLS, lock_manager_opts) }
|
10
|
-
let(:redis_client) { Redis.new }
|
10
|
+
let(:redis_client) { Redis.new(url: "redis://#{redis1_host}:#{redis1_port}") }
|
11
11
|
let(:resource_key) { SecureRandom.hex(3) }
|
12
12
|
let(:ttl) { 1000 }
|
13
13
|
let(:redis1_host) { ENV["REDIS1_HOST"] || "localhost" }
|
14
14
|
let(:redis1_port) { ENV["REDIS1_PORT"] || "6379" }
|
15
15
|
let(:redis2_host) { ENV["REDIS2_HOST"] || "127.0.0.1" }
|
16
16
|
let(:redis2_port) { ENV["REDIS2_PORT"] || "6379" }
|
17
|
+
let(:redis3_host) { ENV["REDIS3_HOST"] || "127.0.0.1" }
|
18
|
+
let(:redis3_port) { ENV["REDIS3_PORT"] || "6379" }
|
19
|
+
let(:unreachable_redis) {
|
20
|
+
redis = Redis.new(url: 'redis://localhost:46864')
|
21
|
+
def redis.with
|
22
|
+
yield self
|
23
|
+
end
|
24
|
+
redis
|
25
|
+
}
|
17
26
|
|
18
27
|
describe 'initialize' do
|
19
28
|
it 'accepts both redis URLs and Redis objects' do
|
@@ -35,6 +44,15 @@ RSpec.describe Redlock::Client do
|
|
35
44
|
expect(resource_key).to_not be_lockable(lock_manager, ttl)
|
36
45
|
lock_manager.unlock(lock_info)
|
37
46
|
end
|
47
|
+
|
48
|
+
it 'does not load scripts' do
|
49
|
+
redis_client.script(:flush)
|
50
|
+
|
51
|
+
pool = ConnectionPool.new { Redis.new(url: "redis://#{redis1_host}:#{redis1_port}") }
|
52
|
+
redlock = Redlock::Client.new([pool])
|
53
|
+
|
54
|
+
expect(redis_client.info["number_of_cached_scripts"]).to eq("0")
|
55
|
+
end
|
38
56
|
end
|
39
57
|
|
40
58
|
describe 'lock' do
|
@@ -186,6 +204,41 @@ RSpec.describe Redlock::Client do
|
|
186
204
|
lock_manager.lock(resource_key, ttl)
|
187
205
|
lock_manager.unlock(another_lock_info)
|
188
206
|
end
|
207
|
+
|
208
|
+
context 'when retry_count is given' do
|
209
|
+
it 'prioritizes the retry_count in option and tries up to \'retry_count\' + 1 times' do
|
210
|
+
retry_count = 1
|
211
|
+
expect(retry_count).not_to eq(lock_manager_opts[:retry_count])
|
212
|
+
expect(lock_manager).to receive(:lock_instances).exactly(retry_count + 1).times.and_return(false)
|
213
|
+
lock_manager.lock(resource_key, ttl, retry_count: retry_count)
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
context 'when retry_delay is given' do
|
218
|
+
it 'prioritizes the retry_delay in option and sleeps at least the specified retry_delay in milliseconds' do
|
219
|
+
retry_delay = 300
|
220
|
+
expect(retry_delay > described_class::DEFAULT_RETRY_DELAY).to eq(true)
|
221
|
+
expected_minimum = retry_delay
|
222
|
+
|
223
|
+
expect(lock_manager).to receive(:sleep) do |sleep|
|
224
|
+
expect(sleep).to satisfy { |value| value >= expected_minimum / 1000.to_f }
|
225
|
+
end.at_least(:once)
|
226
|
+
lock_manager.lock(resource_key, ttl, retry_delay: retry_delay)
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
context 'when retry_jitter is given' do
|
231
|
+
it 'prioritizes the retry_jitter in option and sleeps a maximum of retry_delay + retry_jitter in milliseconds' do
|
232
|
+
retry_jitter = 60
|
233
|
+
expect(retry_jitter > described_class::DEFAULT_RETRY_JITTER).to eq(true)
|
234
|
+
|
235
|
+
expected_maximum = described_class::DEFAULT_RETRY_DELAY + retry_jitter
|
236
|
+
expect(lock_manager).to receive(:sleep) do |sleep|
|
237
|
+
expect(sleep).to satisfy { |value| value < expected_maximum / 1000.to_f }
|
238
|
+
end.at_least(:once)
|
239
|
+
lock_manager.lock(resource_key, ttl, retry_jitter: retry_jitter)
|
240
|
+
end
|
241
|
+
end
|
189
242
|
end
|
190
243
|
|
191
244
|
context 'when a server goes away' do
|
@@ -212,14 +265,6 @@ RSpec.describe Redlock::Client do
|
|
212
265
|
end
|
213
266
|
end
|
214
267
|
|
215
|
-
def unreachable_redis
|
216
|
-
redis = Redis.new(url: 'redis://localhost:46864')
|
217
|
-
def redis.with
|
218
|
-
yield self
|
219
|
-
end
|
220
|
-
redis
|
221
|
-
end
|
222
|
-
|
223
268
|
context 'when script cache has been flushed' do
|
224
269
|
before(:each) do
|
225
270
|
@manipulated_instance = lock_manager.instance_variable_get(:@servers).first
|
@@ -367,6 +412,154 @@ RSpec.describe Redlock::Client do
|
|
367
412
|
end
|
368
413
|
end
|
369
414
|
|
415
|
+
describe 'get_remaining_ttl_for_resource' do
|
416
|
+
context 'when lock is valid' do
|
417
|
+
after(:each) { lock_manager.unlock(@lock_info) if @lock_info }
|
418
|
+
|
419
|
+
it 'gets the remaining ttl of a lock' do
|
420
|
+
ttl = 20_000
|
421
|
+
@lock_info = lock_manager.lock(resource_key, ttl)
|
422
|
+
remaining_ttl = lock_manager.get_remaining_ttl_for_resource(resource_key)
|
423
|
+
expect(remaining_ttl).to be_within(300).of(ttl)
|
424
|
+
end
|
425
|
+
|
426
|
+
context 'when servers respond with varying ttls' do
|
427
|
+
let (:servers) {
|
428
|
+
[
|
429
|
+
"redis://#{redis1_host}:#{redis1_port}",
|
430
|
+
"redis://#{redis2_host}:#{redis2_port}",
|
431
|
+
"redis://#{redis3_host}:#{redis3_port}"
|
432
|
+
]
|
433
|
+
}
|
434
|
+
let (:redlock) { Redlock::Client.new(servers) }
|
435
|
+
after(:each) { redlock.unlock(@lock_info) if @lock_info }
|
436
|
+
|
437
|
+
it 'returns the minimum ttl value' do
|
438
|
+
ttl = 20_000
|
439
|
+
@lock_info = redlock.lock(resource_key, ttl)
|
440
|
+
|
441
|
+
# Mock redis server responses to return different ttls
|
442
|
+
returned_ttls = [20_000, 15_000, 10_000]
|
443
|
+
redlock.instance_variable_get(:@servers).each_with_index do |server, index|
|
444
|
+
allow(server).to(receive(:get_remaining_ttl))
|
445
|
+
.with(resource_key)
|
446
|
+
.and_return([@lock_info[:value], returned_ttls[index]])
|
447
|
+
end
|
448
|
+
|
449
|
+
remaining_ttl = redlock.get_remaining_ttl_for_lock(@lock_info)
|
450
|
+
|
451
|
+
# Assert that the TTL is closest to the closest to the correct value
|
452
|
+
expect(remaining_ttl).to be_within(300).of(returned_ttls[1])
|
453
|
+
end
|
454
|
+
end
|
455
|
+
end
|
456
|
+
|
457
|
+
context 'when lock is not valid' do
|
458
|
+
it 'returns nil' do
|
459
|
+
lock_info = lock_manager.lock(resource_key, ttl)
|
460
|
+
lock_manager.unlock(lock_info)
|
461
|
+
remaining_ttl = lock_manager.get_remaining_ttl_for_resource(resource_key)
|
462
|
+
expect(remaining_ttl).to be_nil
|
463
|
+
end
|
464
|
+
end
|
465
|
+
|
466
|
+
context 'when server goes away' do
|
467
|
+
after(:each) { lock_manager.unlock(@lock_info) if @lock_info }
|
468
|
+
|
469
|
+
it 'does not raise an error on connection issues' do
|
470
|
+
@lock_info = lock_manager.lock(resource_key, ttl)
|
471
|
+
|
472
|
+
# Replace redis with unreachable instance
|
473
|
+
redis_instance = lock_manager.instance_variable_get(:@servers).first
|
474
|
+
old_redis = redis_instance.instance_variable_get(:@redis)
|
475
|
+
redis_instance.instance_variable_set(:@redis, unreachable_redis)
|
476
|
+
|
477
|
+
expect {
|
478
|
+
remaining_ttl = lock_manager.get_remaining_ttl_for_resource(resource_key)
|
479
|
+
expect(remaining_ttl).to be_nil
|
480
|
+
}.to_not raise_error
|
481
|
+
end
|
482
|
+
end
|
483
|
+
|
484
|
+
context 'when a server comes back' do
|
485
|
+
after(:each) { lock_manager.unlock(@lock_info) if @lock_info }
|
486
|
+
|
487
|
+
it 'recovers from connection issues' do
|
488
|
+
@lock_info = lock_manager.lock(resource_key, ttl)
|
489
|
+
|
490
|
+
# Replace redis with unreachable instance
|
491
|
+
redis_instance = lock_manager.instance_variable_get(:@servers).first
|
492
|
+
old_redis = redis_instance.instance_variable_get(:@redis)
|
493
|
+
redis_instance.instance_variable_set(:@redis, unreachable_redis)
|
494
|
+
|
495
|
+
expect(lock_manager.get_remaining_ttl_for_resource(resource_key)).to be_nil
|
496
|
+
|
497
|
+
# Restore redis
|
498
|
+
redis_instance.instance_variable_set(:@redis, old_redis)
|
499
|
+
expect(lock_manager.get_remaining_ttl_for_resource(resource_key)).to be_truthy
|
500
|
+
end
|
501
|
+
end
|
502
|
+
end
|
503
|
+
|
504
|
+
describe 'get_remaining_ttl_for_lock' do
|
505
|
+
context 'when lock is valid' do
|
506
|
+
it 'gets the remaining ttl of a lock' do
|
507
|
+
ttl = 20_000
|
508
|
+
lock_info = lock_manager.lock(resource_key, ttl)
|
509
|
+
remaining_ttl = lock_manager.get_remaining_ttl_for_lock(lock_info)
|
510
|
+
expect(remaining_ttl).to be_within(300).of(ttl)
|
511
|
+
lock_manager.unlock(lock_info)
|
512
|
+
end
|
513
|
+
end
|
514
|
+
|
515
|
+
context 'when lock is not valid' do
|
516
|
+
it 'returns nil' do
|
517
|
+
lock_info = lock_manager.lock(resource_key, ttl)
|
518
|
+
lock_manager.unlock(lock_info)
|
519
|
+
remaining_ttl = lock_manager.get_remaining_ttl_for_lock(lock_info)
|
520
|
+
expect(remaining_ttl).to be_nil
|
521
|
+
end
|
522
|
+
end
|
523
|
+
end
|
524
|
+
|
525
|
+
describe 'locked?' do
|
526
|
+
context 'when lock is available' do
|
527
|
+
after(:each) { lock_manager.unlock(@lock_info) if @lock_info }
|
528
|
+
|
529
|
+
it 'returns true' do
|
530
|
+
@lock_info = lock_manager.lock(resource_key, ttl)
|
531
|
+
expect(lock_manager).to be_locked(resource_key)
|
532
|
+
end
|
533
|
+
end
|
534
|
+
|
535
|
+
context 'when lock is not available' do
|
536
|
+
it 'returns false' do
|
537
|
+
lock_info = lock_manager.lock(resource_key, ttl)
|
538
|
+
lock_manager.unlock(lock_info)
|
539
|
+
expect(lock_manager).not_to be_locked(resource_key)
|
540
|
+
end
|
541
|
+
end
|
542
|
+
end
|
543
|
+
|
544
|
+
describe 'valid_lock?' do
|
545
|
+
context 'when lock is available' do
|
546
|
+
after(:each) { lock_manager.unlock(@lock_info) if @lock_info }
|
547
|
+
|
548
|
+
it 'returns true' do
|
549
|
+
@lock_info = lock_manager.lock(resource_key, ttl)
|
550
|
+
expect(lock_manager).to be_valid_lock(@lock_info)
|
551
|
+
end
|
552
|
+
end
|
553
|
+
|
554
|
+
context 'when lock is not available' do
|
555
|
+
it 'returns false' do
|
556
|
+
lock_info = lock_manager.lock(resource_key, ttl)
|
557
|
+
lock_manager.unlock(lock_info)
|
558
|
+
expect(lock_manager).not_to be_valid_lock(lock_info)
|
559
|
+
end
|
560
|
+
end
|
561
|
+
end
|
562
|
+
|
370
563
|
describe '#default_time_source' do
|
371
564
|
context 'when CLOCK_MONOTONIC is available (MRI, JRuby)' do
|
372
565
|
it 'returns a callable using Process.clock_gettime()' do
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: redlock
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Leandro Moreira
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2022-09-05 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: redis
|
@@ -19,7 +19,7 @@ dependencies:
|
|
19
19
|
version: 3.0.0
|
20
20
|
- - "<"
|
21
21
|
- !ruby/object:Gem::Version
|
22
|
-
version: '
|
22
|
+
version: '6.0'
|
23
23
|
type: :runtime
|
24
24
|
prerelease: false
|
25
25
|
version_requirements: !ruby/object:Gem::Requirement
|
@@ -29,7 +29,21 @@ dependencies:
|
|
29
29
|
version: 3.0.0
|
30
30
|
- - "<"
|
31
31
|
- !ruby/object:Gem::Version
|
32
|
-
version: '
|
32
|
+
version: '6.0'
|
33
|
+
- !ruby/object:Gem::Dependency
|
34
|
+
name: connection_pool
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '2.2'
|
40
|
+
type: :development
|
41
|
+
prerelease: false
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - "~>"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '2.2'
|
33
47
|
- !ruby/object:Gem::Dependency
|
34
48
|
name: coveralls
|
35
49
|
requirement: !ruby/object:Gem::Requirement
|
@@ -44,6 +58,26 @@ dependencies:
|
|
44
58
|
- - "~>"
|
45
59
|
- !ruby/object:Gem::Version
|
46
60
|
version: '0.8'
|
61
|
+
- !ruby/object:Gem::Dependency
|
62
|
+
name: json
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - ">="
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: 2.3.0
|
68
|
+
- - "~>"
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
version: 2.3.1
|
71
|
+
type: :development
|
72
|
+
prerelease: false
|
73
|
+
version_requirements: !ruby/object:Gem::Requirement
|
74
|
+
requirements:
|
75
|
+
- - ">="
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: 2.3.0
|
78
|
+
- - "~>"
|
79
|
+
- !ruby/object:Gem::Version
|
80
|
+
version: 2.3.1
|
47
81
|
- !ruby/object:Gem::Dependency
|
48
82
|
name: rake
|
49
83
|
requirement: !ruby/object:Gem::Requirement
|
@@ -84,20 +118,6 @@ dependencies:
|
|
84
118
|
- - ">="
|
85
119
|
- !ruby/object:Gem::Version
|
86
120
|
version: 3.0.0
|
87
|
-
- !ruby/object:Gem::Dependency
|
88
|
-
name: connection_pool
|
89
|
-
requirement: !ruby/object:Gem::Requirement
|
90
|
-
requirements:
|
91
|
-
- - "~>"
|
92
|
-
- !ruby/object:Gem::Version
|
93
|
-
version: '2.2'
|
94
|
-
type: :development
|
95
|
-
prerelease: false
|
96
|
-
version_requirements: !ruby/object:Gem::Requirement
|
97
|
-
requirements:
|
98
|
-
- - "~>"
|
99
|
-
- !ruby/object:Gem::Version
|
100
|
-
version: '2.2'
|
101
121
|
description: Distributed lock using Redis written in Ruby. Highly inspired by https://github.com/antirez/redlock-rb.
|
102
122
|
email:
|
103
123
|
- leandro.ribeiro.moreira@gmail.com
|
@@ -105,9 +125,9 @@ executables: []
|
|
105
125
|
extensions: []
|
106
126
|
extra_rdoc_files: []
|
107
127
|
files:
|
128
|
+
- ".github/workflows/ci.yml"
|
108
129
|
- ".gitignore"
|
109
130
|
- ".rspec"
|
110
|
-
- ".travis.yml"
|
111
131
|
- CONTRIBUTORS
|
112
132
|
- Gemfile
|
113
133
|
- Gemfile.lock
|
@@ -119,6 +139,7 @@ files:
|
|
119
139
|
- docker-compose.yml
|
120
140
|
- lib/redlock.rb
|
121
141
|
- lib/redlock/client.rb
|
142
|
+
- lib/redlock/scripts.rb
|
122
143
|
- lib/redlock/testing.rb
|
123
144
|
- lib/redlock/version.rb
|
124
145
|
- redlock.gemspec
|
@@ -144,7 +165,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
144
165
|
- !ruby/object:Gem::Version
|
145
166
|
version: '0'
|
146
167
|
requirements: []
|
147
|
-
rubygems_version: 3.
|
168
|
+
rubygems_version: 3.3.7
|
148
169
|
signing_key:
|
149
170
|
specification_version: 4
|
150
171
|
summary: Distributed lock using Redis written in Ruby.
|