redis-cluster-client 0.7.5 → 0.8.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.
@@ -22,10 +22,10 @@ class RedisClient
22
22
  SLOT_SIZE = 16_384
23
23
  MIN_SLOT = 0
24
24
  MAX_SLOT = SLOT_SIZE - 1
25
- IGNORE_GENERIC_CONFIG_KEYS = %i[url host port path].freeze
26
25
  DEAD_FLAGS = %w[fail? fail handshake noaddr noflags].freeze
27
26
  ROLE_FLAGS = %w[master slave].freeze
28
27
  EMPTY_ARRAY = [].freeze
28
+ EMPTY_HASH = {}.freeze
29
29
 
30
30
  ReloadNeeded = Class.new(::RedisClient::Error)
31
31
 
@@ -92,119 +92,19 @@ class RedisClient
92
92
  end
93
93
  end
94
94
 
95
- class << self
96
- def load_info(options, concurrent_worker, slow_command_timeout: -1, **kwargs) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
97
- raise ::RedisClient::Cluster::InitialSetupError, [] if options.nil? || options.empty?
98
-
99
- startup_size = options.size > MAX_STARTUP_SAMPLE ? MAX_STARTUP_SAMPLE : options.size
100
- startup_options = options.to_a.sample(startup_size).to_h
101
- startup_nodes = ::RedisClient::Cluster::Node.new(startup_options, concurrent_worker, **kwargs)
102
- work_group = concurrent_worker.new_group(size: startup_size)
103
-
104
- startup_nodes.each_with_index do |raw_client, i|
105
- work_group.push(i, raw_client) do |client|
106
- regular_timeout = client.read_timeout
107
- client.read_timeout = slow_command_timeout > 0.0 ? slow_command_timeout : regular_timeout
108
- reply = client.call('CLUSTER', 'NODES')
109
- client.read_timeout = regular_timeout
110
- parse_cluster_node_reply(reply)
111
- rescue StandardError => e
112
- e
113
- ensure
114
- client&.close
115
- end
116
- end
117
-
118
- node_info_list = errors = nil
119
-
120
- work_group.each do |i, v|
121
- case v
122
- when StandardError
123
- errors ||= Array.new(startup_size)
124
- errors[i] = v
125
- else
126
- node_info_list ||= Array.new(startup_size)
127
- node_info_list[i] = v
128
- end
129
- end
130
-
131
- work_group.close
132
-
133
- raise ::RedisClient::Cluster::InitialSetupError, errors if node_info_list.nil?
134
-
135
- grouped = node_info_list.compact.group_by do |info_list|
136
- info_list.sort_by!(&:id)
137
- info_list.each_with_object(String.new(capacity: 128 * info_list.size)) do |e, a|
138
- a << e.id << e.node_key << e.role << e.primary_id << e.config_epoch
139
- end
140
- end
141
-
142
- grouped.max_by { |_, v| v.size }[1].first.freeze
143
- end
144
-
145
- private
146
-
147
- # @see https://redis.io/commands/cluster-nodes/
148
- # @see https://github.com/redis/redis/blob/78960ad57b8a5e6af743d789ed8fd767e37d42b8/src/cluster.c#L4660-L4683
149
- def parse_cluster_node_reply(reply) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
150
- reply.each_line("\n", chomp: true).filter_map do |line|
151
- fields = line.split
152
- flags = fields[2].split(',')
153
- next unless fields[7] == 'connected' && (flags & DEAD_FLAGS).empty?
154
-
155
- slots = if fields[8].nil?
156
- EMPTY_ARRAY
157
- else
158
- fields[8..].reject { |str| str.start_with?('[') }
159
- .map { |str| str.split('-').map { |s| Integer(s) } }
160
- .map { |a| a.size == 1 ? a << a.first : a }
161
- .map(&:sort)
162
- end
163
-
164
- ::RedisClient::Cluster::Node::Info.new(
165
- id: fields[0],
166
- node_key: parse_node_key(fields[1]),
167
- role: (flags & ROLE_FLAGS).first,
168
- primary_id: fields[3],
169
- ping_sent: fields[4],
170
- pong_recv: fields[5],
171
- config_epoch: fields[6],
172
- link_state: fields[7],
173
- slots: slots
174
- )
175
- end
176
- end
177
-
178
- # As redirection node_key is dependent on `cluster-preferred-endpoint-type` config,
179
- # node_key should use hostname if present in CLUSTER NODES output.
180
- #
181
- # See https://redis.io/commands/cluster-nodes/ for details on the output format.
182
- # node_address matches fhe format: <ip:port@cport[,hostname[,auxiliary_field=value]*]>
183
- def parse_node_key(node_address)
184
- ip_chunk, hostname, _auxiliaries = node_address.split(',')
185
- ip_port_string = ip_chunk.split('@').first
186
- return ip_port_string if hostname.nil? || hostname.empty?
187
-
188
- port = ip_port_string.split(':')[1]
189
- "#{hostname}:#{port}"
190
- end
191
- end
192
-
193
95
  def initialize(
194
- options,
195
96
  concurrent_worker,
196
- node_info_list: [],
197
- with_replica: false,
198
- replica_affinity: :random,
97
+ config:,
199
98
  pool: nil,
200
99
  **kwargs
201
100
  )
202
101
 
203
102
  @concurrent_worker = concurrent_worker
204
- @slots = build_slot_node_mappings(node_info_list)
205
- @replications = build_replication_mappings(node_info_list)
206
- klass = make_topology_class(with_replica, replica_affinity)
207
- @topology = klass.new(@replications, options, pool, @concurrent_worker, **kwargs)
103
+ @slots = build_slot_node_mappings(EMPTY_ARRAY)
104
+ @replications = build_replication_mappings(EMPTY_ARRAY)
105
+ klass = make_topology_class(config.use_replica?, config.replica_affinity)
106
+ @topology = klass.new(pool, @concurrent_worker, **kwargs)
107
+ @config = config
208
108
  @mutex = Mutex.new
209
109
  end
210
110
 
@@ -255,6 +155,14 @@ class RedisClient
255
155
  @topology.clients_for_scanning(seed: seed).values.sort_by { |c| "#{c.config.host}-#{c.config.port}" }
256
156
  end
257
157
 
158
+ def clients
159
+ @topology.clients.values
160
+ end
161
+
162
+ def primary_clients
163
+ @topology.primary_clients.values
164
+ end
165
+
258
166
  def replica_clients
259
167
  @topology.replica_clients.values
260
168
  end
@@ -292,6 +200,20 @@ class RedisClient
292
200
  end
293
201
  end
294
202
 
203
+ def reload!
204
+ with_reload_lock do
205
+ with_startup_clients(MAX_STARTUP_SAMPLE) do |startup_clients|
206
+ @node_info = refetch_node_info_list(startup_clients)
207
+ @node_configs = @node_info.to_h do |node_info|
208
+ [node_info.node_key, @config.client_config_for_node(node_info.node_key)]
209
+ end
210
+ @slots = build_slot_node_mappings(@node_info)
211
+ @replications = build_replication_mappings(@node_info)
212
+ @topology.process_topology_update!(@replications, @node_configs)
213
+ end
214
+ end
215
+ end
216
+
295
217
  private
296
218
 
297
219
  def make_topology_class(with_replica, replica_affinity)
@@ -378,6 +300,137 @@ class RedisClient
378
300
 
379
301
  [results, errors]
380
302
  end
303
+
304
+ def refetch_node_info_list(startup_clients) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
305
+ startup_size = startup_clients.size
306
+ work_group = @concurrent_worker.new_group(size: startup_size)
307
+
308
+ startup_clients.each_with_index do |raw_client, i|
309
+ work_group.push(i, raw_client) do |client|
310
+ regular_timeout = client.read_timeout
311
+ client.read_timeout = @config.slow_command_timeout > 0.0 ? @config.slow_command_timeout : regular_timeout
312
+ reply = client.call('CLUSTER', 'NODES')
313
+ client.read_timeout = regular_timeout
314
+ parse_cluster_node_reply(reply)
315
+ rescue StandardError => e
316
+ e
317
+ ensure
318
+ client&.close
319
+ end
320
+ end
321
+
322
+ node_info_list = errors = nil
323
+
324
+ work_group.each do |i, v|
325
+ case v
326
+ when StandardError
327
+ errors ||= Array.new(startup_size)
328
+ errors[i] = v
329
+ else
330
+ node_info_list ||= Array.new(startup_size)
331
+ node_info_list[i] = v
332
+ end
333
+ end
334
+
335
+ work_group.close
336
+
337
+ raise ::RedisClient::Cluster::InitialSetupError, errors if node_info_list.nil?
338
+
339
+ grouped = node_info_list.compact.group_by do |info_list|
340
+ info_list.sort_by!(&:id)
341
+ info_list.each_with_object(String.new(capacity: 128 * info_list.size)) do |e, a|
342
+ a << e.id << e.node_key << e.role << e.primary_id << e.config_epoch
343
+ end
344
+ end
345
+
346
+ grouped.max_by { |_, v| v.size }[1].first
347
+ end
348
+
349
+ def parse_cluster_node_reply(reply) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
350
+ reply.each_line("\n", chomp: true).filter_map do |line|
351
+ fields = line.split
352
+ flags = fields[2].split(',')
353
+ next unless fields[7] == 'connected' && (flags & DEAD_FLAGS).empty?
354
+
355
+ slots = if fields[8].nil?
356
+ EMPTY_ARRAY
357
+ else
358
+ fields[8..].reject { |str| str.start_with?('[') }
359
+ .map { |str| str.split('-').map { |s| Integer(s) } }
360
+ .map { |a| a.size == 1 ? a << a.first : a }
361
+ .map(&:sort)
362
+ end
363
+
364
+ ::RedisClient::Cluster::Node::Info.new(
365
+ id: fields[0],
366
+ node_key: parse_node_key(fields[1]),
367
+ role: (flags & ROLE_FLAGS).first,
368
+ primary_id: fields[3],
369
+ ping_sent: fields[4],
370
+ pong_recv: fields[5],
371
+ config_epoch: fields[6],
372
+ link_state: fields[7],
373
+ slots: slots
374
+ )
375
+ end
376
+ end
377
+
378
+ # As redirection node_key is dependent on `cluster-preferred-endpoint-type` config,
379
+ # node_key should use hostname if present in CLUSTER NODES output.
380
+ #
381
+ # See https://redis.io/commands/cluster-nodes/ for details on the output format.
382
+ # node_address matches fhe format: <ip:port@cport[,hostname[,auxiliary_field=value]*]>
383
+ def parse_node_key(node_address)
384
+ ip_chunk, hostname, _auxiliaries = node_address.split(',')
385
+ ip_port_string = ip_chunk.split('@').first
386
+ return ip_port_string if hostname.nil? || hostname.empty?
387
+
388
+ port = ip_port_string.split(':')[1]
389
+ "#{hostname}:#{port}"
390
+ end
391
+
392
+ def with_startup_clients(count) # rubocop:disable Metrics/AbcSize
393
+ if @config.connect_with_original_config
394
+ # If connect_with_original_config is set, that means we need to build actual client objects
395
+ # and close them, so that we e.g. re-resolve a DNS entry with the cluster nodes in it.
396
+ begin
397
+ # Memoize the startup clients, so we maintain RedisClient's internal circuit breaker configuration
398
+ # if it's set.
399
+ @startup_clients ||= @config.startup_nodes.values.sample(count).map do |node_config|
400
+ ::RedisClient::Cluster::Node::Config.new(**node_config).new_client
401
+ end
402
+ yield @startup_clients
403
+ ensure
404
+ # Close the startup clients when we're done, so we don't maintain pointless open connections to
405
+ # the cluster though
406
+ @startup_clients&.each(&:close)
407
+ end
408
+ else
409
+ # (re-)connect using nodes we already know about.
410
+ # If this is the first time we're connecting to the cluster, we need to seed the topology with the
411
+ # startup clients though.
412
+ @topology.process_topology_update!({}, @config.startup_nodes) if @topology.clients.empty?
413
+ yield @topology.clients.values.sample(count)
414
+ end
415
+ end
416
+
417
+ def with_reload_lock
418
+ # What should happen with concurrent calls #reload? This is a realistic possibility if the cluster goes into
419
+ # a CLUSTERDOWN state, and we're using a pooled backend. Every thread will independently discover this, and
420
+ # call reload!.
421
+ # For now, if a reload is in progress, wait for that to complete, and consider that the same as us having
422
+ # performed the reload.
423
+ # Probably in the future we should add a circuit breaker to #reload itself, and stop trying if the cluster is
424
+ # obviously not working.
425
+ wait_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
426
+ @mutex.synchronize do
427
+ return if @last_reloaded_at && @last_reloaded_at > wait_start
428
+
429
+ r = yield
430
+ @last_reloaded_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
431
+ r
432
+ end
433
+ end
381
434
  end
382
435
  end
383
436
  end
@@ -31,6 +31,10 @@ class RedisClient
31
31
  def build_from_host_port(host, port)
32
32
  "#{host}#{DELIMITER}#{port}"
33
33
  end
34
+
35
+ def build_from_client(client)
36
+ "#{client.config.host}#{DELIMITER}#{client.config.port}"
37
+ end
34
38
  end
35
39
  end
36
40
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redis_client'
4
+ require 'redis_client/cluster/transaction'
5
+
6
+ class RedisClient
7
+ class Cluster
8
+ class OptimisticLocking
9
+ def initialize(router)
10
+ @router = router
11
+ @asking = false
12
+ end
13
+
14
+ def watch(keys)
15
+ slot = find_slot(keys)
16
+ raise ::RedisClient::Cluster::Transaction::ConsistencyError, "unsafe watch: #{keys.join(' ')}" if slot.nil?
17
+
18
+ # We have not yet selected a node for this transaction, initially, which means we can handle
19
+ # redirections freely initially (i.e. for the first WATCH call)
20
+ node = @router.find_primary_node_by_slot(slot)
21
+ handle_redirection(node, retry_count: 1) do |nd|
22
+ nd.with do |c|
23
+ c.ensure_connected_cluster_scoped(retryable: false) do
24
+ c.call('ASKING') if @asking
25
+ c.call('WATCH', *keys)
26
+ begin
27
+ yield(c, slot, @asking)
28
+ rescue ::RedisClient::ConnectionError
29
+ # No need to unwatch on a connection error.
30
+ raise
31
+ rescue StandardError
32
+ c.call('UNWATCH')
33
+ raise
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def handle_redirection(node, retry_count: 1, &blk)
43
+ @router.handle_redirection(node, retry_count: retry_count) do |nd|
44
+ handle_asking_once(nd, &blk)
45
+ end
46
+ end
47
+
48
+ def handle_asking_once(node)
49
+ yield node
50
+ rescue ::RedisClient::CommandError => e
51
+ raise unless ErrorIdentification.client_owns_error?(e, node)
52
+ raise unless e.message.start_with?('ASK')
53
+
54
+ node = @router.assign_asking_node(e.message)
55
+ @asking = true
56
+ yield node
57
+ ensure
58
+ @asking = false
59
+ end
60
+
61
+ def find_slot(keys)
62
+ return if keys.empty?
63
+ return if keys.any? { |k| k.nil? || k.empty? }
64
+
65
+ slots = keys.map { |k| @router.find_slot_by_key(k) }
66
+ return if slots.uniq.size != 1
67
+
68
+ slots.first
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RedisClient
4
+ class Cluster
5
+ class PinningNode
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ def call(*args, **kwargs, &block)
11
+ @client.call(*args, **kwargs, &block)
12
+ end
13
+
14
+ def call_v(args, &block)
15
+ @client.call_v(args, &block)
16
+ end
17
+
18
+ def call_once(*args, **kwargs, &block)
19
+ @client.call_once(*args, **kwargs, &block)
20
+ end
21
+
22
+ def call_once_v(args, &block)
23
+ @client.call_once_v(args, &block)
24
+ end
25
+
26
+ def blocking_call(timeout, *args, **kwargs, &block)
27
+ @client.blocking_call(timeout, *args, **kwargs, &block)
28
+ end
29
+
30
+ def blocking_call_v(timeout, args, &block)
31
+ @client.blocking_call_v(timeout, args, &block)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -12,7 +12,7 @@ class RedisClient
12
12
  class Extended < ::RedisClient::Pipeline
13
13
  attr_reader :outer_indices
14
14
 
15
- def initialize(command_builder)
15
+ def initialize(...)
16
16
  super
17
17
  @outer_indices = nil
18
18
  end
@@ -50,28 +50,31 @@ class RedisClient
50
50
  end
51
51
 
52
52
  ::RedisClient::ConnectionMixin.module_eval do
53
- def call_pipelined_aware_of_redirection(commands, timeouts) # rubocop:disable Metrics/AbcSize
53
+ def call_pipelined_aware_of_redirection(commands, timeouts, exception:) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
54
54
  size = commands.size
55
55
  results = Array.new(commands.size)
56
56
  @pending_reads += size
57
57
  write_multi(commands)
58
58
 
59
59
  redirection_indices = nil
60
+ first_exception = nil
60
61
  size.times do |index|
61
62
  timeout = timeouts && timeouts[index]
62
63
  result = read(timeout)
63
64
  @pending_reads -= 1
64
- if result.is_a?(CommandError)
65
+ if result.is_a?(::RedisClient::Error)
65
66
  result._set_command(commands[index])
66
- if result.message.start_with?('MOVED', 'ASK')
67
+ if result.is_a?(::RedisClient::CommandError) && result.message.start_with?('MOVED', 'ASK')
67
68
  redirection_indices ||= []
68
69
  redirection_indices << index
70
+ elsif exception
71
+ first_exception ||= result
69
72
  end
70
73
  end
71
-
72
74
  results[index] = result
73
75
  end
74
76
 
77
+ raise first_exception if exception && first_exception
75
78
  return results if redirection_indices.nil?
76
79
 
77
80
  err = ::RedisClient::Cluster::Pipeline::RedirectionNeeded.new
@@ -95,10 +98,11 @@ class RedisClient
95
98
  attr_accessor :replies, :indices
96
99
  end
97
100
 
98
- def initialize(router, command_builder, concurrent_worker, seed: Random.new_seed)
101
+ def initialize(router, command_builder, concurrent_worker, exception:, seed: Random.new_seed)
99
102
  @router = router
100
103
  @command_builder = command_builder
101
104
  @concurrent_worker = concurrent_worker
105
+ @exception = exception
102
106
  @seed = seed
103
107
  @pipelines = nil
104
108
  @size = 0
@@ -158,15 +162,13 @@ class RedisClient
158
162
  end
159
163
  end
160
164
 
161
- all_replies = errors = nil
165
+ all_replies = errors = required_redirections = nil
162
166
 
163
167
  work_group.each do |node_key, v|
164
168
  case v
165
169
  when ::RedisClient::Cluster::Pipeline::RedirectionNeeded
166
- all_replies ||= Array.new(@size)
167
- pipeline = @pipelines[node_key]
168
- v.indices.each { |i| v.replies[i] = handle_redirection(v.replies[i], pipeline, i) }
169
- pipeline.outer_indices.each_with_index { |outer, inner| all_replies[outer] = v.replies[inner] }
170
+ required_redirections ||= {}
171
+ required_redirections[node_key] = v
170
172
  when StandardError
171
173
  errors ||= {}
172
174
  errors[node_key] = v
@@ -177,9 +179,15 @@ class RedisClient
177
179
  end
178
180
 
179
181
  work_group.close
180
-
181
182
  raise ::RedisClient::Cluster::ErrorCollection, errors unless errors.nil?
182
183
 
184
+ required_redirections&.each do |node_key, v|
185
+ all_replies ||= Array.new(@size)
186
+ pipeline = @pipelines[node_key]
187
+ v.indices.each { |i| v.replies[i] = handle_redirection(v.replies[i], pipeline, i) }
188
+ pipeline.outer_indices.each_with_index { |outer, inner| all_replies[outer] = v.replies[inner] }
189
+ end
190
+
183
191
  all_replies
184
192
  end
185
193
 
@@ -205,7 +213,7 @@ class RedisClient
205
213
  results = client.ensure_connected_cluster_scoped(retryable: pipeline._retryable?) do |connection|
206
214
  commands = pipeline._commands
207
215
  client.middlewares.call_pipelined(commands, client.config) do
208
- connection.call_pipelined_aware_of_redirection(commands, pipeline._timeouts)
216
+ connection.call_pipelined_aware_of_redirection(commands, pipeline._timeouts, exception: @exception)
209
217
  end
210
218
  end
211
219
 
@@ -229,7 +237,7 @@ class RedisClient
229
237
  def try_redirection(node, pipeline, inner_index)
230
238
  redirect_command(node, pipeline, inner_index)
231
239
  rescue StandardError => e
232
- e
240
+ @exception ? raise : e
233
241
  end
234
242
 
235
243
  def redirect_command(node, pipeline, inner_index)