redis-client 0.11.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +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+
|