redis-client 0.11.2 → 0.12.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 +6 -0
- data/Gemfile.lock +3 -3
- data/README.md +47 -8
- data/lib/redis_client/circuit_breaker.rb +108 -0
- data/lib/redis_client/config.rb +8 -2
- data/lib/redis_client/ruby_connection/buffered_io.rb +1 -1
- data/lib/redis_client/version.rb +1 -1
- data/lib/redis_client.rb +8 -3
- metadata +4 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 398676851a40081cd70d1334fa845292b0df4b7036d6ae33d0fa3ae6fa7ee040
|
4
|
+
data.tar.gz: 0a17e311c65180dcd67f106bcaca6e1f08a441e38e9ffdbab9876e2b3f62bc0a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 196f490ad7632f4139099b6047c3973b8957be2df24a92f1ba9f5f7588360c2a89b04b487cd24251e4aabf6006bd51cece7429f9aeae75b9e5013cb00240bdfc
|
7
|
+
data.tar.gz: bee6d01afc6805fa70720c09b213841d719a4bb045b6694d5a1d9c9efe6a5875c403464b7451d0d0a4d8056467440306936250cd6f537d47b45ee962dfa90658
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,11 @@
|
|
1
1
|
# Unreleased
|
2
2
|
|
3
|
+
# 0.12.0
|
4
|
+
|
5
|
+
- hiredis: fix a compilation issue on macOS and Ruby 3.2.0. See: #79
|
6
|
+
- Close connection on MASTERDOWN errors. Similar to READONLY.
|
7
|
+
- Add a `circuit_breaker` configuration option for cache servers and other disposable Redis servers. See #55 / #70
|
8
|
+
|
3
9
|
# 0.11.2
|
4
10
|
|
5
11
|
- Close connection on READONLY errors. Fix: #64
|
data/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
redis-client (0.
|
4
|
+
redis-client (0.12.0)
|
5
5
|
connection_pool
|
6
6
|
|
7
7
|
GEM
|
@@ -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.23)
|
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.
|
@@ -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)
|
@@ -142,7 +142,7 @@ class RedisClient
|
|
142
142
|
when :wait_writable
|
143
143
|
@io.to_io.wait_writable(@write_timeout) or raise WriteTimeoutError
|
144
144
|
when nil
|
145
|
-
raise
|
145
|
+
raise EOFError
|
146
146
|
else
|
147
147
|
raise "Unexpected `read_nonblock` return: #{bytes.inspect}"
|
148
148
|
end
|
data/lib/redis_client/version.rb
CHANGED
data/lib/redis_client.rb
CHANGED
@@ -62,7 +62,7 @@ class RedisClient
|
|
62
62
|
write_timeout: config.write_timeout
|
63
63
|
)
|
64
64
|
@config = config
|
65
|
-
@id = id
|
65
|
+
@id = id&.to_s
|
66
66
|
@connect_timeout = connect_timeout
|
67
67
|
@read_timeout = read_timeout
|
68
68
|
@write_timeout = write_timeout
|
@@ -125,10 +125,14 @@ class RedisClient
|
|
125
125
|
ReadOnlyError = Class.new(ConnectionError)
|
126
126
|
ReadOnlyError.include(HasCommand)
|
127
127
|
|
128
|
+
MasterDownError = Class.new(ConnectionError)
|
129
|
+
MasterDownError.include(HasCommand)
|
130
|
+
|
128
131
|
CommandError::ERRORS = {
|
129
132
|
"WRONGPASS" => AuthenticationError,
|
130
133
|
"NOPERM" => PermissionError,
|
131
134
|
"READONLY" => ReadOnlyError,
|
135
|
+
"MASTERDOWN" => MasterDownError,
|
132
136
|
"WRONGTYPE" => WrongTypeError,
|
133
137
|
"OOM" => OutOfMemoryError,
|
134
138
|
}.freeze
|
@@ -668,7 +672,7 @@ class RedisClient
|
|
668
672
|
prelude = config.connection_prelude.dup
|
669
673
|
|
670
674
|
if id
|
671
|
-
prelude << ["CLIENT", "SETNAME", id
|
675
|
+
prelude << ["CLIENT", "SETNAME", id]
|
672
676
|
end
|
673
677
|
|
674
678
|
# The connection prelude is deliberately not sent to Middlewares
|
@@ -687,7 +691,7 @@ class RedisClient
|
|
687
691
|
end
|
688
692
|
|
689
693
|
connection
|
690
|
-
rescue FailoverError
|
694
|
+
rescue FailoverError, CannotConnectError
|
691
695
|
raise
|
692
696
|
rescue ConnectionError => error
|
693
697
|
raise CannotConnectError, error.message, error.backtrace
|
@@ -702,5 +706,6 @@ class RedisClient
|
|
702
706
|
end
|
703
707
|
|
704
708
|
require "redis_client/pooled"
|
709
|
+
require "redis_client/circuit_breaker"
|
705
710
|
|
706
711
|
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.12.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-01-09 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: connection_pool
|
@@ -39,6 +39,7 @@ 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
|
@@ -74,7 +75,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
74
75
|
- !ruby/object:Gem::Version
|
75
76
|
version: '0'
|
76
77
|
requirements: []
|
77
|
-
rubygems_version: 3.
|
78
|
+
rubygems_version: 3.4.1
|
78
79
|
signing_key:
|
79
80
|
specification_version: 4
|
80
81
|
summary: Simple low-level client for Redis 6+
|