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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24ef1685664b6af416108d0994b89e1c8d70e3ba1e5d17363f9bc92a0a34137f
4
- data.tar.gz: 065e1efd905230b129fe0e82569ae4691644c2126c507eb8e9c44fec556e6584
3
+ metadata.gz: 398676851a40081cd70d1334fa845292b0df4b7036d6ae33d0fa3ae6fa7ee040
4
+ data.tar.gz: 0a17e311c65180dcd67f106bcaca6e1f08a441e38e9ffdbab9876e2b3f62bc0a
5
5
  SHA512:
6
- metadata.gz: 4f1b143a96e5c5d7f96919187e8a7d7b481da1661d19044c4b699cad713761fcf371b5733ab5200d05e176f782318c6fe23a73568eeb6f610a061a97f1706181
7
- data.tar.gz: b97603e2d45b1008b4920a9576315809f7d41de0cd95f05f2c2a1e1241e3e9bbdf3d57c80afc15cc823d795bb41686ad079f7cb27b203cd03d884291a165787a
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.11.2)
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.0)
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.22)
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: [0, 0.05, 0.1])
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
@@ -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 Errno::ECONNRESET
145
+ raise EOFError
146
146
  else
147
147
  raise "Unexpected `read_nonblock` return: #{bytes.inspect}"
148
148
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class RedisClient
4
- VERSION = "0.11.2"
4
+ VERSION = "0.12.0"
5
5
  end
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.to_s]
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.11.2
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: 2022-11-18 00:00:00.000000000 Z
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.3.7
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+