redis-client 0.11.1 → 0.14.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 +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+
|