redis-cluster-client 0.16.5 → 0.16.6

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: ee5d5f4c7bb89d0899ef2c3aec48503580d01a1e84a369f1e9ee0c1f60011f15
4
- data.tar.gz: b17966f3f4c74ce89005b49188d769c4e8b6e399d6d65e0c4d68e14868cee9c7
3
+ metadata.gz: 201f719d07b33c0df8cde529564564e2993495a7817419730b6699047e40c068
4
+ data.tar.gz: 45790a9f41cd3708e8ffc7c2729e767204711c966cf9dd19aed419b7abc9fb5c
5
5
  SHA512:
6
- metadata.gz: 580dbda062e93296be5869169a3f8f4d389f7bc7aba34cb473cc1a1877cd8a552a29617bf6e09cc2bea8e372cbf895f28ffd086bf75ce986a2dd448bd7170dae
7
- data.tar.gz: d3abefad63034a97613319637fe617b66f64b2053f67ed8c6de7d81350976645c0eec250037fd100b0a4d5e518c3674c96e07fb043c56ffd327a8e918626405d
6
+ metadata.gz: '085310367927882a2e2d6547419ca4edc254ea2296411ed0c8f0f7352b48d98c54a6e005cc4e11ab0b5455810fa9b086dfefa9786373f926de520c7972bae5fe'
7
+ data.tar.gz: 5d3db4f8ecee72af9d44c239bab164c581b4983c21a7eca1063d3724f0f70d1ed5ef73d5b141a43d34a32edc6419210fd839c0d48b61efac18ac88d5306a5a03
@@ -9,15 +9,25 @@ class RedisClient
9
9
  class Command
10
10
  EMPTY_STRING = ''
11
11
  EMPTY_HASH = {}.freeze
12
+ EMPTY_POLICIES = [nil, nil].freeze
13
+ REQUEST_POLICY_PREFIX = 'request_policy:'
14
+ RESPONSE_POLICY_PREFIX = 'response_policy:'
15
+ SUBCOMMAND_DELIMITER = '|'
12
16
 
13
- private_constant :EMPTY_HASH
17
+ private_constant :EMPTY_HASH, :EMPTY_POLICIES, :REQUEST_POLICY_PREFIX,
18
+ :RESPONSE_POLICY_PREFIX, :SUBCOMMAND_DELIMITER
14
19
 
20
+ # @see https://redis.io/docs/latest/commands/command/ The reply of the COMMAND command
21
+ # @see https://redis.io/docs/latest/develop/reference/command-tips/ Command tips
15
22
  Spec = Struct.new(
16
23
  'RedisCommandSpec',
17
24
  :first_key_position,
18
25
  :key_step,
19
26
  :write?,
20
27
  :readonly?,
28
+ :request_policy,
29
+ :response_policy,
30
+ :subcommands,
21
31
  keyword_init: true
22
32
  ) do
23
33
  def extract_first_key(command)
@@ -86,40 +96,92 @@ class RedisClient
86
96
 
87
97
  private
88
98
 
89
- def parse_command_reply(rows) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
99
+ def parse_command_reply(rows)
90
100
  rows&.each_with_object({}) do |row, acc|
91
101
  next if row.first.nil?
92
102
 
93
- # TODO: in redis 7.0 or later, subcommand information included in the command reply
94
-
95
- pos = case row.first
96
- when 'eval', 'evalsha', 'zinterstore', 'zunionstore' then 3
97
- when 'object', 'xgroup' then 2
98
- when 'migrate', 'xread', 'xreadgroup' then 0
99
- else row[3]
100
- end
101
-
102
- writable = case row.first
103
- when 'xgroup' then true
104
- else row[2].include?('write')
105
- end
106
-
107
- acc[row.first] = ::RedisClient::Cluster::Command::Spec.new(
108
- first_key_position: pos,
109
- key_step: row[5],
110
- write?: writable,
111
- readonly?: row[2].include?('readonly')
112
- ).freeze
103
+ acc[row.first] = build_spec(row)
113
104
  end.freeze || EMPTY_HASH
114
105
  end
106
+
107
+ def build_spec(row)
108
+ request_policy, response_policy = parse_tips(row[7])
109
+
110
+ ::RedisClient::Cluster::Command::Spec.new(
111
+ first_key_position: parse_first_key_position(row),
112
+ key_step: row[5],
113
+ write?: parse_writability(row),
114
+ readonly?: row[2].include?('readonly'),
115
+ request_policy: request_policy,
116
+ response_policy: response_policy,
117
+ subcommands: parse_subcommands(row[9])
118
+ ).freeze
119
+ end
120
+
121
+ # The redis 6.2 or earlier doesn't include the information of the subcommands in the reply.
122
+ # These hard-coded positions are the fallback for such old versions.
123
+ def parse_first_key_position(row)
124
+ case row.first
125
+ when 'eval', 'evalsha', 'zinterstore', 'zunionstore' then 3
126
+ when 'object', 'xgroup' then 2
127
+ when 'migrate', 'xread', 'xreadgroup' then 0
128
+ else row[3]
129
+ end
130
+ end
131
+
132
+ def parse_writability(row)
133
+ case row.first
134
+ when 'xgroup' then true
135
+ else row[2].include?('write')
136
+ end
137
+ end
138
+
139
+ # The command tips are available in the redis 7.0 or later.
140
+ def parse_tips(tips)
141
+ return EMPTY_POLICIES if tips.nil? || tips.empty?
142
+
143
+ request_policy = response_policy = nil
144
+
145
+ tips.each do |tip|
146
+ if tip.start_with?(REQUEST_POLICY_PREFIX)
147
+ request_policy = -tip[REQUEST_POLICY_PREFIX.size..]
148
+ elsif tip.start_with?(RESPONSE_POLICY_PREFIX)
149
+ response_policy = -tip[RESPONSE_POLICY_PREFIX.size..]
150
+ end
151
+ end
152
+
153
+ [request_policy, response_policy]
154
+ end
155
+
156
+ # The information of the subcommands is available in the redis 7.0 or later.
157
+ # A container command such as XINFO reports its key positions per subcommand.
158
+ def parse_subcommands(rows)
159
+ return if rows.nil? || rows.empty?
160
+
161
+ rows.each_with_object({}) do |row, acc|
162
+ name = row.first
163
+ next if name.nil?
164
+
165
+ # The server replies with a full name of the subcommand such as `xinfo|stream`.
166
+ i = name.index(SUBCOMMAND_DELIMITER)
167
+ acc[i.nil? ? name : name[(i + 1)..]] = build_spec(row)
168
+ end.freeze
169
+ end
115
170
  end
116
171
 
117
172
  def initialize(commands)
118
173
  @commands = commands || EMPTY_HASH
119
174
  end
120
175
 
121
- def get_spec(name)
122
- @commands[name] || @commands[name.to_s.downcase(:ascii)]
176
+ def get_spec(command) # rubocop:disable Metrics/AbcSize
177
+ name = command.first
178
+ spec = @commands[name] || @commands[name.to_s.downcase(:ascii)]
179
+ return spec if spec.nil? || spec.subcommands.nil?
180
+
181
+ subcommand = command[1]
182
+ return spec if subcommand.nil?
183
+
184
+ spec.subcommands[subcommand] || spec.subcommands[subcommand.to_s.downcase(:ascii)] || spec
123
185
  end
124
186
 
125
187
  def exists?(name)
@@ -141,6 +141,16 @@ class RedisClient
141
141
  call_multiple_nodes!(@topology.replica_clients, method, command, args, &block)
142
142
  end
143
143
 
144
+ # for the `response_policy:one_succeeded` command tip
145
+ def call_all_leniently(method, command, args, &block)
146
+ call_multiple_nodes_leniently(@topology.clients, method, command, args, &block)
147
+ end
148
+
149
+ # for the `response_policy:one_succeeded` command tip
150
+ def call_primaries_leniently(method, command, args, &block)
151
+ call_multiple_nodes_leniently(@topology.primary_clients, method, command, args, &block)
152
+ end
153
+
144
154
  def send_ping(method, command, args, &block)
145
155
  result_values, errors = call_multiple_nodes(@topology.clients, method, command, args, &block)
146
156
  return result_values if errors.nil? || errors.empty?
@@ -265,6 +275,13 @@ class RedisClient
265
275
  raise ::RedisClient::Cluster::ErrorCollection.with_errors(errors)
266
276
  end
267
277
 
278
+ def call_multiple_nodes_leniently(clients, method, command, args, &block)
279
+ result_values, errors = call_multiple_nodes(clients, method, command, args, &block)
280
+ return result_values unless result_values.nil? || result_values.empty?
281
+
282
+ raise ::RedisClient::Cluster::ErrorCollection.with_errors(errors)
283
+ end
284
+
268
285
  def try_map(clients, &block) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
269
286
  return [{}, {}] if clients.empty?
270
287
 
@@ -403,7 +420,7 @@ class RedisClient
403
420
  shard = shard.each_slice(2).to_h if resp2
404
421
  nodes = shard.fetch('nodes')
405
422
  nodes = nodes.map { |n| n.each_slice(2).to_h } if resp2
406
- primary_id = nodes.find { |n| n.fetch('role') == 'master' }.fetch('id')
423
+ primary_id = nodes.find { |n| n.fetch('role') == 'master' }&.fetch('id') || EMPTY_STRING
407
424
 
408
425
  nodes.each do |node|
409
426
  host = pick_shard_host(node)
@@ -17,38 +17,39 @@ class RedisClient
17
17
  class Router
18
18
  ZERO_CURSOR_FOR_SCAN = '0'
19
19
  TSF = ->(f, x) { f.nil? ? x : f.call(x) }.curry
20
+ RoutingAction = Struct.new('RedisCommandRoutingAction', :method_name, :reply_transformer, keyword_init: true)
21
+ PICK_FIRST = ->(reply) { reply.first } # rubocop:disable Style/SymbolProc
22
+ FLATTEN_STRINGS = ->(reply) { reply.flatten.sort_by(&:to_s) }
23
+ SUM_NUM = ->(reply) { reply.select { |e| e.is_a?(Integer) }.sum }
24
+ SORT_NUMBERS = ->(reply) { reply.sort_by(&:to_i) }
25
+ if Object.const_defined?(:Ractor, false) && Ractor.respond_to?(:make_shareable)
26
+ Ractor.make_shareable(PICK_FIRST)
27
+ Ractor.make_shareable(FLATTEN_STRINGS)
28
+ Ractor.make_shareable(SUM_NUM)
29
+ Ractor.make_shareable(SORT_NUMBERS)
30
+ end
20
31
  DEDICATED_ACTIONS = lambda do # rubocop:disable Metrics/BlockLength
21
- action = Struct.new('RedisCommandRoutingAction', :method_name, :reply_transformer, keyword_init: true)
22
- pick_first = ->(reply) { reply.first } # rubocop:disable Style/SymbolProc
23
- flatten_strings = ->(reply) { reply.flatten.sort_by(&:to_s) }
24
- sum_num = ->(reply) { reply.select { |e| e.is_a?(Integer) }.sum }
25
- sort_numbers = ->(reply) { reply.sort_by(&:to_i) }
26
- if Object.const_defined?(:Ractor, false) && Ractor.respond_to?(:make_shareable)
27
- Ractor.make_shareable(pick_first)
28
- Ractor.make_shareable(flatten_strings)
29
- Ractor.make_shareable(sum_num)
30
- Ractor.make_shareable(sort_numbers)
31
- end
32
- multiple_key_action = action.new(method_name: :send_multiple_keys_command)
33
- all_node_first_action = action.new(method_name: :send_command_to_all_nodes, reply_transformer: pick_first)
34
- primary_first_action = action.new(method_name: :send_command_to_primaries, reply_transformer: pick_first)
35
- not_supported_action = action.new(method_name: :fail_not_supported_command)
36
- keyless_action = action.new(method_name: :fail_keyless_command)
32
+ multiple_key_action = RoutingAction.new(method_name: :send_multiple_keys_command)
33
+ all_node_first_action = RoutingAction.new(method_name: :send_command_to_all_nodes, reply_transformer: PICK_FIRST)
34
+ primary_first_action = RoutingAction.new(method_name: :send_command_to_primaries, reply_transformer: PICK_FIRST)
35
+ not_supported_action = RoutingAction.new(method_name: :fail_not_supported_command)
36
+ keyless_action = RoutingAction.new(method_name: :fail_keyless_command)
37
+ single_node_action = RoutingAction.new(method_name: :assign_node_and_send_command)
37
38
  {
38
- 'ping' => action.new(method_name: :send_ping_command, reply_transformer: pick_first),
39
- 'wait' => action.new(method_name: :send_wait_command),
40
- 'keys' => action.new(method_name: :send_command_to_replicas, reply_transformer: flatten_strings),
41
- 'dbsize' => action.new(method_name: :send_command_to_replicas, reply_transformer: sum_num),
42
- 'scan' => action.new(method_name: :send_scan_command),
43
- 'lastsave' => action.new(method_name: :send_command_to_all_nodes, reply_transformer: sort_numbers),
44
- 'role' => action.new(method_name: :send_command_to_all_nodes),
45
- 'config' => action.new(method_name: :send_config_command),
46
- 'client' => action.new(method_name: :send_client_command),
47
- 'cluster' => action.new(method_name: :send_cluster_command),
48
- 'memory' => action.new(method_name: :send_memory_command),
49
- 'script' => action.new(method_name: :send_script_command),
50
- 'pubsub' => action.new(method_name: :send_pubsub_command),
51
- 'watch' => action.new(method_name: :send_watch_command),
39
+ 'ping' => RoutingAction.new(method_name: :send_ping_command, reply_transformer: PICK_FIRST),
40
+ 'wait' => RoutingAction.new(method_name: :send_wait_command),
41
+ 'keys' => RoutingAction.new(method_name: :send_command_to_replicas, reply_transformer: FLATTEN_STRINGS),
42
+ 'dbsize' => RoutingAction.new(method_name: :send_command_to_replicas, reply_transformer: SUM_NUM),
43
+ 'scan' => RoutingAction.new(method_name: :send_scan_command),
44
+ 'lastsave' => RoutingAction.new(method_name: :send_command_to_all_nodes, reply_transformer: SORT_NUMBERS),
45
+ 'role' => RoutingAction.new(method_name: :send_command_to_all_nodes),
46
+ 'config' => RoutingAction.new(method_name: :send_config_command),
47
+ 'client' => RoutingAction.new(method_name: :send_client_command),
48
+ 'cluster' => RoutingAction.new(method_name: :send_cluster_command),
49
+ 'memory' => RoutingAction.new(method_name: :send_memory_command),
50
+ 'script' => RoutingAction.new(method_name: :send_script_command),
51
+ 'pubsub' => RoutingAction.new(method_name: :send_pubsub_command),
52
+ 'watch' => RoutingAction.new(method_name: :send_watch_command),
52
53
  'mget' => multiple_key_action,
53
54
  'mset' => multiple_key_action,
54
55
  'del' => multiple_key_action,
@@ -61,6 +62,10 @@ class RedisClient
61
62
  'select' => all_node_first_action,
62
63
  'flushall' => primary_first_action,
63
64
  'flushdb' => primary_first_action,
65
+ # The redis 7.0 tags RANDOMKEY with `request_policy:all_shards` but without any response policy.
66
+ # It was corrected to `response_policy:special` in the redis 7.2.
67
+ # This entry keeps the historical single node routing for the redis 7.0.
68
+ 'randomkey' => single_node_action,
64
69
  'readonly' => not_supported_action,
65
70
  'readwrite' => not_supported_action,
66
71
  'shutdown' => not_supported_action,
@@ -74,7 +79,33 @@ class RedisClient
74
79
  end
75
80
  end.call.freeze
76
81
 
77
- private_constant :ZERO_CURSOR_FOR_SCAN, :TSF, :DEDICATED_ACTIONS
82
+ # The routing which the command tips of the COMMAND command reply instruct.
83
+ # The `multi_shard` and the `special` request policies are out of scope.
84
+ # The `special` response policy is also out of scope because the aggregation is undefined,
85
+ # and the fan-out would change the return value of commands such as INFO.
86
+ # The `agg_min`, `agg_max` and `agg_logical_*` response policies are out of scope too:
87
+ # the only reachable command with them today is WAITAOF, whose reply is an array,
88
+ # and the aggregation of array replies is undefined. Such a command falls back to
89
+ # the single node routing until a command with a settled semantics appears.
90
+ # The entries of the DEDICATED_ACTIONS take precedence over these to keep the existing behavior.
91
+ # @see https://redis.io/docs/latest/develop/reference/command-tips/
92
+ POLICY_ACTIONS = {
93
+ 'all_shards' => {
94
+ nil => RoutingAction.new(method_name: :send_command_to_primaries).freeze,
95
+ 'all_succeeded' => RoutingAction.new(method_name: :send_command_to_primaries, reply_transformer: PICK_FIRST).freeze,
96
+ 'one_succeeded' => RoutingAction.new(method_name: :send_command_to_primaries_leniently, reply_transformer: PICK_FIRST).freeze,
97
+ 'agg_sum' => RoutingAction.new(method_name: :send_command_to_primaries, reply_transformer: SUM_NUM).freeze
98
+ }.freeze,
99
+ 'all_nodes' => {
100
+ nil => RoutingAction.new(method_name: :send_command_to_all_nodes).freeze,
101
+ 'all_succeeded' => RoutingAction.new(method_name: :send_command_to_all_nodes, reply_transformer: PICK_FIRST).freeze,
102
+ 'one_succeeded' => RoutingAction.new(method_name: :send_command_to_all_nodes_leniently, reply_transformer: PICK_FIRST).freeze,
103
+ 'agg_sum' => RoutingAction.new(method_name: :send_command_to_all_nodes, reply_transformer: SUM_NUM).freeze
104
+ }.freeze
105
+ }.freeze
106
+
107
+ private_constant :ZERO_CURSOR_FOR_SCAN, :TSF, :RoutingAction, :PICK_FIRST, :FLATTEN_STRINGS,
108
+ :SUM_NUM, :SORT_NUMBERS, :DEDICATED_ACTIONS, :POLICY_ACTIONS
78
109
 
79
110
  attr_reader :config
80
111
 
@@ -94,7 +125,12 @@ class RedisClient
94
125
 
95
126
  def send_command(method, command, *args, &block) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
96
127
  action = DEDICATED_ACTIONS[command.first]
97
- return assign_node_and_send_command(method, command, args, &block) if action.nil?
128
+ if action.nil?
129
+ cmd_spec = @command.get_spec(command)
130
+ action = find_policy_action(cmd_spec)
131
+ return assign_node_and_send_command(method, command, args, cmd_spec: cmd_spec, &block) if action.nil?
132
+ end
133
+
98
134
  return send(action.method_name, method, command, args, &block) if action.reply_transformer.nil?
99
135
 
100
136
  reply = send(action.method_name, method, command, args)
@@ -124,8 +160,8 @@ class RedisClient
124
160
  end
125
161
 
126
162
  # @see https://redis.io/docs/reference/cluster-spec/#redirection-and-resharding Redirection and resharding
127
- def assign_node_and_send_command(method, command, args, retry_count: 3, &block)
128
- node = assign_node(command)
163
+ def assign_node_and_send_command(method, command, args, retry_count: 3, cmd_spec: nil, &block)
164
+ node = assign_node(command, cmd_spec: cmd_spec)
129
165
  send_command_to_node(node, method, command, args, retry_count: retry_count, &block)
130
166
  end
131
167
 
@@ -218,9 +254,9 @@ class RedisClient
218
254
  end
219
255
  end
220
256
 
221
- def assign_node(command)
257
+ def assign_node(command, cmd_spec: nil)
222
258
  handle_node_reload_error do
223
- node_key = find_node_key(command)
259
+ node_key = find_node_key(command, cmd_spec: cmd_spec)
224
260
  @node.find_by(node_key)
225
261
  end
226
262
  end
@@ -246,8 +282,8 @@ class RedisClient
246
282
  end
247
283
  end
248
284
 
249
- def find_node_key(command, seed: nil)
250
- cmd_spec = @command.get_spec(command.first)
285
+ def find_node_key(command, seed: nil, cmd_spec: nil)
286
+ cmd_spec = @command.get_spec(command) if cmd_spec.nil?
251
287
  find_node_key_by_key(
252
288
  cmd_spec&.extract_first_key(command),
253
289
  seed: seed,
@@ -256,14 +292,14 @@ class RedisClient
256
292
  end
257
293
 
258
294
  def find_primary_node_key(command)
259
- key = @command.get_spec(command.first)&.extract_first_key(command)
295
+ key = @command.get_spec(command)&.extract_first_key(command)
260
296
  return unless key&.size&.> 0
261
297
 
262
298
  find_node_key_by_key(key, primary: true)
263
299
  end
264
300
 
265
301
  def find_slot(command)
266
- find_slot_by_key(@command.get_spec(command.first)&.extract_first_key(command))
302
+ find_slot_by_key(@command.get_spec(command)&.extract_first_key(command))
267
303
  end
268
304
 
269
305
  def find_slot_by_key(key)
@@ -308,6 +344,15 @@ class RedisClient
308
344
 
309
345
  private
310
346
 
347
+ def find_policy_action(cmd_spec)
348
+ return if cmd_spec.nil?
349
+
350
+ request_policy = cmd_spec.request_policy
351
+ return if request_policy.nil?
352
+
353
+ POLICY_ACTIONS.dig(request_policy, cmd_spec.response_policy)
354
+ end
355
+
311
356
  def send_command_to_all_nodes(method, command, args, &block)
312
357
  @node.call_all(method, command, args, &block)
313
358
  end
@@ -316,6 +361,14 @@ class RedisClient
316
361
  @node.call_primaries(method, command, args, &block)
317
362
  end
318
363
 
364
+ def send_command_to_all_nodes_leniently(method, command, args, &block)
365
+ @node.call_all_leniently(method, command, args, &block)
366
+ end
367
+
368
+ def send_command_to_primaries_leniently(method, command, args, &block)
369
+ @node.call_primaries_leniently(method, command, args, &block)
370
+ end
371
+
319
372
  def send_command_to_replicas(method, command, args, &block)
320
373
  @node.call_replicas(method, command, args, &block)
321
374
  end
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.16.5
4
+ version: 0.16.6
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.10
77
+ rubygems_version: 4.0.16
78
78
  specification_version: 4
79
79
  summary: Redis cluster-aware client for Ruby
80
80
  test_files: []