redis-client 0.25.2 → 0.30.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 +4 -4
- data/CHANGELOG.md +55 -1
- data/README.md +54 -1
- data/lib/redis_client/config.rb +44 -5
- data/lib/redis_client/connection_mixin.rb +28 -6
- data/lib/redis_client/hash_ring.rb +87 -0
- data/lib/redis_client/pooled.rb +1 -1
- data/lib/redis_client/ruby_connection.rb +12 -10
- data/lib/redis_client/sentinel_config.rb +21 -5
- data/lib/redis_client/version.rb +1 -1
- data/lib/redis_client.rb +112 -17
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6aa2af422440587f4e300deda124b9ca9ea0ed499d1bbf0e10b3194879bc6798
|
|
4
|
+
data.tar.gz: b2574d805366f3044c82aa5147f55113cce6a3429340473cccbfeeff327d8025
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 42cbc0f198df2fffacc9741672898e43256ea79c849774d59f916434109d2abe0e406dd52b46a5a83fbdcad0d875c57e49b7a618f0220b10b9521fd1ff5839aa
|
|
7
|
+
data.tar.gz: e88305d9c5724aef28c7a9fd57b8199fd4242668edc6291d8d309d20ac44d54aabdf45925571379659c7733475a5dd1590c84a7c212e27df1d84943de8f60754
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
# Unreleased
|
|
2
2
|
|
|
3
|
+
# 0.30.0
|
|
4
|
+
|
|
5
|
+
- hiredis-client: Now support configuring SSL `verify_mode`.
|
|
6
|
+
|
|
7
|
+
# 0.29.0
|
|
8
|
+
|
|
9
|
+
- Fix connecting to Redis 7.1 and older with `driver_info:`set.
|
|
10
|
+
- Added `RedisClient::Error#next_error` for an easier way to check all errors produced by a pipeline.
|
|
11
|
+
|
|
12
|
+
# 0.28.0
|
|
13
|
+
|
|
14
|
+
- Added `RedisClient::HashRing` for horizontal sharing (compatible with `Redis::Distributed` from `redis-rb`).
|
|
15
|
+
|
|
16
|
+
# 0.27.0
|
|
17
|
+
|
|
18
|
+
- Added `idle_timeout` to revalidate connections that haven't been successfuly used in a long time. Defaults to 30 seconds.
|
|
19
|
+
- Added `driver_info` configuration, to issue `CLIENT SETINFO` during connection prelude.
|
|
20
|
+
|
|
21
|
+
# 0.26.4
|
|
22
|
+
|
|
23
|
+
- Further improve `rediss://` URLs used with Redis sentinel. Now avoid override explictly set `ssl:` parameter.
|
|
24
|
+
- Fix compatibility with `redis-rb` in sentinel mode.
|
|
25
|
+
|
|
26
|
+
# 0.26.3
|
|
27
|
+
|
|
28
|
+
- Fix `rediss://` (ssl) URLs used with Redis sentinel.
|
|
29
|
+
- Handle Ruby 4.0 connection timeout raising an `IO::Timeout` instead of `Errno::ETIMEDOUT`.
|
|
30
|
+
- Entirely close the connection on authentication failures.
|
|
31
|
+
|
|
32
|
+
# 0.26.2
|
|
33
|
+
|
|
34
|
+
- Fix compatibility with `connection_pool` version 3+.
|
|
35
|
+
|
|
36
|
+
# 0.26.1
|
|
37
|
+
|
|
38
|
+
- Fix a few corner cases where `RedisClient::Error#final?` was innacurate.
|
|
39
|
+
- hiredis-client: Properly reconnect to the new leader after a sentinel failover.
|
|
40
|
+
|
|
41
|
+
# 0.26.0
|
|
42
|
+
|
|
43
|
+
- Add `RedisClient::Error#final?` and `#retriable?` to allow middleware to filter out non-final errors.
|
|
44
|
+
- Fix precedence of `db: nil` initialization parameter.
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
Redis.new(url: "redis://localhost:6379/3", db: nil).db
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Before: `0`
|
|
51
|
+
After: `3`
|
|
52
|
+
|
|
53
|
+
# 0.25.3
|
|
54
|
+
|
|
55
|
+
- Fix `hiredis-client` compilation with `clang 21`.
|
|
56
|
+
|
|
3
57
|
# 0.25.2
|
|
4
58
|
|
|
5
59
|
- Fix circuit breakers to respect the `error_threshold_timeout` config is provided.
|
|
@@ -201,7 +255,7 @@
|
|
|
201
255
|
- Added `_v` versions of `call` methods to make it easier to pass commands as arrays without splating.
|
|
202
256
|
- Fix calling `blocking_call` with a block in a pipeline.
|
|
203
257
|
- `blocking_call` now raise `ReadTimeoutError` if the command didn't complete in time.
|
|
204
|
-
- Fix `blocking_call` to not respect `
|
|
258
|
+
- Fix `blocking_call` to not respect `reconnect_attempts` on timeout.
|
|
205
259
|
- Stop parsing RESP3 sets as Ruby Set instances.
|
|
206
260
|
- Fix `SystemStackError` when parsing very large hashes. Fix: #30
|
|
207
261
|
- `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
|
-
|
|
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.
|
data/lib/redis_client/config.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
71
|
-
raise
|
|
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
|
data/lib/redis_client/pooled.rb
CHANGED
|
@@ -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
|
|
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
|
|
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::
|
|
101
|
-
raise
|
|
101
|
+
rescue RedisClient::RESP3::Error => error
|
|
102
|
+
raise protocol_error(error.message)
|
|
102
103
|
rescue SystemCallError, IOError, OpenSSL::SSL::SSLError => error
|
|
103
|
-
raise
|
|
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(**@
|
|
148
|
+
Config.new(**@sentinel_client_config, **@extra_config, url: sentinel)
|
|
133
149
|
else
|
|
134
|
-
Config.new(**@
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
data/lib/redis_client/version.rb
CHANGED
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)
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
792
|
-
|
|
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
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
+
version: 0.30.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jean Boussier
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
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:
|
|
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: []
|