redis-cluster-client 0.16.5 → 0.17.0

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: 7df1ba6006c9d3c3e7c242e9406f6443a95aa371c7e77244b73565058f476435
4
+ data.tar.gz: 5613bf92dc543482c1bcb2e74d61096092c299ef84877b8c06da1174db82c6d4
5
5
  SHA512:
6
- metadata.gz: 580dbda062e93296be5869169a3f8f4d389f7bc7aba34cb473cc1a1877cd8a552a29617bf6e09cc2bea8e372cbf895f28ffd086bf75ce986a2dd448bd7170dae
7
- data.tar.gz: d3abefad63034a97613319637fe617b66f64b2053f67ed8c6de7d81350976645c0eec250037fd100b0a4d5e518c3674c96e07fb043c56ffd327a8e918626405d
6
+ metadata.gz: 58e9ecaa1aecf6bfb298cba78e08d349f742439a2f5a53e19cd55bb49c8a600b4e4f47a54af1798ef0afe51cb56cf5cf292eda3bdf75e522ed180fa065e37954
7
+ data.tar.gz: c158336f83f4453e27798a2521c4e282a867db446dfa7e74e988af9e568567235b321b59386bf79ac5d0e32dee0698137755e9c416ceeb3acb150ebfc60d5208
@@ -9,15 +9,24 @@ class RedisClient
9
9
  class Command
10
10
  EMPTY_STRING = ''
11
11
  EMPTY_HASH = {}.freeze
12
+ REQUEST_POLICY_PREFIX = 'request_policy:'
13
+ RESPONSE_POLICY_PREFIX = 'response_policy:'
14
+ SUBCOMMAND_DELIMITER = '|'
12
15
 
13
- private_constant :EMPTY_HASH
16
+ private_constant :EMPTY_HASH, :REQUEST_POLICY_PREFIX,
17
+ :RESPONSE_POLICY_PREFIX, :SUBCOMMAND_DELIMITER
14
18
 
19
+ # @see https://redis.io/docs/latest/commands/command/ The reply of the COMMAND command
20
+ # @see https://redis.io/docs/latest/develop/reference/command-tips/ Command tips
15
21
  Spec = Struct.new(
16
22
  'RedisCommandSpec',
17
23
  :first_key_position,
18
24
  :key_step,
19
25
  :write?,
20
26
  :readonly?,
27
+ :request_policy,
28
+ :response_policy,
29
+ :subcommands,
21
30
  keyword_init: true
22
31
  ) do
23
32
  def extract_first_key(command)
@@ -86,40 +95,80 @@ class RedisClient
86
95
 
87
96
  private
88
97
 
89
- def parse_command_reply(rows) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
98
+ def parse_command_reply(rows)
90
99
  rows&.each_with_object({}) do |row, acc|
91
100
  next if row.first.nil?
92
101
 
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
102
+ acc[row.first] = build_spec(row)
113
103
  end.freeze || EMPTY_HASH
114
104
  end
105
+
106
+ def build_spec(row)
107
+ ::RedisClient::Cluster::Command::Spec.new(
108
+ first_key_position: parse_first_key_position(row),
109
+ key_step: row[5],
110
+ write?: parse_writability(row),
111
+ readonly?: row[2].include?('readonly'),
112
+ request_policy: parse_policy_tip(row[7], REQUEST_POLICY_PREFIX),
113
+ response_policy: parse_policy_tip(row[7], RESPONSE_POLICY_PREFIX),
114
+ subcommands: parse_subcommands(row[9])
115
+ ).freeze
116
+ end
117
+
118
+ # The redis 6.2 or earlier doesn't include the information of the subcommands in the reply.
119
+ # These hard-coded positions are the fallback for such old versions.
120
+ def parse_first_key_position(row)
121
+ case row.first
122
+ when 'eval', 'evalsha', 'zinterstore', 'zunionstore' then 3
123
+ when 'object', 'xgroup' then 2
124
+ when 'migrate', 'xread', 'xreadgroup' then 0
125
+ else row[3]
126
+ end
127
+ end
128
+
129
+ def parse_writability(row)
130
+ case row.first
131
+ when 'xgroup' then true
132
+ else row[2].include?('write')
133
+ end
134
+ end
135
+
136
+ # The command tips are available in the redis 7.0 or later.
137
+ # It returns a single value instead of a pair to avoid the allocation of the array.
138
+ def parse_policy_tip(tips, prefix)
139
+ tip = tips&.find { |t| t.start_with?(prefix) }
140
+ tip.nil? ? nil : -tip.delete_prefix(prefix)
141
+ end
142
+
143
+ # The information of the subcommands is available in the redis 7.0 or later.
144
+ # A container command such as XINFO reports its key positions per subcommand.
145
+ def parse_subcommands(rows)
146
+ return if rows.nil? || rows.empty?
147
+
148
+ rows.each_with_object({}) do |row, acc|
149
+ name = row.first
150
+ next if name.nil?
151
+
152
+ # The server replies with a full name of the subcommand such as `xinfo|stream`.
153
+ i = name.index(SUBCOMMAND_DELIMITER)
154
+ acc[i.nil? ? name : name[i + 1, name.size]] = build_spec(row)
155
+ end.freeze
156
+ end
115
157
  end
116
158
 
117
159
  def initialize(commands)
118
160
  @commands = commands || EMPTY_HASH
119
161
  end
120
162
 
121
- def get_spec(name)
122
- @commands[name] || @commands[name.to_s.downcase(:ascii)]
163
+ def get_spec(command) # rubocop:disable Metrics/AbcSize
164
+ name = command.first
165
+ spec = @commands[name] || @commands[name.to_s.downcase(:ascii)]
166
+ return spec if spec.nil? || spec.subcommands.nil?
167
+
168
+ subcommand = command[1]
169
+ return spec if subcommand.nil?
170
+
171
+ spec.subcommands[subcommand] || spec.subcommands[subcommand.to_s.downcase(:ascii)] || spec
123
172
  end
124
173
 
125
174
  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)
@@ -26,7 +26,7 @@ class RedisClient
26
26
  pos = node_key.rindex(DELIMITER, -1)
27
27
  return [node_key, nil] if pos.nil?
28
28
 
29
- [node_key[0, pos], node_key[(pos + 1)..]]
29
+ [node_key[0, pos], node_key[pos + 1, node_key.size]]
30
30
  end
31
31
 
32
32
  def split_bracketed(node_key)
@@ -36,7 +36,7 @@ class RedisClient
36
36
  return nil if end_bracket.nil?
37
37
 
38
38
  host = node_key[1, end_bracket - 1]
39
- remainder = node_key[(end_bracket + 1)..]
39
+ remainder = node_key[end_bracket + 1, node_key.size]
40
40
  port = remainder.start_with?(DELIMITER) ? remainder[1..] : nil
41
41
  [host, port]
42
42
  end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redis_client/cluster/errors'
4
+
5
+ class RedisClient
6
+ class Cluster
7
+ class Router
8
+ # The routing table for the commands which shouldn't be routed by their keys.
9
+ # The built-in entries can be overridden with the `command_routings` option of the config.
10
+ class RoutingTable
11
+ BuildError = Class.new(::RedisClient::Cluster::Error)
12
+ RoutingAction = Struct.new('RedisCommandRoutingAction', :method_name, :reply_transformer, keyword_init: true)
13
+ PICK_FIRST = ->(reply) { reply.first } # rubocop:disable Style/SymbolProc
14
+ FLATTEN_STRINGS = ->(reply) { reply.flatten.sort_by(&:to_s) }
15
+ SUM_NUM = ->(reply) { reply.select { |e| e.is_a?(Integer) }.sum }
16
+ SORT_NUMBERS = ->(reply) { reply.sort_by(&:to_i) }
17
+ if Object.const_defined?(:Ractor, false) && Ractor.respond_to?(:make_shareable)
18
+ Ractor.make_shareable(PICK_FIRST)
19
+ Ractor.make_shareable(FLATTEN_STRINGS)
20
+ Ractor.make_shareable(SUM_NUM)
21
+ Ractor.make_shareable(SORT_NUMBERS)
22
+ end
23
+ DEDICATED_ACTIONS = lambda do # rubocop:disable Metrics/BlockLength
24
+ multiple_key_action = RoutingAction.new(method_name: :send_multiple_keys_command)
25
+ all_node_first_action = RoutingAction.new(method_name: :send_command_to_all_nodes, reply_transformer: PICK_FIRST)
26
+ primary_first_action = RoutingAction.new(method_name: :send_command_to_primaries, reply_transformer: PICK_FIRST)
27
+ not_supported_action = RoutingAction.new(method_name: :fail_not_supported_command)
28
+ keyless_action = RoutingAction.new(method_name: :fail_keyless_command)
29
+ single_node_action = RoutingAction.new(method_name: :assign_node_and_send_command)
30
+ {
31
+ 'ping' => RoutingAction.new(method_name: :send_ping_command, reply_transformer: PICK_FIRST),
32
+ 'wait' => RoutingAction.new(method_name: :send_wait_command),
33
+ 'keys' => RoutingAction.new(method_name: :send_command_to_replicas, reply_transformer: FLATTEN_STRINGS),
34
+ 'dbsize' => RoutingAction.new(method_name: :send_command_to_replicas, reply_transformer: SUM_NUM),
35
+ 'scan' => RoutingAction.new(method_name: :send_scan_command),
36
+ 'lastsave' => RoutingAction.new(method_name: :send_command_to_all_nodes, reply_transformer: SORT_NUMBERS),
37
+ 'role' => RoutingAction.new(method_name: :send_command_to_all_nodes),
38
+ 'config' => RoutingAction.new(method_name: :send_config_command),
39
+ 'client' => RoutingAction.new(method_name: :send_client_command),
40
+ 'cluster' => RoutingAction.new(method_name: :send_cluster_command),
41
+ 'memory' => RoutingAction.new(method_name: :send_memory_command),
42
+ 'script' => RoutingAction.new(method_name: :send_script_command),
43
+ 'pubsub' => RoutingAction.new(method_name: :send_pubsub_command),
44
+ 'watch' => RoutingAction.new(method_name: :send_watch_command),
45
+ 'mget' => multiple_key_action,
46
+ 'mset' => multiple_key_action,
47
+ 'del' => multiple_key_action,
48
+ 'acl' => all_node_first_action,
49
+ 'auth' => all_node_first_action,
50
+ 'bgrewriteaof' => all_node_first_action,
51
+ 'bgsave' => all_node_first_action,
52
+ 'quit' => all_node_first_action,
53
+ 'save' => all_node_first_action,
54
+ 'select' => all_node_first_action,
55
+ 'flushall' => primary_first_action,
56
+ 'flushdb' => primary_first_action,
57
+ # The redis 7.0 tags RANDOMKEY with `request_policy:all_shards` but without any response policy.
58
+ # It was corrected to `response_policy:special` in the redis 7.2.
59
+ # This entry keeps the historical single node routing for the redis 7.0.
60
+ 'randomkey' => single_node_action,
61
+ 'readonly' => not_supported_action,
62
+ 'readwrite' => not_supported_action,
63
+ 'shutdown' => not_supported_action,
64
+ 'discard' => keyless_action,
65
+ 'exec' => keyless_action,
66
+ 'multi' => keyless_action,
67
+ 'unwatch' => keyless_action
68
+ }.each_with_object({}) do |(k, v), acc|
69
+ acc[k] = v.freeze
70
+ acc[k.upcase] = v.freeze
71
+ end
72
+ end.call.freeze
73
+
74
+ # The routing which the command tips of the COMMAND command reply instruct.
75
+ # The `multi_shard` and the `special` request policies are out of scope.
76
+ # The `special` response policy is also out of scope because the aggregation is undefined,
77
+ # and the fan-out would change the return value of commands such as INFO.
78
+ # The `agg_min`, `agg_max` and `agg_logical_*` response policies are out of scope too:
79
+ # the only reachable command with them today is WAITAOF, whose reply is an array,
80
+ # and the aggregation of array replies is undefined. Such a command falls back to
81
+ # the single node routing until a command with a settled semantics appears.
82
+ # The entries of the DEDICATED_ACTIONS take precedence over these to keep the existing behavior.
83
+ # @see https://redis.io/docs/latest/develop/reference/command-tips/
84
+ POLICY_ACTIONS = {
85
+ 'all_shards' => {
86
+ nil => RoutingAction.new(method_name: :send_command_to_primaries).freeze,
87
+ 'all_succeeded' => RoutingAction.new(method_name: :send_command_to_primaries, reply_transformer: PICK_FIRST).freeze,
88
+ 'one_succeeded' => RoutingAction.new(method_name: :send_command_to_primaries_leniently, reply_transformer: PICK_FIRST).freeze,
89
+ 'agg_sum' => RoutingAction.new(method_name: :send_command_to_primaries, reply_transformer: SUM_NUM).freeze
90
+ }.freeze,
91
+ 'all_nodes' => {
92
+ nil => RoutingAction.new(method_name: :send_command_to_all_nodes).freeze,
93
+ 'all_succeeded' => RoutingAction.new(method_name: :send_command_to_all_nodes, reply_transformer: PICK_FIRST).freeze,
94
+ 'one_succeeded' => RoutingAction.new(method_name: :send_command_to_all_nodes_leniently, reply_transformer: PICK_FIRST).freeze,
95
+ 'agg_sum' => RoutingAction.new(method_name: :send_command_to_all_nodes, reply_transformer: SUM_NUM).freeze
96
+ }.freeze
97
+ }.freeze
98
+
99
+ private_constant :RoutingAction, :PICK_FIRST, :FLATTEN_STRINGS, :SUM_NUM, :SORT_NUMBERS,
100
+ :DEDICATED_ACTIONS, :POLICY_ACTIONS
101
+
102
+ class << self
103
+ # Builds the routing table: the built-in entries overridden by the command routings
104
+ # which the config validated and normalized. A nil routing removes the built-in entry
105
+ # of the command, so that the command follows the default resolution: the command tips
106
+ # which the server reports, or the routing by its key.
107
+ # It raises a BuildError for an unnormalized input as a defense, e.g. an unsupported policy.
108
+ def build(routings)
109
+ return DEDICATED_ACTIONS if routings.nil? || routings.empty?
110
+
111
+ routings.each_with_object(DEDICATED_ACTIONS.dup) do |(key, policies), acc|
112
+ merge_action(acc, key, fetch_action(key, policies))
113
+ end.freeze
114
+ end
115
+
116
+ def find_policy_action(request_policy, response_policy)
117
+ POLICY_ACTIONS.dig(request_policy, response_policy)
118
+ end
119
+
120
+ private
121
+
122
+ # Stores both the lowercase and the uppercase keys to avoid a per-call case conversion.
123
+ def merge_action(table, key, action)
124
+ if action.nil?
125
+ table.delete(key)
126
+ table.delete(key.upcase)
127
+ else
128
+ table[key] = action
129
+ table[key.upcase] = action
130
+ end
131
+ end
132
+
133
+ def fetch_action(key, policies)
134
+ return if policies.nil?
135
+
136
+ action = POLICY_ACTIONS.dig(policies[:request_policy], policies[:response_policy])
137
+ return action unless action.nil?
138
+
139
+ raise BuildError, "the policies of the #{key} command are unsupported: #{policies.inspect}"
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -11,75 +11,20 @@ require 'redis_client/cluster/transaction'
11
11
  require 'redis_client/cluster/optimistic_locking'
12
12
  require 'redis_client/cluster/pipeline'
13
13
  require 'redis_client/cluster/error_identification'
14
+ require 'redis_client/cluster/router/routing_table'
14
15
 
15
16
  class RedisClient
16
17
  class Cluster
17
18
  class Router
18
19
  ZERO_CURSOR_FOR_SCAN = '0'
19
20
  TSF = ->(f, x) { f.nil? ? x : f.call(x) }.curry
20
- 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)
37
- {
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),
52
- 'mget' => multiple_key_action,
53
- 'mset' => multiple_key_action,
54
- 'del' => multiple_key_action,
55
- 'acl' => all_node_first_action,
56
- 'auth' => all_node_first_action,
57
- 'bgrewriteaof' => all_node_first_action,
58
- 'bgsave' => all_node_first_action,
59
- 'quit' => all_node_first_action,
60
- 'save' => all_node_first_action,
61
- 'select' => all_node_first_action,
62
- 'flushall' => primary_first_action,
63
- 'flushdb' => primary_first_action,
64
- 'readonly' => not_supported_action,
65
- 'readwrite' => not_supported_action,
66
- 'shutdown' => not_supported_action,
67
- 'discard' => keyless_action,
68
- 'exec' => keyless_action,
69
- 'multi' => keyless_action,
70
- 'unwatch' => keyless_action
71
- }.each_with_object({}) do |(k, v), acc|
72
- acc[k] = v.freeze
73
- acc[k.upcase] = v.freeze
74
- end
75
- end.call.freeze
76
-
77
- private_constant :ZERO_CURSOR_FOR_SCAN, :TSF, :DEDICATED_ACTIONS
21
+ private_constant :ZERO_CURSOR_FOR_SCAN, :TSF
78
22
 
79
23
  attr_reader :config
80
24
 
81
25
  def initialize(config, concurrent_worker, pool: nil, **kwargs)
82
26
  @config = config
27
+ @dedicated_actions = RoutingTable.build(config.command_routings)
83
28
  @concurrent_worker = concurrent_worker
84
29
  @pool = pool
85
30
  @client_kwargs = kwargs
@@ -93,8 +38,13 @@ class RedisClient
93
38
  end
94
39
 
95
40
  def send_command(method, command, *args, &block) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
96
- action = DEDICATED_ACTIONS[command.first]
97
- return assign_node_and_send_command(method, command, args, &block) if action.nil?
41
+ action = @dedicated_actions[command.first]
42
+ if action.nil?
43
+ cmd_spec = @command.get_spec(command)
44
+ action = find_policy_action(cmd_spec)
45
+ return assign_node_and_send_command(method, command, args, cmd_spec: cmd_spec, &block) if action.nil?
46
+ end
47
+
98
48
  return send(action.method_name, method, command, args, &block) if action.reply_transformer.nil?
99
49
 
100
50
  reply = send(action.method_name, method, command, args)
@@ -124,8 +74,8 @@ class RedisClient
124
74
  end
125
75
 
126
76
  # @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)
77
+ def assign_node_and_send_command(method, command, args, retry_count: 3, cmd_spec: nil, &block)
78
+ node = assign_node(command, cmd_spec: cmd_spec)
129
79
  send_command_to_node(node, method, command, args, retry_count: retry_count, &block)
130
80
  end
131
81
 
@@ -218,9 +168,9 @@ class RedisClient
218
168
  end
219
169
  end
220
170
 
221
- def assign_node(command)
171
+ def assign_node(command, cmd_spec: nil)
222
172
  handle_node_reload_error do
223
- node_key = find_node_key(command)
173
+ node_key = find_node_key(command, cmd_spec: cmd_spec)
224
174
  @node.find_by(node_key)
225
175
  end
226
176
  end
@@ -246,8 +196,8 @@ class RedisClient
246
196
  end
247
197
  end
248
198
 
249
- def find_node_key(command, seed: nil)
250
- cmd_spec = @command.get_spec(command.first)
199
+ def find_node_key(command, seed: nil, cmd_spec: nil)
200
+ cmd_spec = @command.get_spec(command) if cmd_spec.nil?
251
201
  find_node_key_by_key(
252
202
  cmd_spec&.extract_first_key(command),
253
203
  seed: seed,
@@ -256,14 +206,14 @@ class RedisClient
256
206
  end
257
207
 
258
208
  def find_primary_node_key(command)
259
- key = @command.get_spec(command.first)&.extract_first_key(command)
209
+ key = @command.get_spec(command)&.extract_first_key(command)
260
210
  return unless key&.size&.> 0
261
211
 
262
212
  find_node_key_by_key(key, primary: true)
263
213
  end
264
214
 
265
215
  def find_slot(command)
266
- find_slot_by_key(@command.get_spec(command.first)&.extract_first_key(command))
216
+ find_slot_by_key(@command.get_spec(command)&.extract_first_key(command))
267
217
  end
268
218
 
269
219
  def find_slot_by_key(key)
@@ -308,6 +258,15 @@ class RedisClient
308
258
 
309
259
  private
310
260
 
261
+ def find_policy_action(cmd_spec)
262
+ return if cmd_spec.nil?
263
+
264
+ request_policy = cmd_spec.request_policy
265
+ return if request_policy.nil?
266
+
267
+ RoutingTable.find_policy_action(request_policy, cmd_spec.response_policy)
268
+ end
269
+
311
270
  def send_command_to_all_nodes(method, command, args, &block)
312
271
  @node.call_all(method, command, args, &block)
313
272
  end
@@ -316,6 +275,14 @@ class RedisClient
316
275
  @node.call_primaries(method, command, args, &block)
317
276
  end
318
277
 
278
+ def send_command_to_all_nodes_leniently(method, command, args, &block)
279
+ @node.call_all_leniently(method, command, args, &block)
280
+ end
281
+
282
+ def send_command_to_primaries_leniently(method, command, args, &block)
283
+ @node.call_primaries_leniently(method, command, args, &block)
284
+ end
285
+
319
286
  def send_command_to_replicas(method, command, args, &block)
320
287
  @node.call_replicas(method, command, args, &block)
321
288
  end
@@ -6,6 +6,7 @@ require 'redis_client/cluster'
6
6
  require 'redis_client/cluster/errors'
7
7
  require 'redis_client/cluster/node_key'
8
8
  require 'redis_client/cluster/noop_command_builder'
9
+ require 'redis_client/cluster/router/routing_table'
9
10
  require 'redis_client/command_builder'
10
11
 
11
12
  class RedisClient
@@ -25,10 +26,18 @@ class RedisClient
25
26
  SLOW_COMMAND_TIMEOUT = Float(ENV.fetch('REDIS_CLIENT_SLOW_COMMAND_TIMEOUT', -1))
26
27
  # Controls the balance between startup load and stability during initialization or cluster state changes.
27
28
  MAX_STARTUP_SAMPLE = Integer(ENV.fetch('REDIS_CLIENT_MAX_STARTUP_SAMPLE', 3))
29
+ VALID_COMMAND_ROUTING_KEYS = %i[request_policy response_policy].freeze
30
+ # Routing these could wedge the state of the connections in the pool,
31
+ # e.g. leave them inside a MULTI or a subscription.
32
+ UNSAFE_COMMAND_ROUTINGS = %w[
33
+ multi exec discard watch unwatch
34
+ subscribe unsubscribe psubscribe punsubscribe ssubscribe sunsubscribe
35
+ ].freeze
28
36
 
29
37
  private_constant :DEFAULT_HOST, :DEFAULT_PORT, :DEFAULT_SCHEME, :SECURE_SCHEME, :DEFAULT_NODES,
30
38
  :VALID_SCHEMES, :VALID_NODES_KEYS, :MERGE_CONFIG_KEYS, :IGNORE_GENERIC_CONFIG_KEYS,
31
- :MAX_WORKERS, :SLOW_COMMAND_TIMEOUT, :MAX_STARTUP_SAMPLE
39
+ :MAX_WORKERS, :SLOW_COMMAND_TIMEOUT, :MAX_STARTUP_SAMPLE,
40
+ :VALID_COMMAND_ROUTING_KEYS, :UNSAFE_COMMAND_ROUTINGS
32
41
 
33
42
  InvalidClientConfigError = Class.new(::RedisClient::Cluster::Error)
34
43
 
@@ -39,9 +48,9 @@ class RedisClient
39
48
  private_constant :SENSITIVE_INSPECT_KEYS, :INSPECT_REDACTED_KEYS, :INSPECT_PLACEHOLDER
40
49
 
41
50
  attr_reader :command_builder, :client_config, :replica_affinity, :slow_command_timeout,
42
- :connect_with_original_config, :startup_nodes, :max_startup_sample, :id
51
+ :connect_with_original_config, :startup_nodes, :max_startup_sample, :id, :command_routings
43
52
 
44
- def initialize( # rubocop:disable Metrics/ParameterLists
53
+ def initialize( # rubocop:disable Metrics/ParameterLists, Metrics/AbcSize
45
54
  nodes: DEFAULT_NODES,
46
55
  replica: false,
47
56
  replica_affinity: :random,
@@ -52,6 +61,7 @@ class RedisClient
52
61
  slow_command_timeout: SLOW_COMMAND_TIMEOUT,
53
62
  command_builder: ::RedisClient::CommandBuilder,
54
63
  max_startup_sample: MAX_STARTUP_SAMPLE,
64
+ command_routings: nil,
55
65
  **client_config
56
66
  )
57
67
  @replica = true & replica
@@ -67,6 +77,7 @@ class RedisClient
67
77
  @client_implementation = client_implementation
68
78
  @slow_command_timeout = slow_command_timeout
69
79
  @max_startup_sample = max_startup_sample
80
+ @command_routings = normalize_command_routings(command_routings)
70
81
  @id = client_config[:id]
71
82
  end
72
83
 
@@ -197,6 +208,52 @@ class RedisClient
197
208
  raise InvalidClientConfigError, e.message
198
209
  end
199
210
 
211
+ def normalize_command_routings(routings)
212
+ return if routings.nil? || (routings.is_a?(Hash) && routings.empty?)
213
+ raise InvalidClientConfigError, "`command_routings` option must be a Hash: #{routings.class}" unless routings.is_a?(Hash)
214
+
215
+ routings.each_with_object({}) do |(name, value), acc|
216
+ acc[normalize_command_routing_name(name)] = normalize_command_routing_value(name, value)
217
+ end.freeze
218
+ end
219
+
220
+ def normalize_command_routing_name(name)
221
+ raise InvalidClientConfigError, "`command_routings` option includes an invalid command name: #{name.inspect}" unless name.is_a?(String) || name.is_a?(Symbol)
222
+
223
+ key = name.to_s.downcase
224
+ raise InvalidClientConfigError, '`command_routings` option includes an empty command name' if key.empty?
225
+ raise InvalidClientConfigError, "`command_routings` option can route a command, not a subcommand: #{name.inspect}" if key.match?(/\s/)
226
+ raise InvalidClientConfigError, "`command_routings` option can't route the command which changes the state of a connection: #{key}" if UNSAFE_COMMAND_ROUTINGS.include?(key)
227
+
228
+ -key
229
+ end
230
+
231
+ def normalize_command_routing_value(name, value)
232
+ return if value.nil? || (value.is_a?(Hash) && value.empty?)
233
+ raise InvalidClientConfigError, "`command_routings` option includes an invalid routing of #{name}: #{value.inspect}" unless value.is_a?(Hash)
234
+
235
+ normalize_command_routing_policies(name, value.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k })
236
+ end
237
+
238
+ def normalize_command_routing_policies(name, policies)
239
+ unknown_keys = policies.keys - VALID_COMMAND_ROUTING_KEYS
240
+ raise InvalidClientConfigError, "`command_routings` option includes unknown keys of #{name}: #{unknown_keys.join(', ')}" unless unknown_keys.empty?
241
+ raise InvalidClientConfigError, "`command_routings` option needs the request_policy key of #{name}" if policies[:request_policy].nil?
242
+
243
+ build_command_routing_policies(name, policies[:request_policy], policies[:response_policy])
244
+ end
245
+
246
+ def build_command_routing_policies(name, request_policy, response_policy)
247
+ request_policy = -request_policy.to_s
248
+ response_policy = response_policy.nil? ? nil : -response_policy.to_s
249
+ if ::RedisClient::Cluster::Router::RoutingTable.find_policy_action(request_policy, response_policy).nil?
250
+ raise InvalidClientConfigError,
251
+ "`command_routings` option includes unsupported policies of #{name}: request_policy=#{request_policy}, response_policy=#{response_policy.inspect}"
252
+ end
253
+
254
+ { request_policy: request_policy, response_policy: response_policy }.freeze
255
+ end
256
+
200
257
  def merge_generic_config(client_config, node_configs)
201
258
  cfg = node_configs.first || {}
202
259
  client_config.reject { |k, _| IGNORE_GENERIC_CONFIG_KEYS.include?(k) }
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.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Taishi Kasuga
@@ -51,6 +51,7 @@ files:
51
51
  - lib/redis_client/cluster/pipeline.rb
52
52
  - lib/redis_client/cluster/pub_sub.rb
53
53
  - lib/redis_client/cluster/router.rb
54
+ - lib/redis_client/cluster/router/routing_table.rb
54
55
  - lib/redis_client/cluster/transaction.rb
55
56
  - lib/redis_client/cluster_config.rb
56
57
  - lib/redis_cluster_client.rb
@@ -74,7 +75,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
74
75
  - !ruby/object:Gem::Version
75
76
  version: '0'
76
77
  requirements: []
77
- rubygems_version: 4.0.10
78
+ rubygems_version: 4.0.16
78
79
  specification_version: 4
79
80
  summary: Redis cluster-aware client for Ruby
80
81
  test_files: []