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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f27e3a9be9009072c86f2f38a18bdfdcd2f177890aac365d981711dc040acd2e
4
- data.tar.gz: efedd1080833cdee147afe6427878ad0df7c824e981e4e8a48c563ac68350d05
3
+ metadata.gz: 91ee96071b53b7710c8268b7146c1c44f77a50b1b38267ccc9c088ad2ce8ca0c
4
+ data.tar.gz: 3d5adc81e331d1f971b440c14c9c8418257c7c499c8cc40a515dbc80c564dda5
5
5
  SHA512:
6
- metadata.gz: aed115da4ab51ae932533d73e2f2f79d7722e6d77471028bb685d89149d6085fd395af368e9a143231b25b680a57c1ad4d236365396974c12416dac1131cabd8
7
- data.tar.gz: 75b69cd60c2f562b087179dc939c2fb2bbf3514018dd8c302cf4bc2c4cfa4352a1310dd7cfc35042badcd8c86fc5d237d3a6bcafc57385ca2afcc5a2a280c7bf
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
@@ -1,3 +1,4 @@
1
1
  *.gem
2
2
  coverage/
3
3
  .bundle/
4
+ dump.rdb
data/Gemfile.lock CHANGED
@@ -1,46 +1,51 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- redlock (1.2.0)
5
- redis (>= 3.0.0, < 5.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.2)
11
- coveralls (0.8.22)
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 (~> 0.19.4)
15
+ thor (>= 0.19.4, < 2.0)
16
16
  tins (~> 1.6)
17
- diff-lcs (1.3)
18
- docile (1.3.1)
19
- json (2.1.0)
20
- rake (13.0.1)
21
- redis (4.1.1)
22
- rspec (3.5.0)
23
- rspec-core (~> 3.5.0)
24
- rspec-expectations (~> 3.5.0)
25
- rspec-mocks (~> 3.5.0)
26
- rspec-core (3.5.4)
27
- rspec-support (~> 3.5.0)
28
- rspec-expectations (3.5.0)
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.5.0)
31
- rspec-mocks (3.5.0)
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.5.0)
34
- rspec-support (3.5.0)
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
- term-ansicolor (1.6.0)
43
+ sync (0.5.0)
44
+ term-ansicolor (1.7.1)
41
45
  tins (~> 1.0)
42
- thor (0.19.4)
43
- tins (1.16.3)
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
- 1.17.2
62
+ 2.3.7
data/Makefile CHANGED
@@ -7,3 +7,6 @@ build:
7
7
 
8
8
  publish:
9
9
  docker-compose run --rm test gem push `ls -lt *gem | head -n 1 | awk '{ print $$9 }'`
10
+
11
+ updateLock:
12
+ docker-compose run --rm test bundle lock --update
data/README.md CHANGED
@@ -1,8 +1,7 @@
1
- [![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)
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
 
@@ -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 @lock_script_sha, keys: [resource], argv: [val, ttl, allow_new_lock] }
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 @unlock_script_sha, keys: [resource], argv: [val] }
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
- @unlock_script_sha = @redis.with { |conn| conn.script(:load, UNLOCK_SCRIPT) }
166
- @lock_script_sha = @redis.with { |conn| conn.script(:load, LOCK_SCRIPT) }
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
- tries = options[:extend] ? 1 : (@retry_count + 1)
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 @retry_delay.respond_to?(:call)
205
- @retry_delay.call(attempt_number)
246
+ if retry_delay.respond_to?(:call)
247
+ retry_delay.call(attempt_number)
206
248
  else
207
- @retry_delay
249
+ retry_delay
208
250
  end
209
251
 
210
- (retry_delay + rand(@retry_jitter)).to_f / 1000
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
@@ -1,3 +1,5 @@
1
+ require 'redlock'
2
+
1
3
  module Redlock
2
4
  class Client
3
5
  class << self
@@ -1,3 +1,3 @@
1
1
  module Redlock
2
- VERSION = '1.2.0'
2
+ VERSION = '1.3.0'
3
3
  end
data/lib/redlock.rb CHANGED
@@ -2,6 +2,7 @@ require 'redlock/version'
2
2
 
3
3
  module Redlock
4
4
  autoload :Client, 'redlock/client'
5
+ autoload :Scripts, 'redlock/scripts'
5
6
 
6
7
  class LockError < StandardError
7
8
  def initialize(resource)
data/redlock.gemspec CHANGED
@@ -1,27 +1,28 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
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 = "redlock"
8
+ spec.name = 'redlock'
8
9
  spec.version = Redlock::VERSION
9
- spec.authors = ["Leandro Moreira"]
10
- spec.email = ["leandro.ribeiro.moreira@gmail.com"]
11
- spec.summary = %q{Distributed lock using Redis written in Ruby.}
12
- spec.description = %q{Distributed lock using Redis written in Ruby. Highly inspired by https://github.com/antirez/redlock-rb.}
13
- spec.homepage = "https://github.com/leandromoreira/redlock-rb"
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 = ["lib"]
19
+ spec.require_paths = ['lib']
20
20
 
21
- spec.add_dependency 'redis', '>= 3.0.0', '< 5.0'
21
+ spec.add_dependency 'redis', '>= 3.0.0', '< 6.0'
22
22
 
23
- spec.add_development_dependency "coveralls", "~> 0.8"
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.2.0
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: 2020-02-29 00:00:00.000000000 Z
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: '5.0'
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: '5.0'
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.1.2
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.
data/.travis.yml DELETED
@@ -1,10 +0,0 @@
1
- language: ruby
2
- services:
3
- - redis-server
4
- rvm:
5
- - 2.2.2
6
- - 2.5.6
7
- - 2.6.4
8
- script: bundle exec rspec spec
9
- sudo: false
10
- cache: bundler