redis-client 0.11.2 → 0.12.2

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: f5e453ca140b38cff3f62e947e4168ca950117d1ff03562e5d6ed2bad0dbdd83
4
+ data.tar.gz: 8fe7edeb11051105d2505f95e6cda7360c2cf6a438b93e5bb0f775538456f28d
5
5
  SHA512:
6
- metadata.gz: 4f1b143a96e5c5d7f96919187e8a7d7b481da1661d19044c4b699cad713761fcf371b5733ab5200d05e176f782318c6fe23a73568eeb6f610a061a97f1706181
7
- data.tar.gz: b97603e2d45b1008b4920a9576315809f7d41de0cd95f05f2c2a1e1241e3e9bbdf3d57c80afc15cc823d795bb41686ad079f7cb27b203cd03d884291a165787a
6
+ metadata.gz: 27596015912265226cd39b7c6e27fff214e096166121aa0e8dba0a0f81a7ba45663c0d28239e3610d57c3955ace8cfc1f428c4af8f9373b80bb196eeb89c61a8
7
+ data.tar.gz: 91b24e94e5332f4e74ca2dbb2e9adc60f7e8f9c5b698b1af5dfd5483bf836c95f6a79f39689272b742c6693ff8b6a6dd0d275e820f4a29f6d86b4dab650be6b4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Unreleased
2
2
 
3
+ # 0.12.2
4
+
5
+ - Cache calls to `Process.pid` on Ruby 3.1+. #91.
6
+
7
+ # 0.12.1
8
+
9
+ - Improve compatibility with `uri 0.12.0` (default in Ruby 3.2.0).
10
+
11
+ # 0.12.0
12
+
13
+ - hiredis: fix a compilation issue on macOS and Ruby 3.2.0. See: #79
14
+ - Close connection on MASTERDOWN errors. Similar to READONLY.
15
+ - Add a `circuit_breaker` configuration option for cache servers and other disposable Redis servers. See #55 / #70
16
+
3
17
  # 0.11.2
4
18
 
5
19
  - 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.2)
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)
@@ -175,7 +181,15 @@ class RedisClient
175
181
 
176
182
  super(**kwargs)
177
183
 
178
- @host = host || uri&.host&.sub(/\A\[(.*)\]\z/, '\1') || DEFAULT_HOST
184
+ @host = host
185
+ unless @host
186
+ uri_host = uri&.host
187
+ uri_host = nil if uri_host&.empty?
188
+ if uri_host
189
+ @host = uri_host&.sub(/\A\[(.*)\]\z/, '\1')
190
+ end
191
+ end
192
+ @host ||= DEFAULT_HOST
179
193
  @port = Integer(port || uri&.port || DEFAULT_PORT)
180
194
  @path = path
181
195
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RedisClient
4
+ module PIDCache
5
+ if !Process.respond_to?(:fork) # JRuby or TruffleRuby
6
+ @pid = Process.pid
7
+ singleton_class.attr_reader(:pid)
8
+ elsif Process.respond_to?(:_fork) # Ruby 3.1+
9
+ class << self
10
+ attr_reader :pid
11
+
12
+ def update!
13
+ @pid = Process.pid
14
+ end
15
+ end
16
+ update!
17
+
18
+ module CoreExt
19
+ def _fork
20
+ child_pid = super
21
+ PIDCache.update! if child_pid == 0
22
+ child_pid
23
+ end
24
+ end
25
+ Process.singleton_class.prepend(CoreExt)
26
+ else # Ruby 3.0 or older
27
+ class << self
28
+ def pid
29
+ Process.pid
30
+ end
31
+ end
32
+ end
33
+ end
34
+ 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.2"
4
+ VERSION = "0.12.2"
5
5
  end
data/lib/redis_client.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "redis_client/version"
4
4
  require "redis_client/command_builder"
5
5
  require "redis_client/config"
6
+ require "redis_client/pid_cache"
6
7
  require "redis_client/sentinel_config"
7
8
  require "redis_client/middlewares"
8
9
 
@@ -62,12 +63,12 @@ class RedisClient
62
63
  write_timeout: config.write_timeout
63
64
  )
64
65
  @config = config
65
- @id = id
66
+ @id = id&.to_s
66
67
  @connect_timeout = connect_timeout
67
68
  @read_timeout = read_timeout
68
69
  @write_timeout = write_timeout
69
70
  @command_builder = config.command_builder
70
- @pid = Process.pid
71
+ @pid = PIDCache.pid
71
72
  end
72
73
 
73
74
  def timeout=(timeout)
@@ -125,10 +126,14 @@ class RedisClient
125
126
  ReadOnlyError = Class.new(ConnectionError)
126
127
  ReadOnlyError.include(HasCommand)
127
128
 
129
+ MasterDownError = Class.new(ConnectionError)
130
+ MasterDownError.include(HasCommand)
131
+
128
132
  CommandError::ERRORS = {
129
133
  "WRONGPASS" => AuthenticationError,
130
134
  "NOPERM" => PermissionError,
131
135
  "READONLY" => ReadOnlyError,
136
+ "MASTERDOWN" => MasterDownError,
132
137
  "WRONGTYPE" => WrongTypeError,
133
138
  "OOM" => OutOfMemoryError,
134
139
  }.freeze
@@ -605,7 +610,7 @@ class RedisClient
605
610
  end
606
611
 
607
612
  def ensure_connected(retryable: true)
608
- close if !config.inherit_socket && @pid != Process.pid
613
+ close if !config.inherit_socket && @pid != PIDCache.pid
609
614
 
610
615
  if @disable_reconnection
611
616
  if block_given?
@@ -654,7 +659,7 @@ class RedisClient
654
659
  end
655
660
 
656
661
  def connect
657
- @pid = Process.pid
662
+ @pid = PIDCache.pid
658
663
 
659
664
  connection = @middlewares.connect(config) do
660
665
  config.driver.new(
@@ -668,7 +673,7 @@ class RedisClient
668
673
  prelude = config.connection_prelude.dup
669
674
 
670
675
  if id
671
- prelude << ["CLIENT", "SETNAME", id.to_s]
676
+ prelude << ["CLIENT", "SETNAME", id]
672
677
  end
673
678
 
674
679
  # The connection prelude is deliberately not sent to Middlewares
@@ -687,7 +692,7 @@ class RedisClient
687
692
  end
688
693
 
689
694
  connection
690
- rescue FailoverError
695
+ rescue FailoverError, CannotConnectError
691
696
  raise
692
697
  rescue ConnectionError => error
693
698
  raise CannotConnectError, error.message, error.backtrace
@@ -702,5 +707,6 @@ class RedisClient
702
707
  end
703
708
 
704
709
  require "redis_client/pooled"
710
+ require "redis_client/circuit_breaker"
705
711
 
706
712
  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.2
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-02-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: connection_pool
@@ -39,11 +39,13 @@ 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
45
46
  - lib/redis_client/decorator.rb
46
47
  - lib/redis_client/middlewares.rb
48
+ - lib/redis_client/pid_cache.rb
47
49
  - lib/redis_client/pooled.rb
48
50
  - lib/redis_client/ruby_connection.rb
49
51
  - lib/redis_client/ruby_connection/buffered_io.rb
@@ -74,7 +76,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
74
76
  - !ruby/object:Gem::Version
75
77
  version: '0'
76
78
  requirements: []
77
- rubygems_version: 3.3.7
79
+ rubygems_version: 3.4.1
78
80
  signing_key:
79
81
  specification_version: 4
80
82
  summary: Simple low-level client for Redis 6+