redis-client 0.27.0 → 0.29.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: 286c927e4067dd45e1fcee65daab6e11af6d3b4d79604e1fc0b2a7a930f75201
4
- data.tar.gz: 899fab7bb4ce71f7bbf68ce00f9a008816690ff0ea8302b92edc2333a478d9e3
3
+ metadata.gz: dc26284bc8ddd661e51d52ecf7afcf6d853b566128baa4176908eed4ed7e6ce2
4
+ data.tar.gz: 4051332c5b82220c88cb43efc0257e6dc61cba6b2c61ace4429d197591ecbd53
5
5
  SHA512:
6
- metadata.gz: 4c900ff2f7bea9662273bcb0666c78a44adb6c7b34fa1d7e10d5d56cac6e2f868de8811d769f42ea52a8b70304cacf650dea7aadcc31b3ee88a4eb2dfc6f28bc
7
- data.tar.gz: fa6093d8d2101a120164f6d86857343d84cc69c077290ce6b41b80626918165045190ae3a004e8defc1f61c5fc72c623d0acf121a3b2efe32a4d14fb71787f74
6
+ metadata.gz: 7f200a254286ae8c63dd708f4cc1d945b33a8ec96e5466de8b6fe966ddb2ebdca727cb532d817a05021eabdba9809eb2450a0ad31dfc24e39ae2e4473ca9a36a
7
+ data.tar.gz: 192f624ea10bf819f8d4f98d980861b945cbe5fad8a079dbb0ed247d031b70646eb16cf9a34eb9f0b7b80abb7eee191a3b006bc46a7b0ac8d7b9a9924ee00d1a
data/CHANGELOG.md CHANGED
@@ -1,8 +1,17 @@
1
1
  # Unreleased
2
2
 
3
+ # 0.29.0
4
+
5
+ - Fix connecting to Redis 7.1 and older with `driver_info:`set.
6
+ - Added `RedisClient::Error#next_error` for an easier way to check all errors produced by a pipeline.
7
+
8
+ # 0.28.0
9
+
10
+ - Added `RedisClient::HashRing` for horizontal sharing (compatible with `Redis::Distributed` from `redis-rb`).
11
+
3
12
  # 0.27.0
4
13
 
5
- - Added `idle_timeout` to revalidate connections that haven't been successfuly used in a long time. Defaults to 60 seconds.
14
+ - Added `idle_timeout` to revalidate connections that haven't been successfuly used in a long time. Defaults to 30 seconds.
6
15
  - Added `driver_info` configuration, to issue `CLIENT SETINFO` during connection prelude.
7
16
 
8
17
  # 0.26.4
data/README.md CHANGED
@@ -83,7 +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 `60` seconds.
86
+ - `idle_timeout`: Amount of time after which an idle connection has to be revalidated with a PING command. Defaults to `30` seconds.
87
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.
88
88
  - `circuit_breaker`: A Hash with circuit breaker configuration. Defaults to `nil`. See the [circuit breaker section](#circuit-breaker) for details.
89
89
  - `protocol:` The version of the RESP protocol to use. Default to `3`.
@@ -144,6 +144,7 @@ redis_config = RedisClient.sentinel(name: 'mymaster', sentinel_username: 'appuse
144
144
  If you specify a username and/or password at the top level for your main Redis instance, Sentinel *will not* using thouse credentials
145
145
 
146
146
  ```ruby
147
+
147
148
  # Use 'mysecret' to authenticate against the mymaster instance, but skip authentication for the sentinels:
148
149
  SENTINELS = [{ host: '127.0.0.1', port: 26380 },
149
150
  { host: '127.0.0.1', port: 26381 }]
@@ -167,6 +168,30 @@ Also the `name`, `password`, `username` and `db` for Redis instance can be passe
167
168
  redis_config = RedisClient.sentinel(url: "redis://appuser:mysecret@mymaster/10", sentinels: SENTINELS, role: :master)
168
169
  ```
169
170
 
171
+ ### Consistent Hashing
172
+
173
+ To horizontally shard keys across multiple servers without relying on clustering, a `RedisClient::HashRing` class is provided:
174
+
175
+ ```ruby
176
+ ring = RedisClient.ring(
177
+ RedisClient.config(host: "10.0.1.1", port: 6380).new_pool(timeout: 0.5, size: 3),
178
+ RedisClient.config(host: "10.0.1.2", port: 6380).new_pool(timeout: 0.5, size: 3),
179
+ RedisClient.config(host: "10.0.1.3", port: 6380).new_pool(timeout: 0.5, size: 3),
180
+ )
181
+
182
+ ring.node_for("cache_key").call("GET", "cache_key") # => "value"
183
+
184
+ ring.nodes_for("key1", "key2", "key3").each do |node, keys|
185
+ node.call("DEL", *keys)
186
+ end
187
+
188
+ ring.nodes.each do |node|
189
+ node.close
190
+ end
191
+ ```
192
+
193
+ 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.
194
+
170
195
  ### Type support
171
196
 
172
197
  Only a select few Ruby types are supported as arguments beside strings.
@@ -336,7 +361,11 @@ end
336
361
 
337
362
  #### Exception management
338
363
 
339
- The `exception` flag in the `#pipelined` method of `RedisClient` is a feature that modifies the pipeline execution
364
+ By default, when a pipeline produce multiple command errors, only the first encountered error is raised.
365
+
366
+ However, the raised exception have a `.next_error` method you can use like a linked list to check all encountered errors.
367
+
368
+ Alternatively, the `exception` flag in the `#pipelined` method of `RedisClient` is a feature that modifies the pipeline execution
340
369
  behavior. When set to `false`, it doesn't raise an exception when a command error occurs. Instead, it allows the
341
370
  pipeline to execute all commands, and any failed command will be available in the returned array. (Defaults to `true`)
342
371
 
@@ -47,7 +47,7 @@ class RedisClient
47
47
  end
48
48
 
49
49
  def call_pipelined(commands, timeouts, exception: true)
50
- first_exception = nil
50
+ first_error = last_error = nil
51
51
 
52
52
  size = commands.size
53
53
  results = Array.new(commands.size)
@@ -69,14 +69,16 @@ class RedisClient
69
69
  result._set_command(commands[index])
70
70
  result._set_config(config)
71
71
  result._set_retry_attempt(@retry_attempt)
72
- first_exception ||= result
72
+
73
+ last_error&._set_next_error(result)
74
+ first_error ||= last_error = result
73
75
  end
74
76
 
75
77
  results[index] = result
76
78
  end
77
79
 
78
- if first_exception && exception
79
- raise first_exception
80
+ if first_error && exception
81
+ raise first_error
80
82
  else
81
83
  results
82
84
  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
@@ -116,6 +116,10 @@ class RedisClient
116
116
  end
117
117
 
118
118
  def check_role!(role)
119
+ unless role.is_a?(String)
120
+ raise TypeError, "Expected role to be a string, got: #{role.inspect}"
121
+ end
122
+
119
123
  if @role == :master
120
124
  unless role == "master"
121
125
  sleep SENTINEL_DELAY
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class RedisClient
4
- VERSION = "0.27.0"
4
+ VERSION = "0.29.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
@@ -138,11 +148,17 @@ class RedisClient
138
148
  include HasConfig
139
149
  include Retriable
140
150
 
151
+ attr_reader :next_error
152
+
141
153
  def self.with_config(message, config = nil)
142
154
  error = new(message)
143
155
  error._set_config(config)
144
156
  error
145
157
  end
158
+
159
+ def _set_next_error(error) # :nodoc:
160
+ @next_error = error
161
+ end
146
162
  end
147
163
 
148
164
  ProtocolError = Class.new(Error)
@@ -229,6 +245,12 @@ class RedisClient
229
245
  SentinelConfig.new(client_implementation: self, **kwargs)
230
246
  end
231
247
 
248
+ def ring(*clients, **options)
249
+ clients.flatten!
250
+ require "redis_client/hash_ring" unless defined?(HashRing)
251
+ HashRing.new(clients, **options)
252
+ end
253
+
232
254
  def new(arg = nil, **kwargs)
233
255
  if arg.is_a?(Config::Common)
234
256
  super
@@ -847,19 +869,26 @@ class RedisClient
847
869
  prelude << ["CLIENT", "SETNAME", id]
848
870
  end
849
871
 
850
- # The connection prelude is deliberately not sent to Middlewares
851
872
  if config.sentinel?
852
873
  prelude << ["ROLE"]
853
- role, = @middlewares.call_pipelined(prelude, config) do
854
- @raw_connection.call_pipelined(prelude, nil).last
874
+ end
875
+
876
+ unless prelude.empty?
877
+ results = @middlewares.call_pipelined(prelude, config) do
878
+ @raw_connection.call_pipelined(prelude, nil, exception: false)
855
879
  end
856
- config.check_role!(role)
857
- else
858
- unless prelude.empty?
859
- @middlewares.call_pipelined(prelude, config) do
860
- @raw_connection.call_pipelined(prelude, nil)
880
+
881
+ results.each do |result|
882
+ # CLIENT SETINFO is unsupported on Redis < 7.2. The pipeline drained all responses before raising,
883
+ # so if that's the only error, the socket is healthy: keep it.
884
+ if result.is_a?(CommandError) && !(result.command[0] == "CLIENT" && result.command[1] == "SETINFO")
885
+ raise result
861
886
  end
862
887
  end
888
+
889
+ if config.sentinel?
890
+ config.check_role!(results.last.first)
891
+ end
863
892
  end
864
893
  rescue FailoverError, CannotConnectError => error
865
894
  @raw_connection&.close
@@ -872,12 +901,10 @@ class RedisClient
872
901
  raise connect_error
873
902
  rescue CommandError => error
874
903
  @raw_connection&.close
875
- if error.message.match?(/ERR unknown command ['`]HELLO['`]/)
904
+
905
+ if error.command&.first == "HELLO" && error.message.match?(/ERR unknown command/)
876
906
  raise UnsupportedServer,
877
- "redis-client requires Redis 6+ with HELLO command available (#{config.server_url})"
878
- # Ignore CLIENT SETINFO errors (Redis < 7.2 doesn't support it)
879
- elsif error.message.match?(/unknown subcommand.*setinfo/i)
880
- # Silently ignore - CLIENT SETINFO is not supported on Redis < 7.2
907
+ "redis-client requires Redis 6+ with HELLO command available (#{config.server_url})", cause: error
881
908
  else
882
909
  raise
883
910
  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.27.0
4
+ version: 0.29.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: 4.0.3
74
+ rubygems_version: 4.0.6
74
75
  specification_version: 4
75
76
  summary: Simple low-level client for Redis 6+
76
77
  test_files: []