diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b96f105b9..c21f59ec7 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -197,7 +197,7 @@ jobs: LOW_TIMEOUT: "0.14" DRIVER: ruby REDIS_BRANCH: "7.0" - REDIS_CLUSTER: "true" + BUNDLE_GEMFILE: redis_cluster/Gemfile steps: - name: Check out code uses: actions/checkout@v3 diff --git a/.rubocop.yml b/.rubocop.yml index 93cb2964f..2dd9c17cc 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -148,3 +148,7 @@ Style/SymbolProc: Bundler/OrderedGems: Enabled: false + +Gemspec/RequiredRubyVersion: + Exclude: + - redis_cluster/redis_cluster.gemspec diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ded75a0..41d35335b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ # Unreleased 5.0.0 +- Cluster support has been moved to a `redis_cluster` companion gem. - `select` no longer record the current database. If the client has to reconnect after `select` was used, it will reconnect to the original database. - Removed `logger` option. - Removed `reconnect_delay_max` and `reconnect_delay`, you can pass precise sleep durations to `reconnect_attempts` instead. diff --git a/Gemfile b/Gemfile index 80aad35bb..6007fd7cd 100644 --- a/Gemfile +++ b/Gemfile @@ -11,4 +11,3 @@ gem 'mocha' gem 'redis-client', github: 'redis-rb/redis-client' gem 'hiredis-client' -gem 'redis-cluster-client', github: 'redis-rb/redis-cluster-client' if ENV['REDIS_CLUSTER'] diff --git a/README.md b/README.md index b15ec63df..858ff9bbe 100644 --- a/README.md +++ b/README.md @@ -116,59 +116,7 @@ redis = Redis.new(name: 'mymaster', sentinels: SENTINELS, role: :master) ## Cluster support -`redis-rb` supports [clustering](https://redis.io/topics/cluster-spec). - -```ruby -# Nodes can be passed to the client as an array of connection URLs. -nodes = (7000..7005).map { |port| "redis://127.0.0.1:#{port}" } -redis = Redis.new(cluster: nodes) - -# You can also specify the options as a Hash. The options are the same as for a single server connection. -(7000..7005).map { |port| { host: '127.0.0.1', port: port } } -``` - -You can also specify only a subset of the nodes, and the client will discover the missing ones using the [CLUSTER NODES](https://redis.io/commands/cluster-nodes) command. - -```ruby -Redis.new(cluster: %w[redis://127.0.0.1:7000]) -``` - -If you want [the connection to be able to read from any replica](https://redis.io/commands/readonly), you must pass the `replica: true`. Note that this connection won't be usable to write keys. - -```ruby -Redis.new(cluster: nodes, replica: true) -``` - -The calling code is responsible for [avoiding cross slot commands](https://redis.io/topics/cluster-spec#keys-distribution-model). - -```ruby -redis = Redis.new(cluster: %w[redis://127.0.0.1:7000]) - -redis.mget('key1', 'key2') -#=> Redis::CommandError (CROSSSLOT Keys in request don't hash to the same slot) - -redis.mget('{key}1', '{key}2') -#=> [nil, nil] -``` - -* The client automatically reconnects after a failover occurred, but the caller is responsible for handling errors while it is happening. -* The client support permanent node failures, and will reroute requests to promoted slaves. -* The client supports `MOVED` and `ASK` redirections transparently. - -## Cluster mode with SSL/TLS -Since Redis can return FQDN of nodes in reply to client since `7.*` with CLUSTER commands, we can use cluster feature with SSL/TLS connection like this: - -```ruby -Redis.new(cluster: %w[rediss://foo.example.com:6379]) -``` - -On the other hand, in Redis versions prior to `6.*`, you can specify options like the following if cluster mode is enabled and client has to connect to nodes via single endpoint with SSL/TLS. - -```ruby -Redis.new(cluster: %w[rediss://foo-endpoint.example.com:6379], fixed_hostname: 'foo-endpoint.example.com') -``` - -In case of the above architecture, if you don't pass the `fixed_hostname` option to the client and servers return IP addresses of nodes, the client may fail to verify certificates. +[Clustering](https://redis.io/topics/cluster-spec). is supported via the [`redis_cluster` gem](redis_cluster/). ## Storing objects diff --git a/Rakefile b/Rakefile index c8d8c4d90..827844ea5 100644 --- a/Rakefile +++ b/Rakefile @@ -1,10 +1,12 @@ # frozen_string_literal: true require 'bundler/gem_tasks' +Bundler::GemHelper.install_tasks(dir: "redis_cluster", name: "redis_cluster") + require 'rake/testtask' namespace :test do - groups = %i(redis distributed sentinel cluster) + groups = %i(redis distributed sentinel) groups.each do |group| Rake::TestTask.new(group) do |t| t.libs << "test" @@ -18,6 +20,13 @@ namespace :test do unless lost_tests.empty? abort "The following test files are in no group:\n#{lost_tests.join("\n")}" end + + Rake::TestTask.new(:cluster) do |t| + t.libs << "redis_cluster/test" << "test" + t.libs << "redis_cluster/lib" << "lib" + t.test_files = FileList["redis_cluster/test/**/*_test.rb"] + t.options = '-v' if ENV['CI'] || ENV['VERBOSE'] + end end task test: ["test:redis", "test:distributed", "test:sentinel", "test:cluster"] diff --git a/bin/cluster_creator b/bin/cluster_creator index a4d79353a..ec8dad30d 100755 --- a/bin/cluster_creator +++ b/bin/cluster_creator @@ -5,7 +5,7 @@ puts ARGV.join(" ") require 'bundler/setup' $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) -require_relative '../test/support/cluster/orchestrator' +require_relative '../redis_cluster/test/support/orchestrator' urls = ARGV.map { |host_port| "redis://#{host_port}" } orchestrator = ClusterOrchestrator.new(urls, timeout: 3.0) diff --git a/lib/redis.rb b/lib/redis.rb index 5423e47e5..c56900626 100644 --- a/lib/redis.rb +++ b/lib/redis.rb @@ -8,8 +8,6 @@ class Redis BASE_PATH = __dir__ Deprecated = Class.new(StandardError) - autoload :ClusterClient, "redis/cluster_client" - class << self attr_accessor :silence_deprecations, :raise_deprecations @@ -50,12 +48,6 @@ def deprecate!(message) # @option options [Boolean] :inherit_socket (false) Whether to use socket in forked process or not # @option options [String] :name The name of the server group to connect to. # @option options [Array] :sentinels List of sentinels to contact - # @option options [Symbol] :role (:master) Role to fetch via Sentinel, either `:master` or `:slave` - # @option options [Array String, Integer}>] :cluster List of cluster nodes to contact - # @option options [Boolean] :replica Whether to use readonly replica nodes in Redis Cluster or not - # @option options [String] :fixed_hostname Specify a FQDN if cluster mode enabled and - # client has to connect nodes via single endpoint with SSL/TLS - # @option options [Class] :connector Class of custom connector # # @return [Redis] a new client instance def initialize(options = {}) @@ -68,34 +60,7 @@ def initialize(options = {}) inherit_socket = @options.delete(:inherit_socket) @subscription_client = nil - @client = if @cluster_mode = options.key?(:cluster) - @options[:nodes] ||= @options.delete(:cluster) - cluster_config = RedisClient.cluster(**@options, protocol: 2, client_implementation: ClusterClient) - begin - cluster_config.new_client - rescue ::RedisClient::Error => error - raise ClusterClient::ERROR_MAPPING.fetch(error.class), error.message, error.backtrace - end - elsif @options.key?(:sentinels) - if url = @options.delete(:url) - uri = URI.parse(url) - if !@options.key?(:name) && uri.host - @options[:name] = uri.host - end - - if !@options.key?(:password) && uri.password && !uri.password.empty? - @options[:password] = uri.password - end - - if !@options.key?(:username) && uri.user && !uri.user.empty? - @options[:username] = uri.user - end - end - - Client.sentinel(**@options).new_client - else - Client.config(**@options).new_client - end + @client = initialize_client(@options) @client.inherit_socket! if inherit_socket end @@ -145,10 +110,6 @@ def dup end def connection - if @cluster_mode - raise NotImplementedError, "Redis::Cluster doesn't implement #connection" - end - { host: @client.host, port: @client.port, @@ -160,6 +121,33 @@ def connection private + def initialize_client(options) + if options.key?(:cluster) + raise "Redis Cluster support was moved to the `redis_cluster` gem." + end + + if options.key?(:sentinels) + if url = options.delete(:url) + uri = URI.parse(url) + if !options.key?(:name) && uri.host + options[:name] = uri.host + end + + if !options.key?(:password) && uri.password && !uri.password.empty? + options[:password] = uri.password + end + + if !options.key?(:username) && uri.user && !uri.user.empty? + options[:username] = uri.user + end + end + + Client.sentinel(**options).new_client + else + Client.config(**options).new_client + end + end + def synchronize @monitor.synchronize { yield(@client) } end diff --git a/lib/redis/cluster_client.rb b/lib/redis/cluster_client.rb deleted file mode 100644 index 0b4eec830..000000000 --- a/lib/redis/cluster_client.rb +++ /dev/null @@ -1,92 +0,0 @@ -# frozen_string_literal: true - -require 'redis-cluster-client' - -class Redis - class ClusterClient < RedisClient::Cluster - ERROR_MAPPING = ::Redis::Client::ERROR_MAPPING.merge( - RedisClient::Cluster::InitialSetupError => Redis::Cluster::InitialSetupError, - RedisClient::Cluster::OrchestrationCommandNotSupported => Redis::Cluster::OrchestrationCommandNotSupported, - RedisClient::Cluster::AmbiguousNodeError => Redis::Cluster::AmbiguousNodeError, - RedisClient::Cluster::ErrorCollection => Redis::Cluster::CommandErrorCollection - ).freeze - - class << self - def config(**kwargs) - super(protocol: 2, **kwargs) - end - - def sentinel(**kwargs) - super(protocol: 2, **kwargs) - end - end - - def initialize(*) - super - @inherit_socket = false - @pid = Process.pid - end - ruby2_keywords :initialize if respond_to?(:ruby2_keywords, true) - - def id - @router.node.node_keys.join(' ') - end - - def server_url - @router.node.node_keys - end - - def connected? - true - end - - def disable_reconnection - yield # TODO: do we need this, is it doable? - end - - def timeout - config.read_timeout - end - - def db - 0 - end - - undef_method :call - undef_method :call_once - undef_method :call_once_v - undef_method :blocking_call - - def call_v(command, &block) - handle_errors { super(command, &block) } - end - - def blocking_call_v(timeout, command, &block) - timeout += self.timeout if timeout && timeout > 0 - handle_errors { super(timeout, command, &block) } - end - - def pipelined(&block) - handle_errors { super(&block) } - end - - def multi(&block) - handle_errors { super(&block) } - end - - private - - def handle_errors - yield - rescue RedisClient::Cluster::ErrorCollection => error - error.errors.each do |_node, node_error| - if node_error.is_a?(RedisClient::AuthenticationError) - raise ERROR_MAPPING.fetch(node_error.class), node_error.message, node_error.backtrace - end - end - raise ERROR_MAPPING.fetch(error.class), error.message, error.backtrace - rescue ::RedisClient::Error => error - raise ERROR_MAPPING.fetch(error.class), error.message, error.backtrace - end - end -end diff --git a/lib/redis/commands/cluster.rb b/lib/redis/commands/cluster.rb index 49c5776a4..390b49746 100644 --- a/lib/redis/commands/cluster.rb +++ b/lib/redis/commands/cluster.rb @@ -12,24 +12,7 @@ module Cluster # # @return [Object] depends on the subcommand def cluster(subcommand, *args) - subcommand = subcommand.to_s.downcase - block = case subcommand - when 'slots' - HashifyClusterSlots - when 'nodes' - HashifyClusterNodes - when 'slaves' - HashifyClusterSlaves - when 'info' - HashifyInfo - else - Noop - end - - # @see https://github.com/antirez/redis/blob/unstable/src/redis-trib.rb#L127 raw reply expected - block = Noop unless @cluster_mode - - send_command([:cluster, subcommand] + args, &block) + send_command([:cluster, subcommand] + args) end # Sends `ASKING` command to random node and returns its reply. diff --git a/lib/redis/errors.rb b/lib/redis/errors.rb index dbd65d9f2..144da2fec 100644 --- a/lib/redis/errors.rb +++ b/lib/redis/errors.rb @@ -52,40 +52,4 @@ class InheritedError < BaseConnectionError # Raised when client options are invalid. class InvalidClientOptionError < BaseError end - - class Cluster - # Raised when client connected to redis as cluster mode - # and failed to fetch cluster state information by commands. - class InitialSetupError < BaseError - end - - # Raised when client connected to redis as cluster mode - # and some cluster subcommands were called. - class OrchestrationCommandNotSupported < BaseError - def initialize(command, subcommand = '') - str = [command, subcommand].map(&:to_s).reject(&:empty?).join(' ').upcase - msg = "#{str} command should be used with care "\ - 'only by applications orchestrating Redis Cluster, like redis-trib, '\ - 'and the command if used out of the right context can leave the cluster '\ - 'in a wrong state or cause data loss.' - super(msg) - end - end - - # Raised when error occurs on any node of cluster. - class CommandErrorCollection < BaseError - attr_reader :errors - - # @param errors [Hash{String => Redis::CommandError}] - # @param error_message [String] - def initialize(errors, error_message = 'Command errors were replied on any node') - @errors = errors - super(error_message) - end - end - - # Raised when cluster client can't select node. - class AmbiguousNodeError < BaseError - end - end end diff --git a/redis_cluster/CHANGELOG.md b/redis_cluster/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/redis_cluster/Gemfile b/redis_cluster/Gemfile new file mode 100644 index 000000000..5cb4c22a9 --- /dev/null +++ b/redis_cluster/Gemfile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gemspec + +gem 'minitest' +gem 'rake' +gem 'rubocop', '~> 1.25.1' +gem 'mocha' +gem 'redis-cluster-client', github: 'redis-rb/redis-cluster-client' diff --git a/redis_cluster/LICENSE b/redis_cluster/LICENSE new file mode 100644 index 000000000..5e648fa9d --- /dev/null +++ b/redis_cluster/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2009 Ezra Zygmuntowicz + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/redis_cluster/README.md b/redis_cluster/README.md new file mode 100644 index 000000000..edd311942 --- /dev/null +++ b/redis_cluster/README.md @@ -0,0 +1,69 @@ +# Redis::Cluster + +## Getting started + +Install with: + +``` +$ gem install redis_cluster +``` + +You can connect to Redis by instantiating the `Redis::Cluter` class: + +```ruby +require "redis_cluster" + +redis = Redis::Cluter.new(nodes: (7000..7005).map { |port| "redis://127.0.0.1:#{port}" }) +``` + +```ruby +# Nodes can be passed to the client as an array of connection URLs. +nodes = (7000..7005).map { |port| "redis://127.0.0.1:#{port}" } +redis = Redis::Cluster.new(nodes: nodes) + +# You can also specify the options as a Hash. The options are the same as for a single server connection. +(7000..7005).map { |port| { host: '127.0.0.1', port: port } } +``` + +You can also specify only a subset of the nodes, and the client will discover the missing ones using the [CLUSTER NODES](https://redis.io/commands/cluster-nodes) command. + +```ruby +Redis::Cluster.new(nodes: %w[redis://127.0.0.1:7000]) +``` + +If you want [the connection to be able to read from any replica](https://redis.io/commands/readonly), you must pass the `replica: true`. Note that this connection won't be usable to write keys. + +```ruby +Redis::Cluster.new(nodes: nodes, replica: true) +``` + +The calling code is responsible for [avoiding cross slot commands](https://redis.io/topics/cluster-spec#keys-distribution-model). + +```ruby +redis = Redis::Cluster.new(nodes: %w[redis://127.0.0.1:7000]) + +redis.mget('key1', 'key2') +#=> Redis::CommandError (CROSSSLOT Keys in request don't hash to the same slot) + +redis.mget('{key}1', '{key}2') +#=> [nil, nil] +``` + +* The client automatically reconnects after a failover occurred, but the caller is responsible for handling errors while it is happening. +* The client support permanent node failures, and will reroute requests to promoted slaves. +* The client supports `MOVED` and `ASK` redirections transparently. + +## Cluster mode with SSL/TLS +Since Redis can return FQDN of nodes in reply to client since `7.*` with CLUSTER commands, we can use cluster feature with SSL/TLS connection like this: + +```ruby +Redis.new(cluster: %w[rediss://foo.example.com:6379]) +``` + +On the other hand, in Redis versions prior to `6.*`, you can specify options like the following if cluster mode is enabled and client has to connect to nodes via single endpoint with SSL/TLS. + +```ruby +Redis.new(cluster: %w[rediss://foo-endpoint.example.com:6379], fixed_hostname: 'foo-endpoint.example.com') +``` + +In case of the above architecture, if you don't pass the `fixed_hostname` option to the client and servers return IP addresses of nodes, the client may fail to verify certificates. diff --git a/redis_cluster/lib/redis/cluster.rb b/redis_cluster/lib/redis/cluster.rb new file mode 100644 index 000000000..2d7183fbd --- /dev/null +++ b/redis_cluster/lib/redis/cluster.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "redis" + +class Redis + class Cluster < ::Redis + # Raised when client connected to redis as cluster mode + # and failed to fetch cluster state information by commands. + class InitialSetupError < BaseError + end + + # Raised when client connected to redis as cluster mode + # and some cluster subcommands were called. + class OrchestrationCommandNotSupported < BaseError + def initialize(command, subcommand = '') + str = [command, subcommand].map(&:to_s).reject(&:empty?).join(' ').upcase + msg = "#{str} command should be used with care "\ + 'only by applications orchestrating Redis Cluster, like redis-trib, '\ + 'and the command if used out of the right context can leave the cluster '\ + 'in a wrong state or cause data loss.' + super(msg) + end + end + + # Raised when error occurs on any node of cluster. + class CommandErrorCollection < BaseError + attr_reader :errors + + # @param errors [Hash{String => Redis::CommandError}] + # @param error_message [String] + def initialize(errors, error_message = 'Command errors were replied on any node') + @errors = errors + super(error_message) + end + end + + # Raised when cluster client can't select node. + class AmbiguousNodeError < BaseError + end + + def connection + raise NotImplementedError, "Redis::Cluster doesn't implement #connection" + end + + # Create a new client instance + # + # @param [Hash] options + # @option options [Float] :timeout (5.0) timeout in seconds + # @option options [Float] :connect_timeout (same as timeout) timeout for initial connect in seconds + # @option options [Symbol] :driver Driver to use, currently supported: `:ruby`, `:hiredis` + # @option options [Integer, Array] :reconnect_attempts Number of attempts trying to connect, + # or a list of sleep duration between attempts. + # @option options [Boolean] :inherit_socket (false) Whether to use socket in forked process or not + # @option options [Array String, Integer}>] :nodes List of cluster nodes to contact + # @option options [Boolean] :replica Whether to use readonly replica nodes in Redis Cluster or not + # @option options [String] :fixed_hostname Specify a FQDN if cluster mode enabled and + # client has to connect nodes via single endpoint with SSL/TLS + # @option options [Class] :connector Class of custom connector + # + # @return [Redis::Cluster] a new client instance + def initialize(*) # rubocop:disable Lint/UselessMethodDefinition + super + end + ruby2_keywords :initialize if respond_to?(:ruby2_keywords, true) + + # Sends `CLUSTER *` command to random node and returns its reply. + # + # @see https://redis.io/commands#cluster Reference of cluster command + # + # @param subcommand [String, Symbol] the subcommand of cluster command + # e.g. `:slots`, `:nodes`, `:slaves`, `:info` + # + # @return [Object] depends on the subcommand + def cluster(subcommand, *args) + subcommand = subcommand.to_s.downcase + block = case subcommand + when 'slots' + HashifyClusterSlots + when 'nodes' + HashifyClusterNodes + when 'slaves' + HashifyClusterSlaves + when 'info' + HashifyInfo + else + Noop + end + + send_command([:cluster, subcommand] + args, &block) + end + + private + + def initialize_client(options) + cluster_config = RedisClient.cluster(**options, protocol: 2, client_implementation: ::Redis::Cluster::Client) + cluster_config.new_client + end + end +end + +require "redis/cluster/client" diff --git a/redis_cluster/lib/redis/cluster/client.rb b/redis_cluster/lib/redis/cluster/client.rb new file mode 100644 index 000000000..86638d8fc --- /dev/null +++ b/redis_cluster/lib/redis/cluster/client.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'redis-cluster-client' + +class Redis + class Cluster + class Client < RedisClient::Cluster + ERROR_MAPPING = ::Redis::Client::ERROR_MAPPING.merge( + RedisClient::Cluster::InitialSetupError => Redis::Cluster::InitialSetupError, + RedisClient::Cluster::OrchestrationCommandNotSupported => Redis::Cluster::OrchestrationCommandNotSupported, + RedisClient::Cluster::AmbiguousNodeError => Redis::Cluster::AmbiguousNodeError, + RedisClient::Cluster::ErrorCollection => Redis::Cluster::CommandErrorCollection + ).freeze + + class << self + def config(**kwargs) + super(protocol: 2, **kwargs) + end + + def sentinel(**kwargs) + super(protocol: 2, **kwargs) + end + end + + def initialize(*) + handle_errors { super } + @inherit_socket = false + @pid = Process.pid + end + ruby2_keywords :initialize if respond_to?(:ruby2_keywords, true) + + def id + @router.node.node_keys.join(' ') + end + + def server_url + @router.node.node_keys + end + + def connected? + true + end + + def disable_reconnection + yield # TODO: do we need this, is it doable? + end + + def timeout + config.read_timeout + end + + def db + 0 + end + + undef_method :call + undef_method :call_once + undef_method :call_once_v + undef_method :blocking_call + + def call_v(command, &block) + handle_errors { super(command, &block) } + end + + def blocking_call_v(timeout, command, &block) + timeout += self.timeout if timeout && timeout > 0 + handle_errors { super(timeout, command, &block) } + end + + def pipelined(&block) + handle_errors { super(&block) } + end + + def multi(&block) + handle_errors { super(&block) } + end + + private + + def handle_errors + yield + rescue RedisClient::Cluster::ErrorCollection => error + error.errors.each do |_node, node_error| + if node_error.is_a?(RedisClient::AuthenticationError) + raise ERROR_MAPPING.fetch(node_error.class), node_error.message, node_error.backtrace + end + end + raise ERROR_MAPPING.fetch(error.class), error.message, error.backtrace + rescue ::RedisClient::Error => error + raise ERROR_MAPPING.fetch(error.class), error.message, error.backtrace + end + end + end +end diff --git a/redis_cluster/lib/redis/cluster/version.rb b/redis_cluster/lib/redis/cluster/version.rb new file mode 100644 index 000000000..1e5507a70 --- /dev/null +++ b/redis_cluster/lib/redis/cluster/version.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "redis/version" + +class Redis + class Cluster + VERSION = Redis::VERSION + end +end diff --git a/redis_cluster/lib/redis_cluster.rb b/redis_cluster/lib/redis_cluster.rb new file mode 100644 index 000000000..d03439440 --- /dev/null +++ b/redis_cluster/lib/redis_cluster.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "redis/cluster" diff --git a/redis_cluster/redis_cluster.gemspec b/redis_cluster/redis_cluster.gemspec new file mode 100644 index 000000000..7d1a2c31b --- /dev/null +++ b/redis_cluster/redis_cluster.gemspec @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require_relative "../lib/redis/version" + +Gem::Specification.new do |s| + s.name = "redis_cluster" + + s.version = Redis::VERSION + + github_root = "https://github.com/redis/redis-rb" + s.homepage = "#{github_root}/blob/master/redis_cluster" + + s.summary = "A Ruby client library for Redis Cluster" + + s.description = <<-EOS + A Ruby client that tries to match Redis' Cluster API one-to-one, while still + providing an idiomatic interface. + EOS + + s.license = "MIT" + + s.authors = [ + "Ezra Zygmuntowicz", + "Taylor Weibley", + "Matthew Clark", + "Brian McKinney", + "Salvatore Sanfilippo", + "Luca Guidi", + "Michel Martens", + "Damian Janowski", + "Pieter Noordhuis" + ] + + s.email = ["redis-db@googlegroups.com"] + + s.metadata = { + "bug_tracker_uri" => "#{github_root}/issues", + "changelog_uri" => "#{s.homepage}/CHANGELOG.md", + "documentation_uri" => "https://www.rubydoc.info/gems/redis/#{s.version}", + "homepage_uri" => s.homepage, + "source_code_uri" => "#{github_root}/tree/v#{s.version}/redis_cluster" + } + + s.files = Dir["CHANGELOG.md", "LICENSE", "README.md", "lib/**/*"] + s.executables = `git ls-files -- exe/*`.split("\n").map { |f| File.basename(f) } + + s.required_ruby_version = '>= 2.7.0' + + s.add_runtime_dependency('redis-cluster-client', '~> 0.1') +end diff --git a/test/cluster/abnormal_state_test.rb b/redis_cluster/test/abnormal_state_test.rb similarity index 95% rename from test/cluster/abnormal_state_test.rb rename to redis_cluster/test/abnormal_state_test.rb index 72f579927..87b03f357 100644 --- a/test/cluster/abnormal_state_test.rb +++ b/redis_cluster/test/abnormal_state_test.rb @@ -51,7 +51,7 @@ def test_the_state_of_cluster_node_failure def test_raising_error_when_nodes_are_not_cluster_mode assert_raises(Redis::Cluster::InitialSetupError) do - build_another_client(cluster: %W[redis://127.0.0.1:#{PORT}]) + build_another_client(nodes: %W[redis://127.0.0.1:#{PORT}]) end end end diff --git a/test/cluster/blocking_commands_test.rb b/redis_cluster/test/blocking_commands_test.rb similarity index 100% rename from test/cluster/blocking_commands_test.rb rename to redis_cluster/test/blocking_commands_test.rb diff --git a/test/cluster/client_internals_test.rb b/redis_cluster/test/client_internals_test.rb similarity index 86% rename from test/cluster/client_internals_test.rb rename to redis_cluster/test/client_internals_test.rb index e2e0acff6..2f5d660d5 100644 --- a/test/cluster/client_internals_test.rb +++ b/redis_cluster/test/client_internals_test.rb @@ -56,7 +56,7 @@ def test_inspect def test_acl_auth_success target_version "6.0.0" do with_acl do |username, password| - r = _new_client(cluster: DEFAULT_PORTS.map { |port| "redis://#{username}:#{password}@#{DEFAULT_HOST}:#{port}" }) + r = _new_client(nodes: DEFAULT_PORTS.map { |port| "redis://#{username}:#{password}@#{DEFAULT_HOST}:#{port}" }) assert_equal('PONG', r.ping) end end @@ -66,7 +66,7 @@ def test_acl_auth_failure target_version "6.0.0" do with_acl do |username, _| assert_raises(Redis::Cluster::InitialSetupError) do - _new_client(cluster: DEFAULT_PORTS.map { |port| "redis://#{username}:wrongpassword@#{DEFAULT_HOST}:#{port}" }) + _new_client(nodes: DEFAULT_PORTS.map { |port| "redis://#{username}:wrongpassword@#{DEFAULT_HOST}:#{port}" }) end end end diff --git a/test/cluster/client_pipelining_test.rb b/redis_cluster/test/client_pipelining_test.rb similarity index 100% rename from test/cluster/client_pipelining_test.rb rename to redis_cluster/test/client_pipelining_test.rb diff --git a/test/cluster/client_replicas_test.rb b/redis_cluster/test/client_replicas_test.rb similarity index 100% rename from test/cluster/client_replicas_test.rb rename to redis_cluster/test/client_replicas_test.rb diff --git a/test/cluster/client_transactions_test.rb b/redis_cluster/test/client_transactions_test.rb similarity index 100% rename from test/cluster/client_transactions_test.rb rename to redis_cluster/test/client_transactions_test.rb diff --git a/test/cluster/commands_on_cluster_test.rb b/redis_cluster/test/commands_on_cluster_test.rb similarity index 100% rename from test/cluster/commands_on_cluster_test.rb rename to redis_cluster/test/commands_on_cluster_test.rb diff --git a/test/cluster/commands_on_connection_test.rb b/redis_cluster/test/commands_on_connection_test.rb similarity index 100% rename from test/cluster/commands_on_connection_test.rb rename to redis_cluster/test/commands_on_connection_test.rb diff --git a/test/cluster/commands_on_geo_test.rb b/redis_cluster/test/commands_on_geo_test.rb similarity index 100% rename from test/cluster/commands_on_geo_test.rb rename to redis_cluster/test/commands_on_geo_test.rb diff --git a/test/cluster/commands_on_hashes_test.rb b/redis_cluster/test/commands_on_hashes_test.rb similarity index 100% rename from test/cluster/commands_on_hashes_test.rb rename to redis_cluster/test/commands_on_hashes_test.rb diff --git a/test/cluster/commands_on_hyper_log_log_test.rb b/redis_cluster/test/commands_on_hyper_log_log_test.rb similarity index 100% rename from test/cluster/commands_on_hyper_log_log_test.rb rename to redis_cluster/test/commands_on_hyper_log_log_test.rb diff --git a/test/cluster/commands_on_keys_test.rb b/redis_cluster/test/commands_on_keys_test.rb similarity index 100% rename from test/cluster/commands_on_keys_test.rb rename to redis_cluster/test/commands_on_keys_test.rb diff --git a/test/cluster/commands_on_lists_test.rb b/redis_cluster/test/commands_on_lists_test.rb similarity index 100% rename from test/cluster/commands_on_lists_test.rb rename to redis_cluster/test/commands_on_lists_test.rb diff --git a/test/cluster/commands_on_pub_sub_test.rb b/redis_cluster/test/commands_on_pub_sub_test.rb similarity index 95% rename from test/cluster/commands_on_pub_sub_test.rb rename to redis_cluster/test/commands_on_pub_sub_test.rb index 4782ef2b7..794b17017 100644 --- a/test/cluster/commands_on_pub_sub_test.rb +++ b/redis_cluster/test/commands_on_pub_sub_test.rb @@ -76,7 +76,8 @@ def test_publish_psubscribe_punsubscribe_pubsub assert_equal [], publisher.pubsub(:channels, 'guc*') assert_equal [], publisher.pubsub(:channels, 'her*') assert_equal({}, publisher.pubsub(:numsub)) - assert_equal({ 'burberry1' => 0, 'gucci2' => 0, 'hermes3' => 0 }, publisher.pubsub(:numsub, 'burberry1', 'gucci2', 'hermes3')) + assert_equal({ 'burberry1' => 0, 'gucci2' => 0, 'hermes3' => 0 }, + publisher.pubsub(:numsub, 'burberry1', 'gucci2', 'hermes3')) assert_equal 2, publisher.pubsub(:numpat) publisher.publish('burberry1', 'one') @@ -92,7 +93,8 @@ def test_publish_psubscribe_punsubscribe_pubsub assert_equal [], publisher.pubsub(:channels, 'guc*') assert_equal [], publisher.pubsub(:channels, 'her*') assert_equal({}, publisher.pubsub(:numsub)) - assert_equal({ 'burberry1' => 0, 'gucci2' => 0, 'hermes3' => 0 }, publisher.pubsub(:numsub, 'burberry1', 'gucci2', 'hermes3')) + assert_equal({ 'burberry1' => 0, 'gucci2' => 0, 'hermes3' => 0 }, + publisher.pubsub(:numsub, 'burberry1', 'gucci2', 'hermes3')) assert_equal 0, publisher.pubsub(:numpat) end end diff --git a/test/cluster/commands_on_scripting_test.rb b/redis_cluster/test/commands_on_scripting_test.rb similarity index 100% rename from test/cluster/commands_on_scripting_test.rb rename to redis_cluster/test/commands_on_scripting_test.rb diff --git a/test/cluster/commands_on_server_test.rb b/redis_cluster/test/commands_on_server_test.rb similarity index 100% rename from test/cluster/commands_on_server_test.rb rename to redis_cluster/test/commands_on_server_test.rb diff --git a/test/cluster/commands_on_sets_test.rb b/redis_cluster/test/commands_on_sets_test.rb similarity index 100% rename from test/cluster/commands_on_sets_test.rb rename to redis_cluster/test/commands_on_sets_test.rb diff --git a/test/cluster/commands_on_sorted_sets_test.rb b/redis_cluster/test/commands_on_sorted_sets_test.rb similarity index 100% rename from test/cluster/commands_on_sorted_sets_test.rb rename to redis_cluster/test/commands_on_sorted_sets_test.rb diff --git a/test/cluster/commands_on_streams_test.rb b/redis_cluster/test/commands_on_streams_test.rb similarity index 100% rename from test/cluster/commands_on_streams_test.rb rename to redis_cluster/test/commands_on_streams_test.rb diff --git a/test/cluster/commands_on_strings_test.rb b/redis_cluster/test/commands_on_strings_test.rb similarity index 100% rename from test/cluster/commands_on_strings_test.rb rename to redis_cluster/test/commands_on_strings_test.rb diff --git a/test/cluster/commands_on_transactions_test.rb b/redis_cluster/test/commands_on_transactions_test.rb similarity index 100% rename from test/cluster/commands_on_transactions_test.rb rename to redis_cluster/test/commands_on_transactions_test.rb diff --git a/test/cluster/commands_on_value_types_test.rb b/redis_cluster/test/commands_on_value_types_test.rb similarity index 100% rename from test/cluster/commands_on_value_types_test.rb rename to redis_cluster/test/commands_on_value_types_test.rb diff --git a/redis_cluster/test/helper.rb b/redis_cluster/test/helper.rb new file mode 100644 index 000000000..6c3632e4e --- /dev/null +++ b/redis_cluster/test/helper.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require_relative "../../test/helper" +$LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) + +require "redis_cluster" +require_relative 'support/orchestrator' + +module Helper + module Cluster + include Generic + + DEFAULT_HOST = '127.0.0.1' + DEFAULT_PORTS = (16_380..16_385).freeze + + ClusterSlotsRawReply = lambda { |host, port| + # @see https://redis.io/topics/protocol + <<-REPLY.delete(' ') + *1\r + *4\r + :0\r + :16383\r + *3\r + $#{host.size}\r + #{host}\r + :#{port}\r + $40\r + 649fa246273043021a05f547a79478597d3f1dc5\r + *3\r + $#{host.size}\r + #{host}\r + :#{port}\r + $40\r + 649fa246273043021a05f547a79478597d3f1dc5\r + REPLY + } + + ClusterNodesRawReply = lambda { |host, port| + line = "649fa246273043021a05f547a79478597d3f1dc5 #{host}:#{port}@17000 "\ + 'myself,master - 0 1530797742000 1 connected 0-16383' + "$#{line.size}\r\n#{line}\r\n" + } + + def init(redis) + redis.flushall + redis + rescue Redis::CannotConnectError + puts <<-MSG + + Cannot connect to Redis Cluster. + + Make sure Redis is running on localhost, port #{DEFAULT_PORTS}. + + Try this once: + + $ make stop_cluster + + Then run the build again: + + $ make + + MSG + exit! 1 + end + + def build_another_client(options = {}) + _new_client(options) + end + + def redis_cluster_mock(commands, options = {}) + host = DEFAULT_HOST + port = nil + + cluster_subcommands = if commands.key?(:cluster) + commands.delete(:cluster) + .to_h { |k, v| [k.to_s.downcase, v] } + else + {} + end + + commands[:cluster] = lambda { |subcommand, *args| + subcommand = subcommand.downcase + if cluster_subcommands.key?(subcommand) + cluster_subcommands[subcommand].call(*args) + else + case subcommand.downcase + when 'slots' then ClusterSlotsRawReply.call(host, port) + when 'nodes' then ClusterNodesRawReply.call(host, port) + else '+OK' + end + end + } + + commands[:command] = ->(*_) { "*0\r\n" } + + RedisMock.start(commands, options) do |po| + port = po + scheme = options[:ssl] ? 'rediss' : 'redis' + nodes = %W[#{scheme}://#{host}:#{port}] + yield _new_client(options.merge(nodes: nodes)) + end + end + + def redis_cluster_down + trib = ClusterOrchestrator.new(_default_nodes, timeout: TIMEOUT) + trib.down + yield + ensure + trib.rebuild + trib.close + end + + def redis_cluster_failover + trib = ClusterOrchestrator.new(_default_nodes, timeout: TIMEOUT) + trib.failover + yield + ensure + trib.rebuild + trib.close + end + + def redis_cluster_fail_master + trib = ClusterOrchestrator.new(_default_nodes, timeout: TIMEOUT) + trib.fail_serving_master + yield + ensure + trib.restart_cluster_nodes + trib.rebuild + trib.close + end + + # @param slot [Integer] + # @param src [String] : + # @param dest [String] : + def redis_cluster_resharding(slot, src:, dest:) + trib = ClusterOrchestrator.new(_default_nodes, timeout: TIMEOUT) + trib.start_resharding(slot, src, dest) + yield + trib.finish_resharding(slot, dest) + ensure + trib.rebuild + trib.close + end + + private + + def _default_nodes(host: DEFAULT_HOST, ports: DEFAULT_PORTS) + ports.map { |port| "redis://#{host}:#{port}" } + end + + def _format_options(options) + { + timeout: OPTIONS[:timeout], + nodes: _default_nodes + }.merge(options) + end + + def _new_client(options = {}) + Redis::Cluster.new(_format_options(options).merge(driver: ENV['DRIVER'])) + end + end +end diff --git a/test/support/cluster/orchestrator.rb b/redis_cluster/test/support/orchestrator.rb similarity index 100% rename from test/support/cluster/orchestrator.rb rename to redis_cluster/test/support/orchestrator.rb diff --git a/test/helper.rb b/test/helper.rb index e44db2d3c..6e0348437 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +$LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) + require "minitest/autorun" require "mocha/minitest" require "logger" @@ -15,7 +17,6 @@ require "redis/distributed" require_relative "support/redis_mock" -require_relative 'support/cluster/orchestrator' if ENV["DRIVER"] == "hiredis" require "hiredis-client" @@ -287,157 +288,4 @@ def _new_client(options = {}) Redis::Distributed.new(NODES, _format_options(options).merge(driver: ENV["conn"])) end end - - module Cluster - include Generic - - DEFAULT_HOST = '127.0.0.1' - DEFAULT_PORTS = (16_380..16_385).freeze - - ClusterSlotsRawReply = lambda { |host, port| - # @see https://redis.io/topics/protocol - <<-REPLY.delete(' ') - *1\r - *4\r - :0\r - :16383\r - *3\r - $#{host.size}\r - #{host}\r - :#{port}\r - $40\r - 649fa246273043021a05f547a79478597d3f1dc5\r - *3\r - $#{host.size}\r - #{host}\r - :#{port}\r - $40\r - 649fa246273043021a05f547a79478597d3f1dc5\r - REPLY - } - - ClusterNodesRawReply = lambda { |host, port| - line = "649fa246273043021a05f547a79478597d3f1dc5 #{host}:#{port}@17000 "\ - 'myself,master - 0 1530797742000 1 connected 0-16383' - "$#{line.size}\r\n#{line}\r\n" - } - - def init(redis) - redis.flushall - redis - rescue Redis::CannotConnectError - puts <<-MSG - - Cannot connect to Redis Cluster. - - Make sure Redis is running on localhost, port #{DEFAULT_PORTS}. - - Try this once: - - $ make stop_cluster - - Then run the build again: - - $ make - - MSG - exit! 1 - end - - def build_another_client(options = {}) - _new_client(options) - end - - def redis_cluster_mock(commands, options = {}) - host = DEFAULT_HOST - port = nil - - cluster_subcommands = if commands.key?(:cluster) - commands.delete(:cluster) - .to_h { |k, v| [k.to_s.downcase, v] } - else - {} - end - - commands[:cluster] = lambda { |subcommand, *args| - subcommand = subcommand.downcase - if cluster_subcommands.key?(subcommand) - cluster_subcommands[subcommand].call(*args) - else - case subcommand.downcase - when 'slots' then ClusterSlotsRawReply.call(host, port) - when 'nodes' then ClusterNodesRawReply.call(host, port) - else '+OK' - end - end - } - - commands[:command] = ->(*_) { "*0\r\n" } - - RedisMock.start(commands, options) do |po| - port = po - scheme = options[:ssl] ? 'rediss' : 'redis' - nodes = %W[#{scheme}://#{host}:#{port}] - yield _new_client(options.merge(cluster: nodes)) - end - end - - def redis_cluster_down - trib = ClusterOrchestrator.new(_default_nodes, timeout: TIMEOUT) - trib.down - yield - ensure - trib.rebuild - trib.close - end - - def redis_cluster_failover - trib = ClusterOrchestrator.new(_default_nodes, timeout: TIMEOUT) - trib.failover - yield - ensure - trib.rebuild - trib.close - end - - def redis_cluster_fail_master - trib = ClusterOrchestrator.new(_default_nodes, timeout: TIMEOUT) - trib.fail_serving_master - yield - ensure - trib.restart_cluster_nodes - trib.rebuild - trib.close - end - - # @param slot [Integer] - # @param src [String] : - # @param dest [String] : - def redis_cluster_resharding(slot, src:, dest:) - trib = ClusterOrchestrator.new(_default_nodes, timeout: TIMEOUT) - trib.start_resharding(slot, src, dest) - yield - trib.finish_resharding(slot, dest) - ensure - trib.rebuild - trib.close - end - - private - - def _default_nodes(host: DEFAULT_HOST, ports: DEFAULT_PORTS) - ports.map { |port| "redis://#{host}:#{port}" } - end - - def _format_options(options) - { - timeout: OPTIONS[:timeout], - cluster: _default_nodes - }.merge(options) - end - - def _new_client(options = {}) - Redis.new(_format_options(options).merge(driver: ENV['DRIVER'])) - end - end end