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