redis-client 0.25.2 → 0.30.1

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: e14494ff0e910564d0afcc8dea365b35e428c73364d4da0c31c84f8a9ada8d8b
4
- data.tar.gz: dacf316c8c4893b1d0fd95e010a010837485825390fbd83b1620f8cc6dff6985
3
+ metadata.gz: c611a453cbb5fecfffcb2b631d0b61a978ad151605d4ff60f8f3cf882358c272
4
+ data.tar.gz: 2b91f2484a543961614d9edaa56eefec31528551e8680d3677e1a71a3c4c7f3c
5
5
  SHA512:
6
- metadata.gz: 00ef6af52d83d2b35a5a8e638cb001f98b16d5f9752c35c107e66665415ac108d728992bf879b5577ac72859af67c70c3b0c4b40c62da73aee5cca17122de23f
7
- data.tar.gz: d9564edbac42517600787da3bcf3c929158e5a4d4e757f6b9ccff7cd6ee296637d89521adfbc902fc83ac880d53a155c289e3bbc09254bf32f8530cf3ab308dd
6
+ metadata.gz: 1f69e110f3f3d6775f50e07928650b0bc4bd232ccc2b0186c6579ea9a8fd5bb5e1c40def3d27184c4d30d42faf42e950cbe1c6319d36b8f1f4fb2eaea881ee7f
7
+ data.tar.gz: 3919d4e19ab85dababeae0ddf9d476becde0e7ff09e92d2eb18d9c093299fe18454c038835ef43ef09c31b6487d0ee027a0026df1476a9e900860bdb2db646e7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
1
1
  # Unreleased
2
2
 
3
+ # 0.30.1
4
+
5
+ - hiredis-client: Use `pkg-config` to locate OpenSSL. Should fix compilation issue on precompiled rubies such as the ones from `mise`.
6
+
7
+ # 0.30.0
8
+
9
+ - hiredis-client: Now support configuring SSL `verify_mode`.
10
+
11
+ # 0.29.0
12
+
13
+ - Fix connecting to Redis 7.1 and older with `driver_info:`set.
14
+ - Added `RedisClient::Error#next_error` for an easier way to check all errors produced by a pipeline.
15
+
16
+ # 0.28.0
17
+
18
+ - Added `RedisClient::HashRing` for horizontal sharing (compatible with `Redis::Distributed` from `redis-rb`).
19
+
20
+ # 0.27.0
21
+
22
+ - Added `idle_timeout` to revalidate connections that haven't been successfuly used in a long time. Defaults to 30 seconds.
23
+ - Added `driver_info` configuration, to issue `CLIENT SETINFO` during connection prelude.
24
+
25
+ # 0.26.4
26
+
27
+ - Further improve `rediss://` URLs used with Redis sentinel. Now avoid override explictly set `ssl:` parameter.
28
+ - Fix compatibility with `redis-rb` in sentinel mode.
29
+
30
+ # 0.26.3
31
+
32
+ - Fix `rediss://` (ssl) URLs used with Redis sentinel.
33
+ - Handle Ruby 4.0 connection timeout raising an `IO::Timeout` instead of `Errno::ETIMEDOUT`.
34
+ - Entirely close the connection on authentication failures.
35
+
36
+ # 0.26.2
37
+
38
+ - Fix compatibility with `connection_pool` version 3+.
39
+
40
+ # 0.26.1
41
+
42
+ - Fix a few corner cases where `RedisClient::Error#final?` was innacurate.
43
+ - hiredis-client: Properly reconnect to the new leader after a sentinel failover.
44
+
45
+ # 0.26.0
46
+
47
+ - Add `RedisClient::Error#final?` and `#retriable?` to allow middleware to filter out non-final errors.
48
+ - Fix precedence of `db: nil` initialization parameter.
49
+
50
+ ```ruby
51
+ Redis.new(url: "redis://localhost:6379/3", db: nil).db
52
+ ```
53
+
54
+ Before: `0`
55
+ After: `3`
56
+
57
+ # 0.25.3
58
+
59
+ - Fix `hiredis-client` compilation with `clang 21`.
60
+
3
61
  # 0.25.2
4
62
 
5
63
  - Fix circuit breakers to respect the `error_threshold_timeout` config is provided.
@@ -201,7 +259,7 @@
201
259
  - Added `_v` versions of `call` methods to make it easier to pass commands as arrays without splating.
202
260
  - Fix calling `blocking_call` with a block in a pipeline.
203
261
  - `blocking_call` now raise `ReadTimeoutError` if the command didn't complete in time.
204
- - Fix `blocking_call` to not respect `retry_attempts` on timeout.
262
+ - Fix `blocking_call` to not respect `reconnect_attempts` on timeout.
205
263
  - Stop parsing RESP3 sets as Ruby Set instances.
206
264
  - Fix `SystemStackError` when parsing very large hashes. Fix: #30
207
265
  - `hiredis` now more properly release the GVL when doing IOs.
data/README.md CHANGED
@@ -83,6 +83,7 @@ redis.call("GET", "mykey")
83
83
  - `connect_timeout`: The connection timeout, takes precedence over the general timeout when connecting to the server.
84
84
  - `read_timeout`: The read timeout, takes precedence over the general timeout when reading responses from the server.
85
85
  - `write_timeout`: The write timeout, takes precedence over the general timeout when sending commands to the server.
86
+ - `idle_timeout`: Amount of time after which an idle connection has to be revalidated with a PING command. Defaults to `30` seconds.
86
87
  - `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.
87
88
  - `circuit_breaker`: A Hash with circuit breaker configuration. Defaults to `nil`. See the [circuit breaker section](#circuit-breaker) for details.
88
89
  - `protocol:` The version of the RESP protocol to use. Default to `3`.
@@ -143,6 +144,7 @@ redis_config = RedisClient.sentinel(name: 'mymaster', sentinel_username: 'appuse
143
144
  If you specify a username and/or password at the top level for your main Redis instance, Sentinel *will not* using thouse credentials
144
145
 
145
146
  ```ruby
147
+
146
148
  # Use 'mysecret' to authenticate against the mymaster instance, but skip authentication for the sentinels:
147
149
  SENTINELS = [{ host: '127.0.0.1', port: 26380 },
148
150
  { host: '127.0.0.1', port: 26381 }]
@@ -166,6 +168,30 @@ Also the `name`, `password`, `username` and `db` for Redis instance can be passe
166
168
  redis_config = RedisClient.sentinel(url: "redis://appuser:mysecret@mymaster/10", sentinels: SENTINELS, role: :master)
167
169
  ```
168
170
 
171
+ ### Consistent Hashing
172
+
173
+ To horizontally shard keys across multiple servers without relying on clustering, a `RedisClient::HashRing` class is provided:
174
+
175
+ ```ruby
176
+ ring = RedisClient.ring(
177
+ RedisClient.config(host: "10.0.1.1", port: 6380).new_pool(timeout: 0.5, size: 3),
178
+ RedisClient.config(host: "10.0.1.2", port: 6380).new_pool(timeout: 0.5, size: 3),
179
+ RedisClient.config(host: "10.0.1.3", port: 6380).new_pool(timeout: 0.5, size: 3),
180
+ )
181
+
182
+ ring.node_for("cache_key").call("GET", "cache_key") # => "value"
183
+
184
+ ring.nodes_for("key1", "key2", "key3").each do |node, keys|
185
+ node.call("DEL", *keys)
186
+ end
187
+
188
+ ring.nodes.each do |node|
189
+ node.close
190
+ end
191
+ ```
192
+
193
+ Note that regular clients do respond to `node_for`, `nodes_for` and `nodes`, so that code that support `RedisClient.ring` is also usable with a single server.
194
+
169
195
  ### Type support
170
196
 
171
197
  Only a select few Ruby types are supported as arguments beside strings.
@@ -335,7 +361,11 @@ end
335
361
 
336
362
  #### Exception management
337
363
 
338
- The `exception` flag in the `#pipelined` method of `RedisClient` is a feature that modifies the pipeline execution
364
+ By default, when a pipeline produce multiple command errors, only the first encountered error is raised.
365
+
366
+ However, the raised exception have a `.next_error` method you can use like a linked list to check all encountered errors.
367
+
368
+ Alternatively, the `exception` flag in the `#pipelined` method of `RedisClient` is a feature that modifies the pipeline execution
339
369
  behavior. When set to `false`, it doesn't raise an exception when a command error occurs. Instead, it allows the
340
370
  pipeline to execute all commands, and any failed command will be available in the returned array. (Defaults to `true`)
341
371
 
@@ -459,6 +489,29 @@ RedisClient.register(MyGlobalRedisInstrumentation)
459
489
 
460
490
  redis_config = RedisClient.config(custom: { tags: { "environment": Rails.env }})
461
491
  ```
492
+
493
+ ### Instrumenting Errors
494
+
495
+ It is important to note that when `reconnect_attempts` is enabled, all network errors are reported to the middlewares,
496
+ even the ones that will be retried.
497
+
498
+ In many cases you may want to ignore retriable errors, or report them differently:
499
+
500
+ ```ruby
501
+ module MyGlobalRedisInstrumentation
502
+ def call(command, redis_config)
503
+ super
504
+ rescue RedisClient::Error => error
505
+ if error.final?
506
+ # Error won't be retried.
507
+ else
508
+ # Error will be retried.
509
+ end
510
+ raise
511
+ end
512
+ end
513
+ ```
514
+
462
515
  ### Timeouts
463
516
 
464
517
  The client allows you to configure connect, read, and write timeouts.
@@ -10,11 +10,12 @@ class RedisClient
10
10
  DEFAULT_PORT = 6379
11
11
  DEFAULT_USERNAME = "default"
12
12
  DEFAULT_DB = 0
13
+ DEFAULT_IDLE_TIMEOUT = 30.0
13
14
 
14
15
  module Common
15
16
  attr_reader :db, :id, :ssl, :ssl_params, :command_builder, :inherit_socket,
16
17
  :connect_timeout, :read_timeout, :write_timeout, :driver, :protocol,
17
- :middlewares_stack, :custom, :circuit_breaker
18
+ :middlewares_stack, :custom, :circuit_breaker, :driver_info, :idle_timeout
18
19
 
19
20
  alias_method :ssl?, :ssl
20
21
 
@@ -27,6 +28,7 @@ class RedisClient
27
28
  read_timeout: timeout,
28
29
  write_timeout: timeout,
29
30
  connect_timeout: timeout,
31
+ idle_timeout: DEFAULT_IDLE_TIMEOUT,
30
32
  ssl: nil,
31
33
  custom: {},
32
34
  ssl_params: nil,
@@ -37,7 +39,8 @@ class RedisClient
37
39
  inherit_socket: false,
38
40
  reconnect_attempts: false,
39
41
  middlewares: false,
40
- circuit_breaker: nil
42
+ circuit_breaker: nil,
43
+ driver_info: nil
41
44
  )
42
45
  @username = username
43
46
  @password = password && !password.respond_to?(:call) ? ->(_) { password } : password
@@ -54,6 +57,7 @@ class RedisClient
54
57
  @connect_timeout = connect_timeout
55
58
  @read_timeout = read_timeout
56
59
  @write_timeout = write_timeout
60
+ @idle_timeout = idle_timeout
57
61
 
58
62
  @driver = driver ? RedisClient.driver(driver) : RedisClient.default_driver
59
63
 
@@ -84,6 +88,8 @@ class RedisClient
84
88
  end
85
89
  end
86
90
  @middlewares_stack = middlewares_stack
91
+
92
+ @driver_info = driver_info
87
93
  end
88
94
 
89
95
  def connection_prelude
@@ -107,6 +113,13 @@ class RedisClient
107
113
  prelude << ["SELECT", @db.to_s]
108
114
  end
109
115
 
116
+ # Add CLIENT SETINFO commands for lib-name and lib-ver
117
+ # These commands are supported in Redis 7.2+
118
+ if @driver_info
119
+ prelude << ["CLIENT", "SETINFO", "LIB-NAME", build_lib_name]
120
+ prelude << ["CLIENT", "SETINFO", "LIB-VER", RedisClient::VERSION]
121
+ end
122
+
110
123
  # Deep freeze all the strings and commands
111
124
  prelude.map! do |commands|
112
125
  commands = commands.map { |str| str.frozen? ? str : str.dup.freeze }
@@ -123,6 +136,25 @@ class RedisClient
123
136
  @username || DEFAULT_USERNAME
124
137
  end
125
138
 
139
+ # Build the library name for CLIENT SETINFO LIB-NAME.
140
+ #
141
+ # @param driver_info [String, Array<String>, nil] Upstream driver info
142
+ # @return [String] Library name with optional upstream driver info
143
+ # @raise [ArgumentError] if driver_info is not a String or Array
144
+ def build_lib_name
145
+ return "redis-client" if @driver_info.nil?
146
+
147
+ info = case @driver_info
148
+ when String then @driver_info
149
+ when Array then @driver_info.join(";")
150
+ else raise ArgumentError, "driver_info must be a String or Array of Strings"
151
+ end
152
+
153
+ return "redis-client" if info.empty?
154
+
155
+ "redis-client(#{info})"
156
+ end
157
+
126
158
  def resolved?
127
159
  true
128
160
  end
@@ -140,6 +172,10 @@ class RedisClient
140
172
  @client_implementation.new(self, **kwargs)
141
173
  end
142
174
 
175
+ def retriable?(attempt)
176
+ @reconnect_attempts && @reconnect_attempts[attempt]
177
+ end
178
+
143
179
  def retry_connecting?(attempt, _error)
144
180
  if @reconnect_attempts
145
181
  if (pause = @reconnect_attempts[attempt])
@@ -182,7 +218,7 @@ class RedisClient
182
218
 
183
219
  include Common
184
220
 
185
- attr_reader :host, :port, :path
221
+ attr_reader :host, :port, :path, :server_key
186
222
 
187
223
  def initialize(
188
224
  url: nil,
@@ -191,14 +227,15 @@ class RedisClient
191
227
  path: nil,
192
228
  username: nil,
193
229
  password: nil,
230
+ db: nil,
194
231
  **kwargs
195
232
  )
196
233
  if url
197
234
  url_config = URLConfig.new(url)
198
235
  kwargs = {
199
236
  ssl: url_config.ssl?,
200
- db: url_config.db,
201
237
  }.compact.merge(kwargs)
238
+ db ||= url_config.db
202
239
  host ||= url_config.host
203
240
  port ||= url_config.port
204
241
  path ||= url_config.path
@@ -206,7 +243,7 @@ class RedisClient
206
243
  password ||= url_config.password
207
244
  end
208
245
 
209
- super(username: username, password: password, **kwargs)
246
+ super(username: username, password: password, db: db, **kwargs)
210
247
 
211
248
  if @path = path
212
249
  @host = nil
@@ -215,6 +252,8 @@ class RedisClient
215
252
  @host = host || DEFAULT_HOST
216
253
  @port = Integer(port || DEFAULT_PORT)
217
254
  end
255
+
256
+ @server_key = [@path, @host, @port].freeze
218
257
  end
219
258
  end
220
259
  end
@@ -2,8 +2,14 @@
2
2
 
3
3
  class RedisClient
4
4
  module ConnectionMixin
5
- def initialize
5
+ attr_accessor :retry_attempt
6
+ attr_reader :config
7
+
8
+ def initialize(config)
6
9
  @pending_reads = 0
10
+ @retry_attempt = nil
11
+ @config = config
12
+ @server_key = nil
7
13
  end
8
14
 
9
15
  def reconnect
@@ -17,7 +23,7 @@ class RedisClient
17
23
  end
18
24
 
19
25
  def revalidate
20
- if @pending_reads > 0
26
+ if @pending_reads > 0 || @server_key != @config.server_key
21
27
  close
22
28
  false
23
29
  else
@@ -33,6 +39,7 @@ class RedisClient
33
39
  if result.is_a?(Error)
34
40
  result._set_command(command)
35
41
  result._set_config(config)
42
+ result._set_retry_attempt(@retry_attempt)
36
43
  raise result
37
44
  else
38
45
  result
@@ -40,7 +47,7 @@ class RedisClient
40
47
  end
41
48
 
42
49
  def call_pipelined(commands, timeouts, exception: true)
43
- first_exception = nil
50
+ first_error = last_error = nil
44
51
 
45
52
  size = commands.size
46
53
  results = Array.new(commands.size)
@@ -61,14 +68,17 @@ class RedisClient
61
68
  elsif result.is_a?(Error)
62
69
  result._set_command(commands[index])
63
70
  result._set_config(config)
64
- first_exception ||= result
71
+ result._set_retry_attempt(@retry_attempt)
72
+
73
+ last_error&._set_next_error(result)
74
+ first_error ||= last_error = result
65
75
  end
66
76
 
67
77
  results[index] = result
68
78
  end
69
79
 
70
- if first_exception && exception
71
- raise first_exception
80
+ if first_error && exception
81
+ raise first_error
72
82
  else
73
83
  results
74
84
  end
@@ -82,5 +92,17 @@ class RedisClient
82
92
  # to account for the network delay.
83
93
  timeout + config.read_timeout
84
94
  end
95
+
96
+ def protocol_error(message)
97
+ error = ProtocolError.with_config(message, config)
98
+ error._set_retry_attempt(@retry_attempt)
99
+ error
100
+ end
101
+
102
+ def connection_error(message)
103
+ error = ConnectionError.with_config(message, config)
104
+ error._set_retry_attempt(@retry_attempt)
105
+ error
106
+ end
85
107
  end
86
108
  end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ class RedisClient
6
+ class HashRing
7
+ POINTS_PER_SERVER = 160
8
+
9
+ class << self
10
+ attr_writer :digest
11
+
12
+ def digest
13
+ @digest ||= begin
14
+ require 'digest/md5'
15
+ Digest::MD5
16
+ end
17
+ end
18
+ end
19
+
20
+ attr_reader :nodes
21
+
22
+ def initialize(nodes = [], replicas: POINTS_PER_SERVER, digest: self.class.digest)
23
+ @replicas = replicas
24
+ @ring = {}
25
+ @digest = digest
26
+ ids = {}
27
+ @nodes = nodes.dup.freeze
28
+ nodes.each do |node|
29
+ id = node.id || node.config.server_url
30
+ if ids[id]
31
+ raise ArgumentError, "duplicate node id: #{id.inspect}"
32
+ end
33
+
34
+ ids[id] = true
35
+
36
+ replicas.times do |i|
37
+ @ring[server_hash_for("#{id}:#{i}".freeze)] = node
38
+ end
39
+ end
40
+ @sorted_keys = @ring.keys
41
+ @sorted_keys.sort!
42
+ end
43
+
44
+ # get the node in the hash ring for this key
45
+ def node_for(key)
46
+ hash = hash_for(key)
47
+ idx = binary_search(@sorted_keys, hash)
48
+ @ring[@sorted_keys[idx]]
49
+ end
50
+
51
+ def nodes_for(*keys)
52
+ keys.flatten!
53
+ mapping = {}
54
+ keys.each do |key|
55
+ (mapping[node_for(key)] ||= []) << key
56
+ end
57
+ mapping
58
+ end
59
+
60
+ private
61
+
62
+ def hash_for(key)
63
+ Zlib.crc32(key)
64
+ end
65
+
66
+ def server_hash_for(key)
67
+ @digest.digest(key).unpack1("L>")
68
+ end
69
+
70
+ # Find the closest index in HashRing with value <= the given value
71
+ def binary_search(ary, value)
72
+ upper = ary.size
73
+ lower = 0
74
+
75
+ while lower < upper
76
+ mid = (lower + upper) / 2
77
+ if ary[mid] > value
78
+ upper = mid
79
+ else
80
+ lower = mid + 1
81
+ end
82
+ end
83
+
84
+ upper - 1
85
+ end
86
+ end
87
+ end
@@ -23,7 +23,7 @@ class RedisClient
23
23
  end
24
24
 
25
25
  def with(options = EMPTY_HASH)
26
- pool.with(options) do |client|
26
+ pool.with(**options) do |client|
27
27
  client.connect_timeout = connect_timeout
28
28
  client.read_timeout = read_timeout
29
29
  client.write_timeout = write_timeout
@@ -40,11 +40,8 @@ class RedisClient
40
40
 
41
41
  SUPPORTS_RESOLV_TIMEOUT = Socket.method(:tcp).parameters.any? { |p| p.last == :resolv_timeout }
42
42
 
43
- attr_reader :config
44
-
45
43
  def initialize(config, connect_timeout:, read_timeout:, write_timeout:)
46
- super()
47
- @config = config
44
+ super(config)
48
45
  @connect_timeout = connect_timeout
49
46
  @read_timeout = read_timeout
50
47
  @write_timeout = write_timeout
@@ -75,7 +72,11 @@ class RedisClient
75
72
  begin
76
73
  @io.write(buffer)
77
74
  rescue SystemCallError, IOError, OpenSSL::SSL::SSLError => error
78
- raise ConnectionError.with_config(error.message, config)
75
+ raise connection_error(error.message)
76
+ rescue Error => error
77
+ error._set_config(config)
78
+ error._set_retry_attempt(@retry_attempt)
79
+ raise error
79
80
  end
80
81
  end
81
82
 
@@ -87,7 +88,7 @@ class RedisClient
87
88
  begin
88
89
  @io.write(buffer)
89
90
  rescue SystemCallError, IOError, OpenSSL::SSL::SSLError => error
90
- raise ConnectionError.with_config(error.message, config)
91
+ raise connection_error(error.message)
91
92
  end
92
93
  end
93
94
 
@@ -97,10 +98,10 @@ class RedisClient
97
98
  else
98
99
  @io.with_timeout(timeout) { RESP3.load(@io) }
99
100
  end
100
- rescue RedisClient::RESP3::UnknownType => error
101
- raise RedisClient::ProtocolError.with_config(error.message, config)
101
+ rescue RedisClient::RESP3::Error => error
102
+ raise protocol_error(error.message)
102
103
  rescue SystemCallError, IOError, OpenSSL::SSL::SSLError => error
103
- raise ConnectionError.with_config(error.message, config)
104
+ raise connection_error(error.message)
104
105
  end
105
106
 
106
107
  def measure_round_trip_delay
@@ -112,6 +113,7 @@ class RedisClient
112
113
  private
113
114
 
114
115
  def connect
116
+ @server_key = @config.server_key
115
117
  socket = if @config.path
116
118
  UNIXSocket.new(@config.path)
117
119
  else
@@ -154,7 +156,7 @@ class RedisClient
154
156
  write_timeout: @write_timeout,
155
157
  )
156
158
  true
157
- rescue SystemCallError, OpenSSL::SSL::SSLError, SocketError => error
159
+ rescue SystemCallError, IOError, OpenSSL::SSL::SSLError, SocketError => error
158
160
  socket&.close
159
161
  raise CannotConnectError, error.message, error.backtrace
160
162
  end
@@ -22,12 +22,16 @@ class RedisClient
22
22
  raise ArgumentError, "Expected role to be either :master or :replica, got: #{role.inspect}"
23
23
  end
24
24
 
25
+ # Track whether SSL was explicitly provided by user
26
+ ssl_explicitly_set = client_config.key?(:ssl)
27
+
25
28
  if url
26
29
  url_config = URLConfig.new(url)
27
30
  client_config = {
28
31
  username: url_config.username,
29
32
  password: url_config.password,
30
33
  db: url_config.db,
34
+ ssl: url_config.ssl?,
31
35
  }.compact.merge(client_config)
32
36
  name ||= url_config.host
33
37
  end
@@ -66,6 +70,10 @@ class RedisClient
66
70
 
67
71
  client_config[:reconnect_attempts] ||= DEFAULT_RECONNECT_ATTEMPTS
68
72
  @client_config = client_config || {}
73
+ @sentinel_client_config = @client_config.dup
74
+ # Only remove SSL from sentinel config if it was derived from URL,
75
+ # not if explicitly set by user.
76
+ @sentinel_client_config.delete(:ssl) unless ssl_explicitly_set
69
77
  super(**client_config)
70
78
  @sentinel_configs = sentinels_to_configs(sentinels)
71
79
  end
@@ -82,6 +90,10 @@ class RedisClient
82
90
  end
83
91
  end
84
92
 
93
+ def server_key
94
+ config.server_key
95
+ end
96
+
85
97
  def host
86
98
  config.host
87
99
  end
@@ -104,6 +116,10 @@ class RedisClient
104
116
  end
105
117
 
106
118
  def check_role!(role)
119
+ unless role.is_a?(String)
120
+ raise TypeError, "Expected role to be a string, got: #{role.inspect}"
121
+ end
122
+
107
123
  if @role == :master
108
124
  unless role == "master"
109
125
  sleep SENTINEL_DELAY
@@ -129,9 +145,9 @@ class RedisClient
129
145
  sentinels.map do |sentinel|
130
146
  case sentinel
131
147
  when String
132
- Config.new(**@client_config, **@extra_config, url: sentinel)
148
+ Config.new(**@sentinel_client_config, **@extra_config, url: sentinel)
133
149
  else
134
- Config.new(**@client_config, **@extra_config, **sentinel)
150
+ Config.new(**@sentinel_client_config, **@extra_config, **sentinel)
135
151
  end
136
152
  end
137
153
  end
@@ -148,7 +164,7 @@ class RedisClient
148
164
 
149
165
  def resolve_master
150
166
  each_sentinel do |sentinel_client|
151
- host, port = sentinel_client.call("SENTINEL", "get-master-addr-by-name", @name)
167
+ host, port = sentinel_client.call_v(["SENTINEL", "get-master-addr-by-name", @name])
152
168
  next unless host && port
153
169
 
154
170
  refresh_sentinels(sentinel_client)
@@ -167,7 +183,7 @@ class RedisClient
167
183
 
168
184
  def resolve_replica
169
185
  each_sentinel do |sentinel_client|
170
- replicas = sentinel_client.call("SENTINEL", "replicas", @name, &@to_list_of_hash)
186
+ replicas = sentinel_client.call_v(["SENTINEL", "replicas", @name], &@to_list_of_hash)
171
187
  replicas.reject! do |r|
172
188
  flags = r["flags"].to_s.split(",")
173
189
  flags.include?("s_down") || flags.include?("o_down")
@@ -210,7 +226,7 @@ class RedisClient
210
226
  end
211
227
 
212
228
  def refresh_sentinels(sentinel_client)
213
- sentinel_response = sentinel_client.call("SENTINEL", "sentinels", @name, &@to_list_of_hash)
229
+ sentinel_response = sentinel_client.call_v(["SENTINEL", "sentinels", @name], &@to_list_of_hash)
214
230
  sentinels = sentinel_response.map do |sentinel|
215
231
  { host: sentinel.fetch("ip"), port: Integer(sentinel.fetch("port")) }
216
232
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class RedisClient
4
- VERSION = "0.25.2"
4
+ VERSION = "0.30.1"
5
5
  end
data/lib/redis_client.rb CHANGED
@@ -61,7 +61,7 @@ class RedisClient
61
61
  end
62
62
 
63
63
  module Common
64
- attr_reader :config, :id
64
+ attr_reader :config, :id, :nodes
65
65
  attr_accessor :connect_timeout, :read_timeout, :write_timeout
66
66
 
67
67
  def initialize(
@@ -78,11 +78,21 @@ class RedisClient
78
78
  @write_timeout = write_timeout
79
79
  @command_builder = config.command_builder
80
80
  @pid = PIDCache.pid
81
+ @nodes = [self].freeze
81
82
  end
82
83
 
83
84
  def timeout=(timeout)
84
85
  @connect_timeout = @read_timeout = @write_timeout = timeout
85
86
  end
87
+
88
+ def node_for(_key)
89
+ self
90
+ end
91
+
92
+ def nodes_for(*keys)
93
+ keys.flatten!
94
+ { self => keys }
95
+ end
86
96
  end
87
97
 
88
98
  module HasConfig
@@ -99,13 +109,55 @@ class RedisClient
99
109
  end
100
110
  end
101
111
 
112
+ module Retriable
113
+ def _set_retry_attempt(retry_attempt)
114
+ @retry_attempt = retry_attempt
115
+ end
116
+
117
+ def retry_attempt
118
+ @retry_attempt || 0
119
+ end
120
+
121
+ def retriable?
122
+ !!@retry_attempt
123
+ end
124
+
125
+ def final?
126
+ !@retry_attempt
127
+ end
128
+ end
129
+
130
+ module Final
131
+ def _set_retry_attempt(_retry_attempt)
132
+ end
133
+
134
+ def retry_attempt
135
+ 0
136
+ end
137
+
138
+ def retriable?
139
+ false
140
+ end
141
+
142
+ def final?
143
+ true
144
+ end
145
+ end
146
+
102
147
  class Error < StandardError
103
148
  include HasConfig
149
+ include Retriable
150
+
151
+ attr_reader :next_error
104
152
 
105
153
  def self.with_config(message, config = nil)
106
- new(message).tap do |error|
107
- error._set_config(config)
108
- end
154
+ error = new(message)
155
+ error._set_config(config)
156
+ error
157
+ end
158
+
159
+ def _set_next_error(error) # :nodoc:
160
+ @next_error = error
109
161
  end
110
162
  end
111
163
 
@@ -142,6 +194,7 @@ class RedisClient
142
194
  class CommandError < Error
143
195
  include HasCommand
144
196
  include HasCode
197
+ include Final
145
198
 
146
199
  class << self
147
200
  def parse(error_message)
@@ -192,6 +245,12 @@ class RedisClient
192
245
  SentinelConfig.new(client_implementation: self, **kwargs)
193
246
  end
194
247
 
248
+ def ring(*clients, **options)
249
+ clients.flatten!
250
+ require "redis_client/hash_ring" unless defined?(HashRing)
251
+ HashRing.new(clients, **options)
252
+ end
253
+
195
254
  def new(arg = nil, **kwargs)
196
255
  if arg.is_a?(Config::Common)
197
256
  super
@@ -212,6 +271,7 @@ class RedisClient
212
271
  @middlewares = config.middlewares_stack.new(self)
213
272
  @raw_connection = nil
214
273
  @disable_reconnection = false
274
+ @retry_attempt = nil
215
275
  end
216
276
 
217
277
  def inspect
@@ -231,6 +291,10 @@ class RedisClient
231
291
  config.read_timeout
232
292
  end
233
293
 
294
+ def idle_timeout
295
+ config.idle_timeout
296
+ end
297
+
234
298
  def db
235
299
  config.db
236
300
  end
@@ -706,6 +770,7 @@ class RedisClient
706
770
  close if !config.inherit_socket && @pid != PIDCache.pid
707
771
 
708
772
  if @disable_reconnection
773
+ @raw_connection.retry_attempt = nil
709
774
  if block_given?
710
775
  yield @raw_connection
711
776
  else
@@ -716,9 +781,12 @@ class RedisClient
716
781
  connection = nil
717
782
  preferred_error = nil
718
783
  begin
784
+ @retry_attempt = config.retriable?(tries) ? tries : nil
719
785
  connection = raw_connection
720
786
  if block_given?
721
- yield connection
787
+ result = yield connection
788
+ @last_used_at = RedisClient.now
789
+ result
722
790
  else
723
791
  connection
724
792
  end
@@ -744,7 +812,10 @@ class RedisClient
744
812
  connection = ensure_connected
745
813
  begin
746
814
  @disable_reconnection = true
747
- yield connection
815
+ @raw_connection.retry_attempt = nil
816
+ result = yield connection
817
+ @last_used_at = RedisClient.now
818
+ result
748
819
  rescue ConnectionError, ProtocolError
749
820
  close
750
821
  raise
@@ -758,13 +829,24 @@ class RedisClient
758
829
  if @raw_connection.nil? || !@raw_connection.revalidate
759
830
  connect
760
831
  end
832
+
833
+ if config.idle_timeout && (@last_used_at + config.idle_timeout) < RedisClient.now
834
+ @middlewares.call(["PING"], config) do
835
+ @raw_connection.call(["PING"], nil)
836
+ rescue ConnectionError, ProtocolError
837
+ close
838
+ connect
839
+ end
840
+ end
841
+
842
+ @raw_connection.retry_attempt = @retry_attempt
761
843
  @raw_connection
762
844
  end
763
845
 
764
846
  def connect
765
847
  @pid = PIDCache.pid
766
848
 
767
- if @raw_connection
849
+ if @raw_connection&.revalidate
768
850
  @middlewares.connect(config) do
769
851
  @raw_connection.reconnect
770
852
  end
@@ -778,6 +860,8 @@ class RedisClient
778
860
  )
779
861
  end
780
862
  end
863
+ @last_used_at = RedisClient.now
864
+ @raw_connection.retry_attempt = @retry_attempt
781
865
 
782
866
  prelude = config.connection_prelude.dup
783
867
 
@@ -785,31 +869,42 @@ class RedisClient
785
869
  prelude << ["CLIENT", "SETNAME", id]
786
870
  end
787
871
 
788
- # The connection prelude is deliberately not sent to Middlewares
789
872
  if config.sentinel?
790
873
  prelude << ["ROLE"]
791
- role, = @middlewares.call_pipelined(prelude, config) do
792
- @raw_connection.call_pipelined(prelude, nil).last
874
+ end
875
+
876
+ unless prelude.empty?
877
+ results = @middlewares.call_pipelined(prelude, config) do
878
+ @raw_connection.call_pipelined(prelude, nil, exception: false)
793
879
  end
794
- config.check_role!(role)
795
- else
796
- unless prelude.empty?
797
- @middlewares.call_pipelined(prelude, config) do
798
- @raw_connection.call_pipelined(prelude, nil)
880
+
881
+ results.each do |result|
882
+ # CLIENT SETINFO is unsupported on Redis < 7.2. The pipeline drained all responses before raising,
883
+ # so if that's the only error, the socket is healthy: keep it.
884
+ if result.is_a?(CommandError) && !(result.command[0] == "CLIENT" && result.command[1] == "SETINFO")
885
+ raise result
799
886
  end
800
887
  end
888
+
889
+ if config.sentinel?
890
+ config.check_role!(results.last.first)
891
+ end
801
892
  end
802
893
  rescue FailoverError, CannotConnectError => error
894
+ @raw_connection&.close
803
895
  error._set_config(config)
804
896
  raise error
805
897
  rescue ConnectionError => error
898
+ @raw_connection&.close
806
899
  connect_error = CannotConnectError.with_config(error.message, config)
807
900
  connect_error.set_backtrace(error.backtrace)
808
901
  raise connect_error
809
902
  rescue CommandError => error
810
- if error.message.match?(/ERR unknown command ['`]HELLO['`]/)
903
+ @raw_connection&.close
904
+
905
+ if error.command&.first == "HELLO" && error.message.match?(/ERR unknown command/)
811
906
  raise UnsupportedServer,
812
- "redis-client requires Redis 6+ with HELLO command available (#{config.server_url})"
907
+ "redis-client requires Redis 6+ with HELLO command available (#{config.server_url})", cause: error
813
908
  else
814
909
  raise
815
910
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redis-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.25.2
4
+ version: 0.30.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jean Boussier
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-08-10 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: connection_pool
@@ -39,6 +39,7 @@ files:
39
39
  - lib/redis_client/config.rb
40
40
  - lib/redis_client/connection_mixin.rb
41
41
  - lib/redis_client/decorator.rb
42
+ - lib/redis_client/hash_ring.rb
42
43
  - lib/redis_client/middlewares.rb
43
44
  - lib/redis_client/pid_cache.rb
44
45
  - lib/redis_client/pooled.rb
@@ -70,7 +71,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
70
71
  - !ruby/object:Gem::Version
71
72
  version: '0'
72
73
  requirements: []
73
- rubygems_version: 3.6.2
74
+ rubygems_version: 4.0.12
74
75
  specification_version: 4
75
76
  summary: Simple low-level client for Redis 6+
76
77
  test_files: []