redis-client 0.11.1 → 0.14.1

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: ded07d814692a3e286f61a38a3d8129ef35189659124e73fd3e24e4e53a6a08b
4
- data.tar.gz: 021c3dc9bf79717313a9f41e6bc29973cafbfe08ba15b2eb2b8560228939752b
3
+ metadata.gz: e9858303eff06d56f8e144eb7033995fc92839c41fe39d0bda6f1d9c2fed91e9
4
+ data.tar.gz: a4f4ca2073a32e28932c58e537963a2f0b2321e36ca472c6edc9edab1b29122b
5
5
  SHA512:
6
- metadata.gz: 52ebda137196361b56487d014cdbf5f188cd8822d92f955aeb39ee9fdea448051e38be2e68bf60616cdbeec1df942c25472a5d71e6b6c139ef96b1286db94d32
7
- data.tar.gz: 495caeef04270d289eb58a5a1f0f52f19414db8d3efa0554edea041d165a3e14a33337156fa28c55c56fbee29b5683347d871fb7f84f79fea02371d1e0f605ce
6
+ metadata.gz: 3c20b17e4587980ed89e0877b20cb9aee7d16365401127cfdc3b4f451f7706bf338988e97ac9d1e62d33c9af5b45ebfb3c35310205fbd309995ace46c3b85fce
7
+ data.tar.gz: 21ed1731b716f1bd6e525b638b7715683979860faa0d14f2fc6f210c4d185a95d2329650b56cf731734d22d7008cdadccc2055a8d5ff7a332b043836e0d39b75
data/CHANGELOG.md CHANGED
@@ -1,6 +1,44 @@
1
1
  # Unreleased
2
2
 
3
- - hiredis: Workaround a compilation bug with Xcode 14.0, Fix: #58
3
+ # 0.14.1
4
+
5
+ - Include the timeout value in TimeoutError messages.
6
+ - Fix connection keep-alive on FreeBSD. #102.
7
+
8
+ # 0.14.0
9
+
10
+ - Implement Sentinels list automatic refresh.
11
+ - hiredis binding now implement GC compaction and write barriers.
12
+ - hiredis binding now properly release the GVL around `connect(2)`.
13
+ - hiredis the client memory is now re-used on reconnection when possible to reduce allocation churn.
14
+
15
+ # 0.13.0
16
+
17
+ - Enable TCP keepalive on redis sockets. It sends a keep alive probe every 15 seconds for 2 minutes. #94.
18
+
19
+ # 0.12.2
20
+
21
+ - Cache calls to `Process.pid` on Ruby 3.1+. #91.
22
+
23
+ # 0.12.1
24
+
25
+ - Improve compatibility with `uri 0.12.0` (default in Ruby 3.2.0).
26
+
27
+ # 0.12.0
28
+
29
+ - hiredis: fix a compilation issue on macOS and Ruby 3.2.0. See: #79
30
+ - Close connection on MASTERDOWN errors. Similar to READONLY.
31
+ - Add a `circuit_breaker` configuration option for cache servers and other disposable Redis servers. See #55 / #70
32
+
33
+ # 0.11.2
34
+
35
+ - Close connection on READONLY errors. Fix: #64
36
+ - Handle Redis 6+ servers with a missing HELLO command. See: #67
37
+ - Validate `url` parameters a bit more strictly. Fix #61
38
+
39
+ # 0.11.1
40
+
41
+ - hiredis: Workaround a compilation bug with Xcode 14.0. Fix: #58
4
42
  - Accept `URI` instances as `uri` parameter.
5
43
 
6
44
  # 0.11.0
data/Gemfile.lock CHANGED
@@ -1,16 +1,16 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- redis-client (0.11.1)
4
+ redis-client (0.14.1)
5
5
  connection_pool
6
6
 
7
7
  GEM
8
8
  remote: https://rubygems.org/
9
9
  specs:
10
10
  ast (2.4.2)
11
- benchmark-ips (2.10.0)
11
+ benchmark-ips (2.12.0)
12
12
  byebug (11.1.3)
13
- connection_pool (2.3.0)
13
+ connection_pool (2.4.0)
14
14
  hiredis (0.6.3)
15
15
  hiredis (0.6.3-java)
16
16
  minitest (5.15.0)
@@ -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.24)
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.
data/Rakefile CHANGED
@@ -26,6 +26,7 @@ namespace :test do
26
26
  t.libs << "test"
27
27
  t.libs << "lib"
28
28
  t.test_files = FileList["test/**/*_test.rb"].exclude("test/sentinel/*_test.rb")
29
+ t.options = '-v' if ENV['CI'] || ENV['VERBOSE']
29
30
  end
30
31
 
31
32
  Rake::TestTask.new(:sentinel) do |t|
@@ -33,13 +34,16 @@ namespace :test do
33
34
  t.libs << "test"
34
35
  t.libs << "lib"
35
36
  t.test_files = FileList["test/sentinel/*_test.rb"]
37
+ t.options = '-v' if ENV['CI'] || ENV['VERBOSE']
36
38
  end
37
39
 
38
40
  Rake::TestTask.new(:hiredis) do |t|
39
41
  t.libs << "test/hiredis"
40
42
  t.libs << "test"
43
+ t.libs << "hiredis-client/lib"
41
44
  t.libs << "lib"
42
45
  t.test_files = FileList["test/**/*_test.rb"].exclude("test/sentinel/*_test.rb")
46
+ t.options = '-v' if ENV['CI'] || ENV['VERBOSE']
43
47
  end
44
48
  end
45
49
 
@@ -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)
@@ -106,7 +112,9 @@ class RedisClient
106
112
  end
107
113
 
108
114
  def ssl_context
109
- @ssl_context ||= @driver.ssl_context(@ssl_params || {})
115
+ if ssl
116
+ @ssl_context ||= @driver.ssl_context(@ssl_params || {})
117
+ end
110
118
  end
111
119
 
112
120
  def server_url
@@ -155,6 +163,10 @@ class RedisClient
155
163
  )
156
164
  if url
157
165
  uri = URI(url)
166
+ unless uri.scheme == "redis" || uri.scheme == "rediss"
167
+ raise ArgumentError, "Invalid URL: #{url.inspect}"
168
+ end
169
+
158
170
  kwargs[:ssl] = uri.scheme == "rediss" unless kwargs.key?(:ssl)
159
171
 
160
172
  kwargs[:username] ||= uri.user if uri.password && !uri.user.empty?
@@ -171,7 +183,15 @@ class RedisClient
171
183
 
172
184
  super(**kwargs)
173
185
 
174
- @host = host || uri&.host&.sub(/\A\[(.*)\]\z/, '\1') || DEFAULT_HOST
186
+ @host = host
187
+ unless @host
188
+ uri_host = uri&.host
189
+ uri_host = nil if uri_host&.empty?
190
+ if uri_host
191
+ @host = uri_host&.sub(/\A\[(.*)\]\z/, '\1')
192
+ end
193
+ end
194
+ @host ||= DEFAULT_HOST
175
195
  @port = Integer(port || uri&.port || DEFAULT_PORT)
176
196
  @path = path
177
197
  end
@@ -6,9 +6,22 @@ class RedisClient
6
6
  @pending_reads = 0
7
7
  end
8
8
 
9
+ def reconnect
10
+ close
11
+ connect
12
+ end
13
+
14
+ def close
15
+ @pending_reads = 0
16
+ nil
17
+ end
18
+
9
19
  def revalidate
10
- if @pending_reads == 0 && connected?
11
- self
20
+ if @pending_reads > 0
21
+ close
22
+ false
23
+ else
24
+ connected?
12
25
  end
13
26
  end
14
27
 
@@ -17,7 +30,7 @@ class RedisClient
17
30
  write(command)
18
31
  result = read(timeout)
19
32
  @pending_reads -= 1
20
- if result.is_a?(CommandError)
33
+ if result.is_a?(Error)
21
34
  result._set_command(command)
22
35
  raise result
23
36
  else
@@ -37,7 +50,7 @@ class RedisClient
37
50
  timeout = timeouts && timeouts[index]
38
51
  result = read(timeout)
39
52
  @pending_reads -= 1
40
- if result.is_a?(CommandError)
53
+ if result.is_a?(Error)
41
54
  result._set_command(commands[index])
42
55
  exception ||= result
43
56
  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
@@ -70,9 +70,9 @@ class RedisClient
70
70
  return total
71
71
  end
72
72
  when :wait_readable
73
- @io.to_io.wait_readable(@read_timeout) or raise ReadTimeoutError
73
+ @io.to_io.wait_readable(@read_timeout) or raise(ReadTimeoutError, "Waited #{@read_timeout} seconds")
74
74
  when :wait_writable
75
- @io.to_io.wait_writable(@write_timeout) or raise WriteTimeoutError
75
+ @io.to_io.wait_writable(@write_timeout) or raise(WriteTimeoutError, "Waited #{@write_timeout} seconds")
76
76
  when nil
77
77
  raise Errno::ECONNRESET
78
78
  else
@@ -137,12 +137,12 @@ class RedisClient
137
137
  return if !strict || remaining <= 0
138
138
  when :wait_readable
139
139
  unless @io.to_io.wait_readable(@read_timeout)
140
- raise ReadTimeoutError unless @blocking_reads
140
+ raise ReadTimeoutError, "Waited #{@read_timeout} seconds" unless @blocking_reads
141
141
  end
142
142
  when :wait_writable
143
- @io.to_io.wait_writable(@write_timeout) or raise WriteTimeoutError
143
+ @io.to_io.wait_writable(@write_timeout) or raise(WriteTimeoutError, "Waited #{@write_timeout} seconds")
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
@@ -42,43 +42,11 @@ class RedisClient
42
42
 
43
43
  def initialize(config, connect_timeout:, read_timeout:, write_timeout:)
44
44
  super()
45
- socket = if config.path
46
- UNIXSocket.new(config.path)
47
- else
48
- sock = if SUPPORTS_RESOLV_TIMEOUT
49
- Socket.tcp(config.host, config.port, connect_timeout: connect_timeout, resolv_timeout: connect_timeout)
50
- else
51
- Socket.tcp(config.host, config.port, connect_timeout: connect_timeout)
52
- end
53
- # disables Nagle's Algorithm, prevents multiple round trips with MULTI
54
- sock.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
55
- sock
56
- end
57
-
58
- if config.ssl
59
- socket = OpenSSL::SSL::SSLSocket.new(socket, config.ssl_context)
60
- socket.hostname = config.host
61
- loop do
62
- case status = socket.connect_nonblock(exception: false)
63
- when :wait_readable
64
- socket.to_io.wait_readable(connect_timeout) or raise CannotConnectError
65
- when :wait_writable
66
- socket.to_io.wait_writable(connect_timeout) or raise CannotConnectError
67
- when socket
68
- break
69
- else
70
- raise "Unexpected `connect_nonblock` return: #{status.inspect}"
71
- end
72
- end
73
- end
74
-
75
- @io = BufferedIO.new(
76
- socket,
77
- read_timeout: read_timeout,
78
- write_timeout: write_timeout,
79
- )
80
- rescue SystemCallError, OpenSSL::SSL::SSLError, SocketError => error
81
- raise CannotConnectError, error.message, error.backtrace
45
+ @config = config
46
+ @connect_timeout = connect_timeout
47
+ @read_timeout = read_timeout
48
+ @write_timeout = write_timeout
49
+ connect
82
50
  end
83
51
 
84
52
  def connected?
@@ -87,13 +55,16 @@ class RedisClient
87
55
 
88
56
  def close
89
57
  @io.close
58
+ super
90
59
  end
91
60
 
92
61
  def read_timeout=(timeout)
62
+ @read_timeout = timeout
93
63
  @io.read_timeout = timeout if @io
94
64
  end
95
65
 
96
66
  def write_timeout=(timeout)
67
+ @write_timeout = timeout
97
68
  @io.write_timeout = timeout if @io
98
69
  end
99
70
 
@@ -129,5 +100,78 @@ class RedisClient
129
100
  rescue SystemCallError, IOError, OpenSSL::SSL::SSLError => error
130
101
  raise ConnectionError, error.message
131
102
  end
103
+
104
+ private
105
+
106
+ def connect
107
+ socket = if @config.path
108
+ UNIXSocket.new(@config.path)
109
+ else
110
+ sock = if SUPPORTS_RESOLV_TIMEOUT
111
+ Socket.tcp(@config.host, @config.port, connect_timeout: @connect_timeout, resolv_timeout: @connect_timeout)
112
+ else
113
+ Socket.tcp(@config.host, @config.port, connect_timeout: @connect_timeout)
114
+ end
115
+ # disables Nagle's Algorithm, prevents multiple round trips with MULTI
116
+ sock.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
117
+ enable_socket_keep_alive(sock)
118
+ sock
119
+ end
120
+
121
+ if @config.ssl
122
+ socket = OpenSSL::SSL::SSLSocket.new(socket, @config.ssl_context)
123
+ socket.hostname = @config.host
124
+ loop do
125
+ case status = socket.connect_nonblock(exception: false)
126
+ when :wait_readable
127
+ socket.to_io.wait_readable(@connect_timeout) or raise CannotConnectError
128
+ when :wait_writable
129
+ socket.to_io.wait_writable(@connect_timeout) or raise CannotConnectError
130
+ when socket
131
+ break
132
+ else
133
+ raise "Unexpected `connect_nonblock` return: #{status.inspect}"
134
+ end
135
+ end
136
+ end
137
+
138
+ @io = BufferedIO.new(
139
+ socket,
140
+ read_timeout: @read_timeout,
141
+ write_timeout: @write_timeout,
142
+ )
143
+ true
144
+ rescue SystemCallError, OpenSSL::SSL::SSLError, SocketError => error
145
+ raise CannotConnectError, error.message, error.backtrace
146
+ end
147
+
148
+ KEEP_ALIVE_INTERVAL = 15 # Same as hiredis defaults
149
+ KEEP_ALIVE_TTL = 120 # Longer than hiredis defaults
150
+ KEEP_ALIVE_PROBES = (KEEP_ALIVE_TTL / KEEP_ALIVE_INTERVAL) - 1
151
+ private_constant :KEEP_ALIVE_INTERVAL
152
+ private_constant :KEEP_ALIVE_TTL
153
+ private_constant :KEEP_ALIVE_PROBES
154
+
155
+ if %i[SOL_TCP SOL_SOCKET TCP_KEEPIDLE TCP_KEEPINTVL TCP_KEEPCNT].all? { |c| Socket.const_defined? c } # Linux
156
+ def enable_socket_keep_alive(socket)
157
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
158
+ socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, KEEP_ALIVE_INTERVAL)
159
+ socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, KEEP_ALIVE_INTERVAL)
160
+ socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, KEEP_ALIVE_PROBES)
161
+ end
162
+ elsif %i[IPPROTO_TCP TCP_KEEPINTVL TCP_KEEPCNT].all? { |c| Socket.const_defined? c } # macOS
163
+ def enable_socket_keep_alive(socket)
164
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
165
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_KEEPINTVL, KEEP_ALIVE_INTERVAL)
166
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_KEEPCNT, KEEP_ALIVE_PROBES)
167
+ end
168
+ elsif %i[SOL_SOCKET SO_KEEPALIVE].all? { |c| Socket.const_defined? c } # unknown POSIX
169
+ def enable_socket_keep_alive(socket)
170
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
171
+ end
172
+ else # unknown
173
+ def enable_socket_keep_alive(_socket)
174
+ end
175
+ end
132
176
  end
133
177
  end
@@ -13,9 +13,9 @@ class RedisClient
13
13
  end
14
14
 
15
15
  @to_list_of_hash = @to_hash = nil
16
- extra_config = {}
16
+ @extra_config = {}
17
17
  if client_config[:protocol] == 2
18
- extra_config[:protocol] = client_config[:protocol]
18
+ @extra_config[:protocol] = client_config[:protocol]
19
19
  @to_list_of_hash = lambda do |may_be_a_list|
20
20
  if may_be_a_list.is_a?(Array)
21
21
  may_be_a_list.map { |l| l.each_slice(2).to_h }
@@ -26,14 +26,7 @@ class RedisClient
26
26
  end
27
27
 
28
28
  @name = name
29
- @sentinel_configs = sentinels.map do |s|
30
- case s
31
- when String
32
- Config.new(**extra_config, url: s)
33
- else
34
- Config.new(**extra_config, **s)
35
- end
36
- end
29
+ @sentinel_configs = sentinels_to_configs(sentinels)
37
30
  @sentinels = {}.compare_by_identity
38
31
  @role = role
39
32
  @mutex = Mutex.new
@@ -93,6 +86,17 @@ class RedisClient
93
86
 
94
87
  private
95
88
 
89
+ def sentinels_to_configs(sentinels)
90
+ sentinels.map do |sentinel|
91
+ case sentinel
92
+ when String
93
+ Config.new(**@extra_config, url: sentinel)
94
+ else
95
+ Config.new(**@extra_config, **sentinel)
96
+ end
97
+ end
98
+ end
99
+
96
100
  def config
97
101
  @mutex.synchronize do
98
102
  @config ||= if @role == :master
@@ -106,9 +110,11 @@ class RedisClient
106
110
  def resolve_master
107
111
  each_sentinel do |sentinel_client|
108
112
  host, port = sentinel_client.call("SENTINEL", "get-master-addr-by-name", @name)
109
- if host && port
110
- return Config.new(host: host, port: Integer(port), **@client_config)
111
- end
113
+ next unless host && port
114
+
115
+ refresh_sentinels(sentinel_client)
116
+
117
+ return Config.new(host: host, port: Integer(port), **@client_config)
112
118
  end
113
119
  rescue ConnectionError
114
120
  raise ConnectionError, "No sentinels available"
@@ -159,5 +165,19 @@ class RedisClient
159
165
 
160
166
  raise last_error if last_error
161
167
  end
168
+
169
+ def refresh_sentinels(sentinel_client)
170
+ sentinel_response = sentinel_client.call("SENTINEL", "sentinels", @name, &@to_list_of_hash)
171
+ sentinels = sentinel_response.map do |sentinel|
172
+ { host: sentinel.fetch("ip"), port: Integer(sentinel.fetch("port")) }
173
+ end
174
+ new_sentinels = sentinels.select do |sentinel|
175
+ @sentinel_configs.none? do |sentinel_config|
176
+ sentinel_config.host == sentinel.fetch(:host) && sentinel_config.port == sentinel.fetch(:port)
177
+ end
178
+ end
179
+
180
+ @sentinel_configs.concat sentinels_to_configs(new_sentinels)
181
+ end
162
182
  end
163
183
  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.14.1"
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)
@@ -90,9 +91,17 @@ class RedisClient
90
91
  WriteTimeoutError = Class.new(TimeoutError)
91
92
  CheckoutTimeoutError = Class.new(TimeoutError)
92
93
 
93
- class CommandError < Error
94
+ module HasCommand
94
95
  attr_reader :command
95
96
 
97
+ def _set_command(command)
98
+ @command = command
99
+ end
100
+ end
101
+
102
+ class CommandError < Error
103
+ include HasCommand
104
+
96
105
  class << self
97
106
  def parse(error_message)
98
107
  code = if error_message.start_with?("ERR Error running script")
@@ -107,22 +116,24 @@ class RedisClient
107
116
  klass.new(error_message)
108
117
  end
109
118
  end
110
-
111
- def _set_command(command)
112
- @command = command
113
- end
114
119
  end
115
120
 
116
121
  AuthenticationError = Class.new(CommandError)
117
122
  PermissionError = Class.new(CommandError)
118
- ReadOnlyError = Class.new(CommandError)
119
123
  WrongTypeError = Class.new(CommandError)
120
124
  OutOfMemoryError = Class.new(CommandError)
121
125
 
126
+ ReadOnlyError = Class.new(ConnectionError)
127
+ ReadOnlyError.include(HasCommand)
128
+
129
+ MasterDownError = Class.new(ConnectionError)
130
+ MasterDownError.include(HasCommand)
131
+
122
132
  CommandError::ERRORS = {
123
133
  "WRONGPASS" => AuthenticationError,
124
134
  "NOPERM" => PermissionError,
125
135
  "READONLY" => ReadOnlyError,
136
+ "MASTERDOWN" => MasterDownError,
126
137
  "WRONGTYPE" => WrongTypeError,
127
138
  "OOM" => OutOfMemoryError,
128
139
  }.freeze
@@ -335,7 +346,6 @@ class RedisClient
335
346
 
336
347
  def close
337
348
  @raw_connection&.close
338
- @raw_connection = nil
339
349
  self
340
350
  end
341
351
 
@@ -419,7 +429,7 @@ class RedisClient
419
429
 
420
430
  def close
421
431
  @raw_connection&.close
422
- @raw_connection = nil
432
+ @raw_connection = nil # PubSub can't just reconnect
423
433
  self
424
434
  end
425
435
 
@@ -599,7 +609,7 @@ class RedisClient
599
609
  end
600
610
 
601
611
  def ensure_connected(retryable: true)
602
- close if !config.inherit_socket && @pid != Process.pid
612
+ close if !config.inherit_socket && @pid != PIDCache.pid
603
613
 
604
614
  if @disable_reconnection
605
615
  if block_given?
@@ -643,52 +653,58 @@ class RedisClient
643
653
  end
644
654
 
645
655
  def raw_connection
646
- @raw_connection = @raw_connection&.revalidate
647
- @raw_connection ||= connect
656
+ if @raw_connection.nil? || !@raw_connection.revalidate
657
+ connect
658
+ end
659
+ @raw_connection
648
660
  end
649
661
 
650
662
  def connect
651
- @pid = Process.pid
663
+ @pid = PIDCache.pid
652
664
 
653
- connection = @middlewares.connect(config) do
654
- config.driver.new(
655
- config,
656
- connect_timeout: connect_timeout,
657
- read_timeout: read_timeout,
658
- write_timeout: write_timeout,
659
- )
665
+ if @raw_connection
666
+ @middlewares.connect(config) do
667
+ @raw_connection.reconnect
668
+ end
669
+ else
670
+ @raw_connection = @middlewares.connect(config) do
671
+ config.driver.new(
672
+ config,
673
+ connect_timeout: connect_timeout,
674
+ read_timeout: read_timeout,
675
+ write_timeout: write_timeout,
676
+ )
677
+ end
660
678
  end
661
679
 
662
680
  prelude = config.connection_prelude.dup
663
681
 
664
682
  if id
665
- prelude << ["CLIENT", "SETNAME", id.to_s]
683
+ prelude << ["CLIENT", "SETNAME", id]
666
684
  end
667
685
 
668
686
  # The connection prelude is deliberately not sent to Middlewares
669
687
  if config.sentinel?
670
688
  prelude << ["ROLE"]
671
689
  role, = @middlewares.call_pipelined(prelude, config) do
672
- connection.call_pipelined(prelude, nil).last
690
+ @raw_connection.call_pipelined(prelude, nil).last
673
691
  end
674
692
  config.check_role!(role)
675
693
  else
676
694
  unless prelude.empty?
677
695
  @middlewares.call_pipelined(prelude, config) do
678
- connection.call_pipelined(prelude, nil)
696
+ @raw_connection.call_pipelined(prelude, nil)
679
697
  end
680
698
  end
681
699
  end
682
-
683
- connection
684
- rescue FailoverError
700
+ rescue FailoverError, CannotConnectError
685
701
  raise
686
702
  rescue ConnectionError => error
687
703
  raise CannotConnectError, error.message, error.backtrace
688
704
  rescue CommandError => error
689
- if error.message.include?("ERR unknown command `HELLO`")
705
+ if error.message.match?(/ERR unknown command ['`]HELLO['`]/)
690
706
  raise UnsupportedServer,
691
- "Your Redis server version is too old. redis-client requires Redis 6+. (#{config.server_url})"
707
+ "redis-client requires Redis 6+ with HELLO command available (#{config.server_url})"
692
708
  else
693
709
  raise
694
710
  end
@@ -696,5 +712,6 @@ class RedisClient
696
712
  end
697
713
 
698
714
  require "redis_client/pooled"
715
+ require "redis_client/circuit_breaker"
699
716
 
700
717
  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.14.1
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-03-30 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.6
78
80
  signing_key:
79
81
  specification_version: 4
80
82
  summary: Simple low-level client for Redis 6+