redis-client 0.10.0 → 0.17.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: 27142ef61a44133a72e2a087afc39986ebcfe5fae30a7ee5f6eb3f702ef35ad2
4
- data.tar.gz: f078880a52dec2f62d3b68188cfea8108ec433742f2f7e697def7a7bece332ac
3
+ metadata.gz: c8038f4b1f7316e16bd3561f43311334a9e69896e54a75b1a9666cb2718ba3b0
4
+ data.tar.gz: e37514256b44be8b0d5832c3d944c8d3be2a8228bff1fa05ac3a28347c6445ae
5
5
  SHA512:
6
- metadata.gz: 76c7b23cb0ba419fdb9505e5cd4eb58c1c6d38d2e2fcd82778429b346dfa02a94ce4a9077c28252d8c71d1667cc45270bfc0790190e42b0d37341493bbb105f5
7
- data.tar.gz: 071ec50fabaa72512101d0f408154e432e5f9950927607d8d6bcf8c7f3d5d4c9f49f40de3a24aa6957019751a598b70ae9db525002c03d7beb05312fc082e053
6
+ metadata.gz: f02d99ca8c468cf38a4527817257169104d7f64cea5110c170d434d82b7f30cfea8338e0e746504d2d8c29866f81ef6cf4e81d014400ff364d5138d7653dfdc3
7
+ data.tar.gz: 2a66a02666e43b17c5007f2ae99c08f331cc46eb4645330b0f3df31fec39080ab779860bebcaa40add2beacc057d7a4ce586a7bc4453c50ddb0c4a10b677281f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # Unreleased
2
2
 
3
+ # 0.17.0
4
+
5
+ - Adds `sentinel_username` and `sentinel_password` options for `RedisClient#sentinel`
6
+
7
+ # 0.16.0
8
+
9
+ - Add `RedisClient#disable_reconnection`.
10
+ - Reverted the special discard of connection. A regular `close(2)` should be enough.
11
+
12
+ # 0.15.0
13
+
14
+ - Discard sockets rather than explictly close them when a fork is detected. #126.
15
+ - Allow to configure sentinel client via url. #117.
16
+ - Fix sentinel to preverse the auth/password when refreshing the sentinel list. #107.
17
+
18
+ # 0.14.1
19
+
20
+ - Include the timeout value in TimeoutError messages.
21
+ - Fix connection keep-alive on FreeBSD. #102.
22
+
23
+ # 0.14.0
24
+
25
+ - Implement Sentinels list automatic refresh.
26
+ - hiredis binding now implement GC compaction and write barriers.
27
+ - hiredis binding now properly release the GVL around `connect(2)`.
28
+ - hiredis the client memory is now re-used on reconnection when possible to reduce allocation churn.
29
+
30
+ # 0.13.0
31
+
32
+ - Enable TCP keepalive on redis sockets. It sends a keep alive probe every 15 seconds for 2 minutes. #94.
33
+
34
+ # 0.12.2
35
+
36
+ - Cache calls to `Process.pid` on Ruby 3.1+. #91.
37
+
38
+ # 0.12.1
39
+
40
+ - Improve compatibility with `uri 0.12.0` (default in Ruby 3.2.0).
41
+
42
+ # 0.12.0
43
+
44
+ - hiredis: fix a compilation issue on macOS and Ruby 3.2.0. See: #79
45
+ - Close connection on MASTERDOWN errors. Similar to READONLY.
46
+ - Add a `circuit_breaker` configuration option for cache servers and other disposable Redis servers. See #55 / #70
47
+
48
+ # 0.11.2
49
+
50
+ - Close connection on READONLY errors. Fix: #64
51
+ - Handle Redis 6+ servers with a missing HELLO command. See: #67
52
+ - Validate `url` parameters a bit more strictly. Fix #61
53
+
54
+ # 0.11.1
55
+
56
+ - hiredis: Workaround a compilation bug with Xcode 14.0. Fix: #58
57
+ - Accept `URI` instances as `uri` parameter.
58
+
59
+ # 0.11.0
60
+
61
+ - hiredis: do not eagerly close the connection on read timeout, let the caller decide if a timeout is final.
62
+ - Add `Config#custom` to store configuration metadata. It can be used for per server middleware configuration.
63
+
3
64
  # 0.10.0
4
65
 
5
66
  - Added instance scoped middlewares. See: #53
@@ -14,7 +75,7 @@
14
75
 
15
76
  - Make the client resilient to `Timeout.timeout` or `Thread#kill` use (it still is very much discouraged to use either).
16
77
  Use of async interrupts could cause responses to be interleaved.
17
- - hiredis: handle commands returning a top-level `false` (no command does this today, but some extensions might).
78
+ - hiredis: handle commands returning a top-level `false` (no command does this today, but some extensions might).
18
79
  - Workaround a bug in Ruby 2.6 causing a crash if the `debug` gem is enabled when `redis-client` is being required. Fix: #48
19
80
 
20
81
  # 0.8.0
@@ -33,7 +94,7 @@
33
94
 
34
95
  - Raise a distinct `RedisClient::OutOfMemoryError`, for Redis `OOM` errors.
35
96
  - Fix the instrumentation API to be called even for authentication commands.
36
- - Fix `url:` configuration to accept a trailing slash.
97
+ - Fix `url:` configuration to accept a trailing slash.
37
98
 
38
99
  # 0.7.1
39
100
 
data/Gemfile.lock CHANGED
@@ -1,16 +1,16 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- redis-client (0.10.0)
4
+ redis-client (0.17.0)
5
5
  connection_pool
6
6
 
7
7
  GEM
8
8
  remote: https://rubygems.org/
9
9
  specs:
10
10
  ast (2.4.2)
11
- benchmark-ips (2.10.0)
11
+ benchmark-ips (2.12.0)
12
12
  byebug (11.1.3)
13
- connection_pool (2.3.0)
13
+ connection_pool (2.4.1)
14
14
  hiredis (0.6.3)
15
15
  hiredis (0.6.3-java)
16
16
  minitest (5.15.0)
@@ -19,7 +19,7 @@ GEM
19
19
  ast (~> 2.4.1)
20
20
  rainbow (3.1.1)
21
21
  rake (13.0.6)
22
- rake-compiler (1.2.0)
22
+ rake-compiler (1.2.5)
23
23
  rake
24
24
  redis (4.6.0)
25
25
  regexp_parser (2.5.0)
@@ -38,7 +38,7 @@ GEM
38
38
  rubocop-minitest (0.19.1)
39
39
  rubocop (>= 0.90, < 2.0)
40
40
  ruby-progressbar (1.11.0)
41
- stackprof (0.2.21)
41
+ stackprof (0.2.25)
42
42
  toxiproxy (2.0.2)
43
43
  unicode-display_width (2.2.0)
44
44
 
data/README.md CHANGED
@@ -42,7 +42,7 @@ redis.with do |r|
42
42
  end
43
43
  ```
44
44
 
45
- If you are working in a single threaded environment, or wish to use your own connection pooling mechanism,
45
+ If you are working in a single-threaded environment, or wish to use your own connection pooling mechanism,
46
46
  you can obtain a raw client with `#new_client`
47
47
 
48
48
  ```ruby
@@ -63,15 +63,15 @@ redis.call("GET", "mykey")
63
63
  ### Configuration
64
64
 
65
65
  - `url`: A Redis connection URL, e.g. `redis://example.com:6379/5`, a `rediss://` scheme enable SSL, and the path is interpreted as a database number.
66
- Note that all other configurations take precedence, e.g. `RedisClient.config(url: "redis://localhost:3000" port: 6380)` will connect on port `6380`.
66
+ Note that all other configurations take precedence, e.g. `RedisClient.config(url: "redis://localhost:3000", port: 6380)` will connect on port `6380`.
67
67
  - `host`: The server hostname or IP address. Defaults to `"localhost"`.
68
68
  - `port`: The server port. Defaults to `6379`.
69
69
  - `path`: The path to a UNIX socket, if set `url`, `host` and `port` are ignored.
70
- - `ssl`: Wether to connect using SSL or not.
70
+ - `ssl`: Whether to connect using SSL or not.
71
71
  - `ssl_params`: A configuration Hash passed to [`OpenSSL::SSL::SSLContext#set_params`](https://www.rubydoc.info/stdlib/openssl/OpenSSL%2FSSL%2FSSLContext:set_params), notable options include:
72
72
  - `cert`: The path to the client certificate (e.g. `client.crt`).
73
73
  - `key`: The path to the client key (e.g. `client.key`).
74
- - `ca_file`: The certificate authority to use, useful for self signed certificates (e.g. `ca.crt`),
74
+ - `ca_file`: The certificate authority to use, useful for self-signed certificates (e.g. `ca.crt`),
75
75
  - `db`: The database to select after connecting, defaults to `0`.
76
76
  - `id` ID for the client connection, assigns name to current connection by sending `CLIENT SETNAME`.
77
77
  - `username` Username to authenticate against server, defaults to `"default"`.
@@ -81,7 +81,9 @@ redis.call("GET", "mykey")
81
81
  - `read_timeout`: The read timeout, takes precedence over the general timeout when reading responses from the server.
82
82
  - `write_timeout`: The write timeout, takes precedence over the general timeout when sending commands to the server.
83
83
  - `reconnect_attempts`: Specify how many times the client should retry to send queries. Defaults to `0`. Makes sure to read the [reconnection section](#reconnection) before enabling it.
84
+ - `circuit_breaker`: A Hash with circuit breaker configuration. Defaults to `nil`. See the [circuit breaker section](#circuit-breaker) for details.
84
85
  - `protocol:` The version of the RESP protocol to use. Default to `3`.
86
+ - `custom`: A user-owned value ignored by `redis-client` but available as `Config#custom`. This can be used to hold middleware configurations and other user-specific metadata.
85
87
 
86
88
  ### Sentinel support
87
89
 
@@ -126,6 +128,41 @@ but a few so that if one is down the client will try the next one. The client
126
128
  is able to remember the last Sentinel that was able to reply correctly and will
127
129
  use it for the next requests.
128
130
 
131
+ To [authenticate](https://redis.io/docs/management/sentinel/#configuring-sentinel-instances-with-authentication) Sentinel itself, you can specify the `sentinel_username` and `sentinel_password` options per instance. Exclude the `sentinel_username` option if you're using password-only authentication.
132
+
133
+ ```ruby
134
+ SENTINELS = [{ host: '127.0.0.1', port: 26380},
135
+ { host: '127.0.0.1', port: 26381}]
136
+
137
+ redis_config = RedisClient.sentinel(name: 'mymaster', sentinel_username: 'appuser', sentinel_password: 'mysecret', sentinels: SENTINELS, role: :master)
138
+ ```
139
+
140
+ If you specify a username and/or password at the top level for your main Redis instance, Sentinel *will not* using thouse credentials
141
+
142
+ ```ruby
143
+ # Use 'mysecret' to authenticate against the mymaster instance, but skip authentication for the sentinels:
144
+ SENTINELS = [{ host: '127.0.0.1', port: 26380 },
145
+ { host: '127.0.0.1', port: 26381 }]
146
+
147
+ redis_config = RedisClient.sentinel(name: 'mymaster', sentinels: SENTINELS, role: :master, password: 'mysecret')
148
+ ```
149
+
150
+ So you have to provide Sentinel credential and Redis explictly even they are the same
151
+
152
+ ```ruby
153
+ # Use 'mysecret' to authenticate against the mymaster instance and sentinel
154
+ SENTINELS = [{ host: '127.0.0.1', port: 26380 },
155
+ { host: '127.0.0.1', port: 26381 }]
156
+
157
+ redis_config = RedisClient.sentinel(name: 'mymaster', sentinels: SENTINELS, role: :master, password: 'mysecret', sentinel_password: 'mysecret')
158
+ ```
159
+
160
+ Also the `name`, `password`, `username` and `db` for Redis instance can be passed as an url:
161
+
162
+ ```ruby
163
+ redis_config = RedisClient.sentinel(url: "redis://appuser:mysecret@mymaster/10", sentinels: SENTINELS, role: :master)
164
+ ```
165
+
129
166
  ### Type support
130
167
 
131
168
  Only a select few Ruby types are supported as arguments beside strings.
@@ -341,6 +378,10 @@ loop do
341
378
  end
342
379
  ```
343
380
 
381
+ *Note*: pubsub connections are stateful, as such they won't ever reconnect automatically.
382
+ The caller is responsible for reconnecting if the connection is lost and to resubscribe to
383
+ all channels.
384
+
344
385
  ## Production
345
386
 
346
387
  ### Instrumentation and Middlewares
@@ -374,6 +415,26 @@ redis_config = RedisClient.config(middlewares: [AnotherRedisInstrumentation])
374
415
  redis_config.new_client
375
416
  ```
376
417
 
418
+ If middlewares need a client-specific configuration, `Config#custom` can be used
419
+
420
+ ```ruby
421
+ module MyGlobalRedisInstrumentation
422
+ def connect(redis_config)
423
+ MyMonitoringService.instrument("redis.connect", tags: redis_config.custom[:tags]) { super }
424
+ end
425
+
426
+ def call(command, redis_config)
427
+ MyMonitoringService.instrument("redis.query", tags: redis_config.custom[:tags]) { super }
428
+ end
429
+
430
+ def call_pipelined(commands, redis_config)
431
+ MyMonitoringService.instrument("redis.pipeline", tags: redis_config.custom[:tags]) { super }
432
+ end
433
+ end
434
+ RedisClient.register(MyGlobalRedisInstrumentation)
435
+
436
+ redis_config = RedisClient.config(custom: { tags: { "environment": Rails.env }})
437
+ ```
377
438
  ### Timeouts
378
439
 
379
440
  The client allows you to configure connect, read, and write timeouts.
@@ -405,24 +466,64 @@ It can be set as a number of retries:
405
466
  redis_config = RedisClient.config(reconnect_attempts: 1)
406
467
  ```
407
468
 
408
- Or as a list of sleep durations for implementing exponential backoff:
409
-
410
- ```ruby
411
- redis_config = RedisClient.config(reconnect_attempts: [0, 0.05, 0.1])
412
- ```
413
-
414
469
  **Important Note**: Retrying may cause commands to be issued more than once to the server, so in the case of
415
470
  non-idempotent commands such as `LPUSH` or `INCR`, it may cause consistency issues.
416
471
 
417
472
  To selectively disable automatic retries, you can use the `#call_once` method:
418
473
 
419
474
  ```ruby
420
- redis_config = RedisClient.config(reconnect_attempts: [0, 0.05, 0.1])
475
+ redis_config = RedisClient.config(reconnect_attempts: 3)
421
476
  redis = redis_config.new_client
422
477
  redis.call("GET", "counter") # Will be retried up to 3 times.
423
478
  redis.call_once("INCR", "counter") # Won't be retried.
424
479
  ```
425
480
 
481
+ **Note**: automatic reconnection doesn't apply to pubsub clients as their connection is stateful.
482
+
483
+ ### Exponential backoff
484
+
485
+ Alternatively, `reconnect_attempts` accepts a list of sleep durations for implementing exponential backoff:
486
+
487
+ ```ruby
488
+ redis_config = RedisClient.config(reconnect_attempts: [0, 0.05, 0.1])
489
+ ```
490
+
491
+ This configuration is generally used when the Redis server is expected to failover or recover relatively quickly and
492
+ that it's not really possible to continue without issuing the command.
493
+
494
+ When the Redis server is used as an ephemeral cache, circuit breakers are generally preferred.
495
+
496
+ ### Circuit Breaker
497
+
498
+ When Redis is used as a cache and a connection error happens, you may not want to retry as it might take
499
+ longer than to recompute the value. Instead it's likely preferable to mark the server as unavailable and let it
500
+ recover for a while.
501
+
502
+ [Circuit breakers are a pattern that does exactly that](https://en.wikipedia.org/wiki/Circuit_breaker_design_pattern).
503
+
504
+ Configuation options:
505
+
506
+ - `error_threshold`. The amount of errors to encounter within `error_threshold_timeout` amount of time before opening the circuit, that is to start rejecting requests instantly.
507
+ - `error_threshold_timeout`. The amount of time in seconds that `error_threshold` errors must occur to open the circuit. Defaults to `error_timeout` seconds if not set.
508
+ - `error_timeout`. The amount of time in seconds until trying to query the resource again.
509
+ - `success_threshold`. The amount of successes on the circuit until closing it again, that is to start accepting all requests to the circuit.
510
+
511
+ ```ruby
512
+ RedisClient.config(
513
+ circuit_breaker: {
514
+ # Stop querying the server after 3 errors happened in a 2 seconds window
515
+ error_threshold: 3,
516
+ error_threshold_timeout: 2,
517
+
518
+ # Try querying again after 1 second
519
+ error_timeout: 1,
520
+
521
+ # Stay in half-open state until 3 queries succeeded.
522
+ success_threshold: 3,
523
+ }
524
+ )
525
+ ```
526
+
426
527
  ### Drivers
427
528
 
428
529
  `redis-client` ships with a pure Ruby socket implementation.
data/Rakefile CHANGED
@@ -26,6 +26,7 @@ namespace :test do
26
26
  t.libs << "test"
27
27
  t.libs << "lib"
28
28
  t.test_files = FileList["test/**/*_test.rb"].exclude("test/sentinel/*_test.rb")
29
+ t.options = '-v' if ENV['CI'] || ENV['VERBOSE']
29
30
  end
30
31
 
31
32
  Rake::TestTask.new(:sentinel) do |t|
@@ -33,13 +34,16 @@ namespace :test do
33
34
  t.libs << "test"
34
35
  t.libs << "lib"
35
36
  t.test_files = FileList["test/sentinel/*_test.rb"]
37
+ t.options = '-v' if ENV['CI'] || ENV['VERBOSE']
36
38
  end
37
39
 
38
40
  Rake::TestTask.new(:hiredis) do |t|
39
41
  t.libs << "test/hiredis"
40
42
  t.libs << "test"
43
+ t.libs << "hiredis-client/lib"
41
44
  t.libs << "lib"
42
45
  t.test_files = FileList["test/**/*_test.rb"].exclude("test/sentinel/*_test.rb")
46
+ t.options = '-v' if ENV['CI'] || ENV['VERBOSE']
43
47
  end
44
48
  end
45
49
 
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RedisClient
4
+ class CircuitBreaker
5
+ module Middleware
6
+ def connect(config)
7
+ config.circuit_breaker.protect { super }
8
+ end
9
+
10
+ def call(_command, config)
11
+ config.circuit_breaker.protect { super }
12
+ end
13
+
14
+ def call_pipelined(_commands, config)
15
+ config.circuit_breaker.protect { super }
16
+ end
17
+ end
18
+
19
+ OpenCircuitError = Class.new(CannotConnectError)
20
+
21
+ attr_reader :error_timeout, :error_threshold, :error_threshold_timeout, :success_threshold
22
+
23
+ def initialize(error_threshold:, error_timeout:, error_threshold_timeout: error_timeout, success_threshold: 0)
24
+ @error_threshold = Integer(error_threshold)
25
+ @error_threshold_timeout = Float(error_threshold_timeout)
26
+ @error_timeout = Float(error_timeout)
27
+ @success_threshold = Integer(success_threshold)
28
+ @errors = []
29
+ @successes = 0
30
+ @state = :closed
31
+ @lock = Mutex.new
32
+ end
33
+
34
+ def protect
35
+ if @state == :open
36
+ refresh_state
37
+ end
38
+
39
+ case @state
40
+ when :open
41
+ raise OpenCircuitError, "Too many connection errors happened recently"
42
+ when :closed
43
+ begin
44
+ yield
45
+ rescue ConnectionError
46
+ record_error
47
+ raise
48
+ end
49
+ when :half_open
50
+ begin
51
+ result = yield
52
+ record_success
53
+ result
54
+ rescue ConnectionError
55
+ record_error
56
+ raise
57
+ end
58
+ else
59
+ raise "[BUG] RedisClient::CircuitBreaker unexpected @state (#{@state.inspect}})"
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def refresh_state
66
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ @lock.synchronize do
68
+ if @errors.last < (now - @error_timeout)
69
+ if @success_threshold > 0
70
+ @state = :half_open
71
+ @successes = 0
72
+ else
73
+ @errors.clear
74
+ @state = :closed
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ def record_error
81
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
+ expiry = now - @error_timeout
83
+ @lock.synchronize do
84
+ if @state == :closed
85
+ @errors.reject! { |t| t < expiry }
86
+ end
87
+ @errors << now
88
+ @successes = 0
89
+ if @state == :half_open || (@state == :closed && @errors.size >= @error_threshold)
90
+ @state = :open
91
+ end
92
+ end
93
+ end
94
+
95
+ def record_success
96
+ return unless @state == :half_open
97
+
98
+ @lock.synchronize do
99
+ return unless @state == :half_open
100
+
101
+ @successes += 1
102
+ if @successes >= @success_threshold
103
+ @state = :closed
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -14,7 +14,7 @@ class RedisClient
14
14
  module Common
15
15
  attr_reader :db, :password, :id, :ssl, :ssl_params, :command_builder, :inherit_socket,
16
16
  :connect_timeout, :read_timeout, :write_timeout, :driver, :connection_prelude, :protocol,
17
- :middlewares_stack
17
+ :middlewares_stack, :custom, :circuit_breaker
18
18
 
19
19
  alias_method :ssl?, :ssl
20
20
 
@@ -28,6 +28,7 @@ class RedisClient
28
28
  write_timeout: timeout,
29
29
  connect_timeout: timeout,
30
30
  ssl: nil,
31
+ custom: {},
31
32
  ssl_params: nil,
32
33
  driver: nil,
33
34
  protocol: 3,
@@ -35,7 +36,8 @@ class RedisClient
35
36
  command_builder: CommandBuilder,
36
37
  inherit_socket: false,
37
38
  reconnect_attempts: false,
38
- middlewares: false
39
+ middlewares: false,
40
+ circuit_breaker: nil
39
41
  )
40
42
  @username = username
41
43
  @password = password
@@ -50,6 +52,8 @@ class RedisClient
50
52
 
51
53
  @driver = driver ? RedisClient.driver(driver) : RedisClient.default_driver
52
54
 
55
+ @custom = custom
56
+
53
57
  @client_implementation = client_implementation
54
58
  @protocol = protocol
55
59
  unless protocol == 2 || protocol == 3
@@ -63,6 +67,11 @@ class RedisClient
63
67
  @reconnect_attempts = reconnect_attempts
64
68
  @connection_prelude = build_connection_prelude
65
69
 
70
+ circuit_breaker = CircuitBreaker.new(**circuit_breaker) if circuit_breaker.is_a?(Hash)
71
+ if @circuit_breaker = circuit_breaker
72
+ middlewares = [CircuitBreaker::Middleware] + (middlewares || [])
73
+ end
74
+
66
75
  middlewares_stack = Middlewares
67
76
  if middlewares && !middlewares.empty?
68
77
  middlewares_stack = Class.new(Middlewares)
@@ -103,7 +112,9 @@ class RedisClient
103
112
  end
104
113
 
105
114
  def ssl_context
106
- @ssl_context ||= @driver.ssl_context(@ssl_params || {})
115
+ if ssl
116
+ @ssl_context ||= @driver.ssl_context(@ssl_params || {})
117
+ end
107
118
  end
108
119
 
109
120
  def server_url
@@ -148,28 +159,26 @@ class RedisClient
148
159
  host: nil,
149
160
  port: nil,
150
161
  path: nil,
162
+ username: nil,
163
+ password: nil,
151
164
  **kwargs
152
165
  )
153
166
  if url
154
- uri = URI.parse(url)
155
- kwargs[:ssl] = uri.scheme == "rediss" unless kwargs.key?(:ssl)
156
-
157
- kwargs[:username] ||= uri.user if uri.password && !uri.user.empty?
158
-
159
- kwargs[:password] ||= if uri.user && !uri.password
160
- URI.decode_www_form_component(uri.user)
161
- elsif uri.user && uri.password
162
- URI.decode_www_form_component(uri.password)
163
- end
164
-
165
- db_path = uri.path&.delete_prefix("/")
166
- kwargs[:db] ||= Integer(db_path) if db_path && !db_path.empty?
167
+ url_config = URLConfig.new(url)
168
+ kwargs = {
169
+ ssl: url_config.ssl?,
170
+ db: url_config.db,
171
+ }.compact.merge(kwargs)
172
+ host ||= url_config.host
173
+ port ||= url_config.port
174
+ username ||= url_config.username
175
+ password ||= url_config.password
167
176
  end
168
177
 
169
- super(**kwargs)
178
+ super(username: username, password: password, **kwargs)
170
179
 
171
- @host = host || uri&.host&.sub(/\A\[(.*)\]\z/, '\1') || DEFAULT_HOST
172
- @port = Integer(port || uri&.port || DEFAULT_PORT)
180
+ @host = host || DEFAULT_HOST
181
+ @port = Integer(port || DEFAULT_PORT)
173
182
  @path = path
174
183
  end
175
184
  end
@@ -6,9 +6,22 @@ class RedisClient
6
6
  @pending_reads = 0
7
7
  end
8
8
 
9
+ def reconnect
10
+ close
11
+ connect
12
+ end
13
+
14
+ def close
15
+ @pending_reads = 0
16
+ nil
17
+ end
18
+
9
19
  def revalidate
10
- if @pending_reads == 0 && connected?
11
- self
20
+ if @pending_reads > 0
21
+ close
22
+ false
23
+ else
24
+ connected?
12
25
  end
13
26
  end
14
27
 
@@ -17,7 +30,7 @@ class RedisClient
17
30
  write(command)
18
31
  result = read(timeout)
19
32
  @pending_reads -= 1
20
- if result.is_a?(CommandError)
33
+ if result.is_a?(Error)
21
34
  result._set_command(command)
22
35
  raise result
23
36
  else
@@ -37,7 +50,7 @@ class RedisClient
37
50
  timeout = timeouts && timeouts[index]
38
51
  result = read(timeout)
39
52
  @pending_reads -= 1
40
- if result.is_a?(CommandError)
53
+ if result.is_a?(Error)
41
54
  result._set_command(commands[index])
42
55
  exception ||= result
43
56
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RedisClient
4
+ module PIDCache
5
+ if !Process.respond_to?(:fork) # JRuby or TruffleRuby
6
+ @pid = Process.pid
7
+ singleton_class.attr_reader(:pid)
8
+ elsif Process.respond_to?(:_fork) # Ruby 3.1+
9
+ class << self
10
+ attr_reader :pid
11
+
12
+ def update!
13
+ @pid = Process.pid
14
+ end
15
+ end
16
+ update!
17
+
18
+ module CoreExt
19
+ def _fork
20
+ child_pid = super
21
+ PIDCache.update! if child_pid == 0
22
+ child_pid
23
+ end
24
+ end
25
+ Process.singleton_class.prepend(CoreExt)
26
+ else # Ruby 3.0 or older
27
+ class << self
28
+ def pid
29
+ Process.pid
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -70,9 +70,9 @@ class RedisClient
70
70
  return total
71
71
  end
72
72
  when :wait_readable
73
- @io.to_io.wait_readable(@read_timeout) or raise ReadTimeoutError
73
+ @io.to_io.wait_readable(@read_timeout) or raise(ReadTimeoutError, "Waited #{@read_timeout} seconds")
74
74
  when :wait_writable
75
- @io.to_io.wait_writable(@write_timeout) or raise WriteTimeoutError
75
+ @io.to_io.wait_writable(@write_timeout) or raise(WriteTimeoutError, "Waited #{@write_timeout} seconds")
76
76
  when nil
77
77
  raise Errno::ECONNRESET
78
78
  else
@@ -137,12 +137,12 @@ class RedisClient
137
137
  return if !strict || remaining <= 0
138
138
  when :wait_readable
139
139
  unless @io.to_io.wait_readable(@read_timeout)
140
- raise ReadTimeoutError unless @blocking_reads
140
+ raise ReadTimeoutError, "Waited #{@read_timeout} seconds" unless @blocking_reads
141
141
  end
142
142
  when :wait_writable
143
- @io.to_io.wait_writable(@write_timeout) or raise WriteTimeoutError
143
+ @io.to_io.wait_writable(@write_timeout) or raise(WriteTimeoutError, "Waited #{@write_timeout} seconds")
144
144
  when nil
145
- raise Errno::ECONNRESET
145
+ raise EOFError
146
146
  else
147
147
  raise "Unexpected `read_nonblock` return: #{bytes.inspect}"
148
148
  end
@@ -42,43 +42,11 @@ class RedisClient
42
42
 
43
43
  def initialize(config, connect_timeout:, read_timeout:, write_timeout:)
44
44
  super()
45
- socket = if config.path
46
- UNIXSocket.new(config.path)
47
- else
48
- sock = if SUPPORTS_RESOLV_TIMEOUT
49
- Socket.tcp(config.host, config.port, connect_timeout: connect_timeout, resolv_timeout: connect_timeout)
50
- else
51
- Socket.tcp(config.host, config.port, connect_timeout: connect_timeout)
52
- end
53
- # disables Nagle's Algorithm, prevents multiple round trips with MULTI
54
- sock.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
55
- sock
56
- end
57
-
58
- if config.ssl
59
- socket = OpenSSL::SSL::SSLSocket.new(socket, config.ssl_context)
60
- socket.hostname = config.host
61
- loop do
62
- case status = socket.connect_nonblock(exception: false)
63
- when :wait_readable
64
- socket.to_io.wait_readable(connect_timeout) or raise CannotConnectError
65
- when :wait_writable
66
- socket.to_io.wait_writable(connect_timeout) or raise CannotConnectError
67
- when socket
68
- break
69
- else
70
- raise "Unexpected `connect_nonblock` return: #{status.inspect}"
71
- end
72
- end
73
- end
74
-
75
- @io = BufferedIO.new(
76
- socket,
77
- read_timeout: read_timeout,
78
- write_timeout: write_timeout,
79
- )
80
- rescue SystemCallError, OpenSSL::SSL::SSLError, SocketError => error
81
- raise CannotConnectError, error.message, error.backtrace
45
+ @config = config
46
+ @connect_timeout = connect_timeout
47
+ @read_timeout = read_timeout
48
+ @write_timeout = write_timeout
49
+ connect
82
50
  end
83
51
 
84
52
  def connected?
@@ -87,13 +55,16 @@ class RedisClient
87
55
 
88
56
  def close
89
57
  @io.close
58
+ super
90
59
  end
91
60
 
92
61
  def read_timeout=(timeout)
62
+ @read_timeout = timeout
93
63
  @io.read_timeout = timeout if @io
94
64
  end
95
65
 
96
66
  def write_timeout=(timeout)
67
+ @write_timeout = timeout
97
68
  @io.write_timeout = timeout if @io
98
69
  end
99
70
 
@@ -129,5 +100,84 @@ class RedisClient
129
100
  rescue SystemCallError, IOError, OpenSSL::SSL::SSLError => error
130
101
  raise ConnectionError, error.message
131
102
  end
103
+
104
+ def measure_round_trip_delay
105
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
106
+ call(["PING"], @read_timeout)
107
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start
108
+ end
109
+
110
+ private
111
+
112
+ def connect
113
+ socket = if @config.path
114
+ UNIXSocket.new(@config.path)
115
+ else
116
+ sock = if SUPPORTS_RESOLV_TIMEOUT
117
+ Socket.tcp(@config.host, @config.port, connect_timeout: @connect_timeout, resolv_timeout: @connect_timeout)
118
+ else
119
+ Socket.tcp(@config.host, @config.port, connect_timeout: @connect_timeout)
120
+ end
121
+ # disables Nagle's Algorithm, prevents multiple round trips with MULTI
122
+ sock.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
123
+ enable_socket_keep_alive(sock)
124
+ sock
125
+ end
126
+
127
+ if @config.ssl
128
+ socket = OpenSSL::SSL::SSLSocket.new(socket, @config.ssl_context)
129
+ socket.hostname = @config.host
130
+ loop do
131
+ case status = socket.connect_nonblock(exception: false)
132
+ when :wait_readable
133
+ socket.to_io.wait_readable(@connect_timeout) or raise CannotConnectError
134
+ when :wait_writable
135
+ socket.to_io.wait_writable(@connect_timeout) or raise CannotConnectError
136
+ when socket
137
+ break
138
+ else
139
+ raise "Unexpected `connect_nonblock` return: #{status.inspect}"
140
+ end
141
+ end
142
+ end
143
+
144
+ @io = BufferedIO.new(
145
+ socket,
146
+ read_timeout: @read_timeout,
147
+ write_timeout: @write_timeout,
148
+ )
149
+ true
150
+ rescue SystemCallError, OpenSSL::SSL::SSLError, SocketError => error
151
+ raise CannotConnectError, error.message, error.backtrace
152
+ end
153
+
154
+ KEEP_ALIVE_INTERVAL = 15 # Same as hiredis defaults
155
+ KEEP_ALIVE_TTL = 120 # Longer than hiredis defaults
156
+ KEEP_ALIVE_PROBES = (KEEP_ALIVE_TTL / KEEP_ALIVE_INTERVAL) - 1
157
+ private_constant :KEEP_ALIVE_INTERVAL
158
+ private_constant :KEEP_ALIVE_TTL
159
+ private_constant :KEEP_ALIVE_PROBES
160
+
161
+ if %i[SOL_TCP SOL_SOCKET TCP_KEEPIDLE TCP_KEEPINTVL TCP_KEEPCNT].all? { |c| Socket.const_defined? c } # Linux
162
+ def enable_socket_keep_alive(socket)
163
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
164
+ socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, KEEP_ALIVE_INTERVAL)
165
+ socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, KEEP_ALIVE_INTERVAL)
166
+ socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, KEEP_ALIVE_PROBES)
167
+ end
168
+ elsif %i[IPPROTO_TCP TCP_KEEPINTVL TCP_KEEPCNT].all? { |c| Socket.const_defined? c } # macOS
169
+ def enable_socket_keep_alive(socket)
170
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
171
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_KEEPINTVL, KEEP_ALIVE_INTERVAL)
172
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_KEEPCNT, KEEP_ALIVE_PROBES)
173
+ end
174
+ elsif %i[SOL_SOCKET SO_KEEPALIVE].all? { |c| Socket.const_defined? c } # unknown POSIX
175
+ def enable_socket_keep_alive(socket)
176
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
177
+ end
178
+ else # unknown
179
+ def enable_socket_keep_alive(_socket)
180
+ end
181
+ end
132
182
  end
133
183
  end
@@ -7,15 +7,44 @@ class RedisClient
7
7
  SENTINEL_DELAY = 0.25
8
8
  DEFAULT_RECONNECT_ATTEMPTS = 2
9
9
 
10
- def initialize(name:, sentinels:, role: :master, **client_config)
11
- unless %i(master replica slave).include?(role)
10
+ attr_reader :name
11
+
12
+ def initialize(
13
+ sentinels:,
14
+ sentinel_password: nil,
15
+ sentinel_username: nil,
16
+ role: :master,
17
+ name: nil,
18
+ url: nil,
19
+ **client_config
20
+ )
21
+ unless %i(master replica slave).include?(role.to_sym)
12
22
  raise ArgumentError, "Expected role to be either :master or :replica, got: #{role.inspect}"
13
23
  end
14
24
 
25
+ if url
26
+ url_config = URLConfig.new(url)
27
+ client_config = {
28
+ username: url_config.username,
29
+ password: url_config.password,
30
+ db: url_config.db,
31
+ }.compact.merge(client_config)
32
+ name ||= url_config.host
33
+ end
34
+
35
+ @name = name
36
+ unless @name
37
+ raise ArgumentError, "RedisClient::SentinelConfig requires either a name or an url with a host"
38
+ end
39
+
15
40
  @to_list_of_hash = @to_hash = nil
16
- extra_config = {}
41
+ @extra_config = {
42
+ username: sentinel_username,
43
+ password: sentinel_password,
44
+ db: nil,
45
+ }
17
46
  if client_config[:protocol] == 2
18
- extra_config[:protocol] = client_config[:protocol]
47
+ @extra_config[:protocol] = client_config[:protocol]
19
48
  @to_list_of_hash = lambda do |may_be_a_list|
20
49
  if may_be_a_list.is_a?(Array)
21
50
  may_be_a_list.map { |l| l.each_slice(2).to_h }
@@ -25,23 +54,15 @@ class RedisClient
25
54
  end
26
55
  end
27
56
 
28
- @name = name
29
- @sentinel_configs = sentinels.map do |s|
30
- case s
31
- when String
32
- Config.new(**extra_config, url: s)
33
- else
34
- Config.new(**extra_config, **s)
35
- end
36
- end
37
57
  @sentinels = {}.compare_by_identity
38
- @role = role
58
+ @role = role.to_sym
39
59
  @mutex = Mutex.new
40
60
  @config = nil
41
61
 
42
62
  client_config[:reconnect_attempts] ||= DEFAULT_RECONNECT_ATTEMPTS
43
63
  @client_config = client_config || {}
44
64
  super(**client_config)
65
+ @sentinel_configs = sentinels_to_configs(sentinels)
45
66
  end
46
67
 
47
68
  def sentinels
@@ -93,6 +114,17 @@ class RedisClient
93
114
 
94
115
  private
95
116
 
117
+ def sentinels_to_configs(sentinels)
118
+ sentinels.map do |sentinel|
119
+ case sentinel
120
+ when String
121
+ Config.new(**@client_config, **@extra_config, url: sentinel)
122
+ else
123
+ Config.new(**@client_config, **@extra_config, **sentinel)
124
+ end
125
+ end
126
+ end
127
+
96
128
  def config
97
129
  @mutex.synchronize do
98
130
  @config ||= if @role == :master
@@ -106,9 +138,11 @@ class RedisClient
106
138
  def resolve_master
107
139
  each_sentinel do |sentinel_client|
108
140
  host, port = sentinel_client.call("SENTINEL", "get-master-addr-by-name", @name)
109
- if host && port
110
- return Config.new(host: host, port: Integer(port), **@client_config)
111
- end
141
+ next unless host && port
142
+
143
+ refresh_sentinels(sentinel_client)
144
+
145
+ return Config.new(host: host, port: Integer(port), **@client_config)
112
146
  end
113
147
  rescue ConnectionError
114
148
  raise ConnectionError, "No sentinels available"
@@ -159,5 +193,19 @@ class RedisClient
159
193
 
160
194
  raise last_error if last_error
161
195
  end
196
+
197
+ def refresh_sentinels(sentinel_client)
198
+ sentinel_response = sentinel_client.call("SENTINEL", "sentinels", @name, &@to_list_of_hash)
199
+ sentinels = sentinel_response.map do |sentinel|
200
+ { host: sentinel.fetch("ip"), port: Integer(sentinel.fetch("port")) }
201
+ end
202
+ new_sentinels = sentinels.select do |sentinel|
203
+ @sentinel_configs.none? do |sentinel_config|
204
+ sentinel_config.host == sentinel.fetch(:host) && sentinel_config.port == sentinel.fetch(:port)
205
+ end
206
+ end
207
+
208
+ @sentinel_configs.concat sentinels_to_configs(new_sentinels)
209
+ end
162
210
  end
163
211
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ class RedisClient
6
+ class URLConfig
7
+ DEFAULT_SCHEMA = "redis"
8
+ SSL_SCHEMA = "rediss"
9
+
10
+ attr_reader :url, :uri
11
+
12
+ def initialize(url)
13
+ @url = url
14
+ @uri = URI(url)
15
+ unless uri.scheme == DEFAULT_SCHEMA || uri.scheme == SSL_SCHEMA
16
+ raise ArgumentError, "Invalid URL: #{url.inspect}"
17
+ end
18
+ end
19
+
20
+ def ssl?
21
+ @uri.scheme == SSL_SCHEMA
22
+ end
23
+
24
+ def db
25
+ db_path = uri.path&.delete_prefix("/")
26
+ Integer(db_path) if db_path && !db_path.empty?
27
+ end
28
+
29
+ def username
30
+ uri.user if uri.password && !uri.user.empty?
31
+ end
32
+
33
+ def password
34
+ if uri.user && !uri.password
35
+ URI.decode_www_form_component(uri.user)
36
+ elsif uri.user && uri.password
37
+ URI.decode_www_form_component(uri.password)
38
+ end
39
+ end
40
+
41
+ def host
42
+ return if uri.host.nil? || uri.host.empty?
43
+
44
+ uri.host.sub(/\A\[(.*)\]\z/, '\1')
45
+ end
46
+
47
+ def port
48
+ return unless uri.port
49
+
50
+ Integer(uri.port)
51
+ end
52
+ end
53
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class RedisClient
4
- VERSION = "0.10.0"
4
+ VERSION = "0.17.0"
5
5
  end
data/lib/redis_client.rb CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  require "redis_client/version"
4
4
  require "redis_client/command_builder"
5
+ require "redis_client/url_config"
5
6
  require "redis_client/config"
7
+ require "redis_client/pid_cache"
6
8
  require "redis_client/sentinel_config"
7
9
  require "redis_client/middlewares"
8
10
 
@@ -62,12 +64,12 @@ class RedisClient
62
64
  write_timeout: config.write_timeout
63
65
  )
64
66
  @config = config
65
- @id = id
67
+ @id = id&.to_s
66
68
  @connect_timeout = connect_timeout
67
69
  @read_timeout = read_timeout
68
70
  @write_timeout = write_timeout
69
71
  @command_builder = config.command_builder
70
- @pid = Process.pid
72
+ @pid = PIDCache.pid
71
73
  end
72
74
 
73
75
  def timeout=(timeout)
@@ -90,9 +92,17 @@ class RedisClient
90
92
  WriteTimeoutError = Class.new(TimeoutError)
91
93
  CheckoutTimeoutError = Class.new(TimeoutError)
92
94
 
93
- class CommandError < Error
95
+ module HasCommand
94
96
  attr_reader :command
95
97
 
98
+ def _set_command(command)
99
+ @command = command
100
+ end
101
+ end
102
+
103
+ class CommandError < Error
104
+ include HasCommand
105
+
96
106
  class << self
97
107
  def parse(error_message)
98
108
  code = if error_message.start_with?("ERR Error running script")
@@ -107,22 +117,24 @@ class RedisClient
107
117
  klass.new(error_message)
108
118
  end
109
119
  end
110
-
111
- def _set_command(command)
112
- @command = command
113
- end
114
120
  end
115
121
 
116
122
  AuthenticationError = Class.new(CommandError)
117
123
  PermissionError = Class.new(CommandError)
118
- ReadOnlyError = Class.new(CommandError)
119
124
  WrongTypeError = Class.new(CommandError)
120
125
  OutOfMemoryError = Class.new(CommandError)
121
126
 
127
+ ReadOnlyError = Class.new(ConnectionError)
128
+ ReadOnlyError.include(HasCommand)
129
+
130
+ MasterDownError = Class.new(ConnectionError)
131
+ MasterDownError.include(HasCommand)
132
+
122
133
  CommandError::ERRORS = {
123
134
  "WRONGPASS" => AuthenticationError,
124
135
  "NOPERM" => PermissionError,
125
136
  "READONLY" => ReadOnlyError,
137
+ "MASTERDOWN" => MasterDownError,
126
138
  "WRONGTYPE" => WrongTypeError,
127
139
  "OOM" => OutOfMemoryError,
128
140
  }.freeze
@@ -163,6 +175,10 @@ class RedisClient
163
175
  "#<#{self.class.name} #{config.server_url}#{id_string}>"
164
176
  end
165
177
 
178
+ def server_url
179
+ config.server_url
180
+ end
181
+
166
182
  def size
167
183
  1
168
184
  end
@@ -193,6 +209,14 @@ class RedisClient
193
209
  sub
194
210
  end
195
211
 
212
+ def measure_round_trip_delay
213
+ ensure_connected do |connection|
214
+ @middlewares.call(["PING"], config) do
215
+ connection.measure_round_trip_delay
216
+ end
217
+ end
218
+ end
219
+
196
220
  def call(*command, **kwargs)
197
221
  command = @command_builder.generate(command, kwargs)
198
222
  result = ensure_connected do |connection|
@@ -335,10 +359,13 @@ class RedisClient
335
359
 
336
360
  def close
337
361
  @raw_connection&.close
338
- @raw_connection = nil
339
362
  self
340
363
  end
341
364
 
365
+ def disable_reconnection(&block)
366
+ ensure_connected(retryable: false, &block)
367
+ end
368
+
342
369
  def pipelined
343
370
  pipeline = Pipeline.new(@command_builder)
344
371
  yield pipeline
@@ -419,7 +446,7 @@ class RedisClient
419
446
 
420
447
  def close
421
448
  @raw_connection&.close
422
- @raw_connection = nil
449
+ @raw_connection = nil # PubSub can't just reconnect
423
450
  self
424
451
  end
425
452
 
@@ -599,7 +626,7 @@ class RedisClient
599
626
  end
600
627
 
601
628
  def ensure_connected(retryable: true)
602
- close if !config.inherit_socket && @pid != Process.pid
629
+ close if !config.inherit_socket && @pid != PIDCache.pid
603
630
 
604
631
  if @disable_reconnection
605
632
  if block_given?
@@ -643,52 +670,58 @@ class RedisClient
643
670
  end
644
671
 
645
672
  def raw_connection
646
- @raw_connection = @raw_connection&.revalidate
647
- @raw_connection ||= connect
673
+ if @raw_connection.nil? || !@raw_connection.revalidate
674
+ connect
675
+ end
676
+ @raw_connection
648
677
  end
649
678
 
650
679
  def connect
651
- @pid = Process.pid
680
+ @pid = PIDCache.pid
652
681
 
653
- connection = @middlewares.connect(config) do
654
- config.driver.new(
655
- config,
656
- connect_timeout: connect_timeout,
657
- read_timeout: read_timeout,
658
- write_timeout: write_timeout,
659
- )
682
+ if @raw_connection
683
+ @middlewares.connect(config) do
684
+ @raw_connection.reconnect
685
+ end
686
+ else
687
+ @raw_connection = @middlewares.connect(config) do
688
+ config.driver.new(
689
+ config,
690
+ connect_timeout: connect_timeout,
691
+ read_timeout: read_timeout,
692
+ write_timeout: write_timeout,
693
+ )
694
+ end
660
695
  end
661
696
 
662
697
  prelude = config.connection_prelude.dup
663
698
 
664
699
  if id
665
- prelude << ["CLIENT", "SETNAME", id.to_s]
700
+ prelude << ["CLIENT", "SETNAME", id]
666
701
  end
667
702
 
668
703
  # The connection prelude is deliberately not sent to Middlewares
669
704
  if config.sentinel?
670
705
  prelude << ["ROLE"]
671
706
  role, = @middlewares.call_pipelined(prelude, config) do
672
- connection.call_pipelined(prelude, nil).last
707
+ @raw_connection.call_pipelined(prelude, nil).last
673
708
  end
674
709
  config.check_role!(role)
675
710
  else
676
711
  unless prelude.empty?
677
712
  @middlewares.call_pipelined(prelude, config) do
678
- connection.call_pipelined(prelude, nil)
713
+ @raw_connection.call_pipelined(prelude, nil)
679
714
  end
680
715
  end
681
716
  end
682
-
683
- connection
684
- rescue FailoverError
717
+ rescue FailoverError, CannotConnectError
685
718
  raise
686
719
  rescue ConnectionError => error
687
720
  raise CannotConnectError, error.message, error.backtrace
688
721
  rescue CommandError => error
689
- if error.message.include?("ERR unknown command `HELLO`")
722
+ if error.message.match?(/ERR unknown command ['`]HELLO['`]/)
690
723
  raise UnsupportedServer,
691
- "Your Redis server version is too old. redis-client requires Redis 6+. (#{config.server_url})"
724
+ "redis-client requires Redis 6+ with HELLO command available (#{config.server_url})"
692
725
  else
693
726
  raise
694
727
  end
@@ -696,5 +729,6 @@ class RedisClient
696
729
  end
697
730
 
698
731
  require "redis_client/pooled"
732
+ require "redis_client/circuit_breaker"
699
733
 
700
734
  RedisClient.default_driver
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redis-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jean Boussier
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-10-10 00:00:00.000000000 Z
11
+ date: 2023-09-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: connection_pool
@@ -39,16 +39,19 @@ files:
39
39
  - Rakefile
40
40
  - lib/redis-client.rb
41
41
  - lib/redis_client.rb
42
+ - lib/redis_client/circuit_breaker.rb
42
43
  - lib/redis_client/command_builder.rb
43
44
  - lib/redis_client/config.rb
44
45
  - lib/redis_client/connection_mixin.rb
45
46
  - lib/redis_client/decorator.rb
46
47
  - lib/redis_client/middlewares.rb
48
+ - lib/redis_client/pid_cache.rb
47
49
  - lib/redis_client/pooled.rb
48
50
  - lib/redis_client/ruby_connection.rb
49
51
  - lib/redis_client/ruby_connection/buffered_io.rb
50
52
  - lib/redis_client/ruby_connection/resp3.rb
51
53
  - lib/redis_client/sentinel_config.rb
54
+ - lib/redis_client/url_config.rb
52
55
  - lib/redis_client/version.rb
53
56
  - redis-client.gemspec
54
57
  homepage: https://github.com/redis-rb/redis-client
@@ -74,7 +77,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
74
77
  - !ruby/object:Gem::Version
75
78
  version: '0'
76
79
  requirements: []
77
- rubygems_version: 3.3.7
80
+ rubygems_version: 3.4.10
78
81
  signing_key:
79
82
  specification_version: 4
80
83
  summary: Simple low-level client for Redis 6+