redis-client 0.11.1 → 0.14.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -1
- data/Gemfile.lock +5 -5
- data/README.md +47 -8
- data/Rakefile +4 -0
- data/lib/redis_client/circuit_breaker.rb +108 -0
- data/lib/redis_client/config.rb +24 -4
- data/lib/redis_client/connection_mixin.rb +17 -4
- data/lib/redis_client/pid_cache.rb +34 -0
- data/lib/redis_client/ruby_connection/buffered_io.rb +5 -5
- data/lib/redis_client/ruby_connection.rb +81 -37
- data/lib/redis_client/sentinel_config.rb +33 -13
- data/lib/redis_client/version.rb +1 -1
- data/lib/redis_client.rb +46 -29
- metadata +5 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e9858303eff06d56f8e144eb7033995fc92839c41fe39d0bda6f1d9c2fed91e9
|
4
|
+
data.tar.gz: a4f4ca2073a32e28932c58e537963a2f0b2321e36ca472c6edc9edab1b29122b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3c20b17e4587980ed89e0877b20cb9aee7d16365401127cfdc3b4f451f7706bf338988e97ac9d1e62d33c9af5b45ebfb3c35310205fbd309995ace46c3b85fce
|
7
|
+
data.tar.gz: 21ed1731b716f1bd6e525b638b7715683979860faa0d14f2fc6f210c4d185a95d2329650b56cf731734d22d7008cdadccc2055a8d5ff7a332b043836e0d39b75
|
data/CHANGELOG.md
CHANGED
@@ -1,6 +1,44 @@
|
|
1
1
|
# Unreleased
|
2
2
|
|
3
|
-
|
3
|
+
# 0.14.1
|
4
|
+
|
5
|
+
- Include the timeout value in TimeoutError messages.
|
6
|
+
- Fix connection keep-alive on FreeBSD. #102.
|
7
|
+
|
8
|
+
# 0.14.0
|
9
|
+
|
10
|
+
- Implement Sentinels list automatic refresh.
|
11
|
+
- hiredis binding now implement GC compaction and write barriers.
|
12
|
+
- hiredis binding now properly release the GVL around `connect(2)`.
|
13
|
+
- hiredis the client memory is now re-used on reconnection when possible to reduce allocation churn.
|
14
|
+
|
15
|
+
# 0.13.0
|
16
|
+
|
17
|
+
- Enable TCP keepalive on redis sockets. It sends a keep alive probe every 15 seconds for 2 minutes. #94.
|
18
|
+
|
19
|
+
# 0.12.2
|
20
|
+
|
21
|
+
- Cache calls to `Process.pid` on Ruby 3.1+. #91.
|
22
|
+
|
23
|
+
# 0.12.1
|
24
|
+
|
25
|
+
- Improve compatibility with `uri 0.12.0` (default in Ruby 3.2.0).
|
26
|
+
|
27
|
+
# 0.12.0
|
28
|
+
|
29
|
+
- hiredis: fix a compilation issue on macOS and Ruby 3.2.0. See: #79
|
30
|
+
- Close connection on MASTERDOWN errors. Similar to READONLY.
|
31
|
+
- Add a `circuit_breaker` configuration option for cache servers and other disposable Redis servers. See #55 / #70
|
32
|
+
|
33
|
+
# 0.11.2
|
34
|
+
|
35
|
+
- Close connection on READONLY errors. Fix: #64
|
36
|
+
- Handle Redis 6+ servers with a missing HELLO command. See: #67
|
37
|
+
- Validate `url` parameters a bit more strictly. Fix #61
|
38
|
+
|
39
|
+
# 0.11.1
|
40
|
+
|
41
|
+
- hiredis: Workaround a compilation bug with Xcode 14.0. Fix: #58
|
4
42
|
- Accept `URI` instances as `uri` parameter.
|
5
43
|
|
6
44
|
# 0.11.0
|
data/Gemfile.lock
CHANGED
@@ -1,16 +1,16 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
redis-client (0.
|
4
|
+
redis-client (0.14.1)
|
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.
|
11
|
+
benchmark-ips (2.12.0)
|
12
12
|
byebug (11.1.3)
|
13
|
-
connection_pool (2.
|
13
|
+
connection_pool (2.4.0)
|
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.
|
22
|
+
rake-compiler (1.2.1)
|
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.
|
41
|
+
stackprof (0.2.24)
|
42
42
|
toxiproxy (2.0.2)
|
43
43
|
unicode-display_width (2.2.0)
|
44
44
|
|
data/README.md
CHANGED
@@ -63,7 +63,7 @@ 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.
|
@@ -81,6 +81,7 @@ 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`.
|
85
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 metadatas.
|
86
87
|
|
@@ -426,24 +427,62 @@ It can be set as a number of retries:
|
|
426
427
|
redis_config = RedisClient.config(reconnect_attempts: 1)
|
427
428
|
```
|
428
429
|
|
429
|
-
Or as a list of sleep durations for implementing exponential backoff:
|
430
|
-
|
431
|
-
```ruby
|
432
|
-
redis_config = RedisClient.config(reconnect_attempts: [0, 0.05, 0.1])
|
433
|
-
```
|
434
|
-
|
435
430
|
**Important Note**: Retrying may cause commands to be issued more than once to the server, so in the case of
|
436
431
|
non-idempotent commands such as `LPUSH` or `INCR`, it may cause consistency issues.
|
437
432
|
|
438
433
|
To selectively disable automatic retries, you can use the `#call_once` method:
|
439
434
|
|
440
435
|
```ruby
|
441
|
-
redis_config = RedisClient.config(reconnect_attempts:
|
436
|
+
redis_config = RedisClient.config(reconnect_attempts: 3)
|
442
437
|
redis = redis_config.new_client
|
443
438
|
redis.call("GET", "counter") # Will be retried up to 3 times.
|
444
439
|
redis.call_once("INCR", "counter") # Won't be retried.
|
445
440
|
```
|
446
441
|
|
442
|
+
### Exponential backoff
|
443
|
+
|
444
|
+
Alternatively, `reconnect_attempts` accepts a list of sleep durations for implementing exponential backoff:
|
445
|
+
|
446
|
+
```ruby
|
447
|
+
redis_config = RedisClient.config(reconnect_attempts: [0, 0.05, 0.1])
|
448
|
+
```
|
449
|
+
|
450
|
+
This configuration is generally used when the Redis server is expected to failover or recover relatively quickly and
|
451
|
+
that it's not really possibe to continue without issuing the command.
|
452
|
+
|
453
|
+
When the Redis server is used as an ephemeral cache, circuit breakers are generally prefered.
|
454
|
+
|
455
|
+
### Circuit Breaker
|
456
|
+
|
457
|
+
When Redis is used as a cache and a connection error happens, you may not want to retry as it might take
|
458
|
+
longer than to recompute the value. Instead it's likely preferable to mark the server as unavailable and let it
|
459
|
+
recover for a while.
|
460
|
+
|
461
|
+
[Circuit breakers are a pattern that does exactly that](https://en.wikipedia.org/wiki/Circuit_breaker_design_pattern).
|
462
|
+
|
463
|
+
Configuation options:
|
464
|
+
|
465
|
+
- `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.
|
466
|
+
- `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.
|
467
|
+
- `error_timeout`. The amount of time in seconds until trying to query the resource again.
|
468
|
+
- `success_threshold`. The amount of successes on the circuit until closing it again, that is to start accepting all requests to the circuit.
|
469
|
+
|
470
|
+
```ruby
|
471
|
+
RedisClient.config(
|
472
|
+
circuit_breaker: {
|
473
|
+
# Stop querying the server after 3 errors happened in a 2 seconds window
|
474
|
+
error_threshold: 3,
|
475
|
+
error_threshold_timeout: 2,
|
476
|
+
|
477
|
+
# Try querying again after 1 second
|
478
|
+
error_timeout: 1,
|
479
|
+
|
480
|
+
# Stay in half-open state until 3 queries succeeded.
|
481
|
+
success_threshold: 3,
|
482
|
+
}
|
483
|
+
)
|
484
|
+
```
|
485
|
+
|
447
486
|
### Drivers
|
448
487
|
|
449
488
|
`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
|
data/lib/redis_client/config.rb
CHANGED
@@ -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, :custom
|
17
|
+
:middlewares_stack, :custom, :circuit_breaker
|
18
18
|
|
19
19
|
alias_method :ssl?, :ssl
|
20
20
|
|
@@ -36,7 +36,8 @@ class RedisClient
|
|
36
36
|
command_builder: CommandBuilder,
|
37
37
|
inherit_socket: false,
|
38
38
|
reconnect_attempts: false,
|
39
|
-
middlewares: false
|
39
|
+
middlewares: false,
|
40
|
+
circuit_breaker: nil
|
40
41
|
)
|
41
42
|
@username = username
|
42
43
|
@password = password
|
@@ -66,6 +67,11 @@ class RedisClient
|
|
66
67
|
@reconnect_attempts = reconnect_attempts
|
67
68
|
@connection_prelude = build_connection_prelude
|
68
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
|
+
|
69
75
|
middlewares_stack = Middlewares
|
70
76
|
if middlewares && !middlewares.empty?
|
71
77
|
middlewares_stack = Class.new(Middlewares)
|
@@ -106,7 +112,9 @@ class RedisClient
|
|
106
112
|
end
|
107
113
|
|
108
114
|
def ssl_context
|
109
|
-
|
115
|
+
if ssl
|
116
|
+
@ssl_context ||= @driver.ssl_context(@ssl_params || {})
|
117
|
+
end
|
110
118
|
end
|
111
119
|
|
112
120
|
def server_url
|
@@ -155,6 +163,10 @@ class RedisClient
|
|
155
163
|
)
|
156
164
|
if url
|
157
165
|
uri = URI(url)
|
166
|
+
unless uri.scheme == "redis" || uri.scheme == "rediss"
|
167
|
+
raise ArgumentError, "Invalid URL: #{url.inspect}"
|
168
|
+
end
|
169
|
+
|
158
170
|
kwargs[:ssl] = uri.scheme == "rediss" unless kwargs.key?(:ssl)
|
159
171
|
|
160
172
|
kwargs[:username] ||= uri.user if uri.password && !uri.user.empty?
|
@@ -171,7 +183,15 @@ class RedisClient
|
|
171
183
|
|
172
184
|
super(**kwargs)
|
173
185
|
|
174
|
-
@host = host
|
186
|
+
@host = host
|
187
|
+
unless @host
|
188
|
+
uri_host = uri&.host
|
189
|
+
uri_host = nil if uri_host&.empty?
|
190
|
+
if uri_host
|
191
|
+
@host = uri_host&.sub(/\A\[(.*)\]\z/, '\1')
|
192
|
+
end
|
193
|
+
end
|
194
|
+
@host ||= DEFAULT_HOST
|
175
195
|
@port = Integer(port || uri&.port || DEFAULT_PORT)
|
176
196
|
@path = path
|
177
197
|
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
|
11
|
-
|
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?(
|
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?(
|
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
|
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
|
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
|
143
|
+
@io.to_io.wait_writable(@write_timeout) or raise(WriteTimeoutError, "Waited #{@write_timeout} seconds")
|
144
144
|
when nil
|
145
|
-
raise
|
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
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
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,78 @@ class RedisClient
|
|
129
100
|
rescue SystemCallError, IOError, OpenSSL::SSL::SSLError => error
|
130
101
|
raise ConnectionError, error.message
|
131
102
|
end
|
103
|
+
|
104
|
+
private
|
105
|
+
|
106
|
+
def connect
|
107
|
+
socket = if @config.path
|
108
|
+
UNIXSocket.new(@config.path)
|
109
|
+
else
|
110
|
+
sock = if SUPPORTS_RESOLV_TIMEOUT
|
111
|
+
Socket.tcp(@config.host, @config.port, connect_timeout: @connect_timeout, resolv_timeout: @connect_timeout)
|
112
|
+
else
|
113
|
+
Socket.tcp(@config.host, @config.port, connect_timeout: @connect_timeout)
|
114
|
+
end
|
115
|
+
# disables Nagle's Algorithm, prevents multiple round trips with MULTI
|
116
|
+
sock.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
117
|
+
enable_socket_keep_alive(sock)
|
118
|
+
sock
|
119
|
+
end
|
120
|
+
|
121
|
+
if @config.ssl
|
122
|
+
socket = OpenSSL::SSL::SSLSocket.new(socket, @config.ssl_context)
|
123
|
+
socket.hostname = @config.host
|
124
|
+
loop do
|
125
|
+
case status = socket.connect_nonblock(exception: false)
|
126
|
+
when :wait_readable
|
127
|
+
socket.to_io.wait_readable(@connect_timeout) or raise CannotConnectError
|
128
|
+
when :wait_writable
|
129
|
+
socket.to_io.wait_writable(@connect_timeout) or raise CannotConnectError
|
130
|
+
when socket
|
131
|
+
break
|
132
|
+
else
|
133
|
+
raise "Unexpected `connect_nonblock` return: #{status.inspect}"
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
@io = BufferedIO.new(
|
139
|
+
socket,
|
140
|
+
read_timeout: @read_timeout,
|
141
|
+
write_timeout: @write_timeout,
|
142
|
+
)
|
143
|
+
true
|
144
|
+
rescue SystemCallError, OpenSSL::SSL::SSLError, SocketError => error
|
145
|
+
raise CannotConnectError, error.message, error.backtrace
|
146
|
+
end
|
147
|
+
|
148
|
+
KEEP_ALIVE_INTERVAL = 15 # Same as hiredis defaults
|
149
|
+
KEEP_ALIVE_TTL = 120 # Longer than hiredis defaults
|
150
|
+
KEEP_ALIVE_PROBES = (KEEP_ALIVE_TTL / KEEP_ALIVE_INTERVAL) - 1
|
151
|
+
private_constant :KEEP_ALIVE_INTERVAL
|
152
|
+
private_constant :KEEP_ALIVE_TTL
|
153
|
+
private_constant :KEEP_ALIVE_PROBES
|
154
|
+
|
155
|
+
if %i[SOL_TCP SOL_SOCKET TCP_KEEPIDLE TCP_KEEPINTVL TCP_KEEPCNT].all? { |c| Socket.const_defined? c } # Linux
|
156
|
+
def enable_socket_keep_alive(socket)
|
157
|
+
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
|
158
|
+
socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, KEEP_ALIVE_INTERVAL)
|
159
|
+
socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, KEEP_ALIVE_INTERVAL)
|
160
|
+
socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, KEEP_ALIVE_PROBES)
|
161
|
+
end
|
162
|
+
elsif %i[IPPROTO_TCP TCP_KEEPINTVL TCP_KEEPCNT].all? { |c| Socket.const_defined? c } # macOS
|
163
|
+
def enable_socket_keep_alive(socket)
|
164
|
+
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
|
165
|
+
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_KEEPINTVL, KEEP_ALIVE_INTERVAL)
|
166
|
+
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_KEEPCNT, KEEP_ALIVE_PROBES)
|
167
|
+
end
|
168
|
+
elsif %i[SOL_SOCKET SO_KEEPALIVE].all? { |c| Socket.const_defined? c } # unknown POSIX
|
169
|
+
def enable_socket_keep_alive(socket)
|
170
|
+
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
|
171
|
+
end
|
172
|
+
else # unknown
|
173
|
+
def enable_socket_keep_alive(_socket)
|
174
|
+
end
|
175
|
+
end
|
132
176
|
end
|
133
177
|
end
|
@@ -13,9 +13,9 @@ class RedisClient
|
|
13
13
|
end
|
14
14
|
|
15
15
|
@to_list_of_hash = @to_hash = nil
|
16
|
-
extra_config = {}
|
16
|
+
@extra_config = {}
|
17
17
|
if client_config[:protocol] == 2
|
18
|
-
extra_config[:protocol] = client_config[:protocol]
|
18
|
+
@extra_config[:protocol] = client_config[:protocol]
|
19
19
|
@to_list_of_hash = lambda do |may_be_a_list|
|
20
20
|
if may_be_a_list.is_a?(Array)
|
21
21
|
may_be_a_list.map { |l| l.each_slice(2).to_h }
|
@@ -26,14 +26,7 @@ class RedisClient
|
|
26
26
|
end
|
27
27
|
|
28
28
|
@name = name
|
29
|
-
@sentinel_configs = sentinels
|
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
|
29
|
+
@sentinel_configs = sentinels_to_configs(sentinels)
|
37
30
|
@sentinels = {}.compare_by_identity
|
38
31
|
@role = role
|
39
32
|
@mutex = Mutex.new
|
@@ -93,6 +86,17 @@ class RedisClient
|
|
93
86
|
|
94
87
|
private
|
95
88
|
|
89
|
+
def sentinels_to_configs(sentinels)
|
90
|
+
sentinels.map do |sentinel|
|
91
|
+
case sentinel
|
92
|
+
when String
|
93
|
+
Config.new(**@extra_config, url: sentinel)
|
94
|
+
else
|
95
|
+
Config.new(**@extra_config, **sentinel)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
96
100
|
def config
|
97
101
|
@mutex.synchronize do
|
98
102
|
@config ||= if @role == :master
|
@@ -106,9 +110,11 @@ class RedisClient
|
|
106
110
|
def resolve_master
|
107
111
|
each_sentinel do |sentinel_client|
|
108
112
|
host, port = sentinel_client.call("SENTINEL", "get-master-addr-by-name", @name)
|
109
|
-
|
110
|
-
|
111
|
-
|
113
|
+
next unless host && port
|
114
|
+
|
115
|
+
refresh_sentinels(sentinel_client)
|
116
|
+
|
117
|
+
return Config.new(host: host, port: Integer(port), **@client_config)
|
112
118
|
end
|
113
119
|
rescue ConnectionError
|
114
120
|
raise ConnectionError, "No sentinels available"
|
@@ -159,5 +165,19 @@ class RedisClient
|
|
159
165
|
|
160
166
|
raise last_error if last_error
|
161
167
|
end
|
168
|
+
|
169
|
+
def refresh_sentinels(sentinel_client)
|
170
|
+
sentinel_response = sentinel_client.call("SENTINEL", "sentinels", @name, &@to_list_of_hash)
|
171
|
+
sentinels = sentinel_response.map do |sentinel|
|
172
|
+
{ host: sentinel.fetch("ip"), port: Integer(sentinel.fetch("port")) }
|
173
|
+
end
|
174
|
+
new_sentinels = sentinels.select do |sentinel|
|
175
|
+
@sentinel_configs.none? do |sentinel_config|
|
176
|
+
sentinel_config.host == sentinel.fetch(:host) && sentinel_config.port == sentinel.fetch(:port)
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
@sentinel_configs.concat sentinels_to_configs(new_sentinels)
|
181
|
+
end
|
162
182
|
end
|
163
183
|
end
|
data/lib/redis_client/version.rb
CHANGED
data/lib/redis_client.rb
CHANGED
@@ -3,6 +3,7 @@
|
|
3
3
|
require "redis_client/version"
|
4
4
|
require "redis_client/command_builder"
|
5
5
|
require "redis_client/config"
|
6
|
+
require "redis_client/pid_cache"
|
6
7
|
require "redis_client/sentinel_config"
|
7
8
|
require "redis_client/middlewares"
|
8
9
|
|
@@ -62,12 +63,12 @@ class RedisClient
|
|
62
63
|
write_timeout: config.write_timeout
|
63
64
|
)
|
64
65
|
@config = config
|
65
|
-
@id = id
|
66
|
+
@id = id&.to_s
|
66
67
|
@connect_timeout = connect_timeout
|
67
68
|
@read_timeout = read_timeout
|
68
69
|
@write_timeout = write_timeout
|
69
70
|
@command_builder = config.command_builder
|
70
|
-
@pid =
|
71
|
+
@pid = PIDCache.pid
|
71
72
|
end
|
72
73
|
|
73
74
|
def timeout=(timeout)
|
@@ -90,9 +91,17 @@ class RedisClient
|
|
90
91
|
WriteTimeoutError = Class.new(TimeoutError)
|
91
92
|
CheckoutTimeoutError = Class.new(TimeoutError)
|
92
93
|
|
93
|
-
|
94
|
+
module HasCommand
|
94
95
|
attr_reader :command
|
95
96
|
|
97
|
+
def _set_command(command)
|
98
|
+
@command = command
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
class CommandError < Error
|
103
|
+
include HasCommand
|
104
|
+
|
96
105
|
class << self
|
97
106
|
def parse(error_message)
|
98
107
|
code = if error_message.start_with?("ERR Error running script")
|
@@ -107,22 +116,24 @@ class RedisClient
|
|
107
116
|
klass.new(error_message)
|
108
117
|
end
|
109
118
|
end
|
110
|
-
|
111
|
-
def _set_command(command)
|
112
|
-
@command = command
|
113
|
-
end
|
114
119
|
end
|
115
120
|
|
116
121
|
AuthenticationError = Class.new(CommandError)
|
117
122
|
PermissionError = Class.new(CommandError)
|
118
|
-
ReadOnlyError = Class.new(CommandError)
|
119
123
|
WrongTypeError = Class.new(CommandError)
|
120
124
|
OutOfMemoryError = Class.new(CommandError)
|
121
125
|
|
126
|
+
ReadOnlyError = Class.new(ConnectionError)
|
127
|
+
ReadOnlyError.include(HasCommand)
|
128
|
+
|
129
|
+
MasterDownError = Class.new(ConnectionError)
|
130
|
+
MasterDownError.include(HasCommand)
|
131
|
+
|
122
132
|
CommandError::ERRORS = {
|
123
133
|
"WRONGPASS" => AuthenticationError,
|
124
134
|
"NOPERM" => PermissionError,
|
125
135
|
"READONLY" => ReadOnlyError,
|
136
|
+
"MASTERDOWN" => MasterDownError,
|
126
137
|
"WRONGTYPE" => WrongTypeError,
|
127
138
|
"OOM" => OutOfMemoryError,
|
128
139
|
}.freeze
|
@@ -335,7 +346,6 @@ class RedisClient
|
|
335
346
|
|
336
347
|
def close
|
337
348
|
@raw_connection&.close
|
338
|
-
@raw_connection = nil
|
339
349
|
self
|
340
350
|
end
|
341
351
|
|
@@ -419,7 +429,7 @@ class RedisClient
|
|
419
429
|
|
420
430
|
def close
|
421
431
|
@raw_connection&.close
|
422
|
-
@raw_connection = nil
|
432
|
+
@raw_connection = nil # PubSub can't just reconnect
|
423
433
|
self
|
424
434
|
end
|
425
435
|
|
@@ -599,7 +609,7 @@ class RedisClient
|
|
599
609
|
end
|
600
610
|
|
601
611
|
def ensure_connected(retryable: true)
|
602
|
-
close if !config.inherit_socket && @pid !=
|
612
|
+
close if !config.inherit_socket && @pid != PIDCache.pid
|
603
613
|
|
604
614
|
if @disable_reconnection
|
605
615
|
if block_given?
|
@@ -643,52 +653,58 @@ class RedisClient
|
|
643
653
|
end
|
644
654
|
|
645
655
|
def raw_connection
|
646
|
-
@raw_connection
|
647
|
-
|
656
|
+
if @raw_connection.nil? || !@raw_connection.revalidate
|
657
|
+
connect
|
658
|
+
end
|
659
|
+
@raw_connection
|
648
660
|
end
|
649
661
|
|
650
662
|
def connect
|
651
|
-
@pid =
|
663
|
+
@pid = PIDCache.pid
|
652
664
|
|
653
|
-
|
654
|
-
|
655
|
-
|
656
|
-
|
657
|
-
|
658
|
-
|
659
|
-
|
665
|
+
if @raw_connection
|
666
|
+
@middlewares.connect(config) do
|
667
|
+
@raw_connection.reconnect
|
668
|
+
end
|
669
|
+
else
|
670
|
+
@raw_connection = @middlewares.connect(config) do
|
671
|
+
config.driver.new(
|
672
|
+
config,
|
673
|
+
connect_timeout: connect_timeout,
|
674
|
+
read_timeout: read_timeout,
|
675
|
+
write_timeout: write_timeout,
|
676
|
+
)
|
677
|
+
end
|
660
678
|
end
|
661
679
|
|
662
680
|
prelude = config.connection_prelude.dup
|
663
681
|
|
664
682
|
if id
|
665
|
-
prelude << ["CLIENT", "SETNAME", id
|
683
|
+
prelude << ["CLIENT", "SETNAME", id]
|
666
684
|
end
|
667
685
|
|
668
686
|
# The connection prelude is deliberately not sent to Middlewares
|
669
687
|
if config.sentinel?
|
670
688
|
prelude << ["ROLE"]
|
671
689
|
role, = @middlewares.call_pipelined(prelude, config) do
|
672
|
-
|
690
|
+
@raw_connection.call_pipelined(prelude, nil).last
|
673
691
|
end
|
674
692
|
config.check_role!(role)
|
675
693
|
else
|
676
694
|
unless prelude.empty?
|
677
695
|
@middlewares.call_pipelined(prelude, config) do
|
678
|
-
|
696
|
+
@raw_connection.call_pipelined(prelude, nil)
|
679
697
|
end
|
680
698
|
end
|
681
699
|
end
|
682
|
-
|
683
|
-
connection
|
684
|
-
rescue FailoverError
|
700
|
+
rescue FailoverError, CannotConnectError
|
685
701
|
raise
|
686
702
|
rescue ConnectionError => error
|
687
703
|
raise CannotConnectError, error.message, error.backtrace
|
688
704
|
rescue CommandError => error
|
689
|
-
if error.message.
|
705
|
+
if error.message.match?(/ERR unknown command ['`]HELLO['`]/)
|
690
706
|
raise UnsupportedServer,
|
691
|
-
"
|
707
|
+
"redis-client requires Redis 6+ with HELLO command available (#{config.server_url})"
|
692
708
|
else
|
693
709
|
raise
|
694
710
|
end
|
@@ -696,5 +712,6 @@ class RedisClient
|
|
696
712
|
end
|
697
713
|
|
698
714
|
require "redis_client/pooled"
|
715
|
+
require "redis_client/circuit_breaker"
|
699
716
|
|
700
717
|
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.
|
4
|
+
version: 0.14.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jean Boussier
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2023-03-30 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: connection_pool
|
@@ -39,11 +39,13 @@ 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
|
@@ -74,7 +76,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
74
76
|
- !ruby/object:Gem::Version
|
75
77
|
version: '0'
|
76
78
|
requirements: []
|
77
|
-
rubygems_version: 3.
|
79
|
+
rubygems_version: 3.4.6
|
78
80
|
signing_key:
|
79
81
|
specification_version: 4
|
80
82
|
summary: Simple low-level client for Redis 6+
|