redis-cluster-client 0.7.5 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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