redis-client 0.26.2 → 0.28.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: f75c9f8b8e4542079642b30d284bd0fbda23d27249e7846bc65c0b002ce61732
4
- data.tar.gz: cdc1d818e27551467b14e53692b5e61da762d1f21de78be00d6650830fdb9d13
3
+ metadata.gz: 2fc78ecaeaed568c9917391585a7a185c90f8d7d569b8443c0fb2f0e918c2ca6
4
+ data.tar.gz: f9c7cbcb9c39267853681e926a71c27c3619bf0683fa5cd86885c585f2dfa239
5
5
  SHA512:
6
- metadata.gz: 15d23f1645fc7bc86c3f58ff0bfebcce62a3aec4783fd5434123277c0445a8d7553fea6f7fc89a36c23c433d7fcd5716b88a79d5131d3a3452d58707e61b965d
7
- data.tar.gz: 7447f4f33e056072445d5d0dad650f3e648af69f1a0690505cb6913d525c32b29f5954349e0ca4852b3bd8d47434ada0e8e1ffde09f8966a664cff08acc5a443
6
+ metadata.gz: 3738437f5876f4f8c09f82a19e4e75462779dae006ed37ac06d3d0ca997bb0190fc676b705e5f6186a5854caff4d98dddc8b035c0fbaa854f9d77b179cb01c94
7
+ data.tar.gz: 4f9788a5547647cee34ddf01f60400e9645ca2c73f5329155b58670019733ec945331674f94b5a7720754ea2dd12b85d62040060166145beaea9b462514e6500
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Unreleased
2
2
 
3
+ # 0.28.0
4
+
5
+ - Added `RedisClient::HashRing` for horizontal sharing (compatible with `Redis::Distributed` from `redis-rb`).
6
+
7
+ # 0.27.0
8
+
9
+ - Added `idle_timeout` to revalidate connections that haven't been successfuly used in a long time. Defaults to 30 seconds.
10
+ - Added `driver_info` configuration, to issue `CLIENT SETINFO` during connection prelude.
11
+
12
+ # 0.26.4
13
+
14
+ - Further improve `rediss://` URLs used with Redis sentinel. Now avoid override explictly set `ssl:` parameter.
15
+ - Fix compatibility with `redis-rb` in sentinel mode.
16
+
17
+ # 0.26.3
18
+
19
+ - Fix `rediss://` (ssl) URLs used with Redis sentinel.
20
+ - Handle Ruby 4.0 connection timeout raising an `IO::Timeout` instead of `Errno::ETIMEDOUT`.
21
+ - Entirely close the connection on authentication failures.
22
+
3
23
  # 0.26.2
4
24
 
5
25
  - Fix compatibility with `connection_pool` version 3+.
data/README.md CHANGED
@@ -83,6 +83,7 @@ redis.call("GET", "mykey")
83
83
  - `connect_timeout`: The connection timeout, takes precedence over the general timeout when connecting to the server.
84
84
  - `read_timeout`: The read timeout, takes precedence over the general timeout when reading responses from the server.
85
85
  - `write_timeout`: The write timeout, takes precedence over the general timeout when sending commands to the server.
86
+ - `idle_timeout`: Amount of time after which an idle connection has to be revalidated with a PING command. Defaults to `30` seconds.
86
87
  - `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.
87
88
  - `circuit_breaker`: A Hash with circuit breaker configuration. Defaults to `nil`. See the [circuit breaker section](#circuit-breaker) for details.
88
89
  - `protocol:` The version of the RESP protocol to use. Default to `3`.
@@ -143,6 +144,31 @@ redis_config = RedisClient.sentinel(name: 'mymaster', sentinel_username: 'appuse
143
144
  If you specify a username and/or password at the top level for your main Redis instance, Sentinel *will not* using thouse credentials
144
145
 
145
146
  ```ruby
147
+
148
+ ### Consistent Hashing
149
+
150
+ To horizontally shard keys across multiple servers without relying on clustering, a `RedisClient::HashRing` class is provided:
151
+
152
+ ```ruby
153
+ ring = RedisClient.ring(
154
+ RedisClient.config(host: "10.0.1.1", port: 6380).new_pool(timeout: 0.5, size: 3),
155
+ RedisClient.config(host: "10.0.1.2", port: 6380).new_pool(timeout: 0.5, size: 3),
156
+ RedisClient.config(host: "10.0.1.3", port: 6380).new_pool(timeout: 0.5, size: 3),
157
+ )
158
+
159
+ ring.node_for("cache_key").call("GET", "cache_key") # => "value"
160
+
161
+ ring.nodes_for("key1", "key2", "key3").each do |node, keys|
162
+ node.call("DEL", *keys)
163
+ end
164
+
165
+ ring.nodes.each do |node|
166
+ node.close
167
+ end
168
+ ```
169
+
170
+ Note that regular clients do respond to `node_for`, `nodes_for` and `nodes`, so that code that support `RedisClient.ring` is also usable with a single server.
171
+
146
172
  # Use 'mysecret' to authenticate against the mymaster instance, but skip authentication for the sentinels:
147
173
  SENTINELS = [{ host: '127.0.0.1', port: 26380 },
148
174
  { host: '127.0.0.1', port: 26381 }]
@@ -10,11 +10,12 @@ class RedisClient
10
10
  DEFAULT_PORT = 6379
11
11
  DEFAULT_USERNAME = "default"
12
12
  DEFAULT_DB = 0
13
+ DEFAULT_IDLE_TIMEOUT = 30.0
13
14
 
14
15
  module Common
15
16
  attr_reader :db, :id, :ssl, :ssl_params, :command_builder, :inherit_socket,
16
17
  :connect_timeout, :read_timeout, :write_timeout, :driver, :protocol,
17
- :middlewares_stack, :custom, :circuit_breaker
18
+ :middlewares_stack, :custom, :circuit_breaker, :driver_info, :idle_timeout
18
19
 
19
20
  alias_method :ssl?, :ssl
20
21
 
@@ -27,6 +28,7 @@ class RedisClient
27
28
  read_timeout: timeout,
28
29
  write_timeout: timeout,
29
30
  connect_timeout: timeout,
31
+ idle_timeout: DEFAULT_IDLE_TIMEOUT,
30
32
  ssl: nil,
31
33
  custom: {},
32
34
  ssl_params: nil,
@@ -37,7 +39,8 @@ class RedisClient
37
39
  inherit_socket: false,
38
40
  reconnect_attempts: false,
39
41
  middlewares: false,
40
- circuit_breaker: nil
42
+ circuit_breaker: nil,
43
+ driver_info: nil
41
44
  )
42
45
  @username = username
43
46
  @password = password && !password.respond_to?(:call) ? ->(_) { password } : password
@@ -54,6 +57,7 @@ class RedisClient
54
57
  @connect_timeout = connect_timeout
55
58
  @read_timeout = read_timeout
56
59
  @write_timeout = write_timeout
60
+ @idle_timeout = idle_timeout
57
61
 
58
62
  @driver = driver ? RedisClient.driver(driver) : RedisClient.default_driver
59
63
 
@@ -84,6 +88,8 @@ class RedisClient
84
88
  end
85
89
  end
86
90
  @middlewares_stack = middlewares_stack
91
+
92
+ @driver_info = driver_info
87
93
  end
88
94
 
89
95
  def connection_prelude
@@ -107,6 +113,13 @@ class RedisClient
107
113
  prelude << ["SELECT", @db.to_s]
108
114
  end
109
115
 
116
+ # Add CLIENT SETINFO commands for lib-name and lib-ver
117
+ # These commands are supported in Redis 7.2+
118
+ if @driver_info
119
+ prelude << ["CLIENT", "SETINFO", "LIB-NAME", build_lib_name]
120
+ prelude << ["CLIENT", "SETINFO", "LIB-VER", RedisClient::VERSION]
121
+ end
122
+
110
123
  # Deep freeze all the strings and commands
111
124
  prelude.map! do |commands|
112
125
  commands = commands.map { |str| str.frozen? ? str : str.dup.freeze }
@@ -123,6 +136,25 @@ class RedisClient
123
136
  @username || DEFAULT_USERNAME
124
137
  end
125
138
 
139
+ # Build the library name for CLIENT SETINFO LIB-NAME.
140
+ #
141
+ # @param driver_info [String, Array<String>, nil] Upstream driver info
142
+ # @return [String] Library name with optional upstream driver info
143
+ # @raise [ArgumentError] if driver_info is not a String or Array
144
+ def build_lib_name
145
+ return "redis-client" if @driver_info.nil?
146
+
147
+ info = case @driver_info
148
+ when String then @driver_info
149
+ when Array then @driver_info.join(";")
150
+ else raise ArgumentError, "driver_info must be a String or Array of Strings"
151
+ end
152
+
153
+ return "redis-client" if info.empty?
154
+
155
+ "redis-client(#{info})"
156
+ end
157
+
126
158
  def resolved?
127
159
  true
128
160
  end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ class RedisClient
6
+ class HashRing
7
+ POINTS_PER_SERVER = 160
8
+
9
+ class << self
10
+ attr_writer :digest
11
+
12
+ def digest
13
+ @digest ||= begin
14
+ require 'digest/md5'
15
+ Digest::MD5
16
+ end
17
+ end
18
+ end
19
+
20
+ attr_reader :nodes
21
+
22
+ def initialize(nodes = [], replicas: POINTS_PER_SERVER, digest: self.class.digest)
23
+ @replicas = replicas
24
+ @ring = {}
25
+ @digest = digest
26
+ ids = {}
27
+ @nodes = nodes.dup.freeze
28
+ nodes.each do |node|
29
+ id = node.id || node.config.server_url
30
+ if ids[id]
31
+ raise ArgumentError, "duplicate node id: #{id.inspect}"
32
+ end
33
+
34
+ ids[id] = true
35
+
36
+ replicas.times do |i|
37
+ @ring[server_hash_for("#{id}:#{i}".freeze)] = node
38
+ end
39
+ end
40
+ @sorted_keys = @ring.keys
41
+ @sorted_keys.sort!
42
+ end
43
+
44
+ # get the node in the hash ring for this key
45
+ def node_for(key)
46
+ hash = hash_for(key)
47
+ idx = binary_search(@sorted_keys, hash)
48
+ @ring[@sorted_keys[idx]]
49
+ end
50
+
51
+ def nodes_for(*keys)
52
+ keys.flatten!
53
+ mapping = {}
54
+ keys.each do |key|
55
+ (mapping[node_for(key)] ||= []) << key
56
+ end
57
+ mapping
58
+ end
59
+
60
+ private
61
+
62
+ def hash_for(key)
63
+ Zlib.crc32(key)
64
+ end
65
+
66
+ def server_hash_for(key)
67
+ @digest.digest(key).unpack1("L>")
68
+ end
69
+
70
+ # Find the closest index in HashRing with value <= the given value
71
+ def binary_search(ary, value)
72
+ upper = ary.size
73
+ lower = 0
74
+
75
+ while lower < upper
76
+ mid = (lower + upper) / 2
77
+ if ary[mid] > value
78
+ upper = mid
79
+ else
80
+ lower = mid + 1
81
+ end
82
+ end
83
+
84
+ upper - 1
85
+ end
86
+ end
87
+ end
@@ -156,7 +156,7 @@ class RedisClient
156
156
  write_timeout: @write_timeout,
157
157
  )
158
158
  true
159
- rescue SystemCallError, OpenSSL::SSL::SSLError, SocketError => error
159
+ rescue SystemCallError, IOError, OpenSSL::SSL::SSLError, SocketError => error
160
160
  socket&.close
161
161
  raise CannotConnectError, error.message, error.backtrace
162
162
  end
@@ -22,12 +22,16 @@ class RedisClient
22
22
  raise ArgumentError, "Expected role to be either :master or :replica, got: #{role.inspect}"
23
23
  end
24
24
 
25
+ # Track whether SSL was explicitly provided by user
26
+ ssl_explicitly_set = client_config.key?(:ssl)
27
+
25
28
  if url
26
29
  url_config = URLConfig.new(url)
27
30
  client_config = {
28
31
  username: url_config.username,
29
32
  password: url_config.password,
30
33
  db: url_config.db,
34
+ ssl: url_config.ssl?,
31
35
  }.compact.merge(client_config)
32
36
  name ||= url_config.host
33
37
  end
@@ -66,6 +70,10 @@ class RedisClient
66
70
 
67
71
  client_config[:reconnect_attempts] ||= DEFAULT_RECONNECT_ATTEMPTS
68
72
  @client_config = client_config || {}
73
+ @sentinel_client_config = @client_config.dup
74
+ # Only remove SSL from sentinel config if it was derived from URL,
75
+ # not if explicitly set by user.
76
+ @sentinel_client_config.delete(:ssl) unless ssl_explicitly_set
69
77
  super(**client_config)
70
78
  @sentinel_configs = sentinels_to_configs(sentinels)
71
79
  end
@@ -133,9 +141,9 @@ class RedisClient
133
141
  sentinels.map do |sentinel|
134
142
  case sentinel
135
143
  when String
136
- Config.new(**@client_config, **@extra_config, url: sentinel)
144
+ Config.new(**@sentinel_client_config, **@extra_config, url: sentinel)
137
145
  else
138
- Config.new(**@client_config, **@extra_config, **sentinel)
146
+ Config.new(**@sentinel_client_config, **@extra_config, **sentinel)
139
147
  end
140
148
  end
141
149
  end
@@ -152,7 +160,7 @@ class RedisClient
152
160
 
153
161
  def resolve_master
154
162
  each_sentinel do |sentinel_client|
155
- host, port = sentinel_client.call("SENTINEL", "get-master-addr-by-name", @name)
163
+ host, port = sentinel_client.call_v(["SENTINEL", "get-master-addr-by-name", @name])
156
164
  next unless host && port
157
165
 
158
166
  refresh_sentinels(sentinel_client)
@@ -171,7 +179,7 @@ class RedisClient
171
179
 
172
180
  def resolve_replica
173
181
  each_sentinel do |sentinel_client|
174
- replicas = sentinel_client.call("SENTINEL", "replicas", @name, &@to_list_of_hash)
182
+ replicas = sentinel_client.call_v(["SENTINEL", "replicas", @name], &@to_list_of_hash)
175
183
  replicas.reject! do |r|
176
184
  flags = r["flags"].to_s.split(",")
177
185
  flags.include?("s_down") || flags.include?("o_down")
@@ -214,7 +222,7 @@ class RedisClient
214
222
  end
215
223
 
216
224
  def refresh_sentinels(sentinel_client)
217
- sentinel_response = sentinel_client.call("SENTINEL", "sentinels", @name, &@to_list_of_hash)
225
+ sentinel_response = sentinel_client.call_v(["SENTINEL", "sentinels", @name], &@to_list_of_hash)
218
226
  sentinels = sentinel_response.map do |sentinel|
219
227
  { host: sentinel.fetch("ip"), port: Integer(sentinel.fetch("port")) }
220
228
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class RedisClient
4
- VERSION = "0.26.2"
4
+ VERSION = "0.28.0"
5
5
  end
data/lib/redis_client.rb CHANGED
@@ -61,7 +61,7 @@ class RedisClient
61
61
  end
62
62
 
63
63
  module Common
64
- attr_reader :config, :id
64
+ attr_reader :config, :id, :nodes
65
65
  attr_accessor :connect_timeout, :read_timeout, :write_timeout
66
66
 
67
67
  def initialize(
@@ -78,11 +78,21 @@ class RedisClient
78
78
  @write_timeout = write_timeout
79
79
  @command_builder = config.command_builder
80
80
  @pid = PIDCache.pid
81
+ @nodes = [self].freeze
81
82
  end
82
83
 
83
84
  def timeout=(timeout)
84
85
  @connect_timeout = @read_timeout = @write_timeout = timeout
85
86
  end
87
+
88
+ def node_for(_key)
89
+ self
90
+ end
91
+
92
+ def nodes_for(*keys)
93
+ keys.flatten!
94
+ { self => keys }
95
+ end
86
96
  end
87
97
 
88
98
  module HasConfig
@@ -229,6 +239,12 @@ class RedisClient
229
239
  SentinelConfig.new(client_implementation: self, **kwargs)
230
240
  end
231
241
 
242
+ def ring(*clients, **options)
243
+ clients.flatten!
244
+ require "redis_client/hash_ring" unless defined?(HashRing)
245
+ HashRing.new(clients, **options)
246
+ end
247
+
232
248
  def new(arg = nil, **kwargs)
233
249
  if arg.is_a?(Config::Common)
234
250
  super
@@ -269,6 +285,10 @@ class RedisClient
269
285
  config.read_timeout
270
286
  end
271
287
 
288
+ def idle_timeout
289
+ config.idle_timeout
290
+ end
291
+
272
292
  def db
273
293
  config.db
274
294
  end
@@ -758,7 +778,9 @@ class RedisClient
758
778
  @retry_attempt = config.retriable?(tries) ? tries : nil
759
779
  connection = raw_connection
760
780
  if block_given?
761
- yield connection
781
+ result = yield connection
782
+ @last_used_at = RedisClient.now
783
+ result
762
784
  else
763
785
  connection
764
786
  end
@@ -785,7 +807,9 @@ class RedisClient
785
807
  begin
786
808
  @disable_reconnection = true
787
809
  @raw_connection.retry_attempt = nil
788
- yield connection
810
+ result = yield connection
811
+ @last_used_at = RedisClient.now
812
+ result
789
813
  rescue ConnectionError, ProtocolError
790
814
  close
791
815
  raise
@@ -799,6 +823,16 @@ class RedisClient
799
823
  if @raw_connection.nil? || !@raw_connection.revalidate
800
824
  connect
801
825
  end
826
+
827
+ if config.idle_timeout && (@last_used_at + config.idle_timeout) < RedisClient.now
828
+ @middlewares.call(["PING"], config) do
829
+ @raw_connection.call(["PING"], nil)
830
+ rescue ConnectionError, ProtocolError
831
+ close
832
+ connect
833
+ end
834
+ end
835
+
802
836
  @raw_connection.retry_attempt = @retry_attempt
803
837
  @raw_connection
804
838
  end
@@ -820,6 +854,7 @@ class RedisClient
820
854
  )
821
855
  end
822
856
  end
857
+ @last_used_at = RedisClient.now
823
858
  @raw_connection.retry_attempt = @retry_attempt
824
859
 
825
860
  prelude = config.connection_prelude.dup
@@ -843,16 +878,22 @@ class RedisClient
843
878
  end
844
879
  end
845
880
  rescue FailoverError, CannotConnectError => error
881
+ @raw_connection&.close
846
882
  error._set_config(config)
847
883
  raise error
848
884
  rescue ConnectionError => error
885
+ @raw_connection&.close
849
886
  connect_error = CannotConnectError.with_config(error.message, config)
850
887
  connect_error.set_backtrace(error.backtrace)
851
888
  raise connect_error
852
889
  rescue CommandError => error
890
+ @raw_connection&.close
853
891
  if error.message.match?(/ERR unknown command ['`]HELLO['`]/)
854
892
  raise UnsupportedServer,
855
893
  "redis-client requires Redis 6+ with HELLO command available (#{config.server_url})"
894
+ # Ignore CLIENT SETINFO errors (Redis < 7.2 doesn't support it)
895
+ elsif error.message.match?(/unknown subcommand.*setinfo/i)
896
+ # Silently ignore - CLIENT SETINFO is not supported on Redis < 7.2
856
897
  else
857
898
  raise
858
899
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redis-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.26.2
4
+ version: 0.28.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jean Boussier
@@ -39,6 +39,7 @@ files:
39
39
  - lib/redis_client/config.rb
40
40
  - lib/redis_client/connection_mixin.rb
41
41
  - lib/redis_client/decorator.rb
42
+ - lib/redis_client/hash_ring.rb
42
43
  - lib/redis_client/middlewares.rb
43
44
  - lib/redis_client/pid_cache.rb
44
45
  - lib/redis_client/pooled.rb
@@ -70,7 +71,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
70
71
  - !ruby/object:Gem::Version
71
72
  version: '0'
72
73
  requirements: []
73
- rubygems_version: 3.6.9
74
+ rubygems_version: 4.0.3
74
75
  specification_version: 4
75
76
  summary: Simple low-level client for Redis 6+
76
77
  test_files: []