redis-cluster-client 0.15.0 → 0.16.5

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: 49e9024047bf01e4ce7a03ba26f1ba1691b123e3a7fffa1b6b33fdbf598c040a
4
- data.tar.gz: 1ae28c2abffa0d566a832d7bf4745afd8ebd82f255397068d99d25520578913c
3
+ metadata.gz: ee5d5f4c7bb89d0899ef2c3aec48503580d01a1e84a369f1e9ee0c1f60011f15
4
+ data.tar.gz: b17966f3f4c74ce89005b49188d769c4e8b6e399d6d65e0c4d68e14868cee9c7
5
5
  SHA512:
6
- metadata.gz: d61ebaa012160e38ef5bd42de839a637f209a7c409b507fd6f8a6e7f5898cbb8a61960ebc5566b95bd1aedad98f767b98ab1a7d6d2a8400e48da433794b5e4f2
7
- data.tar.gz: b28d1d461814dafe2e272cc65ed3be2f805b754ecf4ad3831674d7d478eeac3266513e0cdda8a0cabaf604f8292b8034a936b4693066c794a5bc558688ab287a
6
+ metadata.gz: 580dbda062e93296be5869169a3f8f4d389f7bc7aba34cb473cc1a1877cd8a552a29617bf6e09cc2bea8e372cbf895f28ffd086bf75ce986a2dd448bd7170dae
7
+ data.tar.gz: d3abefad63034a97613319637fe617b66f64b2053f67ed8c6de7d81350976645c0eec250037fd100b0a4d5e518c3674c96e07fb043c56ffd327a8e918626405d
@@ -9,18 +9,57 @@ class RedisClient
9
9
  class Command
10
10
  EMPTY_STRING = ''
11
11
  EMPTY_HASH = {}.freeze
12
- EMPTY_ARRAY = [].freeze
13
12
 
14
- private_constant :EMPTY_STRING, :EMPTY_HASH, :EMPTY_ARRAY
13
+ private_constant :EMPTY_HASH
15
14
 
16
- Detail = Struct.new(
17
- 'RedisCommand',
15
+ Spec = Struct.new(
16
+ 'RedisCommandSpec',
18
17
  :first_key_position,
19
18
  :key_step,
20
19
  :write?,
21
20
  :readonly?,
22
21
  keyword_init: true
23
- )
22
+ ) do
23
+ def extract_first_key(command)
24
+ i = first_key_position.to_i
25
+ return command[i] if i > 0
26
+
27
+ i = determine_first_key_position(command)
28
+ return ::RedisClient::Cluster::Command::EMPTY_STRING if i == 0
29
+
30
+ command[i]
31
+ end
32
+
33
+ def should_send_to_primary?
34
+ write?
35
+ end
36
+
37
+ def should_send_to_replica?
38
+ readonly?
39
+ end
40
+
41
+ private
42
+
43
+ def determine_first_key_position(command) # rubocop:disable Metrics/AbcSize
44
+ cmd_name = command.first
45
+ if cmd_name.casecmp('xread').zero?
46
+ determine_optional_key_position(command, 'streams')
47
+ elsif cmd_name.casecmp('xreadgroup').zero?
48
+ determine_optional_key_position(command, 'streams')
49
+ elsif cmd_name.casecmp('migrate').zero?
50
+ command[3].empty? ? determine_optional_key_position(command, 'keys') : 3
51
+ elsif cmd_name.casecmp('memory').zero?
52
+ command[1].to_s.casecmp('usage').zero? ? 2 : 0
53
+ else
54
+ 0
55
+ end
56
+ end
57
+
58
+ def determine_optional_key_position(command, option_name)
59
+ i = command.index { |v| v.to_s.casecmp(option_name).zero? }
60
+ i.nil? ? 0 : i + 1
61
+ end
62
+ end
24
63
 
25
64
  class << self
26
65
  def load(nodes, slow_command_timeout: -1) # rubocop:disable Metrics/AbcSize
@@ -30,13 +69,14 @@ class RedisClient
30
69
  regular_timeout = node.read_timeout
31
70
  node.read_timeout = slow_command_timeout > 0.0 ? slow_command_timeout : regular_timeout
32
71
  reply = node.call('command')
33
- node.read_timeout = regular_timeout
34
72
  commands = parse_command_reply(reply)
35
73
  cmd = ::RedisClient::Cluster::Command.new(commands)
36
74
  break
37
75
  rescue ::RedisClient::Error => e
38
76
  errors ||= []
39
77
  errors << e
78
+ ensure
79
+ node.read_timeout = regular_timeout
40
80
  end
41
81
 
42
82
  return cmd unless cmd.nil?
@@ -64,12 +104,12 @@ class RedisClient
64
104
  else row[2].include?('write')
65
105
  end
66
106
 
67
- acc[row.first] = ::RedisClient::Cluster::Command::Detail.new(
107
+ acc[row.first] = ::RedisClient::Cluster::Command::Spec.new(
68
108
  first_key_position: pos,
69
109
  key_step: row[5],
70
110
  write?: writable,
71
111
  readonly?: row[2].include?('readonly')
72
- )
112
+ ).freeze
73
113
  end.freeze || EMPTY_HASH
74
114
  end
75
115
  end
@@ -78,53 +118,13 @@ class RedisClient
78
118
  @commands = commands || EMPTY_HASH
79
119
  end
80
120
 
81
- def extract_first_key(command)
82
- i = determine_first_key_position(command)
83
- return EMPTY_STRING if i == 0
84
-
85
- command[i]
86
- end
87
-
88
- def should_send_to_primary?(command)
89
- find_command_info(command.first)&.write?
90
- end
91
-
92
- def should_send_to_replica?(command)
93
- find_command_info(command.first)&.readonly?
121
+ def get_spec(name)
122
+ @commands[name] || @commands[name.to_s.downcase(:ascii)]
94
123
  end
95
124
 
96
125
  def exists?(name)
97
126
  @commands.key?(name) || @commands.key?(name.to_s.downcase(:ascii))
98
127
  end
99
-
100
- private
101
-
102
- def find_command_info(name)
103
- @commands[name] || @commands[name.to_s.downcase(:ascii)]
104
- end
105
-
106
- def determine_first_key_position(command) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/PerceivedComplexity
107
- i = find_command_info(command.first)&.first_key_position.to_i
108
- return i if i > 0
109
-
110
- cmd_name = command.first
111
- if cmd_name.casecmp('xread').zero?
112
- determine_optional_key_position(command, 'streams')
113
- elsif cmd_name.casecmp('xreadgroup').zero?
114
- determine_optional_key_position(command, 'streams')
115
- elsif cmd_name.casecmp('migrate').zero?
116
- command[3].empty? ? determine_optional_key_position(command, 'keys') : 3
117
- elsif cmd_name.casecmp('memory').zero?
118
- command[1].to_s.casecmp('usage').zero? ? 2 : 0
119
- else
120
- i
121
- end
122
- end
123
-
124
- def determine_optional_key_position(command, option_name)
125
- i = command.index { |v| v.to_s.casecmp(option_name).zero? }
126
- i.nil? ? 0 : i + 1
127
- end
128
128
  end
129
129
  end
130
130
  end
@@ -3,17 +3,57 @@
3
3
  class RedisClient
4
4
  class Cluster
5
5
  module ConcurrentWorker
6
- class None
7
- def new_group(size:)
8
- ::RedisClient::Cluster::ConcurrentWorker::Group.new(
9
- worker: self,
10
- queue: [],
11
- size: size
6
+ module None
7
+ class Group
8
+ Task = Struct.new(
9
+ 'RedisClusterClientSingleThreadTask',
10
+ :id, :result, keyword_init: true
12
11
  )
12
+
13
+ def initialize(size:)
14
+ @tasks = Array.new(size)
15
+ @idx = 0
16
+ end
17
+
18
+ def push(id, *args, **kwargs, &block)
19
+ raise InvalidNumberOfTasks, "max size reached: #{@idx}" if @idx == @tasks.size
20
+
21
+ result = exec(*args, **kwargs, &block)
22
+ @tasks[@idx] = Task.new(id: id, result: result)
23
+ @idx += 1
24
+ nil
25
+ end
26
+
27
+ def each
28
+ raise InvalidNumberOfTasks, "expected: #{@tasks.size}, actual: #{@idx}" if @idx != @tasks.size
29
+
30
+ @tasks.each { |task| yield(task.id, task.result) }
31
+ nil
32
+ end
33
+
34
+ def close
35
+ @idx = 0
36
+ @tasks.clear
37
+ nil
38
+ end
39
+
40
+ def inspect
41
+ "#<#{self.class.name} size: #{@idx}, max: #{@tasks.size}>"
42
+ end
43
+
44
+ private
45
+
46
+ def exec(*args, **kwargs)
47
+ yield(*args, **kwargs) if block_given?
48
+ rescue StandardError => e
49
+ e
50
+ end
13
51
  end
14
52
 
15
- def push(task)
16
- task.exec
53
+ module_function
54
+
55
+ def new_group(size:)
56
+ Group.new(size: size)
17
57
  end
18
58
 
19
59
  def close; end
@@ -73,7 +73,7 @@ class RedisClient
73
73
 
74
74
  def create(model: :none, size: 5)
75
75
  case model
76
- when :none then ::RedisClient::Cluster::ConcurrentWorker::None.new
76
+ when :none then ::RedisClient::Cluster::ConcurrentWorker::None
77
77
  when :on_demand then ::RedisClient::Cluster::ConcurrentWorker::OnDemand.new(size: size)
78
78
  when :pooled then ::RedisClient::Cluster::ConcurrentWorker::Pooled.new(size: size)
79
79
  else raise ArgumentError, "unknown model: #{model}"
@@ -70,7 +70,7 @@ class RedisClient
70
70
  e = key.index(RIGHT_BRACKET, s + 1)
71
71
  return EMPTY_STRING if e.nil?
72
72
 
73
- key[s + 1..e - 1]
73
+ s + 1 < e ? key[s + 1, e - s - 1] : EMPTY_STRING
74
74
  end
75
75
 
76
76
  def hash_tag_included?(key)
@@ -25,8 +25,7 @@ class RedisClient
25
25
  end
26
26
 
27
27
  def any_primary_node_key(seed: nil)
28
- random = seed.nil? ? Random : Random.new(seed)
29
- @primary_node_keys.sample(random: random)
28
+ @primary_node_keys.sample(random: make_random(seed))
30
29
  end
31
30
 
32
31
  def process_topology_update!(replications, options) # rubocop:disable Metrics/AbcSize
@@ -59,6 +58,11 @@ class RedisClient
59
58
  @clients[node_key] = client
60
59
  end
61
60
  end
61
+
62
+ def make_random(seed)
63
+ # OPTIMIZE: Figure out the most elegant way to pin a node during a pipeline or scan.
64
+ seed.nil? ? Random : Random.new(seed)
65
+ end
62
66
  end
63
67
  end
64
68
  end
@@ -20,8 +20,7 @@ class RedisClient
20
20
  end
21
21
 
22
22
  def any_replica_node_key(seed: nil)
23
- random = seed.nil? ? Random : Random.new(seed)
24
- @existed_replicas.sample(random: random)&.first || any_primary_node_key(seed: seed)
23
+ @existed_replicas.sample(random: make_random(seed))&.first || any_primary_node_key(seed: seed)
25
24
  end
26
25
 
27
26
  def process_topology_update!(replications, options)
@@ -18,8 +18,7 @@ class RedisClient
18
18
  end
19
19
 
20
20
  def any_primary_node_key(seed: nil)
21
- random = seed.nil? ? Random : Random.new(seed)
22
- @primary_node_keys.sample(random: random)
21
+ @primary_node_keys.sample(random: make_random(seed))
23
22
  end
24
23
 
25
24
  alias any_replica_node_key any_primary_node_key
@@ -12,7 +12,7 @@ class RedisClient
12
12
  end
13
13
 
14
14
  def clients_for_scanning(seed: nil)
15
- random = seed.nil? ? Random : Random.new(seed)
15
+ random = make_random(seed)
16
16
  keys = @replications.map do |primary_node_key, replica_node_keys|
17
17
  replica_node_keys.empty? ? primary_node_key : replica_node_keys.sample(random: random)
18
18
  end
@@ -21,13 +21,13 @@ class RedisClient
21
21
  end
22
22
 
23
23
  def find_node_key_of_replica(primary_node_key, seed: nil)
24
- random = seed.nil? ? Random : Random.new(seed)
25
- @replications.fetch(primary_node_key, EMPTY_ARRAY).sample(random: random) || primary_node_key
24
+ replica_node_keys = @replications.fetch(primary_node_key, EMPTY_ARRAY)
25
+ replica_node_key = replica_node_keys.size <= 1 ? replica_node_keys.first : replica_node_keys.sample(random: make_random(seed))
26
+ replica_node_key || primary_node_key
26
27
  end
27
28
 
28
29
  def any_replica_node_key(seed: nil)
29
- random = seed.nil? ? Random : Random.new(seed)
30
- @replica_node_keys.sample(random: random) || any_primary_node_key(seed: seed)
30
+ @replica_node_keys.sample(random: make_random(seed)) || any_primary_node_key(seed: seed)
31
31
  end
32
32
  end
33
33
  end
@@ -12,7 +12,7 @@ class RedisClient
12
12
  end
13
13
 
14
14
  def clients_for_scanning(seed: nil)
15
- random = seed.nil? ? Random : Random.new(seed)
15
+ random = make_random(seed)
16
16
  keys = @replications.map do |primary_node_key, replica_node_keys|
17
17
  decide_use_primary?(random, replica_node_keys.size) ? primary_node_key : replica_node_keys.sample(random: random)
18
18
  end
@@ -21,7 +21,7 @@ class RedisClient
21
21
  end
22
22
 
23
23
  def find_node_key_of_replica(primary_node_key, seed: nil)
24
- random = seed.nil? ? Random : Random.new(seed)
24
+ random = make_random(seed)
25
25
 
26
26
  replica_node_keys = @replications.fetch(primary_node_key, EMPTY_ARRAY)
27
27
  if decide_use_primary?(random, replica_node_keys.size)
@@ -32,8 +32,7 @@ class RedisClient
32
32
  end
33
33
 
34
34
  def any_replica_node_key(seed: nil)
35
- random = seed.nil? ? Random : Random.new(seed)
36
- @replica_node_keys.sample(random: random) || any_primary_node_key(seed: seed)
35
+ @replica_node_keys.sample(random: make_random(seed)) || any_primary_node_key(seed: seed)
37
36
  end
38
37
 
39
38
  private
@@ -24,10 +24,12 @@ class RedisClient
24
24
  ROLE_FLAGS = %w[master slave].freeze
25
25
  EMPTY_ARRAY = [].freeze
26
26
  EMPTY_HASH = {}.freeze
27
- STATE_REFRESH_INTERVAL = (3..10).freeze
27
+ EMPTY_STRING = ''
28
+ JITTER_WINDOW = (3_000_000...10_000_000).freeze # micro seconds
28
29
 
29
30
  private_constant :USE_CHAR_ARRAY_SLOT, :SLOT_SIZE, :MIN_SLOT, :MAX_SLOT,
30
- :DEAD_FLAGS, :ROLE_FLAGS, :EMPTY_ARRAY, :EMPTY_HASH
31
+ :DEAD_FLAGS, :ROLE_FLAGS, :EMPTY_ARRAY, :EMPTY_HASH, :EMPTY_STRING,
32
+ :JITTER_WINDOW
31
33
 
32
34
  ReloadNeeded = Class.new(::RedisClient::Cluster::Error)
33
35
 
@@ -46,20 +48,15 @@ class RedisClient
46
48
  end
47
49
 
48
50
  def serialize(str)
49
- str << id << node_key << role << primary_id << config_epoch
51
+ str << id << node_key << role << primary_id
50
52
  end
51
53
  end
52
54
 
53
55
  class CharArray
54
- BASE = ''
55
- PADDING = '0'
56
-
57
- private_constant :BASE, :PADDING
58
-
59
56
  def initialize(size, elements)
60
57
  @elements = elements
61
- @string = String.new(BASE, encoding: Encoding::BINARY, capacity: size)
62
- size.times { @string << PADDING }
58
+ @string = String.new('', encoding: Encoding::BINARY, capacity: size)
59
+ size.times { @string << 0xff }
63
60
  end
64
61
 
65
62
  def [](index)
@@ -106,8 +103,7 @@ class RedisClient
106
103
  @topology = klass.new(pool, @concurrent_worker, **kwargs)
107
104
  @config = config
108
105
  @mutex = Mutex.new
109
- @last_reloaded_at = nil
110
- @reload_times = 0
106
+ @next_reload_time = nil
111
107
  @random = Random.new
112
108
  end
113
109
 
@@ -193,26 +189,22 @@ class RedisClient
193
189
  end
194
190
 
195
191
  def update_slot(slot, node_key)
196
- return if @mutex.locked?
192
+ return unless @mutex.try_lock
197
193
 
198
- @mutex.synchronize do
199
- @slots[slot] = node_key
200
- rescue RangeError
201
- @slots = Array.new(SLOT_SIZE) { |i| @slots[i] }
202
- @slots[slot] = node_key
203
- end
194
+ @slots[slot] = node_key
195
+ rescue RangeError
196
+ @slots = Array.new(SLOT_SIZE) { |i| @slots[i] }
197
+ @slots[slot] = node_key
198
+ ensure
199
+ @mutex.unlock if @mutex.owned?
204
200
  end
205
201
 
206
- def reload!
202
+ def try_reload!
207
203
  with_reload_lock do
208
- with_startup_clients(@config.max_startup_sample) do |startup_clients|
209
- @node_info = refetch_node_info_list(startup_clients)
210
- @node_configs = @node_info.to_h do |node_info|
211
- [node_info.node_key, @config.client_config_for_node(node_info.node_key)]
204
+ with_reload_jitter do
205
+ with_startup_clients(@config.max_startup_sample) do |clients|
206
+ reload!(clients)
212
207
  end
213
- @slots = build_slot_node_mappings(@node_info)
214
- @replications = build_replication_mappings(@node_info)
215
- @topology.process_topology_update!(@replications, @node_configs)
216
208
  end
217
209
  end
218
210
  end
@@ -312,13 +304,11 @@ class RedisClient
312
304
  work_group.push(i, raw_client) do |client|
313
305
  regular_timeout = client.read_timeout
314
306
  client.read_timeout = @config.slow_command_timeout > 0.0 ? @config.slow_command_timeout : regular_timeout
315
- reply = client.call_once('cluster', 'nodes')
316
- client.read_timeout = regular_timeout
317
- parse_cluster_node_reply(reply)
307
+ fetch_cluster_state(client)
318
308
  rescue StandardError => e
319
309
  e
320
310
  ensure
321
- client&.close
311
+ client.read_timeout = regular_timeout
322
312
  end
323
313
  end
324
314
 
@@ -347,6 +337,16 @@ class RedisClient
347
337
  grouped.max_by { |_, v| v.size }[1].first
348
338
  end
349
339
 
340
+ def fetch_cluster_state(client)
341
+ reply = client.call_once('cluster', 'shards')
342
+ parse_cluster_shards_reply(reply)
343
+ rescue ::RedisClient::CommandError => e
344
+ raise unless e.message.start_with?('ERR Unknown subcommand')
345
+
346
+ reply = client.call_once('cluster', 'nodes')
347
+ parse_cluster_node_reply(reply)
348
+ end
349
+
350
350
  def parse_cluster_node_reply(reply) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
351
351
  reply.each_line("\n", chomp: true).filter_map do |line|
352
352
  fields = line.split
@@ -372,7 +372,7 @@ class RedisClient
372
372
  config_epoch: fields[6],
373
373
  link_state: fields[7],
374
374
  slots: slots
375
- )
375
+ ).freeze
376
376
  end
377
377
  end
378
378
 
@@ -390,39 +390,61 @@ class RedisClient
390
390
  id: id,
391
391
  node_key: NodeKey.build_from_host_port(ip, arr[1]),
392
392
  role: role,
393
- primary_id: role == 'master' ? nil : primary_id,
393
+ primary_id: role == 'master' ? EMPTY_STRING : primary_id,
394
394
  slots: role == 'master' ? slots : EMPTY_ARRAY
395
- )
395
+ ).freeze
396
396
  end
397
- end.freeze
397
+ end
398
398
  end
399
399
 
400
400
  def parse_cluster_shards_reply(reply) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
401
401
  reply.each_with_object([]) do |shard, acc|
402
+ resp2 = shard.is_a?(Array)
403
+ shard = shard.each_slice(2).to_h if resp2
402
404
  nodes = shard.fetch('nodes')
405
+ nodes = nodes.map { |n| n.each_slice(2).to_h } if resp2
403
406
  primary_id = nodes.find { |n| n.fetch('role') == 'master' }.fetch('id')
404
407
 
405
408
  nodes.each do |node|
406
- ip = node.fetch('ip')
407
- next if node.fetch('health') != 'online' || ip.nil? || ip.empty? || ip == '?'
409
+ host = pick_shard_host(node)
410
+ next if node.fetch('health') != 'online' || host.nil? || host.empty? || host == '?'
408
411
 
409
412
  role = node.fetch('role')
410
413
  acc << ::RedisClient::Cluster::Node::Info.new(
411
414
  id: node.fetch('id'),
412
- node_key: NodeKey.build_from_host_port(ip, node['port'] || node['tls-port']),
415
+ node_key: NodeKey.build_from_host_port(host, node['port'] || node['tls-port']),
413
416
  role: role == 'master' ? role : 'slave',
414
- primary_id: role == 'master' ? nil : primary_id,
417
+ primary_id: role == 'master' ? EMPTY_STRING : primary_id,
415
418
  slots: role == 'master' ? shard.fetch('slots').each_slice(2).to_a.freeze : EMPTY_ARRAY
416
- )
419
+ ).freeze
417
420
  end
418
- end.freeze
421
+ end
422
+ end
423
+
424
+ # Pick the host for a CLUSTER SHARDS node entry.
425
+ #
426
+ # `endpoint` is the server-selected preferred endpoint (driven by the
427
+ # `cluster-preferred-endpoint-type` config), so prefer it when present and
428
+ # usable. Some managed services (e.g. AWS ElastiCache Serverless) report
429
+ # `127.0.0.1` in `ip` while exposing the reachable address only via
430
+ # `endpoint` / `hostname`; falling through to `ip` in that case would build
431
+ # an unreachable topology. This mirrors the precedence `parse_node_key`
432
+ # uses for CLUSTER NODES output (see #207).
433
+ def pick_shard_host(node)
434
+ endpoint = node['endpoint']
435
+ return endpoint if endpoint && !endpoint.empty? && endpoint != '?'
436
+
437
+ hostname = node['hostname']
438
+ return hostname if hostname && !hostname.empty?
439
+
440
+ node['ip']
419
441
  end
420
442
 
421
443
  # As redirection node_key is dependent on `cluster-preferred-endpoint-type` config,
422
444
  # node_key should use hostname if present in CLUSTER NODES output.
423
445
  #
424
446
  # See https://redis.io/commands/cluster-nodes/ for details on the output format.
425
- # node_address matches fhe format: <ip:port@cport[,hostname[,auxiliary_field=value]*]>
447
+ # node_address matches the format: <ip:port@cport[,hostname[,auxiliary_field=value]*]>
426
448
  def parse_node_key(node_address)
427
449
  ip_chunk, hostname, _auxiliaries = node_address.split(',')
428
450
  ip_port_string = ip_chunk.split('@').first
@@ -432,6 +454,16 @@ class RedisClient
432
454
  "#{hostname}:#{port}"
433
455
  end
434
456
 
457
+ def reload!(clients)
458
+ @node_info = refetch_node_info_list(clients)
459
+ @node_configs = @node_info.to_h do |node_info|
460
+ [node_info.node_key, @config.client_config_for_node(node_info.node_key)]
461
+ end
462
+ @slots = build_slot_node_mappings(@node_info)
463
+ @replications = build_replication_mappings(@node_info)
464
+ @topology.process_topology_update!(@replications, @node_configs)
465
+ end
466
+
435
467
  def with_startup_clients(count) # rubocop:disable Metrics/AbcSize
436
468
  if @config.connect_with_original_config
437
469
  # If connect_with_original_config is set, that means we need to build actual client objects
@@ -457,35 +489,43 @@ class RedisClient
457
489
  end
458
490
  end
459
491
 
492
+ def with_reload_jitter
493
+ return unless @next_reload_time.nil? || obtain_current_time >= @next_reload_time
494
+
495
+ begin
496
+ yield
497
+ ensure
498
+ @next_reload_time = obtain_current_time + @random.rand(JITTER_WINDOW)
499
+ end
500
+ end
501
+
460
502
  def with_reload_lock
461
- # What should happen with concurrent calls #reload? This is a realistic possibility if the cluster goes into
503
+ # What should happen with concurrent calls #try_reload! This is a realistic possibility if the cluster goes into
462
504
  # a CLUSTERDOWN state, and we're using a pooled backend. Every thread will independently discover this, and
463
- # call reload!.
464
- # For now, if a reload is in progress, wait for that to complete, and consider that the same as us having
465
- # performed the reload.
466
- # Probably in the future we should add a circuit breaker to #reload itself, and stop trying if the cluster is
505
+ # call #try_reload!.
506
+ # For now, if a reload is in progress by a thread, the other threads do not wait for that to complete, and
507
+ # they throw an error.
508
+ # Probably in the future we should add a circuit breaker to #try_reload! itself, and stop trying if the cluster is
467
509
  # obviously not working.
468
- wait_start = obtain_current_time
469
- @mutex.synchronize do
470
- return if @last_reloaded_at && @last_reloaded_at > wait_start
471
-
472
- if @last_reloaded_at && @reload_times > 1
473
- # Mitigate load of servers by naive logic. Don't sleep with exponential backoff.
474
- now = obtain_current_time
475
- elapsed = @last_reloaded_at + @random.rand(STATE_REFRESH_INTERVAL) * 1_000_000
476
- return if now < elapsed
477
- end
510
+ return unless @mutex.try_lock
478
511
 
479
- r = yield
480
- @last_reloaded_at = obtain_current_time
481
- @reload_times += 1
482
- r
483
- end
512
+ yield
513
+ ensure
514
+ @mutex.unlock if @mutex.owned?
484
515
  end
485
516
 
486
517
  def obtain_current_time
487
518
  Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
488
519
  end
520
+
521
+ def bypass_reload!
522
+ # DO NOT USE THIS METHOD
523
+ with_reload_lock do
524
+ with_startup_clients(@config.max_startup_sample) do |clients|
525
+ reload!(clients)
526
+ end
527
+ end
528
+ end
489
529
  end
490
530
  end
491
531
  end
@@ -18,12 +18,30 @@ class RedisClient
18
18
  end
19
19
 
20
20
  def split(node_key)
21
- pos = node_key&.rindex(DELIMITER, -1)
21
+ return [node_key, nil] if node_key.nil? || node_key.empty?
22
+
23
+ bracketed = split_bracketed(node_key)
24
+ return bracketed unless bracketed.nil?
25
+
26
+ pos = node_key.rindex(DELIMITER, -1)
22
27
  return [node_key, nil] if pos.nil?
23
28
 
24
29
  [node_key[0, pos], node_key[(pos + 1)..]]
25
30
  end
26
31
 
32
+ def split_bracketed(node_key)
33
+ return nil unless node_key.start_with?('[')
34
+
35
+ end_bracket = node_key.index(']')
36
+ return nil if end_bracket.nil?
37
+
38
+ host = node_key[1, end_bracket - 1]
39
+ remainder = node_key[(end_bracket + 1)..]
40
+ port = remainder.start_with?(DELIMITER) ? remainder[1..] : nil
41
+ [host, port]
42
+ end
43
+ private_class_method :split_bracketed
44
+
27
45
  def build_from_uri(uri)
28
46
  return '' if uri.nil?
29
47
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'redis_client'
4
4
  require 'redis_client/cluster/transaction'
5
+ require 'redis_client/cluster/error_identification'
5
6
 
6
7
  class RedisClient
7
8
  class Cluster
@@ -54,7 +55,7 @@ class RedisClient
54
55
  rescue ::RedisClient::ConnectionError
55
56
  # Deduct the number of retries that happened _inside_ router#handle_redirection from our remaining
56
57
  # _external_ retries. Always deduct at least one in case handle_redirection raises without trying the block.
57
- retry_count -= [times_block_executed, 1].min
58
+ retry_count -= times_block_executed > 1 ? times_block_executed : 1
58
59
  raise if retry_count < 0
59
60
 
60
61
  retry
@@ -23,10 +23,6 @@ class RedisClient
23
23
  @outer_indices << index
24
24
  end
25
25
 
26
- def get_inner_index(outer_index)
27
- @outer_indices&.find_index(outer_index)
28
- end
29
-
30
26
  def get_callee_method(inner_index)
31
27
  if @timeouts.is_a?(Array) && !@timeouts[inner_index].nil?
32
28
  :blocking_call_v
@@ -66,6 +62,7 @@ class RedisClient
66
62
  if result.is_a?(::RedisClient::Error)
67
63
  result._set_command(commands[index])
68
64
  result._set_config(config)
65
+ result._set_retry_attempt(@retry_attempt)
69
66
 
70
67
  if result.is_a?(::RedisClient::CommandError) && result.message.start_with?('MOVED', 'ASK')
71
68
  redirection_indices ||= []
@@ -230,10 +227,10 @@ class RedisClient
230
227
 
231
228
  def append_pipeline(node_key)
232
229
  @pipelines ||= {}
233
- @pipelines[node_key] ||= ::RedisClient::Cluster::Pipeline::Extended.new(::RedisClient::Cluster::NoopCommandBuilder)
234
- @pipelines[node_key].add_outer_index(@size)
230
+ pi = (@pipelines[node_key] ||= ::RedisClient::Cluster::Pipeline::Extended.new(::RedisClient::Cluster::NoopCommandBuilder))
231
+ pi.add_outer_index(@size)
235
232
  @size += 1
236
- @pipelines[node_key]
233
+ pi
237
234
  end
238
235
 
239
236
  def do_pipelining(client, pipeline)
@@ -74,14 +74,14 @@ class RedisClient
74
74
  def call(*args, **kwargs)
75
75
  command = @command_builder.generate(args, kwargs)
76
76
  _call(command)
77
- @commands << command
77
+ remember_subscription(command)
78
78
  nil
79
79
  end
80
80
 
81
81
  def call_v(command)
82
82
  command = @command_builder.generate(command)
83
83
  _call(command)
84
- @commands << command
84
+ remember_subscription(command)
85
85
  nil
86
86
  end
87
87
 
@@ -179,6 +179,7 @@ class RedisClient
179
179
  end
180
180
 
181
181
  def start_over
182
+ attempt = 0
182
183
  loop do
183
184
  @router.renew_cluster_state
184
185
  @state_dict.each_value(&:close)
@@ -186,9 +187,46 @@ class RedisClient
186
187
  @commands.each { |command| _call(command) }
187
188
  break
188
189
  rescue ::RedisClient::ConnectionError, ::RedisClient::Cluster::NodeMightBeDown
189
- sleep 1.0
190
+ attempt += 1
191
+ raise if attempt >= 10
192
+
193
+ sleep recovery_interval(attempt)
194
+ end
195
+ end
196
+
197
+ def recovery_interval(attempt)
198
+ [1.0 * (2**(attempt - 1)), 30.0].min
199
+ end
200
+
201
+ def remember_subscription(command) # rubocop:disable Metrics/AbcSize
202
+ if command.first.casecmp('subscribe').zero?
203
+ @commands << command
204
+ elsif command.first.casecmp('psubscribe').zero?
205
+ @commands << command
206
+ elsif command.first.casecmp('ssubscribe').zero?
207
+ @commands << command
208
+ elsif command.first.casecmp('unsubscribe').zero?
209
+ forget_subscriptions('subscribe', command[1, command.size])
210
+ elsif command.first.casecmp('punsubscribe').zero?
211
+ forget_subscriptions('psubscribe', command[1, command.size])
212
+ elsif command.first.casecmp('sunsubscribe').zero?
213
+ forget_subscriptions('ssubscribe', command[1, command.size])
190
214
  end
191
215
  end
216
+
217
+ def forget_subscriptions(subscribe_cmd, channels)
218
+ @commands.map! do |command|
219
+ next command unless command.first.casecmp(subscribe_cmd).zero?
220
+ next if channels.nil? || channels.empty?
221
+
222
+ remaining = command[1, command.size] - channels
223
+ next if remaining.empty?
224
+
225
+ [command.first, *remaining]
226
+ end
227
+
228
+ @commands.compact!
229
+ end
192
230
  end
193
231
  end
194
232
  end
@@ -84,7 +84,7 @@ class RedisClient
84
84
  @pool = pool
85
85
  @client_kwargs = kwargs
86
86
  @node = ::RedisClient::Cluster::Node.new(concurrent_worker, config: config, pool: pool, **kwargs)
87
- @node.reload!
87
+ @node.try_reload!
88
88
  @command = ::RedisClient::Cluster::Command.load(@node.replica_clients.shuffle, slow_command_timeout: config.slow_command_timeout)
89
89
  @command_builder = @config.command_builder
90
90
  rescue ::RedisClient::Cluster::InitialSetupError => e
@@ -93,9 +93,8 @@ class RedisClient
93
93
  end
94
94
 
95
95
  def send_command(method, command, *args, &block) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
96
- return assign_node_and_send_command(method, command, args, &block) unless DEDICATED_ACTIONS.key?(command.first)
97
-
98
96
  action = DEDICATED_ACTIONS[command.first]
97
+ return assign_node_and_send_command(method, command, args, &block) if action.nil?
99
98
  return send(action.method_name, method, command, args, &block) if action.reply_transformer.nil?
100
99
 
101
100
  reply = send(action.method_name, method, command, args)
@@ -167,21 +166,21 @@ class RedisClient
167
166
  rescue ::RedisClient::ConnectionError => e
168
167
  raise unless ::RedisClient::Cluster::ErrorIdentification.client_owns_error?(e, node)
169
168
 
170
- retry_count -= 1
171
169
  renew_cluster_state
170
+ raise if command.nil? || command.empty?
172
171
 
173
- if retry_count >= 0
174
- # Find the node to use for this command - if this fails for some reason, though, re-use
175
- # the old node.
176
- begin
177
- node = find_node(find_node_key(command)) if command
178
- rescue StandardError # rubocop:disable Lint/SuppressedException
179
- end
180
- retry
172
+ retry_count -= 1
173
+ raise if retry_count < 0
174
+
175
+ # Find the node to use for this command - if this fails for some reason, though, re-use
176
+ # the old node.
177
+ begin
178
+ node = find_node(find_node_key(command))
179
+ rescue StandardError
180
+ raise e
181
181
  end
182
182
 
183
- retry if retry_count >= 0
184
- raise
183
+ retry
185
184
  end
186
185
 
187
186
  def scan(command, seed: nil) # rubocop:disable Metrics/AbcSize
@@ -229,7 +228,7 @@ class RedisClient
229
228
  def find_node_key_by_key(key, seed: nil, primary: false)
230
229
  if key && !key.empty?
231
230
  slot = ::RedisClient::Cluster::KeySlotConverter.convert(key)
232
- node_key = primary ? @node.find_node_key_of_primary(slot) : @node.find_node_key_of_replica(slot)
231
+ node_key = primary ? @node.find_node_key_of_primary(slot) : @node.find_node_key_of_replica(slot, seed: seed)
233
232
  if node_key.nil?
234
233
  renew_cluster_state
235
234
  raise ::RedisClient::Cluster::NodeMightBeDown.new.with_config(@config)
@@ -248,23 +247,27 @@ class RedisClient
248
247
  end
249
248
 
250
249
  def find_node_key(command, seed: nil)
251
- key = @command.extract_first_key(command)
252
- find_node_key_by_key(key, seed: seed, primary: @command.should_send_to_primary?(command))
250
+ cmd_spec = @command.get_spec(command.first)
251
+ find_node_key_by_key(
252
+ cmd_spec&.extract_first_key(command),
253
+ seed: seed,
254
+ primary: cmd_spec&.should_send_to_primary?
255
+ )
253
256
  end
254
257
 
255
258
  def find_primary_node_key(command)
256
- key = @command.extract_first_key(command)
257
- return nil unless key&.size&.> 0
259
+ key = @command.get_spec(command.first)&.extract_first_key(command)
260
+ return unless key&.size&.> 0
258
261
 
259
262
  find_node_key_by_key(key, primary: true)
260
263
  end
261
264
 
262
265
  def find_slot(command)
263
- find_slot_by_key(@command.extract_first_key(command))
266
+ find_slot_by_key(@command.get_spec(command.first)&.extract_first_key(command))
264
267
  end
265
268
 
266
269
  def find_slot_by_key(key)
267
- return if key.empty?
270
+ return if key.nil? || key.empty?
268
271
 
269
272
  ::RedisClient::Cluster::KeySlotConverter.convert(key)
270
273
  end
@@ -294,7 +297,7 @@ class RedisClient
294
297
  end
295
298
 
296
299
  def renew_cluster_state
297
- @node.reload!
300
+ @node.try_reload!
298
301
  rescue ::RedisClient::Cluster::InitialSetupError
299
302
  # ignore
300
303
  end
@@ -340,8 +343,8 @@ class RedisClient
340
343
  raise if retry_count <= 0
341
344
  raise if e.errors.values.none? { |err| err.message.include?('WAIT cannot be used with replica instances') }
342
345
 
343
- retry_count -= 1
344
346
  renew_cluster_state
347
+ retry_count -= 1
345
348
  retry
346
349
  end
347
350
 
@@ -452,7 +455,7 @@ class RedisClient
452
455
  end
453
456
 
454
457
  def send_multiple_keys_command(method, command, args, &block) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
455
- # This implementation is prioritized performance rather than readability or so.
458
+ # This implementation prioritizes performance over readability.
456
459
  cmd = command.first
457
460
  if cmd.casecmp('mget').zero?
458
461
  single_key_cmd = 'get'
@@ -469,8 +472,13 @@ class RedisClient
469
472
 
470
473
  return assign_node_and_send_command(method, command, args, &block) if command.size <= keys_step + 1 || ::RedisClient::Cluster::KeySlotConverter.hash_tag_included?(command[1])
471
474
 
472
- seed = @config.use_replica? && @config.replica_affinity == :random ? nil : Random.new_seed
473
- pipeline = ::RedisClient::Cluster::Pipeline.new(self, @command_builder, @concurrent_worker, exception: true, seed: seed)
475
+ pipeline = ::RedisClient::Cluster::Pipeline.new(
476
+ self,
477
+ @command_builder,
478
+ @concurrent_worker,
479
+ exception: true,
480
+ seed: Random.new_seed
481
+ )
474
482
 
475
483
  single_command = Array.new(keys_step + 1)
476
484
  single_command[0] = single_key_cmd
@@ -502,8 +510,8 @@ class RedisClient
502
510
  rescue ::RedisClient::Cluster::Node::ReloadNeeded
503
511
  raise ::RedisClient::Cluster::NodeMightBeDown.new.with_config(@config) if retry_count <= 0
504
512
 
505
- retry_count -= 1
506
513
  renew_cluster_state
514
+ retry_count -= 1
507
515
  retry
508
516
  end
509
517
  end
@@ -171,7 +171,7 @@ class RedisClient
171
171
  send_transaction(node, redirect: redirect - 1)
172
172
  elsif err.message.start_with?('ASK')
173
173
  node = @router.assign_asking_node(err.message)
174
- try_asking(node) ? send_transaction(node, redirect: redirect - 1) : err
174
+ try_asking(node) ? send_transaction(node, redirect: redirect - 1) : raise(err)
175
175
  elsif err.message.start_with?('CLUSTERDOWN')
176
176
  @router.renew_cluster_state if @watching_slot.nil?
177
177
  raise err
@@ -95,13 +95,12 @@ class RedisClient
95
95
  end
96
96
 
97
97
  def pipelined(exception: true)
98
- seed = @config.use_replica? && @config.replica_affinity == :random ? nil : Random.new_seed
99
98
  pipeline = ::RedisClient::Cluster::Pipeline.new(
100
99
  router,
101
100
  @command_builder,
102
101
  @concurrent_worker,
103
102
  exception: exception,
104
- seed: seed
103
+ seed: Random.new_seed
105
104
  )
106
105
 
107
106
  yield pipeline
@@ -21,9 +21,9 @@ class RedisClient
21
21
  MERGE_CONFIG_KEYS = %i[ssl username password db].freeze
22
22
  IGNORE_GENERIC_CONFIG_KEYS = %i[url host port path].freeze
23
23
  MAX_WORKERS = Integer(ENV.fetch('REDIS_CLIENT_MAX_THREADS', -1)) # for backward compatibility
24
- # It's used with slow queries of fetching meta data like CLUSTER NODES, COMMAND and so on.
24
+ # Used for slow commands that fetch metadata, e.g. CLUSTER NODES, COMMAND.
25
25
  SLOW_COMMAND_TIMEOUT = Float(ENV.fetch('REDIS_CLIENT_SLOW_COMMAND_TIMEOUT', -1))
26
- # It affects to strike a balance between load and stability in initialization or changed states.
26
+ # Controls the balance between startup load and stability during initialization or cluster state changes.
27
27
  MAX_STARTUP_SAMPLE = Integer(ENV.fetch('REDIS_CLIENT_MAX_STARTUP_SAMPLE', 3))
28
28
 
29
29
  private_constant :DEFAULT_HOST, :DEFAULT_PORT, :DEFAULT_SCHEME, :SECURE_SCHEME, :DEFAULT_NODES,
@@ -32,6 +32,12 @@ class RedisClient
32
32
 
33
33
  InvalidClientConfigError = Class.new(::RedisClient::Cluster::Error)
34
34
 
35
+ SENSITIVE_INSPECT_KEYS = %i[username password].freeze
36
+ INSPECT_REDACTED_KEYS = %i[command_builder].freeze
37
+ INSPECT_PLACEHOLDER = '[FILTERED]'
38
+
39
+ private_constant :SENSITIVE_INSPECT_KEYS, :INSPECT_REDACTED_KEYS, :INSPECT_PLACEHOLDER
40
+
35
41
  attr_reader :command_builder, :client_config, :replica_affinity, :slow_command_timeout,
36
42
  :connect_with_original_config, :startup_nodes, :max_startup_sample, :id
37
43
 
@@ -65,7 +71,7 @@ class RedisClient
65
71
  end
66
72
 
67
73
  def inspect
68
- "#<#{self.class.name} #{startup_nodes.values.map { |v| v.reject { |k| k == :command_builder } }}>"
74
+ "#<#{self.class.name} #{startup_nodes.values.map { |v| redact_for_inspect(v) }}>"
69
75
  end
70
76
 
71
77
  def connect_timeout
@@ -117,6 +123,14 @@ class RedisClient
117
123
 
118
124
  private
119
125
 
126
+ def redact_for_inspect(node_config)
127
+ node_config.each_with_object({}) do |(key, value), redacted|
128
+ next if INSPECT_REDACTED_KEYS.include?(key)
129
+
130
+ redacted[key] = SENSITIVE_INSPECT_KEYS.include?(key) ? INSPECT_PLACEHOLDER : value
131
+ end
132
+ end
133
+
120
134
  def merge_concurrency_option(option)
121
135
  opts = {}
122
136
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redis-cluster-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.0
4
+ version: 0.16.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Taishi Kasuga
@@ -74,7 +74,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
74
74
  - !ruby/object:Gem::Version
75
75
  version: '0'
76
76
  requirements: []
77
- rubygems_version: 4.0.6
77
+ rubygems_version: 4.0.10
78
78
  specification_version: 4
79
79
  summary: Redis cluster-aware client for Ruby
80
80
  test_files: []