redis-client 0.11.1 → 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: ded07d814692a3e286f61a38a3d8129ef35189659124e73fd3e24e4e53a6a08b
4
- data.tar.gz: 021c3dc9bf79717313a9f41e6bc29973cafbfe08ba15b2eb2b8560228939752b
3
+ metadata.gz: 398676851a40081cd70d1334fa845292b0df4b7036d6ae33d0fa3ae6fa7ee040
4
+ data.tar.gz: 0a17e311c65180dcd67f106bcaca6e1f08a441e38e9ffdbab9876e2b3f62bc0a
5
5
  SHA512:
6
- metadata.gz: 52ebda137196361b56487d014cdbf5f188cd8822d92f955aeb39ee9fdea448051e38be2e68bf60616cdbeec1df942c25472a5d71e6b6c139ef96b1286db94d32
7
- data.tar.gz: 495caeef04270d289eb58a5a1f0f52f19414db8d3efa0554edea041d165a3e14a33337156fa28c55c56fbee29b5683347d871fb7f84f79fea02371d1e0f605ce
6
+ metadata.gz: 196f490ad7632f4139099b6047c3973b8957be2df24a92f1ba9f5f7588360c2a89b04b487cd24251e4aabf6006bd51cece7429f9aeae75b9e5013cb00240bdfc
7
+ data.tar.gz: bee6d01afc6805fa70720c09b213841d719a4bb045b6694d5a1d9c9efe6a5875c403464b7451d0d0a4d8056467440306936250cd6f537d47b45ee962dfa90658
data/CHANGELOG.md CHANGED
@@ -1,6 +1,20 @@
1
1
  # Unreleased
2
2
 
3
- - hiredis: Workaround a compilation bug with Xcode 14.0, Fix: #58
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
+
9
+ # 0.11.2
10
+
11
+ - Close connection on READONLY errors. Fix: #64
12
+ - Handle Redis 6+ servers with a missing HELLO command. See: #67
13
+ - Validate `url` parameters a bit more strictly. Fix #61
14
+
15
+ # 0.11.1
16
+
17
+ - hiredis: Workaround a compilation bug with Xcode 14.0. Fix: #58
4
18
  - Accept `URI` instances as `uri` parameter.
5
19
 
6
20
  # 0.11.0
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- redis-client (0.11.1)
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)
@@ -155,6 +161,10 @@ class RedisClient
155
161
  )
156
162
  if url
157
163
  uri = URI(url)
164
+ unless uri.scheme == "redis" || uri.scheme == "rediss"
165
+ raise ArgumentError, "Invalid URL: #{url.inspect}"
166
+ end
167
+
158
168
  kwargs[:ssl] = uri.scheme == "rediss" unless kwargs.key?(:ssl)
159
169
 
160
170
  kwargs[:username] ||= uri.user if uri.password && !uri.user.empty?
@@ -17,7 +17,7 @@ class RedisClient
17
17
  write(command)
18
18
  result = read(timeout)
19
19
  @pending_reads -= 1
20
- if result.is_a?(CommandError)
20
+ if result.is_a?(Error)
21
21
  result._set_command(command)
22
22
  raise result
23
23
  else
@@ -37,7 +37,7 @@ class RedisClient
37
37
  timeout = timeouts && timeouts[index]
38
38
  result = read(timeout)
39
39
  @pending_reads -= 1
40
- if result.is_a?(CommandError)
40
+ if result.is_a?(Error)
41
41
  result._set_command(commands[index])
42
42
  exception ||= result
43
43
  end
@@ -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.1"
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
@@ -90,9 +90,17 @@ class RedisClient
90
90
  WriteTimeoutError = Class.new(TimeoutError)
91
91
  CheckoutTimeoutError = Class.new(TimeoutError)
92
92
 
93
- class CommandError < Error
93
+ module HasCommand
94
94
  attr_reader :command
95
95
 
96
+ def _set_command(command)
97
+ @command = command
98
+ end
99
+ end
100
+
101
+ class CommandError < Error
102
+ include HasCommand
103
+
96
104
  class << self
97
105
  def parse(error_message)
98
106
  code = if error_message.start_with?("ERR Error running script")
@@ -107,22 +115,24 @@ class RedisClient
107
115
  klass.new(error_message)
108
116
  end
109
117
  end
110
-
111
- def _set_command(command)
112
- @command = command
113
- end
114
118
  end
115
119
 
116
120
  AuthenticationError = Class.new(CommandError)
117
121
  PermissionError = Class.new(CommandError)
118
- ReadOnlyError = Class.new(CommandError)
119
122
  WrongTypeError = Class.new(CommandError)
120
123
  OutOfMemoryError = Class.new(CommandError)
121
124
 
125
+ ReadOnlyError = Class.new(ConnectionError)
126
+ ReadOnlyError.include(HasCommand)
127
+
128
+ MasterDownError = Class.new(ConnectionError)
129
+ MasterDownError.include(HasCommand)
130
+
122
131
  CommandError::ERRORS = {
123
132
  "WRONGPASS" => AuthenticationError,
124
133
  "NOPERM" => PermissionError,
125
134
  "READONLY" => ReadOnlyError,
135
+ "MASTERDOWN" => MasterDownError,
126
136
  "WRONGTYPE" => WrongTypeError,
127
137
  "OOM" => OutOfMemoryError,
128
138
  }.freeze
@@ -662,7 +672,7 @@ class RedisClient
662
672
  prelude = config.connection_prelude.dup
663
673
 
664
674
  if id
665
- prelude << ["CLIENT", "SETNAME", id.to_s]
675
+ prelude << ["CLIENT", "SETNAME", id]
666
676
  end
667
677
 
668
678
  # The connection prelude is deliberately not sent to Middlewares
@@ -681,14 +691,14 @@ class RedisClient
681
691
  end
682
692
 
683
693
  connection
684
- rescue FailoverError
694
+ rescue FailoverError, CannotConnectError
685
695
  raise
686
696
  rescue ConnectionError => error
687
697
  raise CannotConnectError, error.message, error.backtrace
688
698
  rescue CommandError => error
689
- if error.message.include?("ERR unknown command `HELLO`")
699
+ if error.message.match?(/ERR unknown command ['`]HELLO['`]/)
690
700
  raise UnsupportedServer,
691
- "Your Redis server version is too old. redis-client requires Redis 6+. (#{config.server_url})"
701
+ "redis-client requires Redis 6+ with HELLO command available (#{config.server_url})"
692
702
  else
693
703
  raise
694
704
  end
@@ -696,5 +706,6 @@ class RedisClient
696
706
  end
697
707
 
698
708
  require "redis_client/pooled"
709
+ require "redis_client/circuit_breaker"
699
710
 
700
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.1
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-04 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+