redis-cluster-client 0.11.0 → 0.13.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: d885c147de46fd113d493e60bd340cac591ecc94b7eafcfddbd6044abe10f79a
4
- data.tar.gz: f13c1012ef977e8f9c3e38d928c5c3eb95ba2242761fa992033323c115b318f9
3
+ metadata.gz: 0abe95ee2079026038121cd93e8ae7929726ee4206b3d1f401dc7ed82f1da2c8
4
+ data.tar.gz: 7fa2e9c6c20d814f54e2fcf8045094869b4b5fc6c39144a8900328a9f5401abc
5
5
  SHA512:
6
- metadata.gz: 68c939431088425c7735c68b4af0949bc5918837d9f19878c62d71aa1fec6e31efee5de2868325447a9e4672c108299a86cc6d047447ef10e7987daa04ecb1e9
7
- data.tar.gz: f8ae784f6977151fd825c34878e7f6162175cd828d0b81a92da07e5af96a00a56467ffbed3822f24c198f2373900dc20ea69f8446e3468fc7ba54945baa44f65
6
+ metadata.gz: 26be35f5eb57e7280b8a38441f3d80c4956638a68418cf6964bdbf27d4cb3051f5955179b90066409c908ce771f3ee4286d0e569711d198ffcbfc22a70cc49bf
7
+ data.tar.gz: 976db1b41a2d3592dac70bf25ea8f44308824bbc7009082dd54fe68ad01b934e1b572b89df1f3b9e45b791851966c909a022defae45f4d9c3270469e9d6acc88
@@ -3,7 +3,6 @@
3
3
  require 'redis_client'
4
4
  require 'redis_client/cluster/errors'
5
5
  require 'redis_client/cluster/key_slot_converter'
6
- require 'redis_client/cluster/normalized_cmd_name'
7
6
 
8
7
  class RedisClient
9
8
  class Cluster
@@ -12,10 +11,11 @@ class RedisClient
12
11
  EMPTY_HASH = {}.freeze
13
12
  EMPTY_ARRAY = [].freeze
14
13
 
14
+ private_constant :EMPTY_STRING, :EMPTY_HASH, :EMPTY_ARRAY
15
+
15
16
  Detail = Struct.new(
16
17
  'RedisCommand',
17
18
  :first_key_position,
18
- :last_key_position,
19
19
  :key_step,
20
20
  :write?,
21
21
  :readonly?,
@@ -23,13 +23,13 @@ class RedisClient
23
23
  )
24
24
 
25
25
  class << self
26
- def load(nodes, slow_command_timeout: -1)
26
+ def load(nodes, slow_command_timeout: -1) # rubocop:disable Metrics/AbcSize
27
27
  cmd = errors = nil
28
28
 
29
29
  nodes&.each do |node|
30
30
  regular_timeout = node.read_timeout
31
31
  node.read_timeout = slow_command_timeout > 0.0 ? slow_command_timeout : regular_timeout
32
- reply = node.call('COMMAND')
32
+ reply = node.call('command')
33
33
  node.read_timeout = regular_timeout
34
34
  commands = parse_command_reply(reply)
35
35
  cmd = ::RedisClient::Cluster::Command.new(commands)
@@ -41,20 +41,33 @@ class RedisClient
41
41
 
42
42
  return cmd unless cmd.nil?
43
43
 
44
- raise ::RedisClient::Cluster::InitialSetupError, errors
44
+ raise ::RedisClient::Cluster::InitialSetupError.from_errors(errors)
45
45
  end
46
46
 
47
47
  private
48
48
 
49
- def parse_command_reply(rows)
49
+ def parse_command_reply(rows) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
50
50
  rows&.each_with_object({}) do |row, acc|
51
- next if row[0].nil?
51
+ next if row.first.nil?
52
+
53
+ # TODO: in redis 7.0 or later, subcommand information included in the command reply
54
+
55
+ pos = case row.first
56
+ when 'eval', 'evalsha', 'zinterstore', 'zunionstore' then 3
57
+ when 'object', 'xgroup' then 2
58
+ when 'migrate', 'xread', 'xreadgroup' then 0
59
+ else row[3]
60
+ end
52
61
 
53
- acc[row[0].downcase] = ::RedisClient::Cluster::Command::Detail.new(
54
- first_key_position: row[3],
55
- last_key_position: row[4],
62
+ writable = case row.first
63
+ when 'xgroup' then true
64
+ else row[2].include?('write')
65
+ end
66
+
67
+ acc[row.first] = ::RedisClient::Cluster::Command::Detail.new(
68
+ first_key_position: pos,
56
69
  key_step: row[5],
57
- write?: row[2].include?('write'),
70
+ write?: writable,
58
71
  readonly?: row[2].include?('readonly')
59
72
  )
60
73
  end.freeze || EMPTY_HASH
@@ -69,89 +82,48 @@ class RedisClient
69
82
  i = determine_first_key_position(command)
70
83
  return EMPTY_STRING if i == 0
71
84
 
72
- (command[i].is_a?(Array) ? command[i].flatten.first : command[i]).to_s
73
- end
74
-
75
- def extract_all_keys(command)
76
- keys_start = determine_first_key_position(command)
77
- keys_end = determine_last_key_position(command, keys_start)
78
- keys_step = determine_key_step(command)
79
- return EMPTY_ARRAY if [keys_start, keys_end, keys_step].any?(&:zero?)
80
-
81
- keys_end = [keys_end, command.size - 1].min
82
- # use .. inclusive range because keys_end is a valid index.
83
- (keys_start..keys_end).step(keys_step).map { |i| command[i] }
85
+ command[i]
84
86
  end
85
87
 
86
88
  def should_send_to_primary?(command)
87
- name = ::RedisClient::Cluster::NormalizedCmdName.instance.get_by_command(command)
88
- @commands[name]&.write?
89
+ find_command_info(command.first)&.write?
89
90
  end
90
91
 
91
92
  def should_send_to_replica?(command)
92
- name = ::RedisClient::Cluster::NormalizedCmdName.instance.get_by_command(command)
93
- @commands[name]&.readonly?
93
+ find_command_info(command.first)&.readonly?
94
94
  end
95
95
 
96
96
  def exists?(name)
97
- @commands.key?(::RedisClient::Cluster::NormalizedCmdName.instance.get_by_name(name))
97
+ @commands.key?(name) || @commands.key?(name.to_s.downcase(:ascii))
98
98
  end
99
99
 
100
100
  private
101
101
 
102
- def determine_first_key_position(command) # rubocop:disable Metrics/CyclomaticComplexity
103
- case name = ::RedisClient::Cluster::NormalizedCmdName.instance.get_by_command(command)
104
- when 'eval', 'evalsha', 'zinterstore', 'zunionstore' then 3
105
- when 'object' then 2
106
- when 'memory'
107
- command[1].to_s.casecmp('usage').zero? ? 2 : 0
108
- when 'migrate'
109
- command[3].empty? ? determine_optional_key_position(command, 'keys') : 3
110
- when 'xread', 'xreadgroup'
111
- determine_optional_key_position(command, 'streams')
112
- else
113
- @commands[name]&.first_key_position.to_i
114
- end
102
+ def find_command_info(name)
103
+ @commands[name] || @commands[name.to_s.downcase(:ascii)]
115
104
  end
116
105
 
117
- # IMPORTANT: this determines the last key position INCLUSIVE of the last key -
118
- # i.e. command[determine_last_key_position(command)] is a key.
119
- # This is in line with what Redis returns from COMMANDS.
120
- def determine_last_key_position(command, keys_start) # rubocop:disable Metrics/AbcSize
121
- case name = ::RedisClient::Cluster::NormalizedCmdName.instance.get_by_command(command)
122
- when 'eval', 'evalsha', 'zinterstore', 'zunionstore'
123
- # EVALSHA sha1 numkeys [key [key ...]] [arg [arg ...]]
124
- # ZINTERSTORE destination numkeys key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE <SUM | MIN | MAX>]
125
- command[2].to_i + 2
126
- when 'object', 'memory'
127
- # OBJECT [ENCODING | FREQ | IDLETIME | REFCOUNT] key
128
- # MEMORY USAGE key [SAMPLES count]
129
- keys_start
130
- when 'migrate'
131
- # MIGRATE host port <key | ""> destination-db timeout [COPY] [REPLACE] [AUTH password | AUTH2 username password] [KEYS key [key ...]]
132
- command[3].empty? ? (command.length - 1) : 3
133
- when 'xread', 'xreadgroup'
134
- # XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] id [id ...]
135
- keys_start + ((command.length - keys_start) / 2) - 1
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
136
119
  else
137
- # If there is a fixed, non-variable number of keys, don't iterate past that.
138
- if @commands[name].last_key_position >= 0
139
- @commands[name].last_key_position
140
- else
141
- command.length + @commands[name].last_key_position
142
- end
120
+ i
143
121
  end
144
122
  end
145
123
 
146
- def determine_optional_key_position(command, option_name) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
147
- idx = command&.flatten&.map(&:to_s)&.map(&:downcase)&.index(option_name&.downcase)
148
- idx.nil? ? 0 : idx + 1
149
- end
150
-
151
- def determine_key_step(command)
152
- name = ::RedisClient::Cluster::NormalizedCmdName.instance.get_by_command(command)
153
- # Some commands like EVALSHA have zero as the step in COMMANDS somehow.
154
- @commands[name].key_step == 0 ? 1 : @commands[name].key_step
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
155
127
  end
156
128
  end
157
129
  end
@@ -5,6 +5,8 @@ class RedisClient
5
5
  module ConcurrentWorker
6
6
  class OnDemand
7
7
  def initialize(size:)
8
+ raise ArgumentError, "size must be positive: #{size}" unless size.positive?
9
+
8
10
  @q = SizedQueue.new(size)
9
11
  end
10
12
 
@@ -11,6 +11,8 @@ class RedisClient
11
11
  # So it consumes memory 1 MB multiplied a number of workers.
12
12
  class Pooled
13
13
  def initialize(size:)
14
+ raise ArgumentError, "size must be positive: #{size}" unless size.positive?
15
+
14
16
  @size = size
15
17
  setup
16
18
  end
@@ -71,14 +71,12 @@ class RedisClient
71
71
 
72
72
  module_function
73
73
 
74
- def create(model: :on_demand, size: 5)
75
- size = size.positive? ? size : 5
76
-
74
+ def create(model: :none, size: 5)
77
75
  case model
78
- when :on_demand, nil then ::RedisClient::Cluster::ConcurrentWorker::OnDemand.new(size: size)
79
- when :pooled then ::RedisClient::Cluster::ConcurrentWorker::Pooled.new(size: size)
80
76
  when :none then ::RedisClient::Cluster::ConcurrentWorker::None.new
81
- else raise ArgumentError, "Unknown model: #{model}"
77
+ when :on_demand then ::RedisClient::Cluster::ConcurrentWorker::OnDemand.new(size: size)
78
+ when :pooled then ::RedisClient::Cluster::ConcurrentWorker::Pooled.new(size: size)
79
+ else raise ArgumentError, "unknown model: #{model}"
82
80
  end
83
81
  end
84
82
  end
@@ -4,50 +4,70 @@ require 'redis_client'
4
4
 
5
5
  class RedisClient
6
6
  class Cluster
7
+ class Error < ::RedisClient::Error
8
+ def with_config(config)
9
+ @config = config
10
+ self
11
+ end
12
+ end
13
+
7
14
  ERR_ARG_NORMALIZATION = ->(arg) { Array[arg].flatten.reject { |e| e.nil? || (e.respond_to?(:empty?) && e.empty?) } }
15
+ Ractor.make_shareable(ERR_ARG_NORMALIZATION) if Object.const_defined?(:Ractor, false) && Ractor.respond_to?(:make_shareable)
16
+
17
+ private_constant :ERR_ARG_NORMALIZATION
8
18
 
9
- class InitialSetupError < ::RedisClient::Error
10
- def initialize(errors)
19
+ class InitialSetupError < Error
20
+ def self.from_errors(errors)
11
21
  msg = ERR_ARG_NORMALIZATION.call(errors).map(&:message).uniq.join(',')
12
- super("Redis client could not fetch cluster information: #{msg}")
22
+ new("Redis client could not fetch cluster information: #{msg}")
13
23
  end
14
24
  end
15
25
 
16
- class OrchestrationCommandNotSupported < ::RedisClient::Error
17
- def initialize(command)
26
+ class OrchestrationCommandNotSupported < Error
27
+ def self.from_command(command)
18
28
  str = ERR_ARG_NORMALIZATION.call(command).map(&:to_s).join(' ').upcase
19
29
  msg = "#{str} command should be used with care " \
20
30
  'only by applications orchestrating Redis Cluster, like redis-cli, ' \
21
31
  'and the command if used out of the right context can leave the cluster ' \
22
32
  'in a wrong state or cause data loss.'
23
- super(msg)
33
+ new(msg)
24
34
  end
25
35
  end
26
36
 
27
- class ErrorCollection < ::RedisClient::Error
37
+ class ErrorCollection < Error
38
+ EMPTY_HASH = {}.freeze
39
+
40
+ private_constant :EMPTY_HASH
28
41
  attr_reader :errors
29
42
 
30
- def initialize(errors)
31
- @errors = {}
43
+ def self.with_errors(errors)
32
44
  if !errors.is_a?(Hash) || errors.empty?
33
- super('')
34
- return
45
+ new(errors.to_s).with_errors(EMPTY_HASH)
46
+ else
47
+ messages = errors.map { |node_key, error| "#{node_key}: (#{error.class}) #{error.message}" }.freeze
48
+ new(messages.join(', ')).with_errors(errors)
35
49
  end
50
+ end
51
+
52
+ def initialize(error_message = nil)
53
+ @errors = nil
54
+ super
55
+ end
36
56
 
37
- @errors = errors
38
- messages = @errors.map { |node_key, error| "#{node_key}: #{error.message}" }
39
- super("Errors occurred on any node: #{messages.join(', ')}")
57
+ def with_errors(errors)
58
+ @errors = errors if @errors.nil?
59
+ self
40
60
  end
41
61
  end
42
62
 
43
- class AmbiguousNodeError < ::RedisClient::Error
44
- def initialize(command)
45
- super("Cluster client doesn't know which node the #{command} command should be sent to.")
63
+ class AmbiguousNodeError < Error
64
+ def self.from_command(command)
65
+ new("Cluster client doesn't know which node the #{command} command should be sent to.")
46
66
  end
47
67
  end
48
68
 
49
- class NodeMightBeDown < ::RedisClient::Error
50
- def initialize(_ = '')
69
+ class NodeMightBeDown < Error
70
+ def initialize(_error_message = nil)
51
71
  super(
52
72
  'The client is trying to fetch the latest cluster state ' \
53
73
  'because a subset of nodes might be down. ' \
@@ -3,6 +3,7 @@
3
3
  class RedisClient
4
4
  class Cluster
5
5
  module KeySlotConverter
6
+ HASH_SLOTS = 16_384
6
7
  EMPTY_STRING = ''
7
8
  LEFT_BRACKET = '{'
8
9
  RIGHT_BRACKET = '}'
@@ -41,7 +42,8 @@ class RedisClient
41
42
  0x6e17, 0x7e36, 0x4e55, 0x5e74, 0x2e93, 0x3eb2, 0x0ed1, 0x1ef0
42
43
  ].freeze
43
44
 
44
- HASH_SLOTS = 16_384
45
+ private_constant :HASH_SLOTS, :EMPTY_STRING,
46
+ :LEFT_BRACKET, :RIGHT_BRACKET, :XMODEM_CRC16_LOOKUP
45
47
 
46
48
  module_function
47
49
 
@@ -8,6 +8,8 @@ class RedisClient
8
8
  EMPTY_HASH = {}.freeze
9
9
  EMPTY_ARRAY = [].freeze
10
10
 
11
+ private_constant :IGNORE_GENERIC_CONFIG_KEYS, :EMPTY_HASH, :EMPTY_ARRAY
12
+
11
13
  attr_reader :clients, :primary_clients, :replica_clients
12
14
 
13
15
  def initialize(pool, concurrent_worker, **kwargs)
@@ -52,7 +54,7 @@ class RedisClient
52
54
  def connect_to_new_nodes(options)
53
55
  (options.keys - @clients.keys).each do |node_key|
54
56
  option = options[node_key].merge(@client_options)
55
- config = ::RedisClient::Cluster::Node::Config.new(scale_read: !@primary_node_keys.include?(node_key), **option)
57
+ config = ::RedisClient::Cluster::Node::Config.new(scale_read: @replica_node_keys.include?(node_key), **option)
56
58
  client = @pool.nil? ? config.new_client : config.new_pool(**@pool)
57
59
  @clients[node_key] = client
58
60
  end
@@ -9,6 +9,8 @@ class RedisClient
9
9
  DUMMY_LATENCY_MSEC = 100 * 1000 * 1000
10
10
  MEASURE_ATTEMPT_COUNT = 10
11
11
 
12
+ private_constant :DUMMY_LATENCY_MSEC, :MEASURE_ATTEMPT_COUNT
13
+
12
14
  def clients_for_scanning(seed: nil) # rubocop:disable Lint/UnusedMethodArgument
13
15
  @clients_for_scanning
14
16
  end
@@ -45,7 +47,7 @@ class RedisClient
45
47
  min = DUMMY_LATENCY_MSEC
46
48
  MEASURE_ATTEMPT_COUNT.times do
47
49
  starting = obtain_current_time
48
- cli.call_once('PING')
50
+ cli.call_once('ping')
49
51
  duration = obtain_current_time - starting
50
52
  min = duration if duration < min
51
53
  end
@@ -7,6 +7,7 @@ require 'redis_client/cluster/node/primary_only'
7
7
  require 'redis_client/cluster/node/random_replica'
8
8
  require 'redis_client/cluster/node/random_replica_or_primary'
9
9
  require 'redis_client/cluster/node/latency_replica'
10
+ require 'redis_client/cluster/node_key'
10
11
 
11
12
  class RedisClient
12
13
  class Cluster
@@ -23,8 +24,12 @@ class RedisClient
23
24
  ROLE_FLAGS = %w[master slave].freeze
24
25
  EMPTY_ARRAY = [].freeze
25
26
  EMPTY_HASH = {}.freeze
27
+ STATE_REFRESH_INTERVAL = (3..10).freeze
26
28
 
27
- ReloadNeeded = Class.new(::RedisClient::Error)
29
+ private_constant :USE_CHAR_ARRAY_SLOT, :SLOT_SIZE, :MIN_SLOT, :MAX_SLOT,
30
+ :DEAD_FLAGS, :ROLE_FLAGS, :EMPTY_ARRAY, :EMPTY_HASH
31
+
32
+ ReloadNeeded = Class.new(::RedisClient::Cluster::Error)
28
33
 
29
34
  Info = Struct.new(
30
35
  'RedisClusterNode',
@@ -39,12 +44,18 @@ class RedisClient
39
44
  def replica?
40
45
  role == 'slave'
41
46
  end
47
+
48
+ def serialize(str)
49
+ str << id << node_key << role << primary_id << config_epoch
50
+ end
42
51
  end
43
52
 
44
53
  class CharArray
45
54
  BASE = ''
46
55
  PADDING = '0'
47
56
 
57
+ private_constant :BASE, :PADDING
58
+
48
59
  def initialize(size, elements)
49
60
  @elements = elements
50
61
  @string = String.new(BASE, encoding: Encoding::BINARY, capacity: size)
@@ -80,11 +91,9 @@ class RedisClient
80
91
  super(**kwargs)
81
92
  end
82
93
 
83
- private
84
-
85
- def build_connection_prelude
94
+ def connection_prelude
86
95
  prelude = super.dup
87
- prelude << ['READONLY'] if @scale_read
96
+ prelude << ['readonly'] if @scale_read
88
97
  prelude.freeze
89
98
  end
90
99
  end
@@ -98,6 +107,8 @@ class RedisClient
98
107
  @config = config
99
108
  @mutex = Mutex.new
100
109
  @last_reloaded_at = nil
110
+ @reload_times = 0
111
+ @random = Random.new
101
112
  end
102
113
 
103
114
  def inspect
@@ -140,7 +151,7 @@ class RedisClient
140
151
 
141
152
  raise ReloadNeeded if errors.values.any?(::RedisClient::ConnectionError)
142
153
 
143
- raise ::RedisClient::Cluster::ErrorCollection, errors
154
+ raise ::RedisClient::Cluster::ErrorCollection.with_errors(errors)
144
155
  end
145
156
 
146
157
  def clients_for_scanning(seed: nil)
@@ -259,7 +270,7 @@ class RedisClient
259
270
  result_values, errors = call_multiple_nodes(clients, method, command, args, &block)
260
271
  return result_values if errors.nil? || errors.empty?
261
272
 
262
- raise ::RedisClient::Cluster::ErrorCollection, errors
273
+ raise ::RedisClient::Cluster::ErrorCollection.with_errors(errors)
263
274
  end
264
275
 
265
276
  def try_map(clients, &block) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
@@ -301,7 +312,7 @@ class RedisClient
301
312
  work_group.push(i, raw_client) do |client|
302
313
  regular_timeout = client.read_timeout
303
314
  client.read_timeout = @config.slow_command_timeout > 0.0 ? @config.slow_command_timeout : regular_timeout
304
- reply = client.call('CLUSTER', 'NODES')
315
+ reply = client.call_once('cluster', 'nodes')
305
316
  client.read_timeout = regular_timeout
306
317
  parse_cluster_node_reply(reply)
307
318
  rescue StandardError => e
@@ -326,13 +337,11 @@ class RedisClient
326
337
 
327
338
  work_group.close
328
339
 
329
- raise ::RedisClient::Cluster::InitialSetupError, errors if node_info_list.nil?
340
+ raise ::RedisClient::Cluster::InitialSetupError.from_errors(errors) if node_info_list.nil?
330
341
 
331
342
  grouped = node_info_list.compact.group_by do |info_list|
332
343
  info_list.sort_by!(&:id)
333
- info_list.each_with_object(String.new(capacity: 128 * info_list.size)) do |e, a|
334
- a << e.id << e.node_key << e.role << e.primary_id << e.config_epoch
335
- end
344
+ info_list.each_with_object(String.new(capacity: 128 * info_list.size)) { |e, a| e.serialize(a) }
336
345
  end
337
346
 
338
347
  grouped.max_by { |_, v| v.size }[1].first
@@ -367,6 +376,48 @@ class RedisClient
367
376
  end
368
377
  end
369
378
 
379
+ def parse_cluster_slots_reply(reply) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
380
+ reply.group_by { |e| e[2][2] }.each_with_object([]) do |(primary_id, group), acc|
381
+ slots = group.map { |e| e[0, 2] }.freeze
382
+
383
+ group.first[2..].each do |arr|
384
+ ip = arr[0]
385
+ next if ip.nil? || ip.empty? || ip == '?'
386
+
387
+ id = arr[2]
388
+ role = id == primary_id ? 'master' : 'slave'
389
+ acc << ::RedisClient::Cluster::Node::Info.new(
390
+ id: id,
391
+ node_key: NodeKey.build_from_host_port(ip, arr[1]),
392
+ role: role,
393
+ primary_id: role == 'master' ? nil : primary_id,
394
+ slots: role == 'master' ? slots : EMPTY_ARRAY
395
+ )
396
+ end
397
+ end.freeze
398
+ end
399
+
400
+ def parse_cluster_shards_reply(reply) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
401
+ reply.each_with_object([]) do |shard, acc|
402
+ nodes = shard.fetch('nodes')
403
+ primary_id = nodes.find { |n| n.fetch('role') == 'master' }.fetch('id')
404
+
405
+ nodes.each do |node|
406
+ ip = node.fetch('ip')
407
+ next if node.fetch('health') != 'online' || ip.nil? || ip.empty? || ip == '?'
408
+
409
+ role = node.fetch('role')
410
+ acc << ::RedisClient::Cluster::Node::Info.new(
411
+ id: node.fetch('id'),
412
+ node_key: NodeKey.build_from_host_port(ip, node['port'] || node['tls-port']),
413
+ role: role == 'master' ? role : 'slave',
414
+ primary_id: role == 'master' ? nil : primary_id,
415
+ slots: role == 'master' ? shard.fetch('slots').each_slice(2).to_a.freeze : EMPTY_ARRAY
416
+ )
417
+ end
418
+ end.freeze
419
+ end
420
+
370
421
  # As redirection node_key is dependent on `cluster-preferred-endpoint-type` config,
371
422
  # node_key should use hostname if present in CLUSTER NODES output.
372
423
  #
@@ -414,15 +465,27 @@ class RedisClient
414
465
  # performed the reload.
415
466
  # Probably in the future we should add a circuit breaker to #reload itself, and stop trying if the cluster is
416
467
  # obviously not working.
417
- wait_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
468
+ wait_start = obtain_current_time
418
469
  @mutex.synchronize do
419
470
  return if @last_reloaded_at && @last_reloaded_at > wait_start
420
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
478
+
421
479
  r = yield
422
- @last_reloaded_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
480
+ @last_reloaded_at = obtain_current_time
481
+ @reload_times += 1
423
482
  r
424
483
  end
425
484
  end
485
+
486
+ def obtain_current_time
487
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
488
+ end
426
489
  end
427
490
  end
428
491
  end
@@ -8,6 +8,8 @@ class RedisClient
8
8
  module NodeKey
9
9
  DELIMITER = ':'
10
10
 
11
+ private_constant :DELIMITER
12
+
11
13
  module_function
12
14
 
13
15
  def hashify(node_key)
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RedisClient
4
+ class Cluster
5
+ module NoopCommandBuilder
6
+ module_function
7
+
8
+ def generate(args, _kwargs = nil)
9
+ args
10
+ end
11
+ end
12
+ end
13
+ end
@@ -11,38 +11,53 @@ class RedisClient
11
11
  @asking = false
12
12
  end
13
13
 
14
- def watch(keys)
14
+ def watch(keys) # rubocop:disable Metrics/AbcSize
15
15
  slot = find_slot(keys)
16
16
  raise ::RedisClient::Cluster::Transaction::ConsistencyError, "unsafe watch: #{keys.join(' ')}" if slot.nil?
17
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|
18
+ handle_redirection(slot, retry_count: 1) do |nd|
22
19
  nd.with do |c|
23
20
  c.ensure_connected_cluster_scoped(retryable: false) do
24
- c.call('ASKING') if @asking
25
- c.call('WATCH', *keys)
21
+ c.call('asking') if @asking
22
+ c.call('watch', *keys)
26
23
  begin
27
24
  yield(c, slot, @asking)
28
25
  rescue ::RedisClient::ConnectionError
29
26
  # No need to unwatch on a connection error.
30
27
  raise
31
28
  rescue StandardError
32
- c.call('UNWATCH')
29
+ c.call('unwatch')
33
30
  raise
34
31
  end
32
+ rescue ::RedisClient::CommandError => e
33
+ @router.renew_cluster_state if e.message.start_with?('CLUSTERDOWN')
34
+ raise
35
35
  end
36
+ rescue ::RedisClient::ConnectionError
37
+ @router.renew_cluster_state
38
+ raise
36
39
  end
37
40
  end
38
41
  end
39
42
 
40
43
  private
41
44
 
42
- def handle_redirection(node, retry_count: 1, &blk)
43
- @router.handle_redirection(node, retry_count: retry_count) do |nd|
45
+ def handle_redirection(slot, retry_count: 1, &blk)
46
+ # We have not yet selected a node for this transaction, initially, which means we can handle
47
+ # redirections freely initially (i.e. for the first WATCH call)
48
+ node = @router.find_primary_node_by_slot(slot)
49
+ times_block_executed = 0
50
+ @router.handle_redirection(node, nil, retry_count: retry_count) do |nd|
51
+ times_block_executed += 1
44
52
  handle_asking_once(nd, &blk)
45
53
  end
54
+ rescue ::RedisClient::ConnectionError
55
+ # Deduct the number of retries that happened _inside_ router#handle_redirection from our remaining
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
58
+ raise if retry_count < 0
59
+
60
+ retry
46
61
  end
47
62
 
48
63
  def handle_asking_once(node)