redis-cluster-client 0.7.4 → 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)
@@ -18,10 +18,13 @@ class RedisClient
18
18
  MERGE_CONFIG_KEYS = %i[ssl username password].freeze
19
19
  IGNORE_GENERIC_CONFIG_KEYS = %i[url host port path].freeze
20
20
  MAX_WORKERS = Integer(ENV.fetch('REDIS_CLIENT_MAX_THREADS', 5))
21
+ # It's used with slow queries of fetching meta data like CLUSTER NODES, COMMAND and so on.
22
+ SLOW_COMMAND_TIMEOUT = Float(ENV.fetch('REDIS_CLIENT_SLOW_COMMAND_TIMEOUT', -1))
21
23
 
22
24
  InvalidClientConfigError = Class.new(::RedisClient::Error)
23
25
 
24
- attr_reader :command_builder, :client_config, :replica_affinity
26
+ attr_reader :command_builder, :client_config, :replica_affinity, :slow_command_timeout,
27
+ :connect_with_original_config, :startup_nodes
25
28
 
26
29
  def initialize(
27
30
  nodes: DEFAULT_NODES,
@@ -29,36 +32,29 @@ class RedisClient
29
32
  replica_affinity: :random,
30
33
  fixed_hostname: '',
31
34
  concurrency: nil,
35
+ connect_with_original_config: false,
32
36
  client_implementation: ::RedisClient::Cluster, # for redis gem
37
+ slow_command_timeout: SLOW_COMMAND_TIMEOUT,
38
+ command_builder: ::RedisClient::CommandBuilder,
33
39
  **client_config
34
40
  )
35
41
 
36
42
  @replica = true & replica
37
43
  @replica_affinity = replica_affinity.to_s.to_sym
38
44
  @fixed_hostname = fixed_hostname.to_s
39
- @node_configs = build_node_configs(nodes.dup)
40
- client_config = client_config.reject { |k, _| IGNORE_GENERIC_CONFIG_KEYS.include?(k) }
41
- @command_builder = client_config.fetch(:command_builder, ::RedisClient::CommandBuilder)
42
- @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)
43
50
  @concurrency = merge_concurrency_option(concurrency)
51
+ @connect_with_original_config = connect_with_original_config
44
52
  @client_implementation = client_implementation
45
- @mutex = Mutex.new
46
- end
47
-
48
- def dup
49
- self.class.new(
50
- nodes: @node_configs,
51
- replica: @replica,
52
- replica_affinity: @replica_affinity,
53
- fixed_hostname: @fixed_hostname,
54
- concurrency: @concurrency,
55
- client_implementation: @client_implementation,
56
- **@client_config
57
- )
53
+ @slow_command_timeout = slow_command_timeout
58
54
  end
59
55
 
60
56
  def inspect
61
- "#<#{self.class.name} #{per_node_key.values}>"
57
+ "#<#{self.class.name} #{startup_nodes.values}>"
62
58
  end
63
59
 
64
60
  def read_timeout
@@ -78,29 +74,14 @@ class RedisClient
78
74
  @client_implementation.new(self, concurrency: @concurrency, **kwargs)
79
75
  end
80
76
 
81
- def per_node_key
82
- @node_configs.to_h do |config|
83
- node_key = ::RedisClient::Cluster::NodeKey.build_from_host_port(config[:host], config[:port])
84
- config = @client_config.merge(config)
85
- config = config.merge(host: @fixed_hostname) unless @fixed_hostname.empty?
86
- [node_key, config]
87
- end
88
- end
89
-
90
77
  def use_replica?
91
78
  @replica
92
79
  end
93
80
 
94
- def update_node(addrs)
95
- return if @mutex.locked?
96
-
97
- @mutex.synchronize { @node_configs = build_node_configs(addrs) }
98
- end
99
-
100
- def add_node(host, port)
101
- return if @mutex.locked?
102
-
103
- @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)
104
85
  end
105
86
 
106
87
  private
@@ -168,11 +149,23 @@ class RedisClient
168
149
  end
169
150
 
170
151
  def merge_generic_config(client_config, node_configs)
171
- 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
172
164
 
173
- cfg = node_configs.first
174
- MERGE_CONFIG_KEYS.each { |k| client_config[k] = cfg[k] if cfg.key?(k) }
175
- 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
176
169
  end
177
170
  end
178
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.4
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-06 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