redis-client 0.11.1 → 0.14.1

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