redis-client 0.11.2 → 0.12.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 +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+
|