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 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+