redis-cluster-client 0.15.0 → 0.16.4

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: 80b5e1d6525a06ecf8747239269b110528bf9efc99ff3424108033c85cdc011a
4
+ data.tar.gz: 35b5aba54bc61e3731509c7ba3959289ddcfb30e40193f365fc26f62f4c7e60d
5
5
  SHA512:
6
- metadata.gz: d61ebaa012160e38ef5bd42de839a637f209a7c409b507fd6f8a6e7f5898cbb8a61960ebc5566b95bd1aedad98f767b98ab1a7d6d2a8400e48da433794b5e4f2
7
- data.tar.gz: b28d1d461814dafe2e272cc65ed3be2f805b754ecf4ad3831674d7d478eeac3266513e0cdda8a0cabaf604f8292b8034a936b4693066c794a5bc558688ab287a
6
+ metadata.gz: ab546dc6dc75d1ec1733c5cd5f29a451881d71db8c21e587be92ab77c2f12f602100468626b742a1d87caa98aa27699906f9aefd7d9cff02512c3be1e355bdd4
7
+ data.tar.gz: 741326d16416834940e08c7084019080188d3e6b252c6dafdf3037bb9a40e2f9aafc0dc771fce7ac4edbb50af6d630067fb3646f4e7f1016ab72d7ceafb04940
@@ -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,7 +104,7 @@ 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,
@@ -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,7 +48,7 @@ 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
 
@@ -106,8 +108,7 @@ class RedisClient
106
108
  @topology = klass.new(pool, @concurrent_worker, **kwargs)
107
109
  @config = config
108
110
  @mutex = Mutex.new
109
- @last_reloaded_at = nil
110
- @reload_times = 0
111
+ @next_reload_time = nil
111
112
  @random = Random.new
112
113
  end
113
114
 
@@ -193,26 +194,22 @@ class RedisClient
193
194
  end
194
195
 
195
196
  def update_slot(slot, node_key)
196
- return if @mutex.locked?
197
+ return unless @mutex.try_lock
197
198
 
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
199
+ @slots[slot] = node_key
200
+ rescue RangeError
201
+ @slots = Array.new(SLOT_SIZE) { |i| @slots[i] }
202
+ @slots[slot] = node_key
203
+ ensure
204
+ @mutex.unlock if @mutex.owned?
204
205
  end
205
206
 
206
- def reload!
207
+ def try_reload!
207
208
  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)]
209
+ with_reload_jitter do
210
+ with_startup_clients(@config.max_startup_sample) do |clients|
211
+ reload!(clients)
212
212
  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
213
  end
217
214
  end
218
215
  end
@@ -312,12 +309,11 @@ class RedisClient
312
309
  work_group.push(i, raw_client) do |client|
313
310
  regular_timeout = client.read_timeout
314
311
  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)
312
+ fetch_cluster_state(client)
318
313
  rescue StandardError => e
319
314
  e
320
315
  ensure
316
+ client.read_timeout = regular_timeout
321
317
  client&.close
322
318
  end
323
319
  end
@@ -347,6 +343,16 @@ class RedisClient
347
343
  grouped.max_by { |_, v| v.size }[1].first
348
344
  end
349
345
 
346
+ def fetch_cluster_state(client)
347
+ reply = client.call_once('cluster', 'shards')
348
+ parse_cluster_shards_reply(reply)
349
+ rescue ::RedisClient::CommandError => e
350
+ raise unless e.message.start_with?('ERR Unknown subcommand')
351
+
352
+ reply = client.call_once('cluster', 'nodes')
353
+ parse_cluster_node_reply(reply)
354
+ end
355
+
350
356
  def parse_cluster_node_reply(reply) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
351
357
  reply.each_line("\n", chomp: true).filter_map do |line|
352
358
  fields = line.split
@@ -390,39 +396,61 @@ class RedisClient
390
396
  id: id,
391
397
  node_key: NodeKey.build_from_host_port(ip, arr[1]),
392
398
  role: role,
393
- primary_id: role == 'master' ? nil : primary_id,
399
+ primary_id: role == 'master' ? EMPTY_STRING : primary_id,
394
400
  slots: role == 'master' ? slots : EMPTY_ARRAY
395
401
  )
396
402
  end
397
- end.freeze
403
+ end
398
404
  end
399
405
 
400
406
  def parse_cluster_shards_reply(reply) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
401
407
  reply.each_with_object([]) do |shard, acc|
408
+ resp2 = shard.is_a?(Array)
409
+ shard = shard.each_slice(2).to_h if resp2
402
410
  nodes = shard.fetch('nodes')
411
+ nodes = nodes.map { |n| n.each_slice(2).to_h } if resp2
403
412
  primary_id = nodes.find { |n| n.fetch('role') == 'master' }.fetch('id')
404
413
 
405
414
  nodes.each do |node|
406
- ip = node.fetch('ip')
407
- next if node.fetch('health') != 'online' || ip.nil? || ip.empty? || ip == '?'
415
+ host = pick_shard_host(node)
416
+ next if node.fetch('health') != 'online' || host.nil? || host.empty? || host == '?'
408
417
 
409
418
  role = node.fetch('role')
410
419
  acc << ::RedisClient::Cluster::Node::Info.new(
411
420
  id: node.fetch('id'),
412
- node_key: NodeKey.build_from_host_port(ip, node['port'] || node['tls-port']),
421
+ node_key: NodeKey.build_from_host_port(host, node['port'] || node['tls-port']),
413
422
  role: role == 'master' ? role : 'slave',
414
- primary_id: role == 'master' ? nil : primary_id,
423
+ primary_id: role == 'master' ? EMPTY_STRING : primary_id,
415
424
  slots: role == 'master' ? shard.fetch('slots').each_slice(2).to_a.freeze : EMPTY_ARRAY
416
425
  )
417
426
  end
418
- end.freeze
427
+ end
428
+ end
429
+
430
+ # Pick the host for a CLUSTER SHARDS node entry.
431
+ #
432
+ # `endpoint` is the server-selected preferred endpoint (driven by the
433
+ # `cluster-preferred-endpoint-type` config), so prefer it when present and
434
+ # usable. Some managed services (e.g. AWS ElastiCache Serverless) report
435
+ # `127.0.0.1` in `ip` while exposing the reachable address only via
436
+ # `endpoint` / `hostname`; falling through to `ip` in that case would build
437
+ # an unreachable topology. This mirrors the precedence `parse_node_key`
438
+ # uses for CLUSTER NODES output (see #207).
439
+ def pick_shard_host(node)
440
+ endpoint = node['endpoint']
441
+ return endpoint if endpoint && !endpoint.empty? && endpoint != '?'
442
+
443
+ hostname = node['hostname']
444
+ return hostname if hostname && !hostname.empty?
445
+
446
+ node['ip']
419
447
  end
420
448
 
421
449
  # As redirection node_key is dependent on `cluster-preferred-endpoint-type` config,
422
450
  # node_key should use hostname if present in CLUSTER NODES output.
423
451
  #
424
452
  # 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]*]>
453
+ # node_address matches the format: <ip:port@cport[,hostname[,auxiliary_field=value]*]>
426
454
  def parse_node_key(node_address)
427
455
  ip_chunk, hostname, _auxiliaries = node_address.split(',')
428
456
  ip_port_string = ip_chunk.split('@').first
@@ -432,6 +460,16 @@ class RedisClient
432
460
  "#{hostname}:#{port}"
433
461
  end
434
462
 
463
+ def reload!(clients)
464
+ @node_info = refetch_node_info_list(clients)
465
+ @node_configs = @node_info.to_h do |node_info|
466
+ [node_info.node_key, @config.client_config_for_node(node_info.node_key)]
467
+ end
468
+ @slots = build_slot_node_mappings(@node_info)
469
+ @replications = build_replication_mappings(@node_info)
470
+ @topology.process_topology_update!(@replications, @node_configs)
471
+ end
472
+
435
473
  def with_startup_clients(count) # rubocop:disable Metrics/AbcSize
436
474
  if @config.connect_with_original_config
437
475
  # If connect_with_original_config is set, that means we need to build actual client objects
@@ -457,35 +495,43 @@ class RedisClient
457
495
  end
458
496
  end
459
497
 
498
+ def with_reload_jitter
499
+ return unless @next_reload_time.nil? || obtain_current_time >= @next_reload_time
500
+
501
+ begin
502
+ yield
503
+ ensure
504
+ @next_reload_time = obtain_current_time + @random.rand(JITTER_WINDOW)
505
+ end
506
+ end
507
+
460
508
  def with_reload_lock
461
- # What should happen with concurrent calls #reload? This is a realistic possibility if the cluster goes into
509
+ # What should happen with concurrent calls #try_reload! This is a realistic possibility if the cluster goes into
462
510
  # 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
511
+ # call #try_reload!.
512
+ # For now, if a reload is in progress by a thread, the other threads do not wait for that to complete, and
513
+ # they throw an error.
514
+ # Probably in the future we should add a circuit breaker to #try_reload! itself, and stop trying if the cluster is
467
515
  # 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
516
+ return unless @mutex.try_lock
478
517
 
479
- r = yield
480
- @last_reloaded_at = obtain_current_time
481
- @reload_times += 1
482
- r
483
- end
518
+ yield
519
+ ensure
520
+ @mutex.unlock if @mutex.owned?
484
521
  end
485
522
 
486
523
  def obtain_current_time
487
524
  Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
488
525
  end
526
+
527
+ def bypass_reload!
528
+ # DO NOT USE THIS METHOD
529
+ with_reload_lock do
530
+ with_startup_clients(@config.max_startup_sample) do |clients|
531
+ reload!(clients)
532
+ end
533
+ end
534
+ end
489
535
  end
490
536
  end
491
537
  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
 
@@ -54,7 +54,7 @@ class RedisClient
54
54
  rescue ::RedisClient::ConnectionError
55
55
  # Deduct the number of retries that happened _inside_ router#handle_redirection from our remaining
56
56
  # _external_ retries. Always deduct at least one in case handle_redirection raises without trying the block.
57
- retry_count -= [times_block_executed, 1].min
57
+ retry_count -= times_block_executed == 0 ? 1 : [times_block_executed, 1].min
58
58
  raise if retry_count < 0
59
59
 
60
60
  retry
@@ -230,10 +230,10 @@ class RedisClient
230
230
 
231
231
  def append_pipeline(node_key)
232
232
  @pipelines ||= {}
233
- @pipelines[node_key] ||= ::RedisClient::Cluster::Pipeline::Extended.new(::RedisClient::Cluster::NoopCommandBuilder)
234
- @pipelines[node_key].add_outer_index(@size)
233
+ pi = (@pipelines[node_key] ||= ::RedisClient::Cluster::Pipeline::Extended.new(::RedisClient::Cluster::NoopCommandBuilder))
234
+ pi.add_outer_index(@size)
235
235
  @size += 1
236
- @pipelines[node_key]
236
+ pi
237
237
  end
238
238
 
239
239
  def do_pipelining(client, pipeline)
@@ -61,7 +61,17 @@ class RedisClient
61
61
  end
62
62
 
63
63
  BUF_SIZE = Integer(ENV.fetch('REDIS_CLIENT_PUBSUB_BUF_SIZE', 1024))
64
- private_constant :BUF_SIZE
64
+ RECOVERY_BASE_INTERVAL = Float(ENV.fetch('REDIS_CLIENT_PUBSUB_RECOVERY_INTERVAL_SEC', 1.0))
65
+ RECOVERY_MAX_INTERVAL = Float(ENV.fetch('REDIS_CLIENT_PUBSUB_RECOVERY_MAX_INTERVAL_SEC', 30.0))
66
+ RECOVERY_MAX_ATTEMPTS = Integer(ENV.fetch('REDIS_CLIENT_PUBSUB_RECOVERY_MAX_ATTEMPTS', 10))
67
+ SUBSCRIBE_COMMANDS = %w[subscribe psubscribe ssubscribe].freeze
68
+ UNSUBSCRIBE_TO_SUBSCRIBE = {
69
+ 'unsubscribe' => 'subscribe',
70
+ 'punsubscribe' => 'psubscribe',
71
+ 'sunsubscribe' => 'ssubscribe'
72
+ }.freeze
73
+ private_constant :BUF_SIZE, :RECOVERY_BASE_INTERVAL, :RECOVERY_MAX_INTERVAL, :RECOVERY_MAX_ATTEMPTS,
74
+ :SUBSCRIBE_COMMANDS, :UNSUBSCRIBE_TO_SUBSCRIBE
65
75
 
66
76
  def initialize(router, command_builder)
67
77
  @router = router
@@ -74,14 +84,14 @@ class RedisClient
74
84
  def call(*args, **kwargs)
75
85
  command = @command_builder.generate(args, kwargs)
76
86
  _call(command)
77
- @commands << command
87
+ remember(command)
78
88
  nil
79
89
  end
80
90
 
81
91
  def call_v(command)
82
92
  command = @command_builder.generate(command)
83
93
  _call(command)
84
- @commands << command
94
+ remember(command)
85
95
  nil
86
96
  end
87
97
 
@@ -118,6 +128,35 @@ class RedisClient
118
128
 
119
129
  private
120
130
 
131
+ def remember(command)
132
+ cmd = command.first.to_s.downcase
133
+ if SUBSCRIBE_COMMANDS.include?(cmd)
134
+ @commands << command
135
+ elsif (subscribe_cmd = UNSUBSCRIBE_TO_SUBSCRIBE[cmd])
136
+ forget_subscriptions(subscribe_cmd, command[1..])
137
+ else
138
+ @commands << command
139
+ end
140
+ end
141
+
142
+ def forget_subscriptions(subscribe_cmd, channels)
143
+ if channels.nil? || channels.empty?
144
+ @commands.reject! { |c| c.first.to_s.casecmp(subscribe_cmd).zero? }
145
+ else
146
+ @commands.reject! { |c| prune_entry(c, subscribe_cmd, channels) }
147
+ end
148
+ end
149
+
150
+ def prune_entry(entry, subscribe_cmd, channels)
151
+ return false unless entry.first.to_s.casecmp(subscribe_cmd).zero?
152
+
153
+ remaining = entry[1..] - channels
154
+ return true if remaining.empty?
155
+
156
+ entry.replace([entry.first, *remaining])
157
+ false
158
+ end
159
+
121
160
  def _call(command) # rubocop:disable Metrics/AbcSize
122
161
  if command.first.casecmp('subscribe').zero?
123
162
  call_to_single_state(command)
@@ -179,6 +218,7 @@ class RedisClient
179
218
  end
180
219
 
181
220
  def start_over
221
+ attempt = 0
182
222
  loop do
183
223
  @router.renew_cluster_state
184
224
  @state_dict.each_value(&:close)
@@ -186,9 +226,16 @@ class RedisClient
186
226
  @commands.each { |command| _call(command) }
187
227
  break
188
228
  rescue ::RedisClient::ConnectionError, ::RedisClient::Cluster::NodeMightBeDown
189
- sleep 1.0
229
+ attempt += 1
230
+ raise if attempt >= RECOVERY_MAX_ATTEMPTS
231
+
232
+ sleep recovery_interval(attempt)
190
233
  end
191
234
  end
235
+
236
+ def recovery_interval(attempt)
237
+ [RECOVERY_BASE_INTERVAL * (2**(attempt - 1)), RECOVERY_MAX_INTERVAL].min
238
+ end
192
239
  end
193
240
  end
194
241
  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,8 +166,8 @@ 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
+ retry_count -= 1
172
171
 
173
172
  if retry_count >= 0
174
173
  # Find the node to use for this command - if this fails for some reason, though, re-use
@@ -180,7 +179,6 @@ class RedisClient
180
179
  retry
181
180
  end
182
181
 
183
- retry if retry_count >= 0
184
182
  raise
185
183
  end
186
184
 
@@ -248,23 +246,27 @@ class RedisClient
248
246
  end
249
247
 
250
248
  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))
249
+ cmd_spec = @command.get_spec(command.first)
250
+ find_node_key_by_key(
251
+ cmd_spec&.extract_first_key(command),
252
+ seed: seed,
253
+ primary: cmd_spec&.should_send_to_primary?
254
+ )
253
255
  end
254
256
 
255
257
  def find_primary_node_key(command)
256
- key = @command.extract_first_key(command)
257
- return nil unless key&.size&.> 0
258
+ key = @command.get_spec(command.first)&.extract_first_key(command)
259
+ return unless key&.size&.> 0
258
260
 
259
261
  find_node_key_by_key(key, primary: true)
260
262
  end
261
263
 
262
264
  def find_slot(command)
263
- find_slot_by_key(@command.extract_first_key(command))
265
+ find_slot_by_key(@command.get_spec(command.first)&.extract_first_key(command))
264
266
  end
265
267
 
266
268
  def find_slot_by_key(key)
267
- return if key.empty?
269
+ return if key.nil? || key.empty?
268
270
 
269
271
  ::RedisClient::Cluster::KeySlotConverter.convert(key)
270
272
  end
@@ -294,7 +296,7 @@ class RedisClient
294
296
  end
295
297
 
296
298
  def renew_cluster_state
297
- @node.reload!
299
+ @node.try_reload!
298
300
  rescue ::RedisClient::Cluster::InitialSetupError
299
301
  # ignore
300
302
  end
@@ -340,8 +342,8 @@ class RedisClient
340
342
  raise if retry_count <= 0
341
343
  raise if e.errors.values.none? { |err| err.message.include?('WAIT cannot be used with replica instances') }
342
344
 
343
- retry_count -= 1
344
345
  renew_cluster_state
346
+ retry_count -= 1
345
347
  retry
346
348
  end
347
349
 
@@ -452,7 +454,7 @@ class RedisClient
452
454
  end
453
455
 
454
456
  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.
457
+ # This implementation prioritizes performance over readability.
456
458
  cmd = command.first
457
459
  if cmd.casecmp('mget').zero?
458
460
  single_key_cmd = 'get'
@@ -469,8 +471,13 @@ class RedisClient
469
471
 
470
472
  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
473
 
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)
474
+ pipeline = ::RedisClient::Cluster::Pipeline.new(
475
+ self,
476
+ @command_builder,
477
+ @concurrent_worker,
478
+ exception: true,
479
+ seed: Random.new_seed
480
+ )
474
481
 
475
482
  single_command = Array.new(keys_step + 1)
476
483
  single_command[0] = single_key_cmd
@@ -502,8 +509,8 @@ class RedisClient
502
509
  rescue ::RedisClient::Cluster::Node::ReloadNeeded
503
510
  raise ::RedisClient::Cluster::NodeMightBeDown.new.with_config(@config) if retry_count <= 0
504
511
 
505
- retry_count -= 1
506
512
  renew_cluster_state
513
+ retry_count -= 1
507
514
  retry
508
515
  end
509
516
  end
@@ -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.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Taishi Kasuga