redis-cluster-client 0.7.5 → 0.7.6

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,56 +1,194 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'redis_client'
4
+ require 'redis_client/cluster/pipeline'
5
+ require 'redis_client/cluster/key_slot_converter'
4
6
 
5
7
  class RedisClient
6
8
  class Cluster
7
9
  class Transaction
8
10
  ConsistencyError = Class.new(::RedisClient::Error)
9
11
 
10
- def initialize(router, command_builder)
12
+ def initialize(router, command_builder, watch)
11
13
  @router = router
12
14
  @command_builder = command_builder
13
- @node_key = nil
15
+ @watch = watch
16
+ @retryable = true
17
+ @pipeline = ::RedisClient::Pipeline.new(@command_builder)
18
+ @buffer = []
19
+ @node = nil
14
20
  end
15
21
 
16
- def call(*command, **kwargs, &_)
22
+ def call(*command, **kwargs, &block)
17
23
  command = @command_builder.generate(command, kwargs)
18
- ensure_node_key(command)
24
+ if prepare(command)
25
+ @pipeline.call_v(command, &block)
26
+ else
27
+ @buffer << -> { @pipeline.call_v(command, &block) }
28
+ end
19
29
  end
20
30
 
21
- def call_v(command, &_)
31
+ def call_v(command, &block)
22
32
  command = @command_builder.generate(command)
23
- ensure_node_key(command)
33
+ if prepare(command)
34
+ @pipeline.call_v(command, &block)
35
+ else
36
+ @buffer << -> { @pipeline.call_v(command, &block) }
37
+ end
24
38
  end
25
39
 
26
- def call_once(*command, **kwargs, &_)
40
+ def call_once(*command, **kwargs, &block)
41
+ @retryable = false
27
42
  command = @command_builder.generate(command, kwargs)
28
- ensure_node_key(command)
43
+ if prepare(command)
44
+ @pipeline.call_once_v(command, &block)
45
+ else
46
+ @buffer << -> { @pipeline.call_once_v(command, &block) }
47
+ end
29
48
  end
30
49
 
31
- def call_once_v(command, &_)
50
+ def call_once_v(command, &block)
51
+ @retryable = false
32
52
  command = @command_builder.generate(command)
33
- ensure_node_key(command)
53
+ if prepare(command)
54
+ @pipeline.call_once_v(command, &block)
55
+ else
56
+ @buffer << -> { @pipeline.call_once_v(command, &block) }
57
+ end
34
58
  end
35
59
 
36
- def execute(watch: nil, &block)
37
- yield self
38
- raise ArgumentError, 'empty transaction' if @node_key.nil?
60
+ def execute
61
+ @buffer.each(&:call)
39
62
 
40
- node = @router.find_node(@node_key)
41
- @router.try_delegate(node, :multi, watch: watch, &block)
63
+ raise ArgumentError, 'empty transaction' if @pipeline._empty?
64
+ raise ConsistencyError, "couldn't determine the node: #{@pipeline._commands}" if @node.nil?
65
+ raise ConsistencyError, "unsafe watch: #{@watch.join(' ')}" unless safe_watch?
66
+
67
+ settle
42
68
  end
43
69
 
44
70
  private
45
71
 
46
- def ensure_node_key(command)
72
+ def watch?
73
+ !@watch.nil? && !@watch.empty?
74
+ end
75
+
76
+ def safe_watch?
77
+ return true unless watch?
78
+ return false if @node.nil?
79
+
80
+ slots = @watch.map do |k|
81
+ return false if k.nil? || k.empty?
82
+
83
+ ::RedisClient::Cluster::KeySlotConverter.convert(k)
84
+ end
85
+
86
+ return false if slots.uniq.size != 1
87
+
88
+ @router.find_primary_node_by_slot(slots.first) == @node
89
+ end
90
+
91
+ def prepare(command)
92
+ return true unless @node.nil?
93
+
47
94
  node_key = @router.find_primary_node_key(command)
48
- raise ConsistencyError, "Client couldn't determine the node to be executed the transaction by: #{command}" if node_key.nil?
95
+ return false if node_key.nil?
96
+
97
+ @node = @router.find_node(node_key)
98
+ @pipeline.call('WATCH', *@watch) if watch?
99
+ @pipeline.call('MULTI')
100
+ @buffer.each(&:call)
101
+ @buffer.clear
102
+ true
103
+ end
104
+
105
+ def settle
106
+ @pipeline.call('EXEC')
107
+ @pipeline.call('UNWATCH') if watch?
108
+ send_transaction(@node, redirect: true)
109
+ end
110
+
111
+ def send_transaction(client, redirect:)
112
+ case client
113
+ when ::RedisClient then send_pipeline(client, redirect: redirect)
114
+ when ::RedisClient::Pooled then client.with { |c| send_pipeline(c, redirect: redirect) }
115
+ else raise NotImplementedError, "#{client.class.name}#multi for cluster client"
116
+ end
117
+ end
118
+
119
+ def send_pipeline(client, redirect:)
120
+ replies = client.ensure_connected_cluster_scoped(retryable: @retryable) do |connection|
121
+ commands = @pipeline._commands
122
+ client.middlewares.call_pipelined(commands, client.config) do
123
+ connection.call_pipelined(commands, nil)
124
+ rescue ::RedisClient::CommandError => e
125
+ return handle_command_error!(commands, e) if redirect
49
126
 
50
- @node_key ||= node_key
51
- raise ConsistencyError, "The transaction should be done for single node: #{@node_key}, #{node_key}" if node_key != @node_key
127
+ raise
128
+ end
129
+ end
130
+
131
+ offset = watch? ? 2 : 1
132
+ coerce_results!(replies[-offset], offset)
133
+ end
134
+
135
+ def coerce_results!(results, offset)
136
+ results.each_with_index do |result, index|
137
+ if result.is_a?(::RedisClient::CommandError)
138
+ result._set_command(@pipeline._commands[index + offset])
139
+ raise result
140
+ end
141
+
142
+ next if @pipeline._blocks.nil?
143
+
144
+ block = @pipeline._blocks[index + offset]
145
+ next if block.nil?
146
+
147
+ results[index] = block.call(result)
148
+ end
149
+
150
+ results
151
+ end
152
+
153
+ def handle_command_error!(commands, err)
154
+ if err.message.start_with?('CROSSSLOT')
155
+ raise ConsistencyError, "#{err.message}: #{err.command}"
156
+ elsif err.message.start_with?('MOVED', 'ASK')
157
+ ensure_the_same_node!(commands)
158
+ handle_redirection(err)
159
+ else
160
+ raise err
161
+ end
162
+ end
163
+
164
+ def ensure_the_same_node!(commands)
165
+ commands.each do |command|
166
+ node_key = @router.find_primary_node_key(command)
167
+ next if node_key.nil?
168
+
169
+ node = @router.find_node(node_key)
170
+ next if @node == node
171
+
172
+ raise ConsistencyError, "the transaction should be executed to a slot in a node: #{commands}"
173
+ end
174
+ end
175
+
176
+ def handle_redirection(err)
177
+ if err.message.start_with?('MOVED')
178
+ node = @router.assign_redirection_node(err.message)
179
+ send_transaction(node, redirect: false)
180
+ elsif err.message.start_with?('ASK')
181
+ node = @router.assign_asking_node(err.message)
182
+ try_asking(node) ? send_transaction(node, redirect: false) : err
183
+ else
184
+ raise err
185
+ end
186
+ end
52
187
 
53
- nil
188
+ def try_asking(node)
189
+ node.call('ASKING') == 'OK'
190
+ rescue StandardError
191
+ false
54
192
  end
55
193
  end
56
194
  end
@@ -5,6 +5,7 @@ require 'redis_client/cluster/pipeline'
5
5
  require 'redis_client/cluster/pub_sub'
6
6
  require 'redis_client/cluster/router'
7
7
  require 'redis_client/cluster/transaction'
8
+ require 'redis_client/cluster/pinning_node'
8
9
 
9
10
  class RedisClient
10
11
  class Cluster
@@ -89,14 +90,26 @@ class RedisClient
89
90
  pipeline.execute
90
91
  end
91
92
 
92
- def multi(watch: nil, &block)
93
- ::RedisClient::Cluster::Transaction.new(@router, @command_builder).execute(watch: watch, &block)
93
+ def multi(watch: nil)
94
+ transaction = ::RedisClient::Cluster::Transaction.new(@router, @command_builder, watch)
95
+ yield transaction
96
+ transaction.execute
94
97
  end
95
98
 
96
99
  def pubsub
97
100
  ::RedisClient::Cluster::PubSub.new(@router, @command_builder)
98
101
  end
99
102
 
103
+ # TODO: This isn't an official public interface yet. Don't use in your production environment.
104
+ # @see https://github.com/redis-rb/redis-cluster-client/issues/299
105
+ def with(key: nil, hashtag: nil, write: true, _retry_count: 0, &_)
106
+ key = process_with_arguments(key, hashtag)
107
+
108
+ node_key = @router.find_node_key_by_key(key, primary: write)
109
+ node = @router.find_node(node_key)
110
+ yield ::RedisClient::Cluster::PinningNode.new(node)
111
+ end
112
+
100
113
  def close
101
114
  @concurrent_worker.close
102
115
  @router.close
@@ -105,6 +118,19 @@ class RedisClient
105
118
 
106
119
  private
107
120
 
121
+ def process_with_arguments(key, hashtag) # rubocop:disable Metrics/CyclomaticComplexity
122
+ raise ArgumentError, 'Only one of key or hashtag may be provided' if key && hashtag
123
+
124
+ if hashtag
125
+ # The documentation says not to wrap your hashtag in {}, but people will probably
126
+ # do it anyway and it's easy for us to fix here.
127
+ key = hashtag&.match?(/^{.*}$/) ? hashtag : "{#{hashtag}}"
128
+ end
129
+ raise ArgumentError, 'One of key or hashtag must be provided' if key.nil? || key.empty?
130
+
131
+ key
132
+ end
133
+
108
134
  def method_missing(name, *args, **kwargs, &block)
109
135
  if @router.command_exists?(name)
110
136
  args.unshift(name)
@@ -23,9 +23,10 @@ class RedisClient
23
23
 
24
24
  InvalidClientConfigError = Class.new(::RedisClient::Error)
25
25
 
26
- attr_reader :command_builder, :client_config, :replica_affinity, :slow_command_timeout, :connect_with_original_config
26
+ attr_reader :command_builder, :client_config, :replica_affinity, :slow_command_timeout,
27
+ :connect_with_original_config, :startup_nodes
27
28
 
28
- def initialize( # rubocop:disable Metrics/AbcSize
29
+ def initialize(
29
30
  nodes: DEFAULT_NODES,
30
31
  replica: false,
31
32
  replica_affinity: :random,
@@ -34,39 +35,26 @@ class RedisClient
34
35
  connect_with_original_config: false,
35
36
  client_implementation: ::RedisClient::Cluster, # for redis gem
36
37
  slow_command_timeout: SLOW_COMMAND_TIMEOUT,
38
+ command_builder: ::RedisClient::CommandBuilder,
37
39
  **client_config
38
40
  )
39
41
 
40
42
  @replica = true & replica
41
43
  @replica_affinity = replica_affinity.to_s.to_sym
42
44
  @fixed_hostname = fixed_hostname.to_s
43
- @node_configs = build_node_configs(nodes.dup)
44
- client_config = client_config.reject { |k, _| IGNORE_GENERIC_CONFIG_KEYS.include?(k) }
45
- @command_builder = client_config.fetch(:command_builder, ::RedisClient::CommandBuilder)
46
- @client_config = merge_generic_config(client_config, @node_configs)
45
+ @command_builder = command_builder
46
+ node_configs = build_node_configs(nodes.dup)
47
+ @client_config = merge_generic_config(client_config, node_configs)
48
+ # Keep tabs on the original startup nodes we were constructed with
49
+ @startup_nodes = build_startup_nodes(node_configs)
47
50
  @concurrency = merge_concurrency_option(concurrency)
48
51
  @connect_with_original_config = connect_with_original_config
49
52
  @client_implementation = client_implementation
50
53
  @slow_command_timeout = slow_command_timeout
51
- @mutex = Mutex.new
52
- end
53
-
54
- def dup
55
- self.class.new(
56
- nodes: @node_configs,
57
- replica: @replica,
58
- replica_affinity: @replica_affinity,
59
- fixed_hostname: @fixed_hostname,
60
- concurrency: @concurrency,
61
- connect_with_original_config: @connect_with_original_config,
62
- client_implementation: @client_implementation,
63
- slow_command_timeout: @slow_command_timeout,
64
- **@client_config
65
- )
66
54
  end
67
55
 
68
56
  def inspect
69
- "#<#{self.class.name} #{per_node_key.values}>"
57
+ "#<#{self.class.name} #{startup_nodes.values}>"
70
58
  end
71
59
 
72
60
  def read_timeout
@@ -86,29 +74,14 @@ class RedisClient
86
74
  @client_implementation.new(self, concurrency: @concurrency, **kwargs)
87
75
  end
88
76
 
89
- def per_node_key
90
- @node_configs.to_h do |config|
91
- node_key = ::RedisClient::Cluster::NodeKey.build_from_host_port(config[:host], config[:port])
92
- config = @client_config.merge(config)
93
- config = config.merge(host: @fixed_hostname) unless @fixed_hostname.empty?
94
- [node_key, config]
95
- end
96
- end
97
-
98
77
  def use_replica?
99
78
  @replica
100
79
  end
101
80
 
102
- def update_node(addrs)
103
- return if @mutex.locked?
104
-
105
- @mutex.synchronize { @node_configs = build_node_configs(addrs) }
106
- end
107
-
108
- def add_node(host, port)
109
- return if @mutex.locked?
110
-
111
- @mutex.synchronize { @node_configs << { host: host, port: port } }
81
+ def client_config_for_node(node_key)
82
+ config = ::RedisClient::Cluster::NodeKey.hashify(node_key)
83
+ config[:port] = ensure_integer(config[:port])
84
+ augment_client_config(config)
112
85
  end
113
86
 
114
87
  private
@@ -176,11 +149,23 @@ class RedisClient
176
149
  end
177
150
 
178
151
  def merge_generic_config(client_config, node_configs)
179
- return client_config if node_configs.empty?
152
+ cfg = node_configs.first || {}
153
+ client_config.reject { |k, _| IGNORE_GENERIC_CONFIG_KEYS.include?(k) }
154
+ .merge(cfg.slice(*MERGE_CONFIG_KEYS))
155
+ end
156
+
157
+ def build_startup_nodes(configs)
158
+ configs.to_h do |config|
159
+ node_key = ::RedisClient::Cluster::NodeKey.build_from_host_port(config[:host], config[:port])
160
+ config = augment_client_config(config)
161
+ [node_key, config]
162
+ end
163
+ end
180
164
 
181
- cfg = node_configs.first
182
- MERGE_CONFIG_KEYS.each { |k| client_config[k] = cfg[k] if cfg.key?(k) }
183
- client_config
165
+ def augment_client_config(config)
166
+ config = @client_config.merge(config)
167
+ config = config.merge(host: @fixed_hostname) unless @fixed_hostname.empty?
168
+ config
184
169
  end
185
170
  end
186
171
  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.7.5
4
+ version: 0.7.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Taishi Kasuga
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-10-20 00:00:00.000000000 Z
11
+ date: 2024-02-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: redis-client
@@ -38,16 +38,18 @@ files:
38
38
  - lib/redis_client/cluster/concurrent_worker/none.rb
39
39
  - lib/redis_client/cluster/concurrent_worker/on_demand.rb
40
40
  - lib/redis_client/cluster/concurrent_worker/pooled.rb
41
+ - lib/redis_client/cluster/error_identification.rb
41
42
  - lib/redis_client/cluster/errors.rb
42
43
  - lib/redis_client/cluster/key_slot_converter.rb
43
44
  - lib/redis_client/cluster/node.rb
45
+ - lib/redis_client/cluster/node/base_topology.rb
44
46
  - lib/redis_client/cluster/node/latency_replica.rb
45
47
  - lib/redis_client/cluster/node/primary_only.rb
46
48
  - lib/redis_client/cluster/node/random_replica.rb
47
49
  - lib/redis_client/cluster/node/random_replica_or_primary.rb
48
- - lib/redis_client/cluster/node/replica_mixin.rb
49
50
  - lib/redis_client/cluster/node_key.rb
50
51
  - lib/redis_client/cluster/normalized_cmd_name.rb
52
+ - lib/redis_client/cluster/pinning_node.rb
51
53
  - lib/redis_client/cluster/pipeline.rb
52
54
  - lib/redis_client/cluster/pub_sub.rb
53
55
  - lib/redis_client/cluster/router.rb
@@ -75,7 +77,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
75
77
  - !ruby/object:Gem::Version
76
78
  version: '0'
77
79
  requirements: []
78
- rubygems_version: 3.4.19
80
+ rubygems_version: 3.5.3
79
81
  signing_key:
80
82
  specification_version: 4
81
83
  summary: A Redis cluster client for Ruby
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class RedisClient
4
- class Cluster
5
- class Node
6
- module ReplicaMixin
7
- attr_reader :clients, :primary_clients
8
-
9
- EMPTY_ARRAY = [].freeze
10
-
11
- def initialize(replications, options, pool, _concurrent_worker, **kwargs)
12
- @replications = replications
13
- @primary_node_keys = @replications.keys.sort
14
- @replica_node_keys = @replications.values.flatten.sort
15
- @clients = build_clients(@primary_node_keys, options, pool, **kwargs)
16
- @primary_clients = @clients.select { |k, _| @primary_node_keys.include?(k) }
17
- end
18
-
19
- def any_primary_node_key(seed: nil)
20
- random = seed.nil? ? Random : Random.new(seed)
21
- @primary_node_keys.sample(random: random)
22
- end
23
-
24
- private
25
-
26
- def build_clients(primary_node_keys, options, pool, **kwargs)
27
- options.to_h do |node_key, option|
28
- option = option.merge(kwargs.reject { |k, _| ::RedisClient::Cluster::Node::IGNORE_GENERIC_CONFIG_KEYS.include?(k) })
29
- config = ::RedisClient::Cluster::Node::Config.new(scale_read: !primary_node_keys.include?(node_key), **option)
30
- client = pool.nil? ? config.new_client : config.new_pool(**pool)
31
- [node_key, client]
32
- end
33
- end
34
- end
35
- end
36
- end
37
- end