redlock 1.0.0 → 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.github/workflows/ci.yml +31 -0
- data/.gitignore +2 -0
- data/Makefile +5 -0
- data/README.md +76 -9
- data/docker-compose.yml +6 -0
- data/lib/redlock/client.rb +139 -34
- data/lib/redlock/scripts.rb +34 -0
- data/lib/redlock/testing.rb +18 -6
- data/lib/redlock/version.rb +1 -1
- data/lib/redlock.rb +6 -1
- data/redlock.gemspec +15 -13
- data/spec/client_spec.rb +278 -22
- metadata +52 -25
- data/.travis.yml +0 -8
- data/Gemfile.lock +0 -54
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 679d1c44fcda7a2eaa70bdfe38c30e4ee79e417bef0b3ce064aceaf0a9cc0bbb
|
4
|
+
data.tar.gz: 48b6a41c4cc27b8ff8878647dd494a6b87e3b17e540b75d8699e32d4ee982aa5
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a31815a7da2fa6f5c3af35734294cdb34fdfba19dc12eaf25abe950db65be1356afc397ff29a73823d267d99d0069f86651385c1e262da6ced7541345212abda
|
7
|
+
data.tar.gz: cd4e41fe60732180b4b5f3d848715954074f8a063c1513c89e73a37311d5d4c0d8fbe1bbd92b063955324929f07bf59232040de104bfeb14d18837d297910d59
|
@@ -0,0 +1,31 @@
|
|
1
|
+
name: Ruby CI
|
2
|
+
|
3
|
+
on:
|
4
|
+
push:
|
5
|
+
branches: [ main ]
|
6
|
+
pull_request:
|
7
|
+
branches: [ main ]
|
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/Makefile
CHANGED
data/README.md
CHANGED
@@ -1,12 +1,8 @@
|
|
1
|
-
[![
|
2
|
-
[![Build Status](https://travis-ci.org/leandromoreira/redlock-rb.svg?branch=master)](https://travis-ci.org/leandromoreira/redlock-rb)
|
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)
|
3
2
|
[![Coverage Status](https://coveralls.io/repos/leandromoreira/redlock-rb/badge.svg?branch=master)](https://coveralls.io/r/leandromoreira/redlock-rb?branch=master)
|
4
3
|
[![Code Climate](https://codeclimate.com/github/leandromoreira/redlock-rb/badges/gpa.svg)](https://codeclimate.com/github/leandromoreira/redlock-rb)
|
5
|
-
[![Dependency Status](https://gemnasium.com/leandromoreira/redlock-rb.svg)](https://gemnasium.com/leandromoreira/redlock-rb)
|
6
4
|
[![Gem Version](https://badge.fury.io/rb/redlock.svg)](http://badge.fury.io/rb/redlock)
|
7
|
-
[![security](https://hakiri.io/github/leandromoreira/redlock-rb/master.svg)](https://hakiri.io/github/leandromoreira/redlock-rb/master)
|
8
5
|
[![Inline docs](http://inch-ci.org/github/leandromoreira/redlock-rb.svg?branch=master)](http://inch-ci.org/github/leandromoreira/redlock-rb)
|
9
|
-
[![Join the chat at https://gitter.im/leandromoreira/redlock-rb](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/leandromoreira/redlock-rb?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
10
6
|
|
11
7
|
|
12
8
|
# Redlock - A ruby distributed lock using redis.
|
@@ -19,7 +15,7 @@ This is an implementation of a proposed [distributed lock algorithm with Redis](
|
|
19
15
|
|
20
16
|
## Compatibility
|
21
17
|
|
22
|
-
Redlock works with Redis versions
|
18
|
+
Redlock works with Redis versions 6.0 or later.
|
23
19
|
|
24
20
|
## Installation
|
25
21
|
|
@@ -45,6 +41,7 @@ Or install it yourself as:
|
|
45
41
|
|
46
42
|
### Acquiring a lock
|
47
43
|
|
44
|
+
NOTE: All expiration durations are in milliseconds.
|
48
45
|
```ruby
|
49
46
|
# Locking
|
50
47
|
lock_manager = Redlock::Client.new([ "redis://127.0.0.1:7777", "redis://127.0.0.1:7778", "redis://127.0.0.1:7779" ])
|
@@ -108,10 +105,72 @@ rescue Redlock::LockError
|
|
108
105
|
end
|
109
106
|
```
|
110
107
|
|
111
|
-
The above code will also acquire the lock if the previous lock has expired and the lock is currently free. Keep in mind that this means the lock could have been acquired by someone else in the meantime. To only extend the life of the lock if currently locked by yourself, use the `
|
108
|
+
The above code will also acquire the lock if the previous lock has expired and the lock is currently free. Keep in mind that this means the lock could have been acquired and released by someone else in the meantime. To only extend the life of the lock if currently locked by yourself, use the `extend_only_if_locked` parameter:
|
112
109
|
|
113
110
|
```ruby
|
114
|
-
lock_manager.lock("resource key", 3000, extend: lock_info,
|
111
|
+
lock_manager.lock("resource key", 3000, extend: lock_info, extend_only_if_locked: true)
|
112
|
+
```
|
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
|
115
174
|
```
|
116
175
|
|
117
176
|
## Redis client configuration
|
@@ -119,7 +178,7 @@ lock_manager.lock("resource key", 3000, extend: lock_info, extend_life: true)
|
|
119
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.
|
120
179
|
|
121
180
|
```ruby
|
122
|
-
servers = [ 'redis://localhost:6379',
|
181
|
+
servers = [ 'redis://localhost:6379', RedisClient.new(:url => 'redis://someotherhost:6379') ]
|
123
182
|
redlock = Redlock::Client.new(servers)
|
124
183
|
```
|
125
184
|
|
@@ -139,6 +198,14 @@ It's possible to customize the retry logic providing the following options:
|
|
139
198
|
})
|
140
199
|
```
|
141
200
|
|
201
|
+
It is possible to associate `:retry_delay` option with `Proc` object. It will be called every time, with attempt number
|
202
|
+
as argument, to get delay time value before next retry.
|
203
|
+
|
204
|
+
```ruby
|
205
|
+
retry_delay = proc { |attempt_number| 200 * attempt_number ** 2 } # delay of 200ms for 1st retry, 800ms for 2nd retry, etc.
|
206
|
+
lock_manager = Redlock::Client.new(servers, retry_delay: retry_delay)
|
207
|
+
```
|
208
|
+
|
142
209
|
For more information you can check [documentation](http://www.rubydoc.info/gems/redlock/Redlock%2FClient:initialize).
|
143
210
|
|
144
211
|
## Run tests
|
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
@@ -1,7 +1,9 @@
|
|
1
|
-
require 'redis'
|
1
|
+
require 'redis-client'
|
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,12 +56,25 @@ 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
|
64
|
+
# * +extend_only_if_life+: Deprecated, same as +extend_only_if_locked+
|
65
|
+
# * +extend_life+: Deprecated, same as +extend_only_if_locked+
|
59
66
|
# +block+:: an optional block to be executed; after its execution, the lock (if successfully
|
60
67
|
# acquired) is automatically unlocked.
|
61
68
|
def lock(resource, ttl, options = {}, &block)
|
62
69
|
lock_info = try_lock_instances(resource, ttl, options)
|
70
|
+
if options[:extend_only_if_life] && !Gem::Deprecate.skip
|
71
|
+
warn 'DEPRECATION WARNING: The `extend_only_if_life` option has been renamed `extend_only_if_locked`.'
|
72
|
+
options[:extend_only_if_locked] = options[:extend_only_if_life]
|
73
|
+
end
|
74
|
+
if options[:extend_life] && !Gem::Deprecate.skip
|
75
|
+
warn 'DEPRECATION WARNING: The `extend_life` option has been renamed `extend_only_if_locked`.'
|
76
|
+
options[:extend_only_if_locked] = options[:extend_life]
|
77
|
+
end
|
63
78
|
|
64
79
|
if block_given?
|
65
80
|
begin
|
@@ -83,73 +98,117 @@ module Redlock
|
|
83
98
|
# Locks a resource, executing the received block only after successfully acquiring the lock,
|
84
99
|
# and returning its return value as a result.
|
85
100
|
# See Redlock::Client#lock for parameters.
|
86
|
-
def lock!(*args)
|
101
|
+
def lock!(resource, *args)
|
87
102
|
fail 'No block passed' unless block_given?
|
88
103
|
|
89
|
-
lock(*args) do |lock_info|
|
90
|
-
raise LockError,
|
104
|
+
lock(resource, *args) do |lock_info|
|
105
|
+
raise LockError, resource unless lock_info
|
91
106
|
return yield
|
92
107
|
end
|
93
108
|
end
|
94
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
|
+
|
95
147
|
private
|
96
148
|
|
97
149
|
class RedisInstance
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
else
|
102
|
-
return 0
|
150
|
+
module ConnectionPoolLike
|
151
|
+
def with
|
152
|
+
yield self
|
103
153
|
end
|
104
|
-
|
105
|
-
|
106
|
-
# thanks to https://github.com/sbertrang/redis-distlock/blob/master/lib/Redis/DistLock.pm
|
107
|
-
# also https://github.com/sbertrang/redis-distlock/issues/2 which proposes the value-checking
|
108
|
-
# and @maltoe for https://github.com/leandromoreira/redlock-rb/pull/20#discussion_r38903633
|
109
|
-
LOCK_SCRIPT = <<-eos
|
110
|
-
if (redis.call("exists", KEYS[1]) == 0 and ARGV[3] == "yes") or redis.call("get", KEYS[1]) == ARGV[1] then
|
111
|
-
return redis.call("set", KEYS[1], ARGV[1], "PX", ARGV[2])
|
112
|
-
end
|
113
|
-
eos
|
154
|
+
end
|
114
155
|
|
115
156
|
def initialize(connection)
|
116
|
-
if connection.respond_to?(:
|
157
|
+
if connection.respond_to?(:with)
|
117
158
|
@redis = connection
|
118
159
|
else
|
119
|
-
|
160
|
+
if connection.respond_to?(:client)
|
161
|
+
@redis = connection
|
162
|
+
else
|
163
|
+
@redis = RedisClient.new(connection)
|
164
|
+
end
|
165
|
+
@redis.extend(ConnectionPoolLike)
|
120
166
|
end
|
121
|
-
|
122
|
-
load_scripts
|
123
167
|
end
|
124
168
|
|
125
169
|
def lock(resource, val, ttl, allow_new_lock)
|
126
170
|
recover_from_script_flush do
|
127
|
-
@redis.
|
171
|
+
@redis.call('EVALSHA', Scripts::LOCK_SCRIPT_SHA, 1, resource, val, ttl, allow_new_lock)
|
128
172
|
end
|
129
|
-
rescue Redis::CannotConnectError
|
130
|
-
false
|
131
173
|
end
|
132
174
|
|
133
175
|
def unlock(resource, val)
|
134
176
|
recover_from_script_flush do
|
135
|
-
@redis.
|
177
|
+
@redis.call('EVALSHA', Scripts::UNLOCK_SCRIPT_SHA, 1, resource, val)
|
136
178
|
end
|
137
179
|
rescue
|
138
180
|
# Nothing to do, unlocking is just a best-effort attempt.
|
139
181
|
end
|
140
182
|
|
183
|
+
def get_remaining_ttl(resource)
|
184
|
+
recover_from_script_flush do
|
185
|
+
@redis.call('EVALSHA', Scripts::PTTL_SCRIPT_SHA, 1, resource)
|
186
|
+
end
|
187
|
+
rescue RedisClient::ConnectionError
|
188
|
+
nil
|
189
|
+
end
|
190
|
+
|
141
191
|
private
|
142
192
|
|
143
193
|
def load_scripts
|
144
|
-
|
145
|
-
|
194
|
+
scripts = [
|
195
|
+
Scripts::UNLOCK_SCRIPT,
|
196
|
+
Scripts::LOCK_SCRIPT,
|
197
|
+
Scripts::PTTL_SCRIPT
|
198
|
+
]
|
199
|
+
|
200
|
+
@redis.with do |connnection|
|
201
|
+
scripts.each do |script|
|
202
|
+
connnection.call('SCRIPT', 'LOAD', script)
|
203
|
+
end
|
204
|
+
end
|
146
205
|
end
|
147
206
|
|
148
207
|
def recover_from_script_flush
|
149
208
|
retry_on_noscript = true
|
150
209
|
begin
|
151
210
|
yield
|
152
|
-
rescue
|
211
|
+
rescue RedisClient::CommandError => e
|
153
212
|
# When somebody has flushed the Redis instance's script cache, we might
|
154
213
|
# want to reload our scripts. Only attempt this once, though, to avoid
|
155
214
|
# going into an infinite loop.
|
@@ -165,11 +224,12 @@ module Redlock
|
|
165
224
|
end
|
166
225
|
|
167
226
|
def try_lock_instances(resource, ttl, options)
|
168
|
-
|
227
|
+
retry_count = options[:retry_count] || @retry_count
|
228
|
+
tries = options[:extend] ? 1 : (retry_count + 1)
|
169
229
|
|
170
230
|
tries.times do |attempt_number|
|
171
231
|
# Wait a random delay before retrying.
|
172
|
-
sleep((
|
232
|
+
sleep(attempt_retry_delay(attempt_number, options)) if attempt_number > 0
|
173
233
|
|
174
234
|
lock_info = lock_instances(resource, ttl, options)
|
175
235
|
return lock_info if lock_info
|
@@ -178,9 +238,23 @@ module Redlock
|
|
178
238
|
false
|
179
239
|
end
|
180
240
|
|
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
|
+
|
245
|
+
retry_delay =
|
246
|
+
if retry_delay.respond_to?(:call)
|
247
|
+
retry_delay.call(attempt_number)
|
248
|
+
else
|
249
|
+
retry_delay
|
250
|
+
end
|
251
|
+
|
252
|
+
(retry_delay + rand(retry_jitter)).to_f / 1000
|
253
|
+
end
|
254
|
+
|
181
255
|
def lock_instances(resource, ttl, options)
|
182
256
|
value = (options[:extend] || { value: SecureRandom.uuid })[:value]
|
183
|
-
allow_new_lock =
|
257
|
+
allow_new_lock = options[:extend_only_if_locked] ? 'no' : 'yes'
|
184
258
|
|
185
259
|
locked, time_elapsed = timed do
|
186
260
|
@servers.select { |s| s.lock resource, value, ttl, allow_new_lock }.size
|
@@ -196,6 +270,37 @@ module Redlock
|
|
196
270
|
end
|
197
271
|
end
|
198
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
|
+
|
199
304
|
def drift(ttl)
|
200
305
|
# Add 2 milliseconds to the drift to account for Redis expires
|
201
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
@@ -1,17 +1,29 @@
|
|
1
|
+
require 'redlock'
|
2
|
+
|
1
3
|
module Redlock
|
2
4
|
class Client
|
3
|
-
|
5
|
+
class << self
|
6
|
+
attr_accessor :testing_mode
|
7
|
+
end
|
8
|
+
|
9
|
+
def testing_mode=(mode)
|
10
|
+
warn 'DEPRECATION WARNING: Instance-level `testing_mode` has been removed, and this ' +
|
11
|
+
'setter will be removed in the future. Please set the testing mode on the `Redlock::Client` ' +
|
12
|
+
'instead, e.g. `Redlock::Client.testing_mode = :bypass`.'
|
13
|
+
|
14
|
+
self.class.testing_mode = mode
|
15
|
+
end
|
4
16
|
|
5
17
|
alias_method :try_lock_instances_without_testing, :try_lock_instances
|
6
18
|
|
7
19
|
def try_lock_instances(resource, ttl, options)
|
8
|
-
if
|
20
|
+
if self.class.testing_mode == :bypass
|
9
21
|
{
|
10
22
|
validity: ttl,
|
11
23
|
resource: resource,
|
12
24
|
value: options[:extend] ? options[:extend].fetch(:value) : SecureRandom.uuid
|
13
25
|
}
|
14
|
-
elsif
|
26
|
+
elsif self.class.testing_mode == :fail
|
15
27
|
false
|
16
28
|
else
|
17
29
|
try_lock_instances_without_testing resource, ttl, options
|
@@ -21,15 +33,15 @@ module Redlock
|
|
21
33
|
alias_method :unlock_without_testing, :unlock
|
22
34
|
|
23
35
|
def unlock(lock_info)
|
24
|
-
unlock_without_testing lock_info unless
|
36
|
+
unlock_without_testing lock_info unless self.class.testing_mode == :bypass
|
25
37
|
end
|
26
38
|
|
27
39
|
class RedisInstance
|
28
40
|
alias_method :load_scripts_without_testing, :load_scripts
|
29
41
|
|
30
42
|
def load_scripts
|
31
|
-
load_scripts_without_testing
|
32
|
-
rescue
|
43
|
+
load_scripts_without_testing unless Redlock::Client.testing_mode == :bypass
|
44
|
+
rescue RedisClient::CommandError
|
33
45
|
# FakeRedis doesn't have #script, but doesn't need it either.
|
34
46
|
raise unless defined?(::FakeRedis)
|
35
47
|
rescue NoMethodError
|
data/lib/redlock/version.rb
CHANGED
data/lib/redlock.rb
CHANGED
@@ -2,6 +2,11 @@ require 'redlock/version'
|
|
2
2
|
|
3
3
|
module Redlock
|
4
4
|
autoload :Client, 'redlock/client'
|
5
|
+
autoload :Scripts, 'redlock/scripts'
|
5
6
|
|
6
|
-
LockError
|
7
|
+
class LockError < StandardError
|
8
|
+
def initialize(resource)
|
9
|
+
super "failed to acquire lock on '#{resource}'".freeze
|
10
|
+
end
|
11
|
+
end
|
7
12
|
end
|
data/redlock.gemspec
CHANGED
@@ -1,26 +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'
|
21
|
+
spec.add_dependency 'redis-client'
|
22
22
|
|
23
|
-
spec.add_development_dependency
|
24
|
-
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'
|
26
|
+
spec.add_development_dependency 'rake', '>= 11.1.2', '~> 13.0'
|
25
27
|
spec.add_development_dependency 'rspec', '~> 3', '>= 3.0.0'
|
26
28
|
end
|
data/spec/client_spec.rb
CHANGED
@@ -1,30 +1,60 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
require 'securerandom'
|
3
|
-
require '
|
3
|
+
require 'connection_pool'
|
4
4
|
|
5
5
|
RSpec.describe Redlock::Client do
|
6
6
|
# It is recommended to have at least 3 servers in production
|
7
7
|
let(:lock_manager_opts) { { retry_count: 3 } }
|
8
8
|
let(:lock_manager) { Redlock::Client.new(Redlock::Client::DEFAULT_REDIS_URLS, lock_manager_opts) }
|
9
|
-
let(:redis_client) {
|
9
|
+
let(:redis_client) { RedisClient.new(url: "redis://#{redis1_host}:#{redis1_port}") }
|
10
10
|
let(:resource_key) { SecureRandom.hex(3) }
|
11
11
|
let(:ttl) { 1000 }
|
12
12
|
let(:redis1_host) { ENV["REDIS1_HOST"] || "localhost" }
|
13
13
|
let(:redis1_port) { ENV["REDIS1_PORT"] || "6379" }
|
14
14
|
let(:redis2_host) { ENV["REDIS2_HOST"] || "127.0.0.1" }
|
15
15
|
let(:redis2_port) { ENV["REDIS2_PORT"] || "6379" }
|
16
|
+
let(:redis3_host) { ENV["REDIS3_HOST"] || "127.0.0.1" }
|
17
|
+
let(:redis3_port) { ENV["REDIS3_PORT"] || "6379" }
|
18
|
+
let(:unreachable_redis) {
|
19
|
+
redis = RedisClient.new(url: 'redis://localhost:46864')
|
20
|
+
def redis.with
|
21
|
+
yield self
|
22
|
+
end
|
23
|
+
redis
|
24
|
+
}
|
16
25
|
|
17
26
|
describe 'initialize' do
|
18
27
|
it 'accepts both redis URLs and Redis objects' do
|
19
|
-
servers = [ "redis://#{redis1_host}:#{redis1_port}",
|
28
|
+
servers = [ "redis://#{redis1_host}:#{redis1_port}", RedisClient.new(url: "redis://#{redis2_host}:#{redis2_port}") ]
|
20
29
|
redlock = Redlock::Client.new(servers)
|
21
30
|
|
22
31
|
redlock_servers = redlock.instance_variable_get(:@servers).map do |s|
|
23
|
-
s.instance_variable_get(:@redis).
|
32
|
+
s.instance_variable_get(:@redis).config.host
|
24
33
|
end
|
25
34
|
|
26
35
|
expect(redlock_servers).to match_array([redis1_host, redis2_host])
|
27
36
|
end
|
37
|
+
|
38
|
+
it 'accepts ConnectionPool objects' do
|
39
|
+
pool = ConnectionPool.new { RedisClient.new(url: "redis://#{redis1_host}:#{redis1_port}") }
|
40
|
+
redlock = Redlock::Client.new([pool])
|
41
|
+
|
42
|
+
lock_info = lock_manager.lock(resource_key, ttl)
|
43
|
+
expect(resource_key).to_not be_lockable(lock_manager, ttl)
|
44
|
+
lock_manager.unlock(lock_info)
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'does not load scripts' do
|
48
|
+
redis_client.call('SCRIPT', 'FLUSH')
|
49
|
+
|
50
|
+
pool = ConnectionPool.new { RedisClient.new(url: "redis://#{redis1_host}:#{redis1_port}") }
|
51
|
+
redlock = Redlock::Client.new([pool])
|
52
|
+
|
53
|
+
raw_info = redis_client.call('INFO')
|
54
|
+
number_of_cached_scripts = raw_info[/number_of_cached_scripts\:\d+/].split(':').last
|
55
|
+
|
56
|
+
expect(number_of_cached_scripts).to eq("0")
|
57
|
+
end
|
28
58
|
end
|
29
59
|
|
30
60
|
describe 'lock' do
|
@@ -46,7 +76,7 @@ RSpec.describe Redlock::Client do
|
|
46
76
|
it 'interprets lock time as milliseconds' do
|
47
77
|
ttl = 20000
|
48
78
|
@lock_info = lock_manager.lock(resource_key, ttl)
|
49
|
-
expect(redis_client.
|
79
|
+
expect(redis_client.call('PTTL', resource_key)).to be_within(200).of(ttl)
|
50
80
|
end
|
51
81
|
|
52
82
|
it 'can extend its own lock' do
|
@@ -64,9 +94,9 @@ RSpec.describe Redlock::Client do
|
|
64
94
|
end
|
65
95
|
end
|
66
96
|
|
67
|
-
context 'when
|
97
|
+
context 'when extend_only_if_locked flag is given' do
|
68
98
|
it 'does not extend a non-existent lock' do
|
69
|
-
@lock_info = lock_manager.lock(resource_key, ttl, extend: {value: 'hello world'},
|
99
|
+
@lock_info = lock_manager.lock(resource_key, ttl, extend: {value: 'hello world'}, extend_only_if_locked: true)
|
70
100
|
expect(@lock_info).to eq(false)
|
71
101
|
end
|
72
102
|
end
|
@@ -76,14 +106,14 @@ RSpec.describe Redlock::Client do
|
|
76
106
|
lock_info = lock_manager.lock(resource_key, ttl)
|
77
107
|
expect(resource_key).to_not be_lockable(lock_manager, ttl)
|
78
108
|
|
79
|
-
lock_info = lock_manager.lock(resource_key, ttl, extend: lock_info,
|
109
|
+
lock_info = lock_manager.lock(resource_key, ttl, extend: lock_info, extend_only_if_locked: true)
|
80
110
|
expect(lock_info).not_to be_nil
|
81
|
-
expect(redis_client.
|
111
|
+
expect(redis_client.call('PTTL', resource_key)).to be_within(200).of(ttl)
|
82
112
|
end
|
83
113
|
|
84
|
-
context 'when
|
114
|
+
context 'when extend_only_if_locked flag is not given' do
|
85
115
|
it "sets the given value when trying to extend a non-existent lock" do
|
86
|
-
@lock_info = lock_manager.lock(resource_key, ttl, extend: {value: 'hello world'},
|
116
|
+
@lock_info = lock_manager.lock(resource_key, ttl, extend: {value: 'hello world'}, extend_only_if_locked: false)
|
87
117
|
expect(@lock_info).to be_lock_info_for(resource_key)
|
88
118
|
expect(@lock_info[:value]).to eq('hello world') # really we should test what's in redis
|
89
119
|
end
|
@@ -94,6 +124,28 @@ RSpec.describe Redlock::Client do
|
|
94
124
|
second_attempt = lock_manager.lock(resource_key, ttl)
|
95
125
|
expect(second_attempt).to eq(false)
|
96
126
|
end
|
127
|
+
|
128
|
+
context 'when extend_life flag is given' do
|
129
|
+
it 'treats it as extend_only_if_locked but warns it is deprecated' do
|
130
|
+
ttl = 20_000
|
131
|
+
lock_info = lock_manager.lock(resource_key, ttl)
|
132
|
+
expect(resource_key).to_not be_lockable(lock_manager, ttl)
|
133
|
+
expect(lock_manager).to receive(:warn).with(/DEPRECATION WARNING: The `extend_life`/)
|
134
|
+
lock_info = lock_manager.lock(resource_key, ttl, extend: lock_info, extend_life: true)
|
135
|
+
expect(lock_info).not_to be_nil
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
context 'when extend_only_if_life flag is given' do
|
140
|
+
it 'treats it as extend_only_if_locked but warns it is deprecated' do
|
141
|
+
ttl = 20_000
|
142
|
+
lock_info = lock_manager.lock(resource_key, ttl)
|
143
|
+
expect(resource_key).to_not be_lockable(lock_manager, ttl)
|
144
|
+
expect(lock_manager).to receive(:warn).with(/DEPRECATION WARNING: The `extend_only_if_life`/)
|
145
|
+
lock_info = lock_manager.lock(resource_key, ttl, extend: lock_info, extend_only_if_life: true)
|
146
|
+
expect(lock_info).not_to be_nil
|
147
|
+
end
|
148
|
+
end
|
97
149
|
end
|
98
150
|
|
99
151
|
context 'when lock is not available' do
|
@@ -138,17 +190,68 @@ RSpec.describe Redlock::Client do
|
|
138
190
|
end.at_least(:once)
|
139
191
|
lock_manager.lock(resource_key, ttl)
|
140
192
|
end
|
193
|
+
|
194
|
+
it 'accepts retry_delay as proc' do
|
195
|
+
retry_delay = proc do |attempt_number|
|
196
|
+
expect(attempt_number).to eq(1)
|
197
|
+
2000
|
198
|
+
end
|
199
|
+
|
200
|
+
lock_manager = Redlock::Client.new(Redlock::Client::DEFAULT_REDIS_URLS, retry_count: 1, retry_delay: retry_delay)
|
201
|
+
another_lock_info = lock_manager.lock(resource_key, ttl)
|
202
|
+
|
203
|
+
expect(lock_manager).to receive(:sleep) do |sleep|
|
204
|
+
expect(sleep * 1000).to be_within(described_class::DEFAULT_RETRY_JITTER).of(2000)
|
205
|
+
end.exactly(:once)
|
206
|
+
lock_manager.lock(resource_key, ttl)
|
207
|
+
lock_manager.unlock(another_lock_info)
|
208
|
+
end
|
209
|
+
|
210
|
+
context 'when retry_count is given' do
|
211
|
+
it 'prioritizes the retry_count in option and tries up to \'retry_count\' + 1 times' do
|
212
|
+
retry_count = 1
|
213
|
+
expect(retry_count).not_to eq(lock_manager_opts[:retry_count])
|
214
|
+
expect(lock_manager).to receive(:lock_instances).exactly(retry_count + 1).times.and_return(false)
|
215
|
+
lock_manager.lock(resource_key, ttl, retry_count: retry_count)
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
context 'when retry_delay is given' do
|
220
|
+
it 'prioritizes the retry_delay in option and sleeps at least the specified retry_delay in milliseconds' do
|
221
|
+
retry_delay = 300
|
222
|
+
expect(retry_delay > described_class::DEFAULT_RETRY_DELAY).to eq(true)
|
223
|
+
expected_minimum = retry_delay
|
224
|
+
|
225
|
+
expect(lock_manager).to receive(:sleep) do |sleep|
|
226
|
+
expect(sleep).to satisfy { |value| value >= expected_minimum / 1000.to_f }
|
227
|
+
end.at_least(:once)
|
228
|
+
lock_manager.lock(resource_key, ttl, retry_delay: retry_delay)
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
context 'when retry_jitter is given' do
|
233
|
+
it 'prioritizes the retry_jitter in option and sleeps a maximum of retry_delay + retry_jitter in milliseconds' do
|
234
|
+
retry_jitter = 60
|
235
|
+
expect(retry_jitter > described_class::DEFAULT_RETRY_JITTER).to eq(true)
|
236
|
+
|
237
|
+
expected_maximum = described_class::DEFAULT_RETRY_DELAY + retry_jitter
|
238
|
+
expect(lock_manager).to receive(:sleep) do |sleep|
|
239
|
+
expect(sleep).to satisfy { |value| value < expected_maximum / 1000.to_f }
|
240
|
+
end.at_least(:once)
|
241
|
+
lock_manager.lock(resource_key, ttl, retry_jitter: retry_jitter)
|
242
|
+
end
|
243
|
+
end
|
141
244
|
end
|
142
245
|
|
143
246
|
context 'when a server goes away' do
|
144
|
-
it '
|
145
|
-
#
|
247
|
+
it 'raises an error on connection issues' do
|
248
|
+
# Set lock manager to a (hopefully) non-existent Redis URL to test error
|
146
249
|
redis_instance = lock_manager.instance_variable_get(:@servers).first
|
147
|
-
redis_instance.instance_variable_set(:@redis,
|
250
|
+
redis_instance.instance_variable_set(:@redis, unreachable_redis)
|
148
251
|
|
149
252
|
expect {
|
150
|
-
|
151
|
-
}.
|
253
|
+
lock_manager.lock(resource_key, ttl)
|
254
|
+
}.to raise_error(RedisClient::CannotConnectError)
|
152
255
|
end
|
153
256
|
end
|
154
257
|
|
@@ -156,9 +259,12 @@ RSpec.describe Redlock::Client do
|
|
156
259
|
it 'recovers from connection issues' do
|
157
260
|
# Same as above.
|
158
261
|
redis_instance = lock_manager.instance_variable_get(:@servers).first
|
159
|
-
redis_instance.
|
160
|
-
|
161
|
-
|
262
|
+
old_redis = redis_instance.instance_variable_get(:@redis)
|
263
|
+
redis_instance.instance_variable_set(:@redis, unreachable_redis)
|
264
|
+
expect {
|
265
|
+
lock_manager.lock(resource_key, ttl)
|
266
|
+
}.to raise_error(RedisClient::CannotConnectError)
|
267
|
+
redis_instance.instance_variable_set(:@redis, old_redis)
|
162
268
|
expect(lock_manager.lock(resource_key, ttl)).to be_truthy
|
163
269
|
end
|
164
270
|
end
|
@@ -166,10 +272,10 @@ RSpec.describe Redlock::Client do
|
|
166
272
|
context 'when script cache has been flushed' do
|
167
273
|
before(:each) do
|
168
274
|
@manipulated_instance = lock_manager.instance_variable_get(:@servers).first
|
169
|
-
@manipulated_instance.instance_variable_get(:@redis).
|
275
|
+
@manipulated_instance.instance_variable_get(:@redis).call('SCRIPT', 'FLUSH')
|
170
276
|
end
|
171
277
|
|
172
|
-
it 'does not raise a
|
278
|
+
it 'does not raise a RedisClient::CommandError: NOSCRIPT error' do
|
173
279
|
expect {
|
174
280
|
lock_manager.lock(resource_key, ttl)
|
175
281
|
}.to_not raise_error
|
@@ -294,7 +400,9 @@ RSpec.describe Redlock::Client do
|
|
294
400
|
after { lock_manager.unlock(@another_lock_info) }
|
295
401
|
|
296
402
|
it 'raises a LockError' do
|
297
|
-
expect { lock_manager.lock!(resource_key, ttl) {} }.to raise_error(
|
403
|
+
expect { lock_manager.lock!(resource_key, ttl) {} }.to raise_error(
|
404
|
+
Redlock::LockError, "failed to acquire lock on '#{resource_key}'"
|
405
|
+
)
|
298
406
|
end
|
299
407
|
|
300
408
|
it 'does not execute the block' do
|
@@ -308,6 +416,154 @@ RSpec.describe Redlock::Client do
|
|
308
416
|
end
|
309
417
|
end
|
310
418
|
|
419
|
+
describe 'get_remaining_ttl_for_resource' do
|
420
|
+
context 'when lock is valid' do
|
421
|
+
after(:each) { lock_manager.unlock(@lock_info) if @lock_info }
|
422
|
+
|
423
|
+
it 'gets the remaining ttl of a lock' do
|
424
|
+
ttl = 20_000
|
425
|
+
@lock_info = lock_manager.lock(resource_key, ttl)
|
426
|
+
remaining_ttl = lock_manager.get_remaining_ttl_for_resource(resource_key)
|
427
|
+
expect(remaining_ttl).to be_within(300).of(ttl)
|
428
|
+
end
|
429
|
+
|
430
|
+
context 'when servers respond with varying ttls' do
|
431
|
+
let (:servers) {
|
432
|
+
[
|
433
|
+
"redis://#{redis1_host}:#{redis1_port}",
|
434
|
+
"redis://#{redis2_host}:#{redis2_port}",
|
435
|
+
"redis://#{redis3_host}:#{redis3_port}"
|
436
|
+
]
|
437
|
+
}
|
438
|
+
let (:redlock) { Redlock::Client.new(servers) }
|
439
|
+
after(:each) { redlock.unlock(@lock_info) if @lock_info }
|
440
|
+
|
441
|
+
it 'returns the minimum ttl value' do
|
442
|
+
ttl = 20_000
|
443
|
+
@lock_info = redlock.lock(resource_key, ttl)
|
444
|
+
|
445
|
+
# Mock redis server responses to return different ttls
|
446
|
+
returned_ttls = [20_000, 15_000, 10_000]
|
447
|
+
redlock.instance_variable_get(:@servers).each_with_index do |server, index|
|
448
|
+
allow(server).to(receive(:get_remaining_ttl))
|
449
|
+
.with(resource_key)
|
450
|
+
.and_return([@lock_info[:value], returned_ttls[index]])
|
451
|
+
end
|
452
|
+
|
453
|
+
remaining_ttl = redlock.get_remaining_ttl_for_lock(@lock_info)
|
454
|
+
|
455
|
+
# Assert that the TTL is closest to the closest to the correct value
|
456
|
+
expect(remaining_ttl).to be_within(300).of(returned_ttls[1])
|
457
|
+
end
|
458
|
+
end
|
459
|
+
end
|
460
|
+
|
461
|
+
context 'when lock is not valid' do
|
462
|
+
it 'returns nil' do
|
463
|
+
lock_info = lock_manager.lock(resource_key, ttl)
|
464
|
+
lock_manager.unlock(lock_info)
|
465
|
+
remaining_ttl = lock_manager.get_remaining_ttl_for_resource(resource_key)
|
466
|
+
expect(remaining_ttl).to be_nil
|
467
|
+
end
|
468
|
+
end
|
469
|
+
|
470
|
+
context 'when server goes away' do
|
471
|
+
after(:each) { lock_manager.unlock(@lock_info) if @lock_info }
|
472
|
+
|
473
|
+
it 'does not raise an error on connection issues' do
|
474
|
+
@lock_info = lock_manager.lock(resource_key, ttl)
|
475
|
+
|
476
|
+
# Replace redis with unreachable instance
|
477
|
+
redis_instance = lock_manager.instance_variable_get(:@servers).first
|
478
|
+
old_redis = redis_instance.instance_variable_get(:@redis)
|
479
|
+
redis_instance.instance_variable_set(:@redis, unreachable_redis)
|
480
|
+
|
481
|
+
expect {
|
482
|
+
remaining_ttl = lock_manager.get_remaining_ttl_for_resource(resource_key)
|
483
|
+
expect(remaining_ttl).to be_nil
|
484
|
+
}.to_not raise_error
|
485
|
+
end
|
486
|
+
end
|
487
|
+
|
488
|
+
context 'when a server comes back' do
|
489
|
+
after(:each) { lock_manager.unlock(@lock_info) if @lock_info }
|
490
|
+
|
491
|
+
it 'recovers from connection issues' do
|
492
|
+
@lock_info = lock_manager.lock(resource_key, ttl)
|
493
|
+
|
494
|
+
# Replace redis with unreachable instance
|
495
|
+
redis_instance = lock_manager.instance_variable_get(:@servers).first
|
496
|
+
old_redis = redis_instance.instance_variable_get(:@redis)
|
497
|
+
redis_instance.instance_variable_set(:@redis, unreachable_redis)
|
498
|
+
|
499
|
+
expect(lock_manager.get_remaining_ttl_for_resource(resource_key)).to be_nil
|
500
|
+
|
501
|
+
# Restore redis
|
502
|
+
redis_instance.instance_variable_set(:@redis, old_redis)
|
503
|
+
expect(lock_manager.get_remaining_ttl_for_resource(resource_key)).to be_truthy
|
504
|
+
end
|
505
|
+
end
|
506
|
+
end
|
507
|
+
|
508
|
+
describe 'get_remaining_ttl_for_lock' do
|
509
|
+
context 'when lock is valid' do
|
510
|
+
it 'gets the remaining ttl of a lock' do
|
511
|
+
ttl = 20_000
|
512
|
+
lock_info = lock_manager.lock(resource_key, ttl)
|
513
|
+
remaining_ttl = lock_manager.get_remaining_ttl_for_lock(lock_info)
|
514
|
+
expect(remaining_ttl).to be_within(300).of(ttl)
|
515
|
+
lock_manager.unlock(lock_info)
|
516
|
+
end
|
517
|
+
end
|
518
|
+
|
519
|
+
context 'when lock is not valid' do
|
520
|
+
it 'returns nil' do
|
521
|
+
lock_info = lock_manager.lock(resource_key, ttl)
|
522
|
+
lock_manager.unlock(lock_info)
|
523
|
+
remaining_ttl = lock_manager.get_remaining_ttl_for_lock(lock_info)
|
524
|
+
expect(remaining_ttl).to be_nil
|
525
|
+
end
|
526
|
+
end
|
527
|
+
end
|
528
|
+
|
529
|
+
describe 'locked?' do
|
530
|
+
context 'when lock is available' do
|
531
|
+
after(:each) { lock_manager.unlock(@lock_info) if @lock_info }
|
532
|
+
|
533
|
+
it 'returns true' do
|
534
|
+
@lock_info = lock_manager.lock(resource_key, ttl)
|
535
|
+
expect(lock_manager).to be_locked(resource_key)
|
536
|
+
end
|
537
|
+
end
|
538
|
+
|
539
|
+
context 'when lock is not available' do
|
540
|
+
it 'returns false' do
|
541
|
+
lock_info = lock_manager.lock(resource_key, ttl)
|
542
|
+
lock_manager.unlock(lock_info)
|
543
|
+
expect(lock_manager).not_to be_locked(resource_key)
|
544
|
+
end
|
545
|
+
end
|
546
|
+
end
|
547
|
+
|
548
|
+
describe 'valid_lock?' do
|
549
|
+
context 'when lock is available' do
|
550
|
+
after(:each) { lock_manager.unlock(@lock_info) if @lock_info }
|
551
|
+
|
552
|
+
it 'returns true' do
|
553
|
+
@lock_info = lock_manager.lock(resource_key, ttl)
|
554
|
+
expect(lock_manager).to be_valid_lock(@lock_info)
|
555
|
+
end
|
556
|
+
end
|
557
|
+
|
558
|
+
context 'when lock is not available' do
|
559
|
+
it 'returns false' do
|
560
|
+
lock_info = lock_manager.lock(resource_key, ttl)
|
561
|
+
lock_manager.unlock(lock_info)
|
562
|
+
expect(lock_manager).not_to be_valid_lock(lock_info)
|
563
|
+
end
|
564
|
+
end
|
565
|
+
end
|
566
|
+
|
311
567
|
describe '#default_time_source' do
|
312
568
|
context 'when CLOCK_MONOTONIC is available (MRI, JRuby)' do
|
313
569
|
it 'returns a callable using Process.clock_gettime()' do
|
metadata
CHANGED
@@ -1,35 +1,43 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: redlock
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 2.0.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: 2023-02-09 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name: redis
|
14
|
+
name: redis-client
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
17
|
- - ">="
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version:
|
20
|
-
- - "<"
|
21
|
-
- !ruby/object:Gem::Version
|
22
|
-
version: '5.0'
|
19
|
+
version: '0'
|
23
20
|
type: :runtime
|
24
21
|
prerelease: false
|
25
22
|
version_requirements: !ruby/object:Gem::Requirement
|
26
23
|
requirements:
|
27
24
|
- - ">="
|
28
25
|
- !ruby/object:Gem::Version
|
29
|
-
version:
|
30
|
-
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: connection_pool
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
31
32
|
- !ruby/object:Gem::Version
|
32
|
-
version: '
|
33
|
+
version: '2.2'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '2.2'
|
33
41
|
- !ruby/object:Gem::Dependency
|
34
42
|
name: coveralls
|
35
43
|
requirement: !ruby/object:Gem::Requirement
|
@@ -45,45 +53,65 @@ dependencies:
|
|
45
53
|
- !ruby/object:Gem::Version
|
46
54
|
version: '0.8'
|
47
55
|
- !ruby/object:Gem::Dependency
|
48
|
-
name:
|
56
|
+
name: json
|
49
57
|
requirement: !ruby/object:Gem::Requirement
|
50
58
|
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 2.3.0
|
62
|
+
- - "~>"
|
63
|
+
- !ruby/object:Gem::Version
|
64
|
+
version: 2.3.1
|
65
|
+
type: :development
|
66
|
+
prerelease: false
|
67
|
+
version_requirements: !ruby/object:Gem::Requirement
|
68
|
+
requirements:
|
69
|
+
- - ">="
|
70
|
+
- !ruby/object:Gem::Version
|
71
|
+
version: 2.3.0
|
51
72
|
- - "~>"
|
52
73
|
- !ruby/object:Gem::Version
|
53
|
-
version:
|
74
|
+
version: 2.3.1
|
75
|
+
- !ruby/object:Gem::Dependency
|
76
|
+
name: rake
|
77
|
+
requirement: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
54
79
|
- - ">="
|
55
80
|
- !ruby/object:Gem::Version
|
56
81
|
version: 11.1.2
|
82
|
+
- - "~>"
|
83
|
+
- !ruby/object:Gem::Version
|
84
|
+
version: '13.0'
|
57
85
|
type: :development
|
58
86
|
prerelease: false
|
59
87
|
version_requirements: !ruby/object:Gem::Requirement
|
60
88
|
requirements:
|
61
|
-
- - "~>"
|
62
|
-
- !ruby/object:Gem::Version
|
63
|
-
version: '11.1'
|
64
89
|
- - ">="
|
65
90
|
- !ruby/object:Gem::Version
|
66
91
|
version: 11.1.2
|
92
|
+
- - "~>"
|
93
|
+
- !ruby/object:Gem::Version
|
94
|
+
version: '13.0'
|
67
95
|
- !ruby/object:Gem::Dependency
|
68
96
|
name: rspec
|
69
97
|
requirement: !ruby/object:Gem::Requirement
|
70
98
|
requirements:
|
71
|
-
- - "~>"
|
72
|
-
- !ruby/object:Gem::Version
|
73
|
-
version: '3'
|
74
99
|
- - ">="
|
75
100
|
- !ruby/object:Gem::Version
|
76
101
|
version: 3.0.0
|
102
|
+
- - "~>"
|
103
|
+
- !ruby/object:Gem::Version
|
104
|
+
version: '3'
|
77
105
|
type: :development
|
78
106
|
prerelease: false
|
79
107
|
version_requirements: !ruby/object:Gem::Requirement
|
80
108
|
requirements:
|
81
|
-
- - "~>"
|
82
|
-
- !ruby/object:Gem::Version
|
83
|
-
version: '3'
|
84
109
|
- - ">="
|
85
110
|
- !ruby/object:Gem::Version
|
86
111
|
version: 3.0.0
|
112
|
+
- - "~>"
|
113
|
+
- !ruby/object:Gem::Version
|
114
|
+
version: '3'
|
87
115
|
description: Distributed lock using Redis written in Ruby. Highly inspired by https://github.com/antirez/redlock-rb.
|
88
116
|
email:
|
89
117
|
- leandro.ribeiro.moreira@gmail.com
|
@@ -91,12 +119,11 @@ executables: []
|
|
91
119
|
extensions: []
|
92
120
|
extra_rdoc_files: []
|
93
121
|
files:
|
122
|
+
- ".github/workflows/ci.yml"
|
94
123
|
- ".gitignore"
|
95
124
|
- ".rspec"
|
96
|
-
- ".travis.yml"
|
97
125
|
- CONTRIBUTORS
|
98
126
|
- Gemfile
|
99
|
-
- Gemfile.lock
|
100
127
|
- LICENSE
|
101
128
|
- Makefile
|
102
129
|
- README.md
|
@@ -105,6 +132,7 @@ files:
|
|
105
132
|
- docker-compose.yml
|
106
133
|
- lib/redlock.rb
|
107
134
|
- lib/redlock/client.rb
|
135
|
+
- lib/redlock/scripts.rb
|
108
136
|
- lib/redlock/testing.rb
|
109
137
|
- lib/redlock/version.rb
|
110
138
|
- redlock.gemspec
|
@@ -130,8 +158,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
130
158
|
- !ruby/object:Gem::Version
|
131
159
|
version: '0'
|
132
160
|
requirements: []
|
133
|
-
|
134
|
-
rubygems_version: 2.5.2.3
|
161
|
+
rubygems_version: 3.0.3.1
|
135
162
|
signing_key:
|
136
163
|
specification_version: 4
|
137
164
|
summary: Distributed lock using Redis written in Ruby.
|
data/.travis.yml
DELETED
data/Gemfile.lock
DELETED
@@ -1,54 +0,0 @@
|
|
1
|
-
PATH
|
2
|
-
remote: .
|
3
|
-
specs:
|
4
|
-
redlock (1.0.0)
|
5
|
-
redis (>= 3.0.0, < 5.0)
|
6
|
-
|
7
|
-
GEM
|
8
|
-
remote: https://rubygems.org/
|
9
|
-
specs:
|
10
|
-
coveralls (0.8.22)
|
11
|
-
json (>= 1.8, < 3)
|
12
|
-
simplecov (~> 0.16.1)
|
13
|
-
term-ansicolor (~> 1.3)
|
14
|
-
thor (~> 0.19.4)
|
15
|
-
tins (~> 1.6)
|
16
|
-
diff-lcs (1.3)
|
17
|
-
docile (1.3.1)
|
18
|
-
json (2.1.0)
|
19
|
-
rake (11.3.0)
|
20
|
-
redis (4.0.3)
|
21
|
-
rspec (3.5.0)
|
22
|
-
rspec-core (~> 3.5.0)
|
23
|
-
rspec-expectations (~> 3.5.0)
|
24
|
-
rspec-mocks (~> 3.5.0)
|
25
|
-
rspec-core (3.5.4)
|
26
|
-
rspec-support (~> 3.5.0)
|
27
|
-
rspec-expectations (3.5.0)
|
28
|
-
diff-lcs (>= 1.2.0, < 2.0)
|
29
|
-
rspec-support (~> 3.5.0)
|
30
|
-
rspec-mocks (3.5.0)
|
31
|
-
diff-lcs (>= 1.2.0, < 2.0)
|
32
|
-
rspec-support (~> 3.5.0)
|
33
|
-
rspec-support (3.5.0)
|
34
|
-
simplecov (0.16.1)
|
35
|
-
docile (~> 1.1)
|
36
|
-
json (>= 1.8, < 3)
|
37
|
-
simplecov-html (~> 0.10.0)
|
38
|
-
simplecov-html (0.10.2)
|
39
|
-
term-ansicolor (1.6.0)
|
40
|
-
tins (~> 1.0)
|
41
|
-
thor (0.19.4)
|
42
|
-
tins (1.16.3)
|
43
|
-
|
44
|
-
PLATFORMS
|
45
|
-
ruby
|
46
|
-
|
47
|
-
DEPENDENCIES
|
48
|
-
coveralls (~> 0.8)
|
49
|
-
rake (~> 11.1, >= 11.1.2)
|
50
|
-
redlock!
|
51
|
-
rspec (~> 3, >= 3.0.0)
|
52
|
-
|
53
|
-
BUNDLED WITH
|
54
|
-
1.17.2
|