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 +4 -4
- data/lib/redis_client/cluster/command.rb +73 -24
- data/lib/redis_client/cluster/node.rb +18 -1
- data/lib/redis_client/cluster/node_key.rb +2 -2
- data/lib/redis_client/cluster/router/routing_table.rb +145 -0
- data/lib/redis_client/cluster/router.rb +35 -68
- data/lib/redis_client/cluster_config.rb +60 -3
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7df1ba6006c9d3c3e7c242e9406f6443a95aa371c7e77244b73565058f476435
|
|
4
|
+
data.tar.gz: 5613bf92dc543482c1bcb2e74d61096092c299ef84877b8c06da1174db82c6d4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
|
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
|
-
|
|
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(
|
|
122
|
-
|
|
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' }
|
|
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[
|
|
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[
|
|
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
|
-
|
|
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 =
|
|
97
|
-
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
78
|
+
rubygems_version: 4.0.16
|
|
78
79
|
specification_version: 4
|
|
79
80
|
summary: Redis cluster-aware client for Ruby
|
|
80
81
|
test_files: []
|