redis-cluster-client 0.0.1 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4339be83a679ef61e9d9a98e26cd2dd52b58b56eaa852a53c8f0f01ef1987396
4
- data.tar.gz: ac7ea102f9d5a711ee495a36c844443027b39d2c64e0d9177d419d00e1fa0de0
3
+ metadata.gz: 0561fdd5885f8c6c5c5c100af032b867224eff3ed91b546b6885f8bf3bd76663
4
+ data.tar.gz: 1ebe59416fb043af9da7a926cd338ac260ce1a46e5220b46f7a6744c8f1b8c2b
5
5
  SHA512:
6
- metadata.gz: 268000fa3691afc08d969b35cf8ac90221e96ffb41db59c63d9737e9ebed3d978f84f36ba4ee91ceb4bb88c957c3d2ecead676bc7b6c7a4411598969213e269c
7
- data.tar.gz: 1a44f77bdd4202f327025d3fa0d0a3b65b577332cd714f787159c8ba41f20f5b3c8b058b5c48aab74f5d0a7b3ec4638f227b5f205ed07c8e32dba4c71220695b
6
+ metadata.gz: 86661400532550e005beb98e740e4939ffcf93c1b66fb47930bc2a5819f5dbba60e940c8e0183d312a6ce7cb55d1cc0634081029eb8e5c1b8f68ea2862d9e0df
7
+ data.tar.gz: 3629f63ed379946bc8951c57ada535573130e8db504a2f5b7644ef9091408ac3223c5b0a260b13d6b84ab889b3e47c4571bbc10f5b21ceaf6758dc60c28a00b3
@@ -8,8 +8,9 @@ class RedisClient
8
8
  class Command
9
9
  class << self
10
10
  def load(nodes)
11
- errors = nodes.map do |node|
12
- details = fetch_command_details(node)
11
+ errors = nodes&.map do |node|
12
+ reply = node.call('COMMAND')
13
+ details = parse_command_details(reply)
13
14
  return ::RedisClient::Cluster::Command.new(details)
14
15
  rescue ::RedisClient::ConnectionError, ::RedisClient::CommandError => e
15
16
  e
@@ -20,9 +21,9 @@ class RedisClient
20
21
 
21
22
  private
22
23
 
23
- def fetch_command_details(node)
24
- node.call('COMMAND').to_h do |reply|
25
- [reply[0], { arity: reply[1], flags: reply[2], first: reply[3], last: reply[4], step: reply[5] }]
24
+ def parse_command_details(rows)
25
+ rows&.reject { |row| row[0].nil? }.to_h do |row|
26
+ [row[0].downcase, { arity: row[1], flags: row[2], first: row[3], last: row[4], step: row[5] }]
26
27
  end
27
28
  end
28
29
  end
@@ -35,7 +36,7 @@ class RedisClient
35
36
  i = determine_first_key_position(command)
36
37
  return '' if i == 0
37
38
 
38
- key = command[i].to_s
39
+ key = (command[i].is_a?(Array) ? command[i].flatten.first : command[i]).to_s
39
40
  hash_tag = extract_hash_tag(key)
40
41
  hash_tag.empty? ? key : hash_tag
41
42
  end
@@ -51,7 +52,7 @@ class RedisClient
51
52
  private
52
53
 
53
54
  def pick_details(details)
54
- details.transform_values do |detail|
55
+ (details || {}).transform_values do |detail|
55
56
  {
56
57
  first_key_position: detail[:first],
57
58
  write: detail[:flags].include?('write'),
@@ -61,14 +62,14 @@ class RedisClient
61
62
  end
62
63
 
63
64
  def dig_details(command, key)
64
- name = command.first.to_s
65
- return unless @details.key?(name)
65
+ name = command&.flatten&.first.to_s.downcase
66
+ return if name.empty? || !@details.key?(name)
66
67
 
67
68
  @details.fetch(name).fetch(key)
68
69
  end
69
70
 
70
- def determine_first_key_position(command)
71
- case command.first.to_s.downcase
71
+ def determine_first_key_position(command) # rubocop:disable Metrics/CyclomaticComplexity
72
+ case command&.flatten&.first.to_s.downcase
72
73
  when 'eval', 'evalsha', 'migrate', 'zinterstore', 'zunionstore' then 3
73
74
  when 'object' then 2
74
75
  when 'memory'
@@ -80,13 +81,14 @@ class RedisClient
80
81
  end
81
82
  end
82
83
 
83
- def determine_optional_key_position(command, option_name)
84
- idx = command.map(&:to_s).map(&:downcase).index(option_name)
84
+ def determine_optional_key_position(command, option_name) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
85
+ idx = command&.flatten&.map(&:to_s)&.map(&:downcase)&.index(option_name&.downcase)
85
86
  idx.nil? ? 0 : idx + 1
86
87
  end
87
88
 
88
89
  # @see https://redis.io/topics/cluster-spec#keys-hash-tags Keys hash tags
89
90
  def extract_hash_tag(key)
91
+ key = key.to_s
90
92
  s = key.index('{')
91
93
  e = key.index('}', s.to_i + 1)
92
94
 
@@ -4,22 +4,24 @@ require 'redis_client'
4
4
 
5
5
  class RedisClient
6
6
  class Cluster
7
+ ERR_ARG_NORMALIZATION = ->(arg) { Array[arg].flatten.reject { |e| e.nil? || (e.respond_to?(:empty?) && e.empty?) } }
8
+
7
9
  # Raised when client connected to redis as cluster mode
8
10
  # and failed to fetch cluster state information by commands.
9
11
  class InitialSetupError < ::RedisClient::Error
10
- # @param errors [Array<Redis::BaseError>]
11
12
  def initialize(errors)
12
- super("Redis client could not fetch cluster information: #{errors.map(&:message).uniq.join(',')}")
13
+ msg = ERR_ARG_NORMALIZATION.call(errors).map(&:message).uniq.join(',')
14
+ super("Redis client could not fetch cluster information: #{msg}")
13
15
  end
14
16
  end
15
17
 
16
18
  # Raised when client connected to redis as cluster mode
17
19
  # and some cluster subcommands were called.
18
20
  class OrchestrationCommandNotSupported < ::RedisClient::Error
19
- def initialize(command, subcommand = '')
20
- str = [command, subcommand].map(&:to_s).reject(&:empty?).join(' ').upcase
21
+ def initialize(command)
22
+ str = ERR_ARG_NORMALIZATION.call(command).map(&:to_s).join(' ').upcase
21
23
  msg = "#{str} command should be used with care "\
22
- 'only by applications orchestrating Redis Cluster, like redis-trib, '\
24
+ 'only by applications orchestrating Redis Cluster, like redis-cli, '\
23
25
  'and the command if used out of the right context can leave the cluster '\
24
26
  'in a wrong state or cause data loss.'
25
27
  super(msg)
@@ -30,11 +32,16 @@ class RedisClient
30
32
  class CommandErrorCollection < ::RedisClient::Error
31
33
  attr_reader :errors
32
34
 
33
- # @param errors [Hash{String => Redis::CommandError}]
34
- # @param error_message [String]
35
- def initialize(errors, error_message = 'Command errors were replied on any node')
35
+ def initialize(errors)
36
+ @errors = {}
37
+ if !errors.is_a?(Hash) || errors.empty?
38
+ super('')
39
+ return
40
+ end
41
+
36
42
  @errors = errors
37
- super(error_message)
43
+ messages = @errors.map { |node_key, error| "#{node_key}: #{error.message}" }
44
+ super("Command errors were replied on any node: #{messages.join(', ')}")
38
45
  end
39
46
  end
40
47
 
@@ -44,13 +51,5 @@ class RedisClient
44
51
  super("Cluster client doesn't know which node the #{command} command should be sent to.")
45
52
  end
46
53
  end
47
-
48
- # Raised when commands in pipelining include cross slot keys.
49
- class CrossSlotPipeliningError < ::RedisClient::Error
50
- def initialize(keys)
51
- super("Cluster client couldn't send pipelining to single node. "\
52
- "The commands include cross slot keys. #{keys}")
53
- end
54
- end
55
54
  end
56
55
  end
@@ -10,6 +10,10 @@ class RedisClient
10
10
  include Enumerable
11
11
 
12
12
  SLOT_SIZE = 16_384
13
+ MIN_SLOT = 0
14
+ MAX_SLOT = SLOT_SIZE - 1
15
+ IGNORE_GENERIC_CONFIG_KEYS = %i[url host port path].freeze
16
+
13
17
  ReloadNeeded = Class.new(::RedisClient::Error)
14
18
 
15
19
  class Config < ::RedisClient::Config
@@ -18,6 +22,8 @@ class RedisClient
18
22
  super(**kwargs)
19
23
  end
20
24
 
25
+ private
26
+
21
27
  def build_connection_prelude
22
28
  prelude = super.dup
23
29
  prelude << ['READONLY'] if @scale_read
@@ -27,10 +33,10 @@ class RedisClient
27
33
 
28
34
  class << self
29
35
  def load_info(options, **kwargs)
30
- tmp_nodes = ::RedisClient::Cluster::Node.new(options, **kwargs)
36
+ startup_nodes = ::RedisClient::Cluster::Node.new(options, **kwargs)
31
37
 
32
- errors = tmp_nodes.map do |tmp_node|
33
- reply = tmp_node.call('CLUSTER', 'NODES')
38
+ errors = startup_nodes.map do |n|
39
+ reply = n.call('CLUSTER', 'NODES')
34
40
  return parse_node_info(reply)
35
41
  rescue ::RedisClient::ConnectionError, ::RedisClient::CommandError => e
36
42
  e
@@ -38,12 +44,13 @@ class RedisClient
38
44
 
39
45
  raise ::RedisClient::Cluster::InitialSetupError, errors
40
46
  ensure
41
- tmp_nodes&.each(&:close)
47
+ startup_nodes&.each(&:close)
42
48
  end
43
49
 
44
50
  private
45
51
 
46
52
  # @see https://redis.io/commands/cluster-nodes/
53
+ # @see https://github.com/redis/redis/blob/78960ad57b8a5e6af743d789ed8fd767e37d42b8/src/cluster.c#L4660-L4683
47
54
  def parse_node_info(info) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
48
55
  rows = info.split("\n").map(&:split)
49
56
  rows.each { |arr| arr[2] = arr[2].split(',') }
@@ -51,7 +58,13 @@ class RedisClient
51
58
  rows.each do |arr|
52
59
  arr[1] = arr[1].split('@').first
53
60
  arr[2] = (arr[2] & %w[master slave]).first
54
- arr[8] = arr[8].nil? ? [] : arr[8].split(',').map { |r| r.split('-').map { |s| Integer(s) } }
61
+ if arr[8].nil?
62
+ arr[8] = []
63
+ next
64
+ end
65
+
66
+ arr[8] = arr[8].split(',').map { |r| r.split('-').map { |s| Integer(s) } }
67
+ arr[8] = arr[8].map { |a| a.size == 1 ? a << a.first : a }.map(&:sort)
55
68
  end
56
69
 
57
70
  rows.map do |arr|
@@ -61,15 +74,16 @@ class RedisClient
61
74
  end
62
75
  end
63
76
 
64
- def initialize(options, node_info = [], pool = nil, with_replica: false, **kwargs)
77
+ def initialize(options, node_info: [], pool: nil, with_replica: false, **kwargs)
65
78
  @with_replica = with_replica
66
79
  @slots = build_slot_node_mappings(node_info)
67
80
  @replications = build_replication_mappings(node_info)
68
- @clients = build_clients(options, pool, **kwargs)
81
+ @clients = build_clients(options, pool: pool, **kwargs)
82
+ @mutex = Mutex.new
69
83
  end
70
84
 
71
85
  def inspect
72
- @clients.keys.sort.to_s
86
+ "#<#{self.class.name} #{node_keys.join(', ')}>"
73
87
  end
74
88
 
75
89
  def each(&block)
@@ -80,6 +94,20 @@ class RedisClient
80
94
  @clients.values.sample
81
95
  end
82
96
 
97
+ def node_keys
98
+ @clients.keys.sort
99
+ end
100
+
101
+ def primary_node_keys
102
+ @clients.filter_map { |k, _| primary?(k) ? k : nil }.sort
103
+ end
104
+
105
+ def replica_node_keys
106
+ return primary_node_keys if replica_disabled?
107
+
108
+ @clients.filter_map { |k, _| replica?(k) ? k : nil }.sort
109
+ end
110
+
83
111
  def find_by(node_key)
84
112
  @clients.fetch(node_key)
85
113
  rescue KeyError
@@ -108,39 +136,41 @@ class RedisClient
108
136
  end.values
109
137
  end
110
138
 
111
- # TODO: impl
112
- def process_all(commands, &block)
113
- try_map { |_, client| client.process(commands, &block) }.values
114
- end
115
-
116
139
  def scale_reading_clients
117
- reading_clients = []
118
-
119
- @clients.each do |node_key, client|
120
- next unless replica_disabled? ? primary?(node_key) : replica?(node_key)
121
-
122
- reading_clients << client
140
+ clients = @clients.select do |node_key, _|
141
+ replica_disabled? ? primary?(node_key) : replica?(node_key)
123
142
  end
124
143
 
125
- reading_clients
144
+ clients.values.sort_by do |client|
145
+ ::RedisClient::Cluster::NodeKey.build_from_host_port(client.config.host, client.config.port)
146
+ end
126
147
  end
127
148
 
128
149
  def slot_exists?(slot)
150
+ slot = Integer(slot)
151
+ return false if slot < MIN_SLOT || slot > MAX_SLOT
152
+
129
153
  !@slots[slot].nil?
130
154
  end
131
155
 
132
156
  def find_node_key_of_primary(slot)
157
+ slot = Integer(slot)
158
+ return if slot < MIN_SLOT || slot > MAX_SLOT
159
+
133
160
  @slots[slot]
134
161
  end
135
162
 
136
163
  def find_node_key_of_replica(slot)
164
+ slot = Integer(slot)
165
+ return if slot < MIN_SLOT || slot > MAX_SLOT
166
+
137
167
  return @slots[slot] if replica_disabled? || @replications[@slots[slot]].size.zero?
138
168
 
139
169
  @replications[@slots[slot]].sample
140
170
  end
141
171
 
142
172
  def update_slot(slot, node_key)
143
- @slots[slot] = node_key
173
+ @mutex.synchronize { @slots[slot] = node_key }
144
174
  end
145
175
 
146
176
  private
@@ -177,12 +207,15 @@ class RedisClient
177
207
  end
178
208
  end
179
209
 
180
- def build_clients(options, pool, **kwargs)
210
+ def build_clients(options, pool: nil, **kwargs)
181
211
  options.filter_map do |node_key, option|
182
212
  next if replica_disabled? && replica?(node_key)
183
213
 
184
- config = ::RedisClient::Cluster::Node::Config.new(scale_read: replica?(node_key), **option)
185
- client = pool.nil? ? config.new_client(**kwargs) : config.new_pool(**pool, **kwargs)
214
+ config = ::RedisClient::Cluster::Node::Config.new(
215
+ scale_read: replica?(node_key),
216
+ **option.merge(kwargs.reject { |k, _| IGNORE_GENERIC_CONFIG_KEYS.include?(k) })
217
+ )
218
+ client = pool.nil? ? config.new_client : config.new_pool(**pool)
186
219
 
187
220
  [node_key, client]
188
221
  end.to_h
@@ -191,15 +224,17 @@ class RedisClient
191
224
  def try_map # rubocop:disable Metrics/MethodLength
192
225
  errors = {}
193
226
  results = {}
194
-
195
- @clients.each do |node_key, client|
196
- reply = yield(node_key, client)
197
- results[node_key] = reply unless reply.nil?
198
- rescue ::RedisClient::CommandError => e
199
- errors[node_key] = e
200
- next
227
+ threads = @clients.map do |k, v|
228
+ Thread.new(k, v) do |node_key, client|
229
+ Thread.pass
230
+ reply = yield(node_key, client)
231
+ results[node_key] = reply unless reply.nil?
232
+ rescue ::RedisClient::CommandError => e
233
+ errors[node_key] = e
234
+ end
201
235
  end
202
236
 
237
+ threads.each(&:join)
203
238
  return results if errors.empty?
204
239
 
205
240
  raise ::RedisClient::Cluster::CommandErrorCollection, errors
@@ -16,10 +16,15 @@ class RedisClient
16
16
  end
17
17
 
18
18
  def split(node_key)
19
- node_key.split(DELIMITER)
19
+ pos = node_key&.rindex(DELIMITER, -1)
20
+ return [node_key, nil] if pos.nil?
21
+
22
+ [node_key[0, pos], node_key[(pos + 1)..]]
20
23
  end
21
24
 
22
25
  def build_from_uri(uri)
26
+ return '' if uri.nil?
27
+
23
28
  "#{uri.host}#{DELIMITER}#{uri.port}"
24
29
  end
25
30
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'redis_client'
3
4
  require 'redis_client/cluster/command'
4
5
  require 'redis_client/cluster/errors'
5
6
  require 'redis_client/cluster/key_slot_converter'
@@ -8,116 +9,175 @@ require 'redis_client/cluster/node_key'
8
9
 
9
10
  class RedisClient
10
11
  class Cluster
12
+ class Pipeline
13
+ ReplySizeError = Class.new(::RedisClient::Error)
14
+
15
+ def initialize(client)
16
+ @client = client
17
+ @grouped = Hash.new([].freeze)
18
+ @size = 0
19
+ end
20
+
21
+ def call(*command, **kwargs)
22
+ node_key = @client.send(:find_node_key, *command, primary_only: true)
23
+ @grouped[node_key] += [[@size, :call, command, kwargs]]
24
+ @size += 1
25
+ end
26
+
27
+ def call_once(*command, **kwargs)
28
+ node_key = @client.send(:find_node_key, *command, primary_only: true)
29
+ @grouped[node_key] += [[@size, :call_once, command, kwargs]]
30
+ @size += 1
31
+ end
32
+
33
+ def blocking_call(timeout, *command, **kwargs)
34
+ node_key = @client.send(:find_node_key, *command, primary_only: true)
35
+ @grouped[node_key] += [[@size, :blocking_call, timeout, command, kwargs]]
36
+ @size += 1
37
+ end
38
+
39
+ def empty?
40
+ @size.zero?
41
+ end
42
+
43
+ def execute # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
44
+ all_replies = []
45
+ threads = @grouped.map do |k, v|
46
+ Thread.new(@client, k, v) do |client, node_key, rows|
47
+ Thread.pass
48
+
49
+ node_key = node_key.nil? ? client.instance_variable_get(:@node).primary_node_keys.sample : node_key
50
+ replies = client.send(:find_node, node_key).pipelined do |pipeline|
51
+ rows.each do |row|
52
+ case row[1]
53
+ when :call then pipeline.call(*row[2], **row[3])
54
+ when :call_once then pipeline.call_once(*row[2], **row[3])
55
+ when :blocking_call then pipeline.blocking_call(row[2], *row[3], **row[4])
56
+ else raise NotImplementedError, row[1]
57
+ end
58
+ end
59
+ end
60
+
61
+ raise ReplySizeError, "commands: #{rows.size}, replies: #{replies.size}" if rows.size != replies.size
62
+
63
+ rows.each_with_index { |row, idx| all_replies[row.first] = replies[idx] }
64
+ end
65
+ end
66
+
67
+ threads.each(&:join)
68
+ all_replies
69
+ end
70
+ end
71
+
72
+ class PubSub
73
+ def initialize(client)
74
+ @client = client
75
+ @pubsub = nil
76
+ end
77
+
78
+ def call(*command, **kwargs)
79
+ close
80
+ @pubsub = @client.send(:assign_node, *command).pubsub
81
+ @pubsub.call(*command, **kwargs)
82
+ end
83
+
84
+ def close
85
+ @pubsub&.close
86
+ @pubsub = nil
87
+ end
88
+
89
+ def next_event(timeout = nil)
90
+ @pubsub&.next_event(timeout)
91
+ end
92
+ end
93
+
94
+ ZERO_CURSOR_FOR_SCAN = '0'
95
+ CMD_SCAN = 'SCAN'
96
+ CMD_SSCAN = 'SSCAN'
97
+ CMD_HSCAN = 'HSCAN'
98
+ CMD_ZSCAN = 'ZSCAN'
99
+ CMD_ASKING = 'ASKING'
100
+ REPLY_OK = 'OK'
101
+ REPLY_MOVED = 'MOVED'
102
+ REPLY_ASK = 'ASK'
103
+
11
104
  def initialize(config, pool: nil, **kwargs)
12
105
  @config = config.dup
13
106
  @pool = pool
14
107
  @client_kwargs = kwargs
15
- @node = fetch_cluster_info!(@config, @pool, **@client_kwargs)
108
+ @node = fetch_cluster_info!(@config, pool: @pool, **@client_kwargs)
16
109
  @command = ::RedisClient::Cluster::Command.load(@node)
110
+ @mutex = Mutex.new
17
111
  end
18
112
 
19
113
  def inspect
20
- @node.inspect
114
+ "#<#{self.class.name} #{@node.node_keys.join(', ')}>"
21
115
  end
22
116
 
23
- def call(*command, **kwargs, &block)
24
- send_command(:call, *command, **kwargs, &block)
117
+ def call(*command, **kwargs)
118
+ send_command(:call, *command, **kwargs)
25
119
  end
26
120
 
27
- def call_once(*command, **kwargs, &block)
28
- send_command(:call_once, *command, **kwargs, &block)
121
+ def call_once(*command, **kwargs)
122
+ send_command(:call_once, *command, **kwargs)
29
123
  end
30
124
 
31
- def blocking_call(timeout, *command, **kwargs, &block)
125
+ def blocking_call(timeout, *command, **kwargs)
32
126
  node = assign_node(*command)
33
- try_send(node, :blocking_call, timeout, *command, **kwargs, &block)
127
+ try_send(node, :blocking_call, timeout, *command, **kwargs)
34
128
  end
35
129
 
36
130
  def scan(*args, **kwargs, &block)
37
- _scan(:scan, *args, **kwargs, &block)
131
+ raise ArgumentError, 'block required' unless block
132
+
133
+ cursor = ZERO_CURSOR_FOR_SCAN
134
+ loop do
135
+ cursor, keys = _scan(CMD_SCAN, cursor, *args, **kwargs)
136
+ keys.each(&block)
137
+ break if cursor == ZERO_CURSOR_FOR_SCAN
138
+ end
38
139
  end
39
140
 
40
141
  def sscan(key, *args, **kwargs, &block)
41
- node = assign_node('SSCAN', key)
142
+ node = assign_node(CMD_SSCAN, key)
42
143
  try_send(node, :sscan, key, *args, **kwargs, &block)
43
144
  end
44
145
 
45
146
  def hscan(key, *args, **kwargs, &block)
46
- node = assign_node('HSCAN', key)
147
+ node = assign_node(CMD_HSCAN, key)
47
148
  try_send(node, :hscan, key, *args, **kwargs, &block)
48
149
  end
49
150
 
50
151
  def zscan(key, *args, **kwargs, &block)
51
- node = assign_node('ZSCAN', key)
152
+ node = assign_node(CMD_ZSCAN, key)
52
153
  try_send(node, :zscan, key, *args, **kwargs, &block)
53
154
  end
54
155
 
55
156
  def pipelined
56
- # TODO: impl
57
- end
157
+ pipeline = ::RedisClient::Cluster::Pipeline.new(self)
158
+ yield pipeline
159
+ return [] if pipeline.empty? == 0
58
160
 
59
- def multi
60
- # TODO: impl
161
+ pipeline.execute
61
162
  end
62
163
 
63
164
  def pubsub
64
- # TODO: impl
65
- end
66
-
67
- def size
68
- # TODO: impl
69
- end
70
-
71
- def with(options = {})
72
- # TODO: impl
73
- end
74
- alias then with
75
-
76
- def id
77
- @node.flat_map(&:id).sort.join(' ')
78
- end
79
-
80
- def connected?
81
- @node.any?(&:connected?)
165
+ ::RedisClient::Cluster::PubSub.new(self)
82
166
  end
83
167
 
84
168
  def close
85
169
  @node.each(&:close)
86
- true
87
- end
88
-
89
- # TODO: remove
90
- def call_pipeline(pipeline)
91
- node_keys = pipeline.commands.filter_map { |cmd| find_node_key(cmd, primary_only: true) }.uniq
92
- if node_keys.size > 1
93
- raise(CrossSlotPipeliningError,
94
- pipeline.commands.map { |cmd| @command.extract_first_key(cmd) }.reject(&:empty?).uniq)
95
- end
96
-
97
- try_send(find_node(node_keys.first), :call_pipeline, pipeline)
98
- end
99
-
100
- # TODO: remove
101
- def process(commands, &block)
102
- if commands.size == 1 &&
103
- %w[unsubscribe punsubscribe].include?(commands.first.first.to_s.downcase) &&
104
- commands.first.size == 1
105
-
106
- # Node is indeterminate. We do just a best-effort try here.
107
- @node.process_all(commands, &block)
108
- else
109
- node = assign_node(commands.first)
110
- try_send(node, :process, commands, &block)
111
- end
170
+ nil
112
171
  end
113
172
 
114
173
  private
115
174
 
116
- def fetch_cluster_info!(config, pool, **kwargs)
175
+ def fetch_cluster_info!(config, pool: nil, **kwargs)
117
176
  node_info = ::RedisClient::Cluster::Node.load_info(config.per_node_key, **kwargs)
118
177
  node_addrs = node_info.map { |info| ::RedisClient::Cluster::NodeKey.hashify(info[:node_key]) }
119
178
  config.update_node(node_addrs)
120
- ::RedisClient::Cluster::Node.new(config.per_node_key, node_info, pool, with_replica: config.use_replica?, **kwargs)
179
+ ::RedisClient::Cluster::Node.new(config.per_node_key,
180
+ node_info: node_info, pool: pool, with_replica: config.use_replica?, **kwargs)
121
181
  end
122
182
 
123
183
  def send_command(method, *command, **kwargs, &block) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
@@ -130,7 +190,7 @@ class RedisClient
130
190
  when 'wait' then @node.call_primary(method, *command, **kwargs, &block).sum
131
191
  when 'keys' then @node.call_replica(method, *command, **kwargs, &block).flatten.sort
132
192
  when 'dbsize' then @node.call_replica(method, *command, **kwargs, &block).sum
133
- when 'scan' then _scan(method, *command, **kwargs, &block)
193
+ when 'scan' then _scan(*command, **kwargs)
134
194
  when 'lastsave' then @node.call_all(method, *command, **kwargs, &block).sort
135
195
  when 'role' then @node.call_all(method, *command, **kwargs, &block)
136
196
  when 'config' then send_config_command(method, *command, **kwargs, &block)
@@ -179,7 +239,7 @@ class RedisClient
179
239
  case subcommand
180
240
  when 'addslots', 'delslots', 'failover', 'forget', 'meet', 'replicate',
181
241
  'reset', 'set-config-epoch', 'setslot'
182
- raise ::RedisClient::Cluster::OrchestrationCommandNotSupported, 'cluster', subcommand
242
+ raise ::RedisClient::Cluster::OrchestrationCommandNotSupported, ['cluster', subcommand]
183
243
  when 'saveconfig' then @node.call_all(method, *command, **kwargs, &block).first
184
244
  else assign_node(*command).send(method, *command, **kwargs, &block)
185
245
  end
@@ -195,7 +255,7 @@ class RedisClient
195
255
  end
196
256
  end
197
257
 
198
- def send_pubsub_command(method, *command, **kwargs, &block) # rubocop:disable Metircs/AbcSize, Metrics/CyclomaticComplexity
258
+ def send_pubsub_command(method, *command, **kwargs, &block) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
199
259
  case command[1].to_s.downcase
200
260
  when 'channels' then @node.call_all(method, *command, **kwargs, &block).flatten.uniq.sort
201
261
  when 'numsub'
@@ -211,17 +271,17 @@ class RedisClient
211
271
  def try_send(node, method, *args, retry_count: 3, **kwargs, &block) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
212
272
  node.send(method, *args, **kwargs, &block)
213
273
  rescue ::RedisClient::CommandError => e
214
- if e.message.start_with?('MOVED')
274
+ if e.message.start_with?(REPLY_MOVED)
215
275
  raise if retry_count <= 0
216
276
 
217
277
  node = assign_redirection_node(e.message)
218
278
  retry_count -= 1
219
279
  retry
220
- elsif e.message.start_with?('ASK')
280
+ elsif e.message.start_with?(REPLY_ASK)
221
281
  raise if retry_count <= 0
222
282
 
223
283
  node = assign_asking_node(e.message)
224
- node.call('ASKING')
284
+ node.call(CMD_ASKING)
225
285
  retry_count -= 1
226
286
  retry
227
287
  else
@@ -232,7 +292,8 @@ class RedisClient
232
292
  raise
233
293
  end
234
294
 
235
- def _scan(method, *command, **kwargs, &block) # rubocop:disable Metrics/MethodLength
295
+ def _scan(*command, **kwargs) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
296
+ command[1] = ZERO_CURSOR_FOR_SCAN if command.size == 1
236
297
  input_cursor = Integer(command[1])
237
298
 
238
299
  client_index = input_cursor % 256
@@ -241,11 +302,11 @@ class RedisClient
241
302
  clients = @node.scale_reading_clients
242
303
 
243
304
  client = clients[client_index]
244
- return ['0', []] unless client
305
+ return [ZERO_CURSOR_FOR_SCAN, []] unless client
245
306
 
246
307
  command[1] = raw_cursor.to_s
247
308
 
248
- result_cursor, result_keys = client.send(method, *command, **kwargs, &block)
309
+ result_cursor, result_keys = client.call(*command, **kwargs)
249
310
  result_cursor = Integer(result_cursor)
250
311
 
251
312
  client_index += 1 if result_cursor == 0
@@ -266,13 +327,17 @@ class RedisClient
266
327
  end
267
328
 
268
329
  def assign_node(*command)
269
- node_key = find_node_key(command)
330
+ node_key = find_node_key(*command)
270
331
  find_node(node_key)
271
332
  end
272
333
 
273
- def find_node_key(command, primary_only: false)
334
+ def find_node_key(*command, primary_only: false) # rubocop:disable Metrics/MethodLength
274
335
  key = @command.extract_first_key(command)
275
- return if key.empty?
336
+ if key.empty?
337
+ return @node.primary_node_keys.sample if @command.should_send_to_primary?(command) || primary_only
338
+
339
+ return @node.replica_node_keys.sample
340
+ end
276
341
 
277
342
  slot = ::RedisClient::Cluster::KeySlotConverter.convert(key)
278
343
  return unless @node.slot_exists?(slot)
@@ -294,13 +359,15 @@ class RedisClient
294
359
  end
295
360
 
296
361
  def update_cluster_info!(node_key = nil)
297
- unless node_key.nil?
298
- host, port = ::RedisClient::Cluster::NodeKey.split(node_key)
299
- @config.add_node(host, port)
362
+ @mutex.synchronize do
363
+ unless node_key.nil?
364
+ host, port = ::RedisClient::Cluster::NodeKey.split(node_key)
365
+ @config.add_node(host, port)
366
+ end
367
+
368
+ @node.each(&:close)
369
+ @node = fetch_cluster_info!(@config, pool: @pool, **@client_kwargs)
300
370
  end
301
-
302
- @node.each(&:close)
303
- @node = fetch_cluster_info!(@config, @pool, **@client_kwargs)
304
371
  end
305
372
  end
306
373
  end
@@ -7,23 +7,29 @@ require 'redis_client/cluster/node_key'
7
7
 
8
8
  class RedisClient
9
9
  class ClusterConfig
10
+ DEFAULT_HOST = '127.0.0.1'
11
+ DEFAULT_PORT = 6379
10
12
  DEFAULT_SCHEME = 'redis'
11
13
  SECURE_SCHEME = 'rediss'
14
+ DEFAULT_NODES = ["#{DEFAULT_SCHEME}://#{DEFAULT_HOST}:#{DEFAULT_PORT}"].freeze
12
15
  VALID_SCHEMES = [DEFAULT_SCHEME, SECURE_SCHEME].freeze
16
+ VALID_NODES_KEYS = %i[ssl username password host port db].freeze
17
+ MERGE_CONFIG_KEYS = %i[ssl username password].freeze
18
+ IGNORE_GENERIC_CONFIG_KEYS = %i[url host port path].freeze
19
+
13
20
  InvalidClientConfigError = Class.new(::RedisClient::Error)
14
21
 
15
- def initialize(nodes:, replica: false, fixed_hostname: nil, **client_config)
16
- @replica = replica
17
- @fixed_hostname = fixed_hostname
18
- @client_config = client_config.dup
22
+ def initialize(nodes: DEFAULT_NODES, replica: false, fixed_hostname: '', **client_config)
23
+ @replica = true & replica
24
+ @fixed_hostname = fixed_hostname.to_s
19
25
  @node_configs = build_node_configs(nodes.dup)
20
- add_common_node_config_if_needed(@client_config, @node_configs, :ssl)
21
- add_common_node_config_if_needed(@client_config, @node_configs, :username)
22
- add_common_node_config_if_needed(@client_config, @node_configs, :password)
26
+ client_config = client_config.reject { |k, _| IGNORE_GENERIC_CONFIG_KEYS.include?(k) }
27
+ @client_config = merge_generic_config(client_config, @node_configs)
28
+ @mutex = Mutex.new
23
29
  end
24
30
 
25
31
  def inspect
26
- per_node_key.to_s
32
+ "#<#{self.class.name} #{per_node_key.values}>"
27
33
  end
28
34
 
29
35
  def new_pool(size: 5, timeout: 5, **kwargs)
@@ -38,7 +44,7 @@ class RedisClient
38
44
  @node_configs.to_h do |config|
39
45
  node_key = ::RedisClient::Cluster::NodeKey.build_from_host_port(config[:host], config[:port])
40
46
  config = @client_config.merge(config)
41
- config = config.merge(host: @fixed_hostname) if @fixed_hostname && !@fixed_hostname.empty?
47
+ config = config.merge(host: @fixed_hostname) unless @fixed_hostname.empty?
42
48
  [node_key, config]
43
49
  end
44
50
  end
@@ -48,11 +54,11 @@ class RedisClient
48
54
  end
49
55
 
50
56
  def update_node(addrs)
51
- @node_configs = build_node_configs(addrs)
57
+ @mutex.synchronize { @node_configs = build_node_configs(addrs) }
52
58
  end
53
59
 
54
60
  def add_node(host, port)
55
- @node_configs << { host: host, port: port }
61
+ @mutex.synchronize { @node_configs << { host: host, port: port } }
56
62
  end
57
63
 
58
64
  def dup
@@ -62,9 +68,10 @@ class RedisClient
62
68
  private
63
69
 
64
70
  def build_node_configs(addrs)
65
- raise InvalidClientConfigError, 'Redis option of `cluster` must be an Array' unless addrs.is_a?(Array)
71
+ configs = Array[addrs].flatten.filter_map { |addr| parse_node_addr(addr) }
72
+ raise InvalidClientConfigError, '`nodes` option is empty' if configs.size.zero?
66
73
 
67
- addrs.map { |addr| parse_node_addr(addr) }
74
+ configs
68
75
  end
69
76
 
70
77
  def parse_node_addr(addr)
@@ -74,41 +81,51 @@ class RedisClient
74
81
  when Hash
75
82
  parse_node_option(addr)
76
83
  else
77
- raise InvalidClientConfigError, 'Redis option of `cluster` must includes String or Hash'
84
+ raise InvalidClientConfigError, "`nodes` option includes invalid type values: #{addr}"
78
85
  end
79
86
  end
80
87
 
81
- def parse_node_url(addr) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
88
+ def parse_node_url(addr) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
89
+ return if addr.empty?
90
+
82
91
  uri = URI(addr)
83
- raise InvalidClientConfigError, "Invalid uri scheme #{addr}" unless VALID_SCHEMES.include?(uri.scheme)
92
+ scheme = uri.scheme || DEFAULT_SCHEME
93
+ raise InvalidClientConfigError, "`nodes` option includes a invalid uri scheme: #{addr}" unless VALID_SCHEMES.include?(scheme)
84
94
 
85
- db = uri.path.split('/')[1]&.to_i
86
95
  username = uri.user ? URI.decode_www_form_component(uri.user) : nil
87
96
  password = uri.password ? URI.decode_www_form_component(uri.password) : nil
97
+ host = uri.host || DEFAULT_HOST
98
+ port = uri.port || DEFAULT_PORT
99
+ db = uri.path.index('/').nil? ? uri.path : uri.path.split('/')[1]
100
+ db = db.nil? || db.empty? ? db : ensure_integer(db)
88
101
 
89
- {
90
- host: uri.host,
91
- port: uri.port,
92
- username: username,
93
- password: password,
94
- db: db,
95
- ssl: uri.scheme == SECURE_SCHEME
96
- }.reject { |_, v| v.nil? || v == '' }
102
+ { ssl: scheme == SECURE_SCHEME, username: username, password: password, host: host, port: port, db: db }
103
+ .reject { |_, v| v.nil? || v == '' || v == false }
97
104
  rescue URI::InvalidURIError => e
98
- raise InvalidClientConfigError, e.message
105
+ raise InvalidClientConfigError, "#{e.message}: #{addr}"
99
106
  end
100
107
 
101
108
  def parse_node_option(addr)
109
+ return if addr.empty?
110
+
102
111
  addr = addr.transform_keys(&:to_sym)
103
- raise InvalidClientConfigError, 'Redis option of `cluster` must includes `:host` and `:port` keys' if addr.values_at(:host, :port).any?(&:nil?)
112
+ addr[:host] ||= DEFAULT_HOST
113
+ addr[:port] = ensure_integer(addr[:port] || DEFAULT_PORT)
114
+ addr.select { |k, _| VALID_NODES_KEYS.include?(k) }
115
+ end
104
116
 
105
- addr
117
+ def ensure_integer(value)
118
+ Integer(value)
119
+ rescue ArgumentError => e
120
+ raise InvalidClientConfigError, e.message
106
121
  end
107
122
 
108
- def add_common_node_config_if_needed(client_config, node_configs, key)
109
- return client_config if client_config[key].nil? && node_configs.first[key].nil?
123
+ def merge_generic_config(client_config, node_configs)
124
+ return client_config if node_configs.size.zero?
110
125
 
111
- client_config[key] ||= node_configs.first[key]
126
+ cfg = node_configs.first
127
+ MERGE_CONFIG_KEYS.each { |k| client_config[k] = cfg[k] if cfg.key?(k) }
128
+ client_config
112
129
  end
113
130
  end
114
131
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redis-cluster-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Taishi Kasuga
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-06-12 00:00:00.000000000 Z
11
+ date: 2022-06-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: redis-client