redlock 1.3.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7de9b6b9d70bce2578e474bdfe8dfa15c22bb57e0c876ac7127d826a5b6ba9b4
4
- data.tar.gz: c294d219107aa4a8682ba4ff303e150497ea61b2dd4abb71d3cac15e78c218bd
3
+ metadata.gz: 229c8a5a2a6c074810df969cd50e20bac41f8bf47e3d5714fe21975888702fb0
4
+ data.tar.gz: d9e1079662c2ef7ddd23d3e03696c48d6b9ae7fe37c0b65760c3a90be07bc239
5
5
  SHA512:
6
- metadata.gz: b0eeead2b1307f12487603d72a7a5e00d6aba1d697a5327ac484a4fa4cc00575e0fcedd43a38aa34ee93da7322550630ad3cec158a5ef0fcf98849033c90ec85
7
- data.tar.gz: 712d3c40ab8c4278f4649f373bfcb6ca4eeb2361461a48ad42d17f44a9d1becba460b82ed99f6b035232698267ad5046831230960b47171d9fa09d9ba0d4b692
6
+ metadata.gz: 6a3436508b649c79682b310ff57094aa06a532ebde60b193d720dcc88116166a9bacc5e60701515528ef9cf8b7946e246c2bc8341a75eb948cc23ecd1a7be8ff
7
+ data.tar.gz: d2fc1c15c559a684f87b08ccd1e7e3adc9988323111803c1ca66f2df5381890c09802218dc86477e1de2099bd7aafca1e5e368c9f15fd85ea9f5d433846c0928
@@ -2,9 +2,9 @@ name: Ruby CI
2
2
 
3
3
  on:
4
4
  push:
5
- branches: [ master ]
5
+ branches: [ main ]
6
6
  pull_request:
7
- branches: [ master ]
7
+ branches: [ main ]
8
8
 
9
9
  jobs:
10
10
  test:
@@ -13,7 +13,7 @@ jobs:
13
13
 
14
14
  strategy:
15
15
  matrix:
16
- ruby-version: [3.1, "3.0", "2.7", "2.6", "2.5", "ruby-head"]
16
+ ruby-version: [3.2, 3.1, "3.0", "2.7", "ruby-head"]
17
17
 
18
18
  steps:
19
19
  - uses: actions/checkout@v2
data/.gitignore CHANGED
@@ -2,3 +2,4 @@
2
2
  coverage/
3
3
  .bundle/
4
4
  dump.rdb
5
+ Gemfile.lock
data/CHANGELOG.md ADDED
@@ -0,0 +1,40 @@
1
+
2
+ # Change Log
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](http://keepachangelog.com/)
6
+ and this project adheres to [Semantic Versioning](http://semver.org/).
7
+
8
+ ## [Unreleased] - yyyy-mm-dd
9
+
10
+ Here we write upgrading notes for brands. It's a team effort to make them as
11
+ straightforward as possible.
12
+
13
+ ### Added
14
+
15
+ ### Changed
16
+
17
+ ### Fixed
18
+
19
+
20
+ ## [2.0.1] - 2023-02-14
21
+
22
+ ### Added
23
+
24
+ ### Changed
25
+
26
+ ### Fixed
27
+
28
+ * always treat redis instance as pool-like #125
29
+
30
+ ## [2.0.0] - 2023-02-09
31
+
32
+ ### Added
33
+
34
+ * support for redis >= 6.0
35
+
36
+ ### Changed
37
+
38
+ * **BREAKING**: The library now only works with `RedisClient` instance.
39
+
40
+ ### Fixed
data/Makefile CHANGED
@@ -1,12 +1,9 @@
1
1
  default: test
2
2
  test:
3
- docker-compose run --rm test
3
+ docker compose run --rm test
4
4
 
5
5
  build:
6
- docker-compose run --rm test gem build redlock.gemspec
6
+ docker compose run --rm test gem build redlock.gemspec
7
7
 
8
8
  publish:
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
9
+ docker compose run --rm test gem push `ls -lt *gem | head -n 1 | awk '{ print $$9 }'`
data/README.md CHANGED
@@ -15,7 +15,8 @@ This is an implementation of a proposed [distributed lock algorithm with Redis](
15
15
 
16
16
  ## Compatibility
17
17
 
18
- Redlock works with Redis versions 2.6 or later.
18
+ * It works with Redis server versions 6.0 or later.
19
+ * Redlock >= 2.0 only works with [`RedisClient`](https://github.com/redis-rb/redis-client) client instance.
19
20
 
20
21
  ## Installation
21
22
 
@@ -151,7 +152,7 @@ lock_manager.get_remaining_ttl_for_lock(lock_info)
151
152
 
152
153
  lock_manager.unlock(lock_info)
153
154
  lock_manager.get_remaining_ttl_for_lock(lock_info)
154
- #=> nil
155
+ #=> nil
155
156
  ```
156
157
 
157
158
  Use `get_remaining_ttl_for_resource` if you do not hold a lock, but want to know the remaining TTL on a locked resource:
@@ -164,25 +165,40 @@ lock_info = lock_manager.lock(resource, 2000)
164
165
  lock_manager.locked?(resource)
165
166
  #=> true
166
167
  lock_manager.get_remaining_ttl_for_resource(resource)
167
- #=> 1975
168
+ #=> 1975
168
169
 
169
170
  # Sometime later
170
171
  lock_manager.locked?(resource)
171
172
  #=> false
172
173
  lock_manager.get_remaining_ttl_for_resource(resource)
173
- #=> nil
174
+ #=> nil
174
175
  ```
175
176
 
176
177
  ## Redis client configuration
177
178
 
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.
179
+ `Redlock::Client` expects URLs, or configurations or Redis objects on initialization. Redis objects should be used for configuring the connection in more detail, i.e. setting username and password.
179
180
 
180
181
  ```ruby
181
- servers = [ 'redis://localhost:6379', Redis.new(:url => 'redis://someotherhost:6379') ]
182
+ servers = [ 'redis://localhost:6379', RedisClient.new(:url => 'redis://someotherhost:6379') ]
182
183
  redlock = Redlock::Client.new(servers)
183
184
  ```
184
185
 
185
- Redlock works seamlessly with [redis sentinel](http://redis.io/topics/sentinel), which is supported in redis 3.2+.
186
+ To utilize `Redlock::Client` with sentinels you can pass an instance of `RedisClient` or just a configuration hash as part of the servers array during initialization.
187
+
188
+ ```ruby
189
+ config = {
190
+ name: "mymaster",
191
+ sentinels: [
192
+ { host: "127.0.0.1", port: 26380 },
193
+ { host: "127.0.0.1", port: 26381 },
194
+ ],
195
+ role: :master
196
+ }
197
+ client = RedisClient.sentinel(**config).new_client
198
+ servers = [ config, client ]
199
+ redlock = Redlock::Client.new(servers)
200
+ ```
201
+ Redlock supports the same configuration hash as `RedisClient`.
186
202
 
187
203
  ## Redlock configuration
188
204
 
@@ -1,9 +1,19 @@
1
- require 'redis'
1
+ require 'monitor'
2
+ require 'redis-client'
2
3
  require 'securerandom'
3
4
 
4
5
  module Redlock
5
6
  include Scripts
6
7
 
8
+ class LockAcquisitionError < LockError
9
+ attr_reader :errors
10
+
11
+ def initialize(message, errors)
12
+ super(message)
13
+ @errors = errors
14
+ end
15
+ end
16
+
7
17
  class Client
8
18
  DEFAULT_REDIS_HOST = ENV["DEFAULT_REDIS_HOST"] || "localhost"
9
19
  DEFAULT_REDIS_PORT = ENV["DEFAULT_REDIS_PORT"] || "6379"
@@ -147,34 +157,62 @@ module Redlock
147
157
  private
148
158
 
149
159
  class RedisInstance
150
- module ConnectionPoolLike
151
- def with
152
- yield self
153
- end
154
- end
155
-
156
160
  def initialize(connection)
157
- if connection.respond_to?(:with)
161
+ @monitor = Monitor.new
162
+
163
+ if connection.respond_to?(:call)
158
164
  @redis = connection
159
165
  else
160
166
  if connection.respond_to?(:client)
161
167
  @redis = connection
168
+ elsif connection.respond_to?(:key?)
169
+ @redis = initialize_client(connection)
162
170
  else
163
- @redis = Redis.new(connection)
171
+ @redis = connection
172
+ end
173
+ end
174
+ end
175
+
176
+ def initialize_client(options)
177
+ if options.key?(:sentinels)
178
+ if url = options.delete(:url)
179
+ uri = URI.parse(url)
180
+ if !options.key?(:name) && uri.host
181
+ options[:name] = uri.host
182
+ end
183
+
184
+ if !options.key?(:password) && uri.password && !uri.password.empty?
185
+ options[:password] = uri.password
186
+ end
187
+
188
+ if !options.key?(:username) && uri.user && !uri.user.empty?
189
+ options[:username] = uri.user
190
+ end
164
191
  end
165
- @redis.extend(ConnectionPoolLike)
192
+
193
+ RedisClient.sentinel(**options).new_client
194
+ else
195
+ RedisClient.config(**options).new_client
166
196
  end
167
197
  end
168
198
 
199
+ def synchronize
200
+ @monitor.synchronize { @redis.then { |connection| yield(connection) } }
201
+ end
202
+
169
203
  def lock(resource, val, ttl, allow_new_lock)
170
204
  recover_from_script_flush do
171
- @redis.with { |conn| conn.evalsha Scripts::LOCK_SCRIPT_SHA, keys: [resource], argv: [val, ttl, allow_new_lock] }
205
+ synchronize { |conn|
206
+ conn.call('EVALSHA', Scripts::LOCK_SCRIPT_SHA, 1, resource, val, ttl, allow_new_lock)
207
+ }
172
208
  end
173
209
  end
174
210
 
175
211
  def unlock(resource, val)
176
212
  recover_from_script_flush do
177
- @redis.with { |conn| conn.evalsha Scripts::UNLOCK_SCRIPT_SHA, keys: [resource], argv: [val] }
213
+ synchronize { |conn|
214
+ conn.call('EVALSHA', Scripts::UNLOCK_SCRIPT_SHA, 1, resource, val)
215
+ }
178
216
  end
179
217
  rescue
180
218
  # Nothing to do, unlocking is just a best-effort attempt.
@@ -182,9 +220,11 @@ module Redlock
182
220
 
183
221
  def get_remaining_ttl(resource)
184
222
  recover_from_script_flush do
185
- @redis.with { |conn| conn.evalsha Scripts::PTTL_SCRIPT_SHA, keys: [resource] }
223
+ synchronize { |conn|
224
+ conn.call('EVALSHA', Scripts::PTTL_SCRIPT_SHA, 1, resource)
225
+ }
186
226
  end
187
- rescue Redis::BaseConnectionError
227
+ rescue RedisClient::ConnectionError
188
228
  nil
189
229
  end
190
230
 
@@ -197,8 +237,24 @@ module Redlock
197
237
  Scripts::PTTL_SCRIPT
198
238
  ]
199
239
 
200
- scripts.each do |script|
201
- @redis.with { |conn| conn.script(:load, script) }
240
+ synchronize do |connnection|
241
+ scripts.each do |script|
242
+ connnection.call('SCRIPT', 'LOAD', script)
243
+ end
244
+ end
245
+ end
246
+
247
+ # Exception classes that may be raised for NOSCRIPT errors.
248
+ # RedisClient::CommandError is raised when using redis-client directly.
249
+ # Redis::CommandError (or its subclass Redis::NoScriptError) is raised
250
+ # when using the redis gem wrapper.
251
+ # See: https://github.com/leandromoreira/redlock-rb/issues/124
252
+ # See: https://github.com/leandromoreira/redlock-rb/issues/148
253
+ def script_error_classes
254
+ @script_error_classes ||= begin
255
+ classes = [RedisClient::CommandError]
256
+ classes << Redis::CommandError if defined?(Redis::CommandError)
257
+ classes
202
258
  end
203
259
  end
204
260
 
@@ -206,7 +262,7 @@ module Redlock
206
262
  retry_on_noscript = true
207
263
  begin
208
264
  yield
209
- rescue Redis::CommandError => e
265
+ rescue *script_error_classes => e
210
266
  # When somebody has flushed the Redis instance's script cache, we might
211
267
  # want to reload our scripts. Only attempt this once, though, to avoid
212
268
  # going into an infinite loop.
@@ -224,6 +280,7 @@ module Redlock
224
280
  def try_lock_instances(resource, ttl, options)
225
281
  retry_count = options[:retry_count] || @retry_count
226
282
  tries = options[:extend] ? 1 : (retry_count + 1)
283
+ last_error = nil
227
284
 
228
285
  tries.times do |attempt_number|
229
286
  # Wait a random delay before retrying.
@@ -231,8 +288,12 @@ module Redlock
231
288
 
232
289
  lock_info = lock_instances(resource, ttl, options)
233
290
  return lock_info if lock_info
291
+ rescue => e
292
+ last_error = e
234
293
  end
235
294
 
295
+ raise last_error if last_error
296
+
236
297
  false
237
298
  end
238
299
 
@@ -253,9 +314,15 @@ module Redlock
253
314
  def lock_instances(resource, ttl, options)
254
315
  value = (options[:extend] || { value: SecureRandom.uuid })[:value]
255
316
  allow_new_lock = options[:extend_only_if_locked] ? 'no' : 'yes'
317
+ errors = []
256
318
 
257
319
  locked, time_elapsed = timed do
258
- @servers.select { |s| s.lock resource, value, ttl, allow_new_lock }.size
320
+ @servers.count do |s|
321
+ s.lock(resource, value, ttl, allow_new_lock)
322
+ rescue => e
323
+ errors << e
324
+ false
325
+ end
259
326
  end
260
327
 
261
328
  validity = ttl - time_elapsed - drift(ttl)
@@ -264,6 +331,12 @@ module Redlock
264
331
  { validity: validity, resource: resource, value: value }
265
332
  else
266
333
  @servers.each { |s| s.unlock(resource, value) }
334
+
335
+ if errors.size >= @quorum
336
+ err_msgs = errors.map { |e| "#{e.class}: #{e.message}" }.join("\n")
337
+ raise LockAcquisitionError.new("Too many Redis errors prevented lock acquisition:\n#{err_msgs}", errors)
338
+ end
339
+
267
340
  false
268
341
  end
269
342
  end
@@ -41,7 +41,7 @@ module Redlock
41
41
 
42
42
  def load_scripts
43
43
  load_scripts_without_testing unless Redlock::Client.testing_mode == :bypass
44
- rescue Redis::CommandError
44
+ rescue RedisClient::CommandError
45
45
  # FakeRedis doesn't have #script, but doesn't need it either.
46
46
  raise unless defined?(::FakeRedis)
47
47
  rescue NoMethodError
@@ -1,3 +1,3 @@
1
1
  module Redlock
2
- VERSION = '1.3.2'
2
+ VERSION = '2.1.0'
3
3
  end
data/redlock.gemspec CHANGED
@@ -5,24 +5,25 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
5
  require 'redlock/version'
6
6
 
7
7
  Gem::Specification.new do |spec|
8
- spec.name = 'redlock'
9
- spec.version = Redlock::VERSION
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'
15
- spec.license = 'BSD-2-Clause'
8
+ spec.name = 'redlock'
9
+ spec.version = Redlock::VERSION
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'
15
+ spec.license = 'BSD-2-Clause'
16
+ spec.required_ruby_version = '>= 2.7.0'
16
17
 
17
18
  spec.files = `git ls-files -z`.split("\x0")
18
19
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
20
  spec.require_paths = ['lib']
20
21
 
21
- spec.add_dependency 'redis', '>= 3.0.0', '< 6.0'
22
+ spec.add_dependency 'redis-client', '>= 0.14.1', '< 1.0.0'
22
23
 
23
24
  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'
25
+ spec.add_development_dependency 'coveralls_reborn', '~> 0.29'
26
+ spec.add_development_dependency 'json', '>= 2.3.0'
26
27
  spec.add_development_dependency 'rake', '>= 11.1.2', '~> 13.0'
27
28
  spec.add_development_dependency 'rspec', '~> 3', '>= 3.0.0'
28
29
  end
data/spec/client_spec.rb CHANGED
@@ -1,13 +1,26 @@
1
1
  require 'spec_helper'
2
2
  require 'securerandom'
3
- require 'redis'
4
3
  require 'connection_pool'
5
4
 
6
5
  RSpec.describe Redlock::Client do
7
6
  # It is recommended to have at least 3 servers in production
8
7
  let(:lock_manager_opts) { { retry_count: 3 } }
9
- let(:lock_manager) { Redlock::Client.new(Redlock::Client::DEFAULT_REDIS_URLS, lock_manager_opts) }
10
- let(:redis_client) { Redis.new(url: "redis://#{redis1_host}:#{redis1_port}") }
8
+ let(:redis_urls_or_clients) {
9
+ urls = Redlock::Client::DEFAULT_REDIS_URLS
10
+ if rand(0..1).zero?
11
+ RSpec.configuration.reporter.message "variant: client urls"
12
+ urls
13
+ else
14
+ RSpec.configuration.reporter.message "variant: client objects"
15
+ urls.map {|url|
16
+ ConnectionPool.new { RedisClient.new(url: url) }
17
+ }
18
+ end
19
+ }
20
+ let(:lock_manager) {
21
+ Redlock::Client.new(redis_urls_or_clients, lock_manager_opts)
22
+ }
23
+ let(:redis_client) { RedisClient.new(url: "redis://#{redis1_host}:#{redis1_port}") }
11
24
  let(:resource_key) { SecureRandom.hex(3) }
12
25
  let(:ttl) { 1000 }
13
26
  let(:redis1_host) { ENV["REDIS1_HOST"] || "localhost" }
@@ -17,41 +30,51 @@ RSpec.describe Redlock::Client do
17
30
  let(:redis3_host) { ENV["REDIS3_HOST"] || "127.0.0.1" }
18
31
  let(:redis3_port) { ENV["REDIS3_PORT"] || "6379" }
19
32
  let(:unreachable_redis) {
20
- redis = Redis.new(url: 'redis://localhost:46864')
21
- def redis.with
22
- yield self
23
- end
24
- redis
33
+ RedisClient.new(url: 'redis://localhost:46864')
25
34
  }
26
35
 
27
36
  describe 'initialize' do
28
37
  it 'accepts both redis URLs and Redis objects' do
29
- servers = [ "redis://#{redis1_host}:#{redis1_port}", Redis.new(url: "redis://#{redis2_host}:#{redis2_port}") ]
38
+ servers = [ "redis://#{redis1_host}:#{redis1_port}", RedisClient.new(url: "redis://#{redis2_host}:#{redis2_port}") ]
30
39
  redlock = Redlock::Client.new(servers)
31
40
 
32
41
  redlock_servers = redlock.instance_variable_get(:@servers).map do |s|
33
- s.instance_variable_get(:@redis).connection[:host]
42
+ s.instance_variable_get(:@redis).config.host
34
43
  end
35
44
 
36
45
  expect(redlock_servers).to match_array([redis1_host, redis2_host])
37
46
  end
38
47
 
39
48
  it 'accepts ConnectionPool objects' do
40
- pool = ConnectionPool.new { Redis.new(url: "redis://#{redis1_host}:#{redis1_port}") }
41
- redlock = Redlock::Client.new([pool])
49
+ pool = ConnectionPool.new { RedisClient.new(url: "redis://#{redis1_host}:#{redis1_port}") }
50
+ _redlock = Redlock::Client.new([pool])
51
+
52
+ lock_info = lock_manager.lock(resource_key, ttl)
53
+ expect(lock_info).to be_a(Hash)
54
+ expect(resource_key).to_not be_lockable(lock_manager, ttl)
55
+ lock_manager.unlock(lock_info)
56
+ end
57
+
58
+ it 'accepts Configuration hashes' do
59
+ config = { url: "redis://#{redis1_host}:#{redis1_port}" }
60
+ _redlock = Redlock::Client.new([config])
42
61
 
43
62
  lock_info = lock_manager.lock(resource_key, ttl)
63
+ expect(lock_info).to be_a(Hash)
44
64
  expect(resource_key).to_not be_lockable(lock_manager, ttl)
45
65
  lock_manager.unlock(lock_info)
46
66
  end
47
67
 
48
68
  it 'does not load scripts' do
49
- redis_client.script(:flush)
69
+ redis_client.call('SCRIPT', 'FLUSH')
70
+
71
+ pool = ConnectionPool.new { RedisClient.new(url: "redis://#{redis1_host}:#{redis1_port}") }
72
+ _redlock = Redlock::Client.new([pool])
50
73
 
51
- pool = ConnectionPool.new { Redis.new(url: "redis://#{redis1_host}:#{redis1_port}") }
52
- redlock = Redlock::Client.new([pool])
74
+ raw_info = redis_client.call('INFO')
75
+ number_of_cached_scripts = raw_info[/number_of_cached_scripts\:\d+/].split(':').last
53
76
 
54
- expect(redis_client.info["number_of_cached_scripts"]).to eq("0")
77
+ expect(number_of_cached_scripts).to eq("0")
55
78
  end
56
79
  end
57
80
 
@@ -59,6 +82,44 @@ RSpec.describe Redlock::Client do
59
82
  context 'when lock is available' do
60
83
  after(:each) { lock_manager.unlock(@lock_info) if @lock_info }
61
84
 
85
+ context 'when redis connection error occurs' do
86
+ let(:servers_with_quorum) {
87
+ [
88
+ "redis://#{redis1_host}:#{redis1_port}",
89
+ "redis://#{redis2_host}:#{redis2_port}",
90
+ unreachable_redis
91
+ ]
92
+ }
93
+
94
+ let(:servers_without_quorum) {
95
+ [
96
+ "redis://#{redis1_host}:#{redis1_port}",
97
+ unreachable_redis,
98
+ unreachable_redis
99
+ ]
100
+ }
101
+
102
+ it 'locks if majority of redis instances are available' do
103
+ redlock = Redlock::Client.new(servers_with_quorum)
104
+
105
+ expect(redlock.lock(resource_key, ttl)).to be_truthy
106
+ end
107
+
108
+ it 'fails to acquire a lock if majority of Redis instances are not available' do
109
+ redlock = Redlock::Client.new(servers_without_quorum)
110
+
111
+ expect {
112
+ redlock.lock(resource_key, ttl)
113
+ }.to raise_error do |error|
114
+ expect(error).to be_a(Redlock::LockAcquisitionError)
115
+ expect(error.message).to include('Too many Redis errors prevented lock acquisition')
116
+ expect(error.message).to include('RedisClient::CannotConnectError')
117
+ expect(error.message).to include('Connection refused')
118
+ expect(error.errors.size).to eq(2)
119
+ end
120
+ end
121
+ end
122
+
62
123
  it 'locks' do
63
124
  @lock_info = lock_manager.lock(resource_key, ttl)
64
125
 
@@ -74,7 +135,7 @@ RSpec.describe Redlock::Client do
74
135
  it 'interprets lock time as milliseconds' do
75
136
  ttl = 20000
76
137
  @lock_info = lock_manager.lock(resource_key, ttl)
77
- expect(redis_client.pttl(resource_key)).to be_within(200).of(ttl)
138
+ expect(redis_client.call('PTTL', resource_key)).to be_within(200).of(ttl)
78
139
  end
79
140
 
80
141
  it 'can extend its own lock' do
@@ -106,7 +167,7 @@ RSpec.describe Redlock::Client do
106
167
 
107
168
  lock_info = lock_manager.lock(resource_key, ttl, extend: lock_info, extend_only_if_locked: true)
108
169
  expect(lock_info).not_to be_nil
109
- expect(redis_client.pttl(resource_key)).to be_within(200).of(ttl)
170
+ expect(redis_client.call('PTTL', resource_key)).to be_within(200).of(ttl)
110
171
  end
111
172
 
112
173
  context 'when extend_only_if_locked flag is not given' do
@@ -195,7 +256,7 @@ RSpec.describe Redlock::Client do
195
256
  2000
196
257
  end
197
258
 
198
- lock_manager = Redlock::Client.new(Redlock::Client::DEFAULT_REDIS_URLS, retry_count: 1, retry_delay: retry_delay)
259
+ lock_manager = Redlock::Client.new(redis_urls_or_clients, retry_count: 1, retry_delay: retry_delay)
199
260
  another_lock_info = lock_manager.lock(resource_key, ttl)
200
261
 
201
262
  expect(lock_manager).to receive(:sleep) do |sleep|
@@ -249,7 +310,10 @@ RSpec.describe Redlock::Client do
249
310
 
250
311
  expect {
251
312
  lock_manager.lock(resource_key, ttl)
252
- }.to raise_error(Redis::CannotConnectError)
313
+ }.to raise_error(Redlock::LockAcquisitionError) do |e|
314
+ expect(e.errors[0]).to be_a(RedisClient::CannotConnectError)
315
+ expect(e.errors.count).to eq 1
316
+ end
253
317
  end
254
318
  end
255
319
 
@@ -261,7 +325,10 @@ RSpec.describe Redlock::Client do
261
325
  redis_instance.instance_variable_set(:@redis, unreachable_redis)
262
326
  expect {
263
327
  lock_manager.lock(resource_key, ttl)
264
- }.to raise_error(Redis::CannotConnectError)
328
+ }.to raise_error(Redlock::LockAcquisitionError) do |e|
329
+ expect(e.errors[0]).to be_a(RedisClient::CannotConnectError)
330
+ expect(e.errors.count).to eq 1
331
+ end
265
332
  redis_instance.instance_variable_set(:@redis, old_redis)
266
333
  expect(lock_manager.lock(resource_key, ttl)).to be_truthy
267
334
  end
@@ -270,10 +337,12 @@ RSpec.describe Redlock::Client do
270
337
  context 'when script cache has been flushed' do
271
338
  before(:each) do
272
339
  @manipulated_instance = lock_manager.instance_variable_get(:@servers).first
273
- @manipulated_instance.instance_variable_get(:@redis).script(:flush)
340
+ @manipulated_instance.instance_variable_get(:@redis).with { |conn|
341
+ conn.call('SCRIPT', 'FLUSH')
342
+ }
274
343
  end
275
344
 
276
- it 'does not raise a Redis::CommandError: NOSCRIPT error' do
345
+ it 'does not raise a RedisClient::CommandError: NOSCRIPT error' do
277
346
  expect {
278
347
  lock_manager.lock(resource_key, ttl)
279
348
  }.to_not raise_error
@@ -289,11 +358,15 @@ RSpec.describe Redlock::Client do
289
358
  # This time we do not pass it through to Redis, in order to simulate a passing
290
359
  # call to LOAD SCRIPT followed by another NOSCRIPT error. Imagine someone
291
360
  # repeatedly calling SCRIPT FLUSH on our Redis instance.
292
- expect(@manipulated_instance).to receive(:load_scripts)
361
+ expect(@manipulated_instance).to receive(:load_scripts).exactly(8).times
293
362
 
294
363
  expect {
295
364
  lock_manager.lock(resource_key, ttl)
296
- }.to raise_error(/NOSCRIPT/)
365
+ }.to raise_error(Redlock::LockAcquisitionError) do |e|
366
+ expect(e.errors[0]).to be_a(RedisClient::CommandError)
367
+ expect(e.errors[0].message).to match(/NOSCRIPT/)
368
+ expect(e.errors.count).to eq 1
369
+ end
297
370
  end
298
371
  end
299
372
 
@@ -304,6 +377,108 @@ RSpec.describe Redlock::Client do
304
377
  end
305
378
  end
306
379
 
380
+ context 'when using the redis gem (Redis::CommandError)' do
381
+ # The redis gem raises Redis::CommandError (or Redis::NoScriptError) instead of
382
+ # RedisClient::CommandError. This tests that both error hierarchies are handled.
383
+ # See: https://github.com/leandromoreira/redlock-rb/issues/124
384
+ # See: https://github.com/leandromoreira/redlock-rb/issues/148
385
+
386
+ before(:all) do
387
+ # Define Redis::CommandError if not already defined (simulates redis gem being loaded)
388
+ unless defined?(Redis::CommandError)
389
+ module Redis
390
+ class CommandError < StandardError; end
391
+ end
392
+ @redis_command_error_defined_by_test = true
393
+ end
394
+ end
395
+
396
+ after(:all) do
397
+ # Clean up the mock class if we defined it
398
+ if @redis_command_error_defined_by_test
399
+ Redis.send(:remove_const, :CommandError)
400
+ Object.send(:remove_const, :Redis) if Redis.constants.empty?
401
+ end
402
+ end
403
+
404
+ describe '#script_error_classes' do
405
+ it 'includes RedisClient::CommandError' do
406
+ redis_instance = lock_manager.instance_variable_get(:@servers).first
407
+ expect(redis_instance.send(:script_error_classes)).to include(RedisClient::CommandError)
408
+ end
409
+
410
+ it 'includes Redis::CommandError when defined' do
411
+ redis_instance = lock_manager.instance_variable_get(:@servers).first
412
+ # Clear memoization to pick up the newly defined class
413
+ redis_instance.instance_variable_set(:@script_error_classes, nil)
414
+ expect(redis_instance.send(:script_error_classes)).to include(Redis::CommandError)
415
+ end
416
+ end
417
+
418
+ context 'when Redis::CommandError NOSCRIPT is raised' do
419
+ it 'recovers by reloading scripts' do
420
+ redis_instance = lock_manager.instance_variable_get(:@servers).first
421
+ # Clear memoization to ensure Redis::CommandError is included
422
+ redis_instance.instance_variable_set(:@script_error_classes, nil)
423
+
424
+ call_count = 0
425
+ allow(redis_instance).to receive(:synchronize).and_wrap_original do |original_method, &block|
426
+ call_count += 1
427
+ if call_count == 1
428
+ # First call raises Redis::CommandError with NOSCRIPT
429
+ raise Redis::CommandError, 'NOSCRIPT No matching script'
430
+ else
431
+ original_method.call(&block)
432
+ end
433
+ end
434
+
435
+ expect(redis_instance).to receive(:load_scripts).and_call_original
436
+
437
+ # Should recover and successfully lock
438
+ lock_info = lock_manager.lock(resource_key, ttl)
439
+ expect(lock_info).to be_truthy
440
+ lock_manager.unlock(lock_info) if lock_info
441
+ end
442
+
443
+ it 'does not retry more than once per operation' do
444
+ redis_instance = lock_manager.instance_variable_get(:@servers).first
445
+ redis_instance.instance_variable_set(:@script_error_classes, nil)
446
+
447
+ # Always raise Redis::CommandError NOSCRIPT
448
+ allow(redis_instance).to receive(:synchronize).and_raise(
449
+ Redis::CommandError, 'NOSCRIPT No matching script'
450
+ )
451
+
452
+ load_scripts_count = 0
453
+ allow(redis_instance).to receive(:load_scripts) { load_scripts_count += 1 }
454
+
455
+ expect {
456
+ lock_manager.lock(resource_key, ttl)
457
+ }.to raise_error(Redlock::LockAcquisitionError)
458
+
459
+ # Should have called load_scripts once per retry attempt (8 times total:
460
+ # lock + unlock for each of retry_count+1 attempts, but unlock swallows errors)
461
+ expect(load_scripts_count).to eq(8)
462
+ end
463
+
464
+ it 're-raises non-NOSCRIPT Redis::CommandError' do
465
+ redis_instance = lock_manager.instance_variable_get(:@servers).first
466
+ redis_instance.instance_variable_set(:@script_error_classes, nil)
467
+
468
+ allow(redis_instance).to receive(:synchronize).and_raise(
469
+ Redis::CommandError, 'ERR some other error'
470
+ )
471
+
472
+ expect {
473
+ lock_manager.lock(resource_key, ttl)
474
+ }.to raise_error(Redlock::LockAcquisitionError) do |e|
475
+ expect(e.errors[0]).to be_a(Redis::CommandError)
476
+ expect(e.errors[0].message).to eq('ERR some other error')
477
+ end
478
+ end
479
+ end
480
+ end
481
+
307
482
  describe 'block syntax' do
308
483
  context 'when lock is available' do
309
484
  it 'locks' do
@@ -473,7 +648,7 @@ RSpec.describe Redlock::Client do
473
648
 
474
649
  # Replace redis with unreachable instance
475
650
  redis_instance = lock_manager.instance_variable_get(:@servers).first
476
- old_redis = redis_instance.instance_variable_get(:@redis)
651
+ _old_redis = redis_instance.instance_variable_get(:@redis)
477
652
  redis_instance.instance_variable_set(:@redis, unreachable_redis)
478
653
 
479
654
  expect {
data/spec/testing_spec.rb CHANGED
@@ -11,7 +11,7 @@ RSpec.describe Redlock::Client do
11
11
  describe '(testing mode)' do
12
12
  describe 'try_lock_instances' do
13
13
  context 'when testing with bypass mode' do
14
- before { lock_manager.testing_mode = :bypass }
14
+ before { Redlock::Client.testing_mode = :bypass }
15
15
 
16
16
  it 'bypasses the redis servers' do
17
17
  expect(lock_manager).to_not receive(:try_lock_instances_without_testing)
@@ -22,7 +22,7 @@ RSpec.describe Redlock::Client do
22
22
  end
23
23
 
24
24
  context 'when testing with fail mode' do
25
- before { lock_manager.testing_mode = :fail }
25
+ before { Redlock::Client.testing_mode = :fail }
26
26
 
27
27
  it 'fails' do
28
28
  expect(lock_manager).to_not receive(:try_lock_instances_without_testing)
@@ -33,7 +33,7 @@ RSpec.describe Redlock::Client do
33
33
  end
34
34
 
35
35
  context 'when testing is disabled' do
36
- before { lock_manager.testing_mode = nil }
36
+ before { Redlock::Client.testing_mode = nil }
37
37
 
38
38
  it 'works as usual' do
39
39
  expect(lock_manager).to receive(:try_lock_instances_without_testing)
metadata CHANGED
@@ -1,35 +1,34 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redlock
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.2
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leandro Moreira
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2022-10-17 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
- name: redis
13
+ name: redis-client
15
14
  requirement: !ruby/object:Gem::Requirement
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: 3.0.0
18
+ version: 0.14.1
20
19
  - - "<"
21
20
  - !ruby/object:Gem::Version
22
- version: '6.0'
21
+ version: 1.0.0
23
22
  type: :runtime
24
23
  prerelease: false
25
24
  version_requirements: !ruby/object:Gem::Requirement
26
25
  requirements:
27
26
  - - ">="
28
27
  - !ruby/object:Gem::Version
29
- version: 3.0.0
28
+ version: 0.14.1
30
29
  - - "<"
31
30
  - !ruby/object:Gem::Version
32
- version: '6.0'
31
+ version: 1.0.0
33
32
  - !ruby/object:Gem::Dependency
34
33
  name: connection_pool
35
34
  requirement: !ruby/object:Gem::Requirement
@@ -45,19 +44,19 @@ dependencies:
45
44
  - !ruby/object:Gem::Version
46
45
  version: '2.2'
47
46
  - !ruby/object:Gem::Dependency
48
- name: coveralls
47
+ name: coveralls_reborn
49
48
  requirement: !ruby/object:Gem::Requirement
50
49
  requirements:
51
50
  - - "~>"
52
51
  - !ruby/object:Gem::Version
53
- version: '0.8'
52
+ version: '0.29'
54
53
  type: :development
55
54
  prerelease: false
56
55
  version_requirements: !ruby/object:Gem::Requirement
57
56
  requirements:
58
57
  - - "~>"
59
58
  - !ruby/object:Gem::Version
60
- version: '0.8'
59
+ version: '0.29'
61
60
  - !ruby/object:Gem::Dependency
62
61
  name: json
63
62
  requirement: !ruby/object:Gem::Requirement
@@ -65,9 +64,6 @@ dependencies:
65
64
  - - ">="
66
65
  - !ruby/object:Gem::Version
67
66
  version: 2.3.0
68
- - - "~>"
69
- - !ruby/object:Gem::Version
70
- version: 2.3.1
71
67
  type: :development
72
68
  prerelease: false
73
69
  version_requirements: !ruby/object:Gem::Requirement
@@ -75,9 +71,6 @@ dependencies:
75
71
  - - ">="
76
72
  - !ruby/object:Gem::Version
77
73
  version: 2.3.0
78
- - - "~>"
79
- - !ruby/object:Gem::Version
80
- version: 2.3.1
81
74
  - !ruby/object:Gem::Dependency
82
75
  name: rake
83
76
  requirement: !ruby/object:Gem::Requirement
@@ -128,9 +121,9 @@ files:
128
121
  - ".github/workflows/ci.yml"
129
122
  - ".gitignore"
130
123
  - ".rspec"
124
+ - CHANGELOG.md
131
125
  - CONTRIBUTORS
132
126
  - Gemfile
133
- - Gemfile.lock
134
127
  - LICENSE
135
128
  - Makefile
136
129
  - README.md
@@ -150,7 +143,6 @@ homepage: https://github.com/leandromoreira/redlock-rb
150
143
  licenses:
151
144
  - BSD-2-Clause
152
145
  metadata: {}
153
- post_install_message:
154
146
  rdoc_options: []
155
147
  require_paths:
156
148
  - lib
@@ -158,15 +150,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
158
150
  requirements:
159
151
  - - ">="
160
152
  - !ruby/object:Gem::Version
161
- version: '0'
153
+ version: 2.7.0
162
154
  required_rubygems_version: !ruby/object:Gem::Requirement
163
155
  requirements:
164
156
  - - ">="
165
157
  - !ruby/object:Gem::Version
166
158
  version: '0'
167
159
  requirements: []
168
- rubygems_version: 3.3.7
169
- signing_key:
160
+ rubygems_version: 4.0.3
170
161
  specification_version: 4
171
162
  summary: Distributed lock using Redis written in Ruby.
172
163
  test_files:
data/Gemfile.lock DELETED
@@ -1,62 +0,0 @@
1
- PATH
2
- remote: .
3
- specs:
4
- redlock (1.3.2)
5
- redis (>= 3.0.0, < 6.0)
6
-
7
- GEM
8
- remote: https://rubygems.org/
9
- specs:
10
- connection_pool (2.3.0)
11
- coveralls (0.8.23)
12
- json (>= 1.8, < 3)
13
- simplecov (~> 0.16.1)
14
- term-ansicolor (~> 1.3)
15
- thor (>= 0.19.4, < 2.0)
16
- tins (~> 1.6)
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.5)
22
- redis-client (>= 0.9.0)
23
- redis-client (0.10.0)
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.1)
32
- diff-lcs (>= 1.2.0, < 2.0)
33
- rspec-support (~> 3.11.0)
34
- rspec-mocks (3.11.1)
35
- diff-lcs (>= 1.2.0, < 2.0)
36
- rspec-support (~> 3.11.0)
37
- rspec-support (3.11.1)
38
- simplecov (0.16.1)
39
- docile (~> 1.1)
40
- json (>= 1.8, < 3)
41
- simplecov-html (~> 0.10.0)
42
- simplecov-html (0.10.2)
43
- sync (0.5.0)
44
- term-ansicolor (1.7.1)
45
- tins (~> 1.0)
46
- thor (1.2.1)
47
- tins (1.31.1)
48
- sync
49
-
50
- PLATFORMS
51
- ruby
52
-
53
- DEPENDENCIES
54
- connection_pool (~> 2.2)
55
- coveralls (~> 0.8)
56
- json (~> 2.3.1, >= 2.3.0)
57
- rake (~> 13.0, >= 11.1.2)
58
- redlock!
59
- rspec (~> 3, >= 3.0.0)
60
-
61
- BUNDLED WITH
62
- 2.3.7