From 3b0c85c50b5bd1ee0a0e9327aa6ad759cb7cbe3a Mon Sep 17 00:00:00 2001 From: Taishi Kasuga Date: Fri, 27 Jul 2018 23:03:00 +0900 Subject: [PATCH] Add Redis Cluster support --- .gitignore | 3 + .travis.yml | 4 +- lib/redis.rb | 99 ++++++- lib/redis/cluster.rb | 277 ++++++++++++++++++ lib/redis/cluster/command.rb | 81 +++++ lib/redis/cluster/command_loader.rb | 32 ++ lib/redis/cluster/key_slot_converter.rb | 72 +++++ lib/redis/cluster/node.rb | 104 +++++++ lib/redis/cluster/node_key.rb | 35 +++ lib/redis/cluster/node_loader.rb | 35 +++ lib/redis/cluster/option.rb | 76 +++++ lib/redis/cluster/slot.rb | 69 +++++ lib/redis/cluster/slot_loader.rb | 47 +++ lib/redis/errors.rb | 38 +++ makefile | 70 ++++- test/cluster_abnormal_state_test.rb | 38 +++ test/cluster_blocking_commands_test.rb | 15 + test/cluster_client_internals_test.rb | 77 +++++ test/cluster_client_key_hash_tags_test.rb | 86 ++++++ test/cluster_client_options_test.rb | 147 ++++++++++ test/cluster_client_pipelining_test.rb | 59 ++++ test/cluster_client_replicas_test.rb | 36 +++ test/cluster_client_slots_test.rb | 94 ++++++ test/cluster_client_transactions_test.rb | 71 +++++ test/cluster_commands_on_cluster_test.rb | 165 +++++++++++ test/cluster_commands_on_connection_test.rb | 40 +++ test/cluster_commands_on_geo_test.rb | 74 +++++ test/cluster_commands_on_hashes_test.rb | 11 + .../cluster_commands_on_hyper_log_log_test.rb | 17 ++ test/cluster_commands_on_keys_test.rb | 134 +++++++++ test/cluster_commands_on_lists_test.rb | 15 + test/cluster_commands_on_pub_sub_test.rb | 101 +++++++ test/cluster_commands_on_scripting_test.rb | 56 ++++ test/cluster_commands_on_server_test.rb | 221 ++++++++++++++ test/cluster_commands_on_sets_test.rb | 39 +++ test/cluster_commands_on_sorted_sets_test.rb | 35 +++ test/cluster_commands_on_streams_test.rb | 196 +++++++++++++ test/cluster_commands_on_strings_test.rb | 15 + test/cluster_commands_on_transactions_test.rb | 41 +++ test/cluster_commands_on_value_types_test.rb | 14 + test/commands_on_hashes_test.rb | 16 +- test/commands_on_hyper_log_log_test.rb | 16 +- test/commands_on_lists_test.rb | 15 +- test/commands_on_sets_test.rb | 72 +---- test/commands_on_sorted_sets_test.rb | 147 +--------- test/commands_on_strings_test.rb | 96 +----- test/distributed_blocking_commands_test.rb | 8 + test/distributed_commands_on_hashes_test.rb | 19 +- ...tributed_commands_on_hyper_log_log_test.rb | 21 +- test/distributed_commands_on_lists_test.rb | 9 +- test/distributed_commands_on_sets_test.rb | 91 +++--- ...istributed_commands_on_sorted_sets_test.rb | 59 +++- test/distributed_commands_on_strings_test.rb | 10 + test/helper.rb | 207 +++++++++++-- test/lint/blocking_commands.rb | 56 +++- test/lint/hashes.rb | 26 ++ test/lint/hyper_log_log.rb | 16 +- test/lint/lists.rb | 16 + test/lint/sets.rb | 142 +++++++++ test/lint/sorted_sets.rb | 185 +++++++++++- test/lint/strings.rb | 102 +++++++ test/support/cluster/orchestrator.rb | 199 +++++++++++++ 62 files changed, 3868 insertions(+), 499 deletions(-) create mode 100644 lib/redis/cluster.rb create mode 100644 lib/redis/cluster/command.rb create mode 100644 lib/redis/cluster/command_loader.rb create mode 100644 lib/redis/cluster/key_slot_converter.rb create mode 100644 lib/redis/cluster/node.rb create mode 100644 lib/redis/cluster/node_key.rb create mode 100644 lib/redis/cluster/node_loader.rb create mode 100644 lib/redis/cluster/option.rb create mode 100644 lib/redis/cluster/slot.rb create mode 100644 lib/redis/cluster/slot_loader.rb create mode 100644 test/cluster_abnormal_state_test.rb create mode 100644 test/cluster_blocking_commands_test.rb create mode 100644 test/cluster_client_internals_test.rb create mode 100644 test/cluster_client_key_hash_tags_test.rb create mode 100644 test/cluster_client_options_test.rb create mode 100644 test/cluster_client_pipelining_test.rb create mode 100644 test/cluster_client_replicas_test.rb create mode 100644 test/cluster_client_slots_test.rb create mode 100644 test/cluster_client_transactions_test.rb create mode 100644 test/cluster_commands_on_cluster_test.rb create mode 100644 test/cluster_commands_on_connection_test.rb create mode 100644 test/cluster_commands_on_geo_test.rb create mode 100644 test/cluster_commands_on_hashes_test.rb create mode 100644 test/cluster_commands_on_hyper_log_log_test.rb create mode 100644 test/cluster_commands_on_keys_test.rb create mode 100644 test/cluster_commands_on_lists_test.rb create mode 100644 test/cluster_commands_on_pub_sub_test.rb create mode 100644 test/cluster_commands_on_scripting_test.rb create mode 100644 test/cluster_commands_on_server_test.rb create mode 100644 test/cluster_commands_on_sets_test.rb create mode 100644 test/cluster_commands_on_sorted_sets_test.rb create mode 100644 test/cluster_commands_on_streams_test.rb create mode 100644 test/cluster_commands_on_strings_test.rb create mode 100644 test/cluster_commands_on_transactions_test.rb create mode 100644 test/cluster_commands_on_value_types_test.rb create mode 100644 test/support/cluster/orchestrator.rb diff --git a/.gitignore b/.gitignore index 9f5963972..0ef4d9e5c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ Gemfile.lock /tmp/ /.idea /.yardoc +/.bundle /coverage/* /doc/ /examples/sentinel/sentinel.conf @@ -14,3 +15,5 @@ Gemfile.lock /redis/* /test/db /test/test.conf +appendonly.aof +temp-rewriteaof-*.aof diff --git a/.travis.yml b/.travis.yml index fb4653aef..b9aa74090 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,7 @@ before_install: - gem update --system 2.6.14 - gem --version -script: make test +script: make rvm: - 2.2.2 @@ -25,7 +25,7 @@ before_script: env: global: - VERBOSE=true - - TIMEOUT=1 + - TIMEOUT=5 matrix: - DRIVER=ruby REDIS_BRANCH=3.0 - DRIVER=ruby REDIS_BRANCH=3.2 diff --git a/lib/redis.rb b/lib/redis.rb index 4af7ad47a..320b69b1c 100644 --- a/lib/redis.rb +++ b/lib/redis.rb @@ -31,12 +31,16 @@ def self.current=(redis) # @option options [Boolean] :inherit_socket (false) Whether to use socket in forked process or not # @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 [Class] :connector Class of custom connector # # @return [Redis] a new client instance def initialize(options = {}) @options = options.dup - @original_client = @client = Client.new(options) + @cluster_mode = options.key?(:cluster) + client = @cluster_mode ? Cluster : Client + @original_client = @client = client.new(options) @queue = Hash.new { |h, k| h[k] = [] } super() # Monitor#initialize @@ -274,9 +278,7 @@ def info(cmd = nil) synchronize do |client| client.call([:info, cmd].compact) do |reply| if reply.kind_of?(String) - reply = Hash[reply.split("\r\n").map do |line| - line.split(":", 2) unless line =~ /^(#|$)/ - end.compact] + reply = HashifyInfo.call(reply) if cmd && cmd.to_s == "commandstats" # Extract nested hashes for INFO COMMANDSTATS @@ -2818,6 +2820,41 @@ def sentinel(subcommand, *args) end end + # 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' then HashifyClusterSlots + when 'nodes' then HashifyClusterNodes + when 'slaves' then HashifyClusterSlaves + when 'info' then 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 + + synchronize do |client| + client.call([:cluster, subcommand] + args, &block) + end + end + + # Sends `ASKING` command to random node and returns its reply. + # + # @see https://redis.io/topics/cluster-spec#ask-redirection ASK redirection + # + # @return [String] `'OK'` + def asking + synchronize { |client| client.call(%i[asking]) } + end + def id @original_client.id end @@ -2831,6 +2868,8 @@ def dup end def connection + return @original_client.connection_info if @cluster_mode + { host: @original_client.host, port: @original_client.port, @@ -2896,6 +2935,56 @@ def method_missing(command, *args) end } + HashifyInfo = + lambda { |reply| + Hash[reply.split("\r\n").map do |line| + line.split(':', 2) unless line =~ /^(#|$)/ + end.compact] + } + + HashifyClusterNodeInfo = + lambda { |str| + arr = str.split(' ') + { + 'node_id' => arr[0], + 'ip_port' => arr[1], + 'flags' => arr[2].split(','), + 'master_node_id' => arr[3], + 'ping_sent' => arr[4], + 'pong_recv' => arr[5], + 'config_epoch' => arr[6], + 'link_state' => arr[7], + 'slots' => arr[8].nil? ? nil : Range.new(*arr[8].split('-')) + } + } + + HashifyClusterSlots = + lambda { |reply| + reply.map do |arr| + first_slot, last_slot = arr[0..1] + master = { 'ip' => arr[2][0], 'port' => arr[2][1], 'node_id' => arr[2][2] } + replicas = arr[3..-1].map { |r| { 'ip' => r[0], 'port' => r[1], 'node_id' => r[2] } } + { + 'start_slot' => first_slot, + 'end_slot' => last_slot, + 'master' => master, + 'replicas' => replicas + } + end + } + + HashifyClusterNodes = + lambda { |reply| + reply.split(/[\r\n]+/).map { |str| HashifyClusterNodeInfo.call(str) } + } + + HashifyClusterSlaves = + lambda { |reply| + reply.map { |str| HashifyClusterNodeInfo.call(str) } + } + + Noop = ->(reply) { reply } + def _geoarguments(*args, options: nil, sort: nil, count: nil) args.push sort if sort args.push 'count', count if count @@ -2918,11 +3007,11 @@ def _subscription(method, timeout, channels, block) @client = original end end - end require_relative "redis/version" require_relative "redis/connection" require_relative "redis/client" +require_relative "redis/cluster" require_relative "redis/pipeline" require_relative "redis/subscribe" diff --git a/lib/redis/cluster.rb b/lib/redis/cluster.rb new file mode 100644 index 000000000..b2a4f782e --- /dev/null +++ b/lib/redis/cluster.rb @@ -0,0 +1,277 @@ +# frozen_string_literal: true + +require_relative 'errors' +require_relative 'client' +require_relative 'cluster/command' +require_relative 'cluster/command_loader' +require_relative 'cluster/key_slot_converter' +require_relative 'cluster/node' +require_relative 'cluster/node_key' +require_relative 'cluster/node_loader' +require_relative 'cluster/option' +require_relative 'cluster/slot' +require_relative 'cluster/slot_loader' + +class Redis + # Redis Cluster client + # + # @see https://github.com/antirez/redis-rb-cluster POC implementation + # @see https://redis.io/topics/cluster-spec Redis Cluster specification + # @see https://redis.io/topics/cluster-tutorial Redis Cluster tutorial + # + # Copyright (C) 2013 Salvatore Sanfilippo + class Cluster + def initialize(options = {}) + @option = Option.new(options) + @node, @slot = fetch_cluster_info!(@option) + @command = fetch_command_details(@node) + end + + def id + @node.map(&:id).sort.join(' ') + end + + # db feature is disabled in cluster mode + def db + 0 + end + + # db feature is disabled in cluster mode + def db=(_db); end + + def timeout + @node.first.timeout + end + + def connected? + @node.any?(&:connected?) + end + + def disconnect + @node.each(&:disconnect) + true + end + + def connection_info + @node.sort_by(&:id).map do |client| + { + host: client.host, + port: client.port, + db: client.db, + id: client.id, + location: client.location + } + end + end + + def with_reconnect(val = true, &block) + try_send(@node.sample, :with_reconnect, val, &block) + end + + def call(command, &block) + send_command(command, &block) + end + + def call_loop(command, timeout = 0, &block) + node = assign_node(command) + try_send(node, :call_loop, command, timeout, &block) + end + + def call_pipeline(pipeline) + try_send(@node.sample, :call_pipeline, pipeline) + end + + def call_with_timeout(command, timeout, &block) + node = assign_node(command) + try_send(node, :call_with_timeout, command, timeout, &block) + end + + def call_without_timeout(command, &block) + call_with_timeout(command, 0, &block) + end + + def process(commands, &block) + if commands.size == 1 && + %w[unsubscribe punsubscribe].include?(commands.first.first.to_s.downcase) && + commands.first.size == 1 + + # Node is indeterminate. We do just a best-effort try here. + @node.process_all(commands, &block) + else + node = assign_node(commands.first) + try_send(node, :process, commands, &block) + end + end + + private + + def fetch_cluster_info!(option) + node = Node.new(option.per_node_key) + available_slots = SlotLoader.load(node) + node_flags = NodeLoader.load_flags(node) + available_node_urls = NodeKey.to_node_urls(available_slots.keys, secure: option.secure?) + option.update_node(available_node_urls) + [Node.new(option.per_node_key, node_flags, option.use_replica?), + Slot.new(available_slots, node_flags, option.use_replica?)] + ensure + node.map(&:disconnect) + end + + def fetch_command_details(nodes) + details = CommandLoader.load(nodes) + Command.new(details) + end + + def send_command(command, &block) + cmd = command.first.to_s.downcase + case cmd + when 'auth', 'bgrewriteaof', 'bgsave', 'quit', 'save' + @node.call_all(command, &block).first + when 'flushall', 'flushdb' + @node.call_master(command, &block).first + when 'keys' then @node.call_slave(command, &block).flatten.sort + when 'dbsize' then @node.call_slave(command, &block).reduce(:+) + when 'lastsave' then @node.call_all(command, &block).sort + when 'role' then @node.call_all(command, &block) + when 'config' then send_config_command(command, &block) + when 'client' then send_client_command(command, &block) + when 'cluster' then send_cluster_command(command, &block) + when 'readonly', 'readwrite', 'shutdown' + raise OrchestrationCommandNotSupported, cmd + when 'memory' then send_memory_command(command, &block) + when 'script' then send_script_command(command, &block) + when 'pubsub' then send_pubsub_command(command, &block) + when 'discard', 'exec', 'multi', 'unwatch' + raise AmbiguousNodeError, cmd + else + node = assign_node(command) + try_send(node, :call, command, &block) + end + end + + def send_config_command(command, &block) + case command[1].to_s.downcase + when 'resetstat', 'rewrite', 'set' + @node.call_all(command, &block).first + else assign_node(command).call(command, &block) + end + end + + def send_memory_command(command, &block) + case command[1].to_s.downcase + when 'stats' then @node.call_all(command, &block) + when 'purge' then @node.call_all(command, &block).first + else assign_node(command).call(command, &block) + end + end + + def send_client_command(command, &block) + case command[1].to_s.downcase + when 'list' then @node.call_all(command, &block).flatten + when 'pause', 'reply', 'setname' + @node.call_all(command, &block).first + else assign_node(command).call(command, &block) + end + end + + def send_cluster_command(command, &block) + subcommand = command[1].to_s.downcase + case subcommand + when 'addslots', 'delslots', 'failover', 'forget', 'meet', 'replicate', + 'reset', 'set-config-epoch', 'setslot' + raise OrchestrationCommandNotSupported, 'cluster', subcommand + when 'saveconfig' then @node.call_all(command, &block).first + else assign_node(command).call(command, &block) + end + end + + def send_script_command(command, &block) + case command[1].to_s.downcase + when 'debug', 'kill' + @node.call_all(command, &block).first + when 'flush', 'load' + @node.call_master(command, &block).first + else assign_node(command).call(command, &block) + end + end + + def send_pubsub_command(command, &block) + case command[1].to_s.downcase + when 'channels' then @node.call_all(command, &block).flatten.uniq.sort + when 'numsub' + @node.call_all(command, &block).reject(&:empty?).map { |e| Hash[*e] } + .reduce({}) { |a, e| a.merge(e) { |_, v1, v2| v1 + v2 } } + when 'numpat' then @node.call_all(command, &block).reduce(:+) + else assign_node(command).call(command, &block) + end + end + + # @see https://redis.io/topics/cluster-spec#redirection-and-resharding + # Redirection and resharding + def try_send(node, method_name, *args, retry_count: 3, &block) + node.public_send(method_name, *args, &block) + rescue CommandError => err + if err.message.start_with?('MOVED') + assign_redirection_node(err.message).public_send(method_name, *args, &block) + elsif err.message.start_with?('ASK') + raise if retry_count <= 0 + node = assign_asking_node(err.message) + node.call(%i[asking]) + retry_count -= 1 + retry + else + raise + end + end + + def assign_redirection_node(err_msg) + _, slot, node_key = err_msg.split(' ') + slot = slot.to_i + @slot.put(slot, node_key) + @node.find_by(node_key) + rescue Node::ReloadNeeded + update_cluster_info!(node_key) + @node.find_by(node_key) + end + + def assign_asking_node(err_msg) + _, _, node_key = err_msg.split(' ') + @node.find_by(node_key) + rescue Node::ReloadNeeded + update_cluster_info!(node_key) + @node.find_by(node_key) + end + + def assign_node(command) + key = @command.extract_first_key(command) + return @node.sample if key.empty? + + slot = KeySlotConverter.convert(key) + return @node.sample unless @slot.exists?(slot) + + node_key = find_node_key(command, slot) + @node.find_by(node_key) + rescue Node::ReloadNeeded + update_cluster_info!(node_key) + @node.find_by(node_key) + end + + def find_node_key(command, slot) + if @command.should_send_to_master?(command) + @slot.find_node_key_of_master(slot) + else + @slot.find_node_key_of_slave(slot) + end + end + + def update_cluster_info!(node_key = nil) + unless node_key.nil? + host, port = NodeKey.split(node_key) + @option.add_node(host, port) + end + + @node.map(&:disconnect) + @node, @slot = fetch_cluster_info!(@option) + end + end +end diff --git a/lib/redis/cluster/command.rb b/lib/redis/cluster/command.rb new file mode 100644 index 000000000..44ec29f7f --- /dev/null +++ b/lib/redis/cluster/command.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require_relative '../errors' + +class Redis + class Cluster + # Keep details about Redis commands for Redis Cluster Client. + # @see https://redis.io/commands/command + class Command + def initialize(details) + @details = pick_details(details) + end + + def extract_first_key(command) + i = determine_first_key_position(command) + return '' if i == 0 + + key = command[i].to_s + hash_tag = extract_hash_tag(key) + hash_tag.empty? ? key : hash_tag + end + + def should_send_to_master?(command) + dig_details(command, :write) + end + + def should_send_to_slave?(command) + dig_details(command, :readonly) + end + + private + + def pick_details(details) + details.map do |command, detail| + [command, { + first_key_position: detail[:first], + write: detail[:flags].include?('write'), + readonly: detail[:flags].include?('readonly') + }] + end.to_h + end + + def dig_details(command, key) + name = command.first.to_s + return unless @details.key?(name) + + @details.fetch(name).fetch(key) + end + + def determine_first_key_position(command) + case command.first.to_s.downcase + when 'eval', 'evalsha', 'migrate' then 3 + when 'object' then 2 + when 'memory' + command[1].to_s.casecmp('usage').zero? ? 2 : 0 + when 'scan', 'sscan', 'hscan', 'zscan' + determine_optional_key_position(command, 'match') + when 'xread', 'xreadgroup' + determine_optional_key_position(command, 'streams') + else + dig_details(command, :first_key_position).to_i + end + end + + def determine_optional_key_position(command, option_name) + idx = command.map(&:to_s).map(&:downcase).index(option_name) + idx.nil? ? 0 : idx + 1 + end + + # @see https://redis.io/topics/cluster-spec#keys-hash-tags Keys hash tags + def extract_hash_tag(key) + s = key.index('{') + e = key.index('}', s.to_i + 1) + + return '' if s.nil? || e.nil? + + key[s + 1..e - 1] + end + end + end +end diff --git a/lib/redis/cluster/command_loader.rb b/lib/redis/cluster/command_loader.rb new file mode 100644 index 000000000..e91963fff --- /dev/null +++ b/lib/redis/cluster/command_loader.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative '../errors' + +class Redis + class Cluster + # Load details about Redis commands for Redis Cluster Client + # @see https://redis.io/commands/command + module CommandLoader + module_function + + def load(nodes) + details = {} + + nodes.each do |node| + details = fetch_command_details(node) + details.empty? ? next : break + end + + details + end + + def fetch_command_details(node) + node.call(%i[command]).map do |reply| + [reply[0], { arity: reply[1], flags: reply[2], first: reply[3], last: reply[4], step: reply[5] }] + end.to_h + rescue CannotConnectError, ConnectionError, CommandError + {} # can retry on another node + end + end + end +end diff --git a/lib/redis/cluster/key_slot_converter.rb b/lib/redis/cluster/key_slot_converter.rb new file mode 100644 index 000000000..72ae3f674 --- /dev/null +++ b/lib/redis/cluster/key_slot_converter.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +class Redis + class Cluster + # Key to slot converter for Redis Cluster Client + # + # We can test it by `CLUSTER KEYSLOT` command. + # + # @see https://github.com/antirez/redis-rb-cluster + # Reference implementation in Ruby + # @see https://redis.io/topics/cluster-spec#appendix + # Reference implementation in ANSI C + # @see https://redis.io/commands/cluster-keyslot + # CLUSTER KEYSLOT command reference + # + # Copyright (C) 2013 Salvatore Sanfilippo + module KeySlotConverter + XMODEM_CRC16_LOOKUP = [ + 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7, + 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef, + 0x1231, 0x0210, 0x3273, 0x2252, 0x52b5, 0x4294, 0x72f7, 0x62d6, + 0x9339, 0x8318, 0xb37b, 0xa35a, 0xd3bd, 0xc39c, 0xf3ff, 0xe3de, + 0x2462, 0x3443, 0x0420, 0x1401, 0x64e6, 0x74c7, 0x44a4, 0x5485, + 0xa56a, 0xb54b, 0x8528, 0x9509, 0xe5ee, 0xf5cf, 0xc5ac, 0xd58d, + 0x3653, 0x2672, 0x1611, 0x0630, 0x76d7, 0x66f6, 0x5695, 0x46b4, + 0xb75b, 0xa77a, 0x9719, 0x8738, 0xf7df, 0xe7fe, 0xd79d, 0xc7bc, + 0x48c4, 0x58e5, 0x6886, 0x78a7, 0x0840, 0x1861, 0x2802, 0x3823, + 0xc9cc, 0xd9ed, 0xe98e, 0xf9af, 0x8948, 0x9969, 0xa90a, 0xb92b, + 0x5af5, 0x4ad4, 0x7ab7, 0x6a96, 0x1a71, 0x0a50, 0x3a33, 0x2a12, + 0xdbfd, 0xcbdc, 0xfbbf, 0xeb9e, 0x9b79, 0x8b58, 0xbb3b, 0xab1a, + 0x6ca6, 0x7c87, 0x4ce4, 0x5cc5, 0x2c22, 0x3c03, 0x0c60, 0x1c41, + 0xedae, 0xfd8f, 0xcdec, 0xddcd, 0xad2a, 0xbd0b, 0x8d68, 0x9d49, + 0x7e97, 0x6eb6, 0x5ed5, 0x4ef4, 0x3e13, 0x2e32, 0x1e51, 0x0e70, + 0xff9f, 0xefbe, 0xdfdd, 0xcffc, 0xbf1b, 0xaf3a, 0x9f59, 0x8f78, + 0x9188, 0x81a9, 0xb1ca, 0xa1eb, 0xd10c, 0xc12d, 0xf14e, 0xe16f, + 0x1080, 0x00a1, 0x30c2, 0x20e3, 0x5004, 0x4025, 0x7046, 0x6067, + 0x83b9, 0x9398, 0xa3fb, 0xb3da, 0xc33d, 0xd31c, 0xe37f, 0xf35e, + 0x02b1, 0x1290, 0x22f3, 0x32d2, 0x4235, 0x5214, 0x6277, 0x7256, + 0xb5ea, 0xa5cb, 0x95a8, 0x8589, 0xf56e, 0xe54f, 0xd52c, 0xc50d, + 0x34e2, 0x24c3, 0x14a0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405, + 0xa7db, 0xb7fa, 0x8799, 0x97b8, 0xe75f, 0xf77e, 0xc71d, 0xd73c, + 0x26d3, 0x36f2, 0x0691, 0x16b0, 0x6657, 0x7676, 0x4615, 0x5634, + 0xd94c, 0xc96d, 0xf90e, 0xe92f, 0x99c8, 0x89e9, 0xb98a, 0xa9ab, + 0x5844, 0x4865, 0x7806, 0x6827, 0x18c0, 0x08e1, 0x3882, 0x28a3, + 0xcb7d, 0xdb5c, 0xeb3f, 0xfb1e, 0x8bf9, 0x9bd8, 0xabbb, 0xbb9a, + 0x4a75, 0x5a54, 0x6a37, 0x7a16, 0x0af1, 0x1ad0, 0x2ab3, 0x3a92, + 0xfd2e, 0xed0f, 0xdd6c, 0xcd4d, 0xbdaa, 0xad8b, 0x9de8, 0x8dc9, + 0x7c26, 0x6c07, 0x5c64, 0x4c45, 0x3ca2, 0x2c83, 0x1ce0, 0x0cc1, + 0xef1f, 0xff3e, 0xcf5d, 0xdf7c, 0xaf9b, 0xbfba, 0x8fd9, 0x9ff8, + 0x6e17, 0x7e36, 0x4e55, 0x5e74, 0x2e93, 0x3eb2, 0x0ed1, 0x1ef0 + ].freeze + + HASH_SLOTS = 16_384 + + module_function + + # Convert key into slot. + # + # @param key [String] the key of the redis command + # + # @return [Integer] slot number + def convert(key) + crc = 0 + key.each_byte do |b| + crc = ((crc << 8) & 0xffff) ^ XMODEM_CRC16_LOOKUP[((crc >> 8) ^ b) & 0xff] + end + + crc % HASH_SLOTS + end + end + end +end diff --git a/lib/redis/cluster/node.rb b/lib/redis/cluster/node.rb new file mode 100644 index 000000000..d495b9a6f --- /dev/null +++ b/lib/redis/cluster/node.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require_relative '../errors' + +class Redis + class Cluster + # Keep client list of node for Redis Cluster Client + class Node + include Enumerable + + ReloadNeeded = Class.new(StandardError) + + ROLE_SLAVE = 'slave' + + def initialize(options, node_flags = {}, with_replica = false) + @with_replica = with_replica + @node_flags = node_flags + @clients = build_clients(options) + end + + def each(&block) + @clients.values.each(&block) + end + + def sample + @clients.values.sample + end + + def find_by(node_key) + @clients.fetch(node_key) + rescue KeyError + raise ReloadNeeded + end + + def call_all(command, &block) + try_map { |_, client| client.call(command, &block) }.values + end + + def call_master(command, &block) + try_map do |node_key, client| + next if slave?(node_key) + client.call(command, &block) + end.values + end + + def call_slave(command, &block) + return call_master(command, &block) if replica_disabled? + + try_map do |node_key, client| + next if master?(node_key) + client.call(command, &block) + end.values + end + + def process_all(commands, &block) + try_map { |_, client| client.process(commands, &block) }.values + end + + private + + def replica_disabled? + !@with_replica + end + + def master?(node_key) + !slave?(node_key) + end + + def slave?(node_key) + @node_flags[node_key] == ROLE_SLAVE + end + + def build_clients(options) + clients = options.map do |node_key, option| + next if replica_disabled? && slave?(node_key) + + client = Client.new(option) + client.call(%i[readonly]) if slave?(node_key) + [node_key, client] + end + + clients.compact.to_h + end + + def try_map + errors = {} + results = {} + + @clients.each do |node_key, client| + begin + reply = yield(node_key, client) + results[node_key] = reply unless reply.nil? + rescue CommandError => err + errors[node_key] = err + next + end + end + + return results if errors.empty? + raise CommandErrorCollection, errors + end + end + end +end diff --git a/lib/redis/cluster/node_key.rb b/lib/redis/cluster/node_key.rb new file mode 100644 index 000000000..8b9829b14 --- /dev/null +++ b/lib/redis/cluster/node_key.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class Redis + class Cluster + # Node key's format is `:`. + # It is different from node id. + # Node id is internal identifying code in Redis Cluster. + module NodeKey + DEFAULT_SCHEME = 'redis' + SECURE_SCHEME = 'rediss' + DELIMITER = ':' + + module_function + + def to_node_urls(node_keys, secure:) + scheme = secure ? SECURE_SCHEME : DEFAULT_SCHEME + node_keys + .map { |k| k.split(DELIMITER) } + .map { |k| URI::Generic.build(scheme: scheme, host: k[0], port: k[1].to_i).to_s } + end + + def split(node_key) + node_key.split(DELIMITER) + end + + def build_from_uri(uri) + "#{uri.host}#{DELIMITER}#{uri.port}" + end + + def build_from_host_port(host, port) + "#{host}#{DELIMITER}#{port}" + end + end + end +end diff --git a/lib/redis/cluster/node_loader.rb b/lib/redis/cluster/node_loader.rb new file mode 100644 index 000000000..a914f3a5c --- /dev/null +++ b/lib/redis/cluster/node_loader.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative '../errors' + +class Redis + class Cluster + # Load and hashify node info for Redis Cluster Client + module NodeLoader + module_function + + def load_flags(nodes) + info = {} + + nodes.each do |node| + info = fetch_node_info(node) + info.empty? ? next : break + end + + return info unless info.empty? + + raise CannotConnectError, 'Redis client could not connect to any cluster nodes' + end + + def fetch_node_info(node) + node.call(%i[cluster nodes]) + .split("\n") + .map { |str| str.split(' ') } + .map { |arr| [arr[1].split('@').first, (arr[2].split(',') & %w[master slave]).first] } + .to_h + rescue CannotConnectError, ConnectionError, CommandError + {} # can retry on another node + end + end + end +end diff --git a/lib/redis/cluster/option.rb b/lib/redis/cluster/option.rb new file mode 100644 index 000000000..7641502e1 --- /dev/null +++ b/lib/redis/cluster/option.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require_relative '../errors' +require_relative 'node_key' + +class Redis + class Cluster + # Keep options for Redis Cluster Client + class Option + DEFAULT_SCHEME = 'redis' + SECURE_SCHEME = 'rediss' + VALID_SCHEMES = [DEFAULT_SCHEME, SECURE_SCHEME].freeze + + def initialize(options) + options = options.dup + node_addrs = options.delete(:cluster) + @node_uris = build_node_uris(node_addrs) + @replica = options.delete(:replica) == true + @options = options + end + + def per_node_key + @node_uris.map { |uri| [NodeKey.build_from_uri(uri), @options.merge(url: uri.to_s)] } + .to_h + end + + def secure? + @node_uris.any? { |uri| uri.scheme == SECURE_SCHEME } || @options[:ssl_params] || false + end + + def use_replica? + @replica + end + + def update_node(addrs) + @node_uris = build_node_uris(addrs) + end + + def add_node(host, port) + @node_uris << parse_node_hash(host: host, port: port) + end + + private + + def build_node_uris(addrs) + raise InvalidClientOptionError, 'Redis option of `cluster` must be an Array' unless addrs.is_a?(Array) + addrs.map { |addr| parse_node_addr(addr) } + end + + def parse_node_addr(addr) + case addr + when String + parse_node_url(addr) + when Hash + parse_node_hash(addr) + else + raise InvalidClientOptionError, 'Redis option of `cluster` must includes String or Hash' + end + end + + def parse_node_url(addr) + uri = URI(addr) + raise InvalidClientOptionError, "Invalid uri scheme #{addr}" unless VALID_SCHEMES.include?(uri.scheme) + uri + rescue URI::InvalidURIError => err + raise InvalidClientOptionError, err.message + end + + def parse_node_hash(addr) + addr = addr.map { |k, v| [k.to_sym, v] }.to_h + raise InvalidClientOptionError, 'Redis option of `cluster` must includes `:host` and `:port` keys' if addr.values_at(:host, :port).any?(&:nil?) + URI::Generic.build(scheme: DEFAULT_SCHEME, host: addr[:host], port: addr[:port].to_i) + end + end + end +end diff --git a/lib/redis/cluster/slot.rb b/lib/redis/cluster/slot.rb new file mode 100644 index 000000000..47d7b523e --- /dev/null +++ b/lib/redis/cluster/slot.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'set' + +class Redis + class Cluster + # Keep slot and node key map for Redis Cluster Client + class Slot + ROLE_SLAVE = 'slave' + + def initialize(available_slots, node_flags = {}, with_replica = false) + @with_replica = with_replica + @node_flags = node_flags + @map = build_slot_node_key_map(available_slots) + end + + def exists?(slot) + @map.key?(slot) + end + + def find_node_key_of_master(slot) + return nil unless exists?(slot) + + @map[slot][:master] + end + + def find_node_key_of_slave(slot) + return nil unless exists?(slot) + return find_node_key_of_master(slot) if replica_disabled? + + @map[slot][:slaves].to_a.sample + end + + def put(slot, node_key) + assign_node_key(@map, slot, node_key) + nil + end + + private + + def replica_disabled? + !@with_replica + end + + def master?(node_key) + !slave?(node_key) + end + + def slave?(node_key) + @node_flags[node_key] == ROLE_SLAVE + end + + def build_slot_node_key_map(available_slots) + available_slots.each_with_object({}) do |(node_key, slots), acc| + slots.each { |slot| assign_node_key(acc, slot, node_key) } + end + end + + def assign_node_key(mappings, slot, node_key) + mappings[slot] ||= { master: nil, slaves: Set.new } + if master?(node_key) + mappings[slot][:master] = node_key + else + mappings[slot][:slaves].add(node_key) + end + end + end + end +end diff --git a/lib/redis/cluster/slot_loader.rb b/lib/redis/cluster/slot_loader.rb new file mode 100644 index 000000000..006903541 --- /dev/null +++ b/lib/redis/cluster/slot_loader.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative '../errors' +require_relative 'node_key' + +class Redis + class Cluster + # Load and hashify slot info for Redis Cluster Client + module SlotLoader + module_function + + def load(nodes) + info = {} + + nodes.each do |node| + info = Hash[*fetch_slot_info(node)] + info.empty? ? next : break + end + + return info unless info.empty? + + raise CannotConnectError, 'Redis client could not connect to any cluster nodes' + end + + def fetch_slot_info(node) + node.call(%i[cluster slots]) + .map { |arr| parse_slot_info(arr, default_ip: node.host) } + .flatten + rescue CannotConnectError, ConnectionError, CommandError + {} # can retry on another node + end + + def parse_slot_info(arr, default_ip:) + first_slot, last_slot = arr[0..1] + slot_range = (first_slot..last_slot).freeze + arr[2..-1].map { |addr| [stringify_node_key(addr, default_ip), slot_range] } + .flatten + end + + def stringify_node_key(arr, default_ip) + ip, port = arr + ip = default_ip if ip.empty? # When cluster is down + NodeKey.build_from_host_port(ip, port) + end + end + end +end diff --git a/lib/redis/errors.rb b/lib/redis/errors.rb index 85b222ec6..e20083ed0 100644 --- a/lib/redis/errors.rb +++ b/lib/redis/errors.rb @@ -37,4 +37,42 @@ class TimeoutError < BaseConnectionError # Raised when the connection was inherited by a child process. class InheritedError < BaseConnectionError end + + # Raised when client options are invalid. + class InvalidClientOptionError < BaseError + end + + class Cluster + # 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 + def initialize(command) + super("Cluster client does't know which node the #{command} command should be sent to.") + end + end + end end diff --git a/makefile b/makefile index a37c23c6a..ac3cb6a14 100644 --- a/makefile +++ b/makefile @@ -1,27 +1,43 @@ -TEST_FILES := $(shell find ./test -name *_test.rb -type f) -REDIS_BRANCH ?= unstable -TMP := tmp -BUILD_DIR := ${TMP}/cache/redis-${REDIS_BRANCH} -TARBALL := ${TMP}/redis-${REDIS_BRANCH}.tar.gz -BINARY := ${BUILD_DIR}/src/redis-server -PID_PATH := ${BUILD_DIR}/redis.pid -SOCKET_PATH := ${BUILD_DIR}/redis.sock -PORT := 6381 +TEST_FILES := $(shell find ./test -name *_test.rb -type f) +REDIS_BRANCH ?= unstable +TMP := tmp +BUILD_DIR := ${TMP}/cache/redis-${REDIS_BRANCH} +TARBALL := ${TMP}/redis-${REDIS_BRANCH}.tar.gz +BINARY := ${BUILD_DIR}/src/redis-server +REDIS_CLIENT := ${BUILD_DIR}/src/redis-cli +REDIS_TRIB := ${BUILD_DIR}/src/redis-trib.rb +PID_PATH := ${BUILD_DIR}/redis.pid +SOCKET_PATH := ${BUILD_DIR}/redis.sock +PORT := 6381 +CLUSTER_PORTS := 7000 7001 7002 7003 7004 7005 +CLUSTER_PID_PATHS := $(addprefix ${TMP}/redis,$(addsuffix .pid,${CLUSTER_PORTS})) +CLUSTER_CONF_PATHS := $(addprefix ${TMP}/nodes,$(addsuffix .conf,${CLUSTER_PORTS})) +CLUSTER_ADDRS := $(addprefix 127.0.0.1:,${CLUSTER_PORTS}) -test: ${TEST_FILES} +define kill-redis + (ls $1 2> /dev/null && kill $$(cat $1) && rm -f $1) || true +endef + +all: make start - env SOCKET_PATH=${SOCKET_PATH} \ - bundle exec ruby -v -e 'ARGV.each { |test_file| require test_file }' ${TEST_FILES} + make start_cluster + make create_cluster + make test make stop + make stop_cluster ${TMP}: - mkdir $@ + mkdir -p $@ ${BINARY}: ${TMP} - bin/build ${REDIS_BRANCH} ${TMP} + bin/build ${REDIS_BRANCH} $< + +test: ${TEST_FILES} + env SOCKET_PATH=${SOCKET_PATH} \ + bundle exec ruby -v -e 'ARGV.each { |test_file| require test_file }' ${TEST_FILES} stop: - (test -f ${PID_PATH} && (kill $$(cat ${PID_PATH}) || true) && rm -f ${PID_PATH}) || true + $(call kill-redis,${PID_PATH}) start: ${BINARY} ${BINARY} \ @@ -30,7 +46,29 @@ start: ${BINARY} --port ${PORT} \ --unixsocket ${SOCKET_PATH} +stop_cluster: + $(call kill-redis,${CLUSTER_PID_PATHS}) + rm -f appendonly.aof || true + rm -f ${CLUSTER_CONF_PATHS} || true + +start_cluster: ${BINARY} + for port in ${CLUSTER_PORTS}; do \ + ${BINARY} \ + --daemonize yes \ + --appendonly yes \ + --cluster-enabled yes \ + --cluster-config-file ${TMP}/nodes$$port.conf \ + --cluster-node-timeout 5000 \ + --pidfile ${TMP}/redis$$port.pid \ + --port $$port \ + --unixsocket ${TMP}/redis$$port.sock; \ + done + +create_cluster: + yes yes | ((bundle exec ruby ${REDIS_TRIB} create --replicas 1 ${CLUSTER_ADDRS}) || \ + (${REDIS_CLIENT} --cluster create ${CLUSTER_ADDRS} --cluster-replicas 1)) + clean: (test -d ${BUILD_DIR} && cd ${BUILD_DIR}/src && make clean distclean) || true -.PHONY: test start stop +.PHONY: all test stop start stop_cluster start_cluster create_cluster clean diff --git a/test/cluster_abnormal_state_test.rb b/test/cluster_abnormal_state_test.rb new file mode 100644 index 000000000..41c4c594a --- /dev/null +++ b/test/cluster_abnormal_state_test.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require_relative 'helper' + +# ruby -w -Itest test/cluster_abnormal_state_test.rb +class TestClusterAbnormalState < Test::Unit::TestCase + include Helper::Cluster + + def test_the_state_of_cluster_down + redis_cluster_down do + assert_raise(Redis::CommandError, 'CLUSTERDOWN Hash slot not served') do + redis.set('key1', 1) + end + + assert_equal 'fail', redis.cluster(:info).fetch('cluster_state') + end + end + + def test_the_state_of_cluster_failover + redis_cluster_failover do + 100.times do |i| + assert_equal 'OK', r.set("key#{i}", i) + end + + 100.times do |i| + assert_equal i.to_s, r.get("key#{i}") + end + + assert_equal 'ok', redis.cluster(:info).fetch('cluster_state') + end + end + + def test_raising_error_when_nodes_are_not_cluster_mode + assert_raise(Redis::CannotConnectError, 'Redis client could not connect to any cluster nodes') do + build_another_client(cluster: %W[redis://127.0.0.1:#{PORT}]) + end + end +end diff --git a/test/cluster_blocking_commands_test.rb b/test/cluster_blocking_commands_test.rb new file mode 100644 index 000000000..c8b3e9b53 --- /dev/null +++ b/test/cluster_blocking_commands_test.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative 'helper' +require_relative 'lint/blocking_commands' + +# ruby -w -Itest test/cluster_blocking_commands_test.rb +class TestClusterBlockingCommands < Test::Unit::TestCase + include Helper::Cluster + include Lint::BlockingCommands + + def mock(options = {}, &blk) + commands = build_mock_commands(options) + redis_cluster_mock(commands, &blk) + end +end diff --git a/test/cluster_client_internals_test.rb b/test/cluster_client_internals_test.rb new file mode 100644 index 000000000..1b911676c --- /dev/null +++ b/test/cluster_client_internals_test.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require_relative 'helper' + +# ruby -w -Itest test/cluster_client_internals_test.rb +class TestClusterClientInternals < Test::Unit::TestCase + include Helper::Cluster + + def test_handle_multiple_servers + 100.times { |i| redis.set(i.to_s, "hogehoge#{i}") } + 100.times { |i| assert_equal "hogehoge#{i}", redis.get(i.to_s) } + end + + def test_info_of_cluster_mode_is_enabled + assert_equal '1', redis.info['cluster_enabled'] + end + + def test_unknown_commands_does_not_work_by_default + assert_raise(Redis::CommandError) do + redis.not_yet_implemented_command('boo', 'foo') + end + end + + def test_with_reconnect + assert_equal('Hello World', redis.with_reconnect { 'Hello World' }) + end + + def test_without_reconnect + assert_equal('Hello World', redis.without_reconnect { 'Hello World' }) + end + + def test_connected? + assert_equal true, redis.connected? + end + + def test_close + assert_equal true, redis.close + end + + def test_disconnect! + assert_equal true, redis.disconnect! + end + + def test_asking + assert_equal 'OK', redis.asking + end + + def test_id + expected = 'redis://127.0.0.1:7000/0 '\ + 'redis://127.0.0.1:7001/0 '\ + 'redis://127.0.0.1:7002/0' + assert_equal expected, redis.id + end + + def test_inspect + expected = "#' + + assert_equal expected, redis.inspect + end + + def test_dup + assert_instance_of Redis, redis.dup + end + + def test_connection + expected = [ + { host: '127.0.0.1', port: 7000, db: 0, id: 'redis://127.0.0.1:7000/0', location: '127.0.0.1:7000' }, + { host: '127.0.0.1', port: 7001, db: 0, id: 'redis://127.0.0.1:7001/0', location: '127.0.0.1:7001' }, + { host: '127.0.0.1', port: 7002, db: 0, id: 'redis://127.0.0.1:7002/0', location: '127.0.0.1:7002' } + ] + + assert_equal expected, redis.connection + end +end diff --git a/test/cluster_client_key_hash_tags_test.rb b/test/cluster_client_key_hash_tags_test.rb new file mode 100644 index 000000000..fff31f027 --- /dev/null +++ b/test/cluster_client_key_hash_tags_test.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require_relative 'helper' + +# ruby -w -Itest test/cluster_client_key_hash_tags_test.rb +class TestClusterClientKeyHashTags < Test::Unit::TestCase + include Helper::Cluster + + def build_described_class + option = Redis::Cluster::Option.new(cluster: ['redis://127.0.0.1:7000']) + node = Redis::Cluster::Node.new(option.per_node_key) + details = Redis::Cluster::CommandLoader.load(node) + Redis::Cluster::Command.new(details) + end + + def test_key_extraction + described_class = build_described_class + + assert_equal 'dogs:1', described_class.extract_first_key(%w[get dogs:1]) + assert_equal 'user1000', described_class.extract_first_key(%w[get {user1000}.following]) + assert_equal 'user1000', described_class.extract_first_key(%w[get {user1000}.followers]) + assert_equal 'foo{}{bar}', described_class.extract_first_key(%w[get foo{}{bar}]) + assert_equal '{bar', described_class.extract_first_key(%w[get foo{{bar}}zap]) + assert_equal 'bar', described_class.extract_first_key(%w[get foo{bar}{zap}]) + + assert_equal '', described_class.extract_first_key([:get, '']) + assert_equal '', described_class.extract_first_key([:get, nil]) + assert_equal '', described_class.extract_first_key([:get]) + + assert_equal '', described_class.extract_first_key([:set, '', 1]) + assert_equal '', described_class.extract_first_key([:set, nil, 1]) + assert_equal '', described_class.extract_first_key([:set]) + + # Keyless commands + assert_equal '', described_class.extract_first_key([:auth, 'password']) + assert_equal '', described_class.extract_first_key(%i[client kill]) + assert_equal '', described_class.extract_first_key(%i[cluster addslots]) + assert_equal '', described_class.extract_first_key(%i[command]) + assert_equal '', described_class.extract_first_key(%i[command count]) + assert_equal '', described_class.extract_first_key(%i[config get]) + assert_equal '', described_class.extract_first_key(%i[debug segfault]) + assert_equal '', described_class.extract_first_key([:echo, 'Hello World']) + assert_equal '', described_class.extract_first_key([:flushall, 'ASYNC']) + assert_equal '', described_class.extract_first_key([:flushdb, 'ASYNC']) + assert_equal '', described_class.extract_first_key([:info, 'cluster']) + assert_equal '', described_class.extract_first_key(%i[memory doctor]) + assert_equal '', described_class.extract_first_key([:ping, 'Hi']) + assert_equal '', described_class.extract_first_key([:psubscribe, 'channel']) + assert_equal '', described_class.extract_first_key([:pubsub, 'channels', '*']) + assert_equal '', described_class.extract_first_key([:publish, 'channel', 'Hi']) + assert_equal '', described_class.extract_first_key([:punsubscribe, 'channel']) + assert_equal '', described_class.extract_first_key([:subscribe, 'channel']) + assert_equal '', described_class.extract_first_key([:unsubscribe, 'channel']) + assert_equal '', described_class.extract_first_key(%w[script exists sha1 sha1]) + assert_equal '', described_class.extract_first_key([:select, 1]) + assert_equal '', described_class.extract_first_key([:shutdown, 'SAVE']) + assert_equal '', described_class.extract_first_key([:slaveof, '127.0.0.1', 6379]) + assert_equal '', described_class.extract_first_key([:slowlog, 'get', 2]) + assert_equal '', described_class.extract_first_key([:swapdb, 0, 1]) + assert_equal '', described_class.extract_first_key([:wait, 1, 0]) + + # 2nd argument is not a key + assert_equal 'key1', described_class.extract_first_key([:eval, 'script', 2, 'key1', 'key2', 'first', 'second']) + assert_equal '', described_class.extract_first_key([:eval, 'return 0', 0]) + assert_equal 'key1', described_class.extract_first_key([:evalsha, 'sha1', 2, 'key1', 'key2', 'first', 'second']) + assert_equal '', described_class.extract_first_key([:evalsha, 'return 0', 0]) + assert_equal 'key1', described_class.extract_first_key([:migrate, '127.0.0.1', 6379, 'key1', 0, 5000]) + assert_equal 'key1', described_class.extract_first_key([:memory, :usage, 'key1']) + assert_equal 'key1', described_class.extract_first_key([:object, 'refcount', 'key1']) + assert_equal 'mystream', described_class.extract_first_key([:xread, 'COUNT', 2, 'STREAMS', 'mystream', 0]) + assert_equal 'mystream', described_class.extract_first_key([:xreadgroup, 'GROUP', 'mygroup', 'Bob', 'COUNT', 2, 'STREAMS', 'mystream', '>']) + end + + def test_whether_the_command_effect_is_readonly_or_not + described_class = build_described_class + + assert_equal true, described_class.should_send_to_master?([:set]) + assert_equal false, described_class.should_send_to_master?([:get]) + + assert_equal false, described_class.should_send_to_slave?([:set]) + assert_equal true, described_class.should_send_to_slave?([:get]) + + assert_equal false, described_class.should_send_to_slave?([:info]) + assert_equal false, described_class.should_send_to_slave?([:info]) + end +end diff --git a/test/cluster_client_options_test.rb b/test/cluster_client_options_test.rb new file mode 100644 index 000000000..09909f618 --- /dev/null +++ b/test/cluster_client_options_test.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require_relative 'helper' + +# ruby -w -Itest test/cluster_client_options_test.rb +class TestClusterClientOptions < Test::Unit::TestCase + include Helper::Cluster + + def test_option_class + option = Redis::Cluster::Option.new(cluster: %w[rediss://127.0.0.1:7000], replica: true) + assert_equal({ '127.0.0.1:7000' => { url: 'rediss://127.0.0.1:7000' } }, option.per_node_key) + assert_equal true, option.secure? + assert_equal true, option.use_replica? + + option = Redis::Cluster::Option.new(cluster: %w[redis://127.0.0.1:7000], replica: false) + assert_equal({ '127.0.0.1:7000' => { url: 'redis://127.0.0.1:7000' } }, option.per_node_key) + assert_equal false, option.secure? + assert_equal false, option.use_replica? + + option = Redis::Cluster::Option.new(cluster: %w[redis://127.0.0.1:7000]) + assert_equal({ '127.0.0.1:7000' => { url: 'redis://127.0.0.1:7000' } }, option.per_node_key) + assert_equal false, option.secure? + assert_equal false, option.use_replica? + end + + def test_client_accepts_valid_node_configs + nodes = ['redis://127.0.0.1:7000', + 'redis://127.0.0.1:7001', + { host: '127.0.0.1', port: '7002' }, + { 'host' => '127.0.0.1', port: 7003 }, + 'redis://127.0.0.1:7004', + 'redis://127.0.0.1:7005'] + + assert_nothing_raised do + build_another_client(cluster: nodes) + end + end + + def test_client_accepts_valid_options + assert_nothing_raised do + build_another_client(timeout: 1.0) + end + end + + def test_client_ignores_invalid_options + assert_nothing_raised do + build_another_client(invalid_option: true) + end + end + + def test_client_works_even_if_so_many_unavailable_nodes_specified + nodes = (6001..7005).map { |port| "redis://127.0.0.1:#{port}" } + redis = build_another_client(cluster: nodes) + + assert_equal 'PONG', redis.ping + end + + def test_client_does_not_accept_db_specified_url + assert_raise(Redis::CannotConnectError, 'Could not connect to any nodes') do + build_another_client(cluster: ['redis://127.0.0.1:7000/1/namespace']) + end + + assert_raise(Redis::CannotConnectError, 'Could not connect to any nodes') do + build_another_client(cluster: [{ host: '127.0.0.1', port: '7000' }], db: 1) + end + end + + def test_client_does_not_accept_unconnectable_node_url_only + nodes = ['redis://127.0.0.1:7006'] + + assert_raise(Redis::CannotConnectError, 'Could not connect to any nodes') do + build_another_client(cluster: nodes) + end + end + + def test_client_accepts_unconnectable_node_url_included + nodes = ['redis://127.0.0.1:7000', 'redis://127.0.0.1:7006'] + + assert_nothing_raised(Redis::CannotConnectError, 'Could not connect to any nodes') do + build_another_client(cluster: nodes) + end + end + + def test_client_does_not_accept_http_scheme_url + nodes = ['http://127.0.0.1:80'] + + assert_raise(Redis::InvalidClientOptionError, "invalid uri scheme 'http'") do + build_another_client(cluster: nodes) + end + end + + def test_client_does_not_accept_blank_included_config + nodes = [''] + + assert_raise(Redis::InvalidClientOptionError, "invalid uri scheme ''") do + build_another_client(cluster: nodes) + end + end + + def test_client_does_not_accept_bool_included_config + nodes = [true] + + assert_raise(Redis::InvalidClientOptionError, "invalid uri scheme ''") do + build_another_client(cluster: nodes) + end + end + + def test_client_does_not_accept_nil_included_config + nodes = [nil] + + assert_raise(Redis::InvalidClientOptionError, "invalid uri scheme ''") do + build_another_client(cluster: nodes) + end + end + + def test_client_does_not_accept_array_included_config + nodes = [[]] + + assert_raise(Redis::InvalidClientOptionError, "invalid uri scheme ''") do + build_another_client(cluster: nodes) + end + end + + def test_client_does_not_accept_empty_hash_included_config + nodes = [{}] + + assert_raise(Redis::InvalidClientOptionError, 'Redis option of `cluster` must includes `:host` and `:port` keys') do + build_another_client(cluster: nodes) + end + end + + def test_client_does_not_accept_object_included_config + nodes = [Object.new] + + assert_raise(Redis::InvalidClientOptionError, 'Redis Cluster node config must includes String or Hash') do + build_another_client(cluster: nodes) + end + end + + def test_client_does_not_accept_not_array_config + nodes = :not_array + + assert_raise(Redis::InvalidClientOptionError, 'Redis Cluster node config must be Array') do + build_another_client(cluster: nodes) + end + end +end diff --git a/test/cluster_client_pipelining_test.rb b/test/cluster_client_pipelining_test.rb new file mode 100644 index 000000000..7813ee3d4 --- /dev/null +++ b/test/cluster_client_pipelining_test.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require_relative 'helper' + +# ruby -w -Itest test/cluster_client_pipelining_test.rb +class TestClusterClientPipelining < Test::Unit::TestCase + include Helper::Cluster + + def test_pipelining_with_a_hash_tag + p1 = p2 = p3 = p4 = p5 = p6 = nil + + redis.pipelined do |r| + r.set('{Presidents.of.USA}:1', 'George Washington') + r.set('{Presidents.of.USA}:2', 'John Adams') + r.set('{Presidents.of.USA}:3', 'Thomas Jefferson') + r.set('{Presidents.of.USA}:4', 'James Madison') + r.set('{Presidents.of.USA}:5', 'James Monroe') + r.set('{Presidents.of.USA}:6', 'John Quincy Adams') + + p1 = r.get('{Presidents.of.USA}:1') + p2 = r.get('{Presidents.of.USA}:2') + p3 = r.get('{Presidents.of.USA}:3') + p4 = r.get('{Presidents.of.USA}:4') + p5 = r.get('{Presidents.of.USA}:5') + p6 = r.get('{Presidents.of.USA}:6') + end + + [p1, p2, p3, p4, p5, p6].each do |actual| + assert_true actual.is_a?(Redis::Future) + end + + assert_equal('George Washington', p1.value) + assert_equal('John Adams', p2.value) + assert_equal('Thomas Jefferson', p3.value) + assert_equal('James Madison', p4.value) + assert_equal('James Monroe', p5.value) + assert_equal('John Quincy Adams', p6.value) + end + + def test_pipelining_without_hash_tags + assert_raise(Redis::CommandError) do + redis.pipelined do + redis.set(:a, 1) + redis.set(:b, 2) + redis.set(:c, 3) + redis.set(:d, 4) + redis.set(:e, 5) + redis.set(:f, 6) + + redis.get(:a) + redis.get(:b) + redis.get(:c) + redis.get(:d) + redis.get(:e) + redis.get(:f) + end + end + end +end diff --git a/test/cluster_client_replicas_test.rb b/test/cluster_client_replicas_test.rb new file mode 100644 index 000000000..71913f09d --- /dev/null +++ b/test/cluster_client_replicas_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative 'helper' + +# ruby -w -Itest test/cluster_client_replicas_test.rb +class TestClusterClientReplicas < Test::Unit::TestCase + include Helper::Cluster + + def test_client_can_command_with_replica + r = build_another_client(replica: true) + + 100.times do |i| + assert_equal 'OK', r.set("key#{i}", i) + end + + 100.times do |i| + assert_equal i.to_s, r.get("key#{i}") + end + end + + def test_client_can_flush_with_replica + r = build_another_client(replica: true) + + assert_equal 'OK', r.flushall + assert_equal 'OK', r.flushdb + end + + def test_some_reference_commands_are_sent_to_slaves_if_needed + r = build_another_client(replica: true) + + 5.times { |i| r.set("key#{i}", i) } + + assert_equal %w[key0 key1 key2 key3 key4], r.keys + assert_equal 5, r.dbsize + end +end diff --git a/test/cluster_client_slots_test.rb b/test/cluster_client_slots_test.rb new file mode 100644 index 000000000..5d5602540 --- /dev/null +++ b/test/cluster_client_slots_test.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require_relative 'helper' + +# ruby -w -Itest test/cluster_client_slots_test.rb +class TestClusterClientSlots < Test::Unit::TestCase + include Helper::Cluster + + def test_slot_class + slot = Redis::Cluster::Slot.new('127.0.0.1:7000' => 1..10) + + assert_equal false, slot.exists?(0) + assert_equal true, slot.exists?(1) + assert_equal true, slot.exists?(10) + assert_equal false, slot.exists?(11) + + assert_equal nil, slot.find_node_key_of_master(0) + assert_equal nil, slot.find_node_key_of_slave(0) + assert_equal '127.0.0.1:7000', slot.find_node_key_of_master(1) + assert_equal '127.0.0.1:7000', slot.find_node_key_of_slave(1) + assert_equal '127.0.0.1:7000', slot.find_node_key_of_master(10) + assert_equal '127.0.0.1:7000', slot.find_node_key_of_slave(10) + assert_equal nil, slot.find_node_key_of_master(11) + assert_equal nil, slot.find_node_key_of_slave(11) + + assert_equal nil, slot.put(1, '127.0.0.1:7001') + end + + def test_slot_class_with_node_flags_and_replicas + slot = Redis::Cluster::Slot.new({ '127.0.0.1:7000' => 1..10, '127.0.0.1:7001' => 1..10 }, + { '127.0.0.1:7000' => 'master', '127.0.0.1:7001' => 'slave' }, + true) + + assert_equal false, slot.exists?(0) + assert_equal true, slot.exists?(1) + assert_equal true, slot.exists?(10) + assert_equal false, slot.exists?(11) + + assert_equal nil, slot.find_node_key_of_master(0) + assert_equal nil, slot.find_node_key_of_slave(0) + assert_equal '127.0.0.1:7000', slot.find_node_key_of_master(1) + assert_equal '127.0.0.1:7001', slot.find_node_key_of_slave(1) + assert_equal '127.0.0.1:7000', slot.find_node_key_of_master(10) + assert_equal '127.0.0.1:7001', slot.find_node_key_of_slave(10) + assert_equal nil, slot.find_node_key_of_master(11) + assert_equal nil, slot.find_node_key_of_slave(11) + + assert_equal nil, slot.put(1, '127.0.0.1:7002') + end + + def test_slot_class_with_node_flags_and_without_replicas + slot = Redis::Cluster::Slot.new({ '127.0.0.1:7000' => 1..10, '127.0.0.1:7001' => 1..10 }, + { '127.0.0.1:7000' => 'master', '127.0.0.1:7001' => 'slave' }, + false) + + assert_equal false, slot.exists?(0) + assert_equal true, slot.exists?(1) + assert_equal true, slot.exists?(10) + assert_equal false, slot.exists?(11) + + assert_equal nil, slot.find_node_key_of_master(0) + assert_equal nil, slot.find_node_key_of_slave(0) + assert_equal '127.0.0.1:7000', slot.find_node_key_of_master(1) + assert_equal '127.0.0.1:7000', slot.find_node_key_of_slave(1) + assert_equal '127.0.0.1:7000', slot.find_node_key_of_master(10) + assert_equal '127.0.0.1:7000', slot.find_node_key_of_slave(10) + assert_equal nil, slot.find_node_key_of_master(11) + assert_equal nil, slot.find_node_key_of_slave(11) + + assert_equal nil, slot.put(1, '127.0.0.1:7002') + end + + def test_slot_class_with_empty_slots + slot = Redis::Cluster::Slot.new({}) + + assert_equal false, slot.exists?(0) + assert_equal false, slot.exists?(1) + + assert_equal nil, slot.find_node_key_of_master(0) + assert_equal nil, slot.find_node_key_of_slave(0) + assert_equal nil, slot.find_node_key_of_master(1) + assert_equal nil, slot.find_node_key_of_slave(1) + + assert_equal nil, slot.put(1, '127.0.0.1:7001') + end + + def test_redirection_when_slot_is_resharding + 100.times { |i| redis.set("{key}#{i}", i) } + + redis_cluster_resharding(12539, src: '127.0.0.1:7002', dest: '127.0.0.1:7000') do + 100.times { |i| assert_equal i.to_s, redis.get("{key}#{i}") } + end + end +end diff --git a/test/cluster_client_transactions_test.rb b/test/cluster_client_transactions_test.rb new file mode 100644 index 000000000..fdf1b3ec9 --- /dev/null +++ b/test/cluster_client_transactions_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require_relative 'helper' + +# ruby -w -Itest test/cluster_client_transactions_test.rb +class TestClusterClientTransactions < Test::Unit::TestCase + include Helper::Cluster + + def test_transaction_with_hash_tag + rc1 = redis + rc2 = build_another_client + + rc1.multi do |cli| + 100.times { |i| cli.set("{key}#{i}", i) } + end + + 100.times { |i| assert_equal i.to_s, rc1.get("{key}#{i}") } + 100.times { |i| assert_equal i.to_s, rc2.get("{key}#{i}") } + end + + def test_transaction_without_hash_tag + rc1 = redis + rc2 = build_another_client + + assert_raise(Redis::CommandError) do + rc1.multi do |cli| + 100.times { |i| cli.set("key#{i}", i) } + end + end + + 100.times { |i| assert_equal nil, rc1.get("key#{i}") } + 100.times { |i| assert_equal nil, rc2.get("key#{i}") } + end + + def test_transaction_with_replicas + rc1 = build_another_client(replica: true) + rc2 = build_another_client(replica: true) + + rc1.multi do |cli| + 100.times { |i| cli.set("{key}#{i}", i) } + end + + sleep 0.1 + + 100.times { |i| assert_equal i.to_s, rc1.get("{key}#{i}") } + 100.times { |i| assert_equal i.to_s, rc2.get("{key}#{i}") } + end + + def test_transaction_with_watch + rc1 = redis + rc2 = build_another_client + + rc1.set('{key}1', 100) + rc1.watch('{key}1') + + rc2.set('{key}1', 200) + val = rc1.get('{key}1').to_i + val += 1 + + rc1.multi do |cli| + cli.set('{key}1', val) + cli.set('{key}2', 300) + end + + assert_equal '200', rc1.get('{key}1') + assert_equal '200', rc2.get('{key}1') + + assert_equal nil, rc1.get('{key}2') + assert_equal nil, rc2.get('{key}2') + end +end diff --git a/test/cluster_commands_on_cluster_test.rb b/test/cluster_commands_on_cluster_test.rb new file mode 100644 index 000000000..184f6da6d --- /dev/null +++ b/test/cluster_commands_on_cluster_test.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require_relative 'helper' + +# ruby -w -Itest test/cluster_commands_on_cluster_test.rb +# @see https://redis.io/commands#cluster +class TestClusterCommandsOnCluster < Test::Unit::TestCase + include Helper::Cluster + + def test_cluster_addslots + assert_raise(Redis::Cluster::OrchestrationCommandNotSupported, 'CLUSTER ADDSLOTS command should be...') do + redis.cluster(:addslots, 0, 1, 2) + end + end + + def test_cluster_count_failure_reports + assert_raise(Redis::CommandError, 'ERR Unknown node unknown-node-id') do + redis.cluster('count-failure-reports', 'unknown-node-id') + end + + node_id = redis.cluster(:nodes).first.fetch('node_id') + assert_true(redis.cluster('count-failure-reports', node_id) >= 0) + end + + def test_cluster_countkeysinslot + assert_true(redis.cluster(:countkeysinslot, 0) >= 0) + assert_true(redis.cluster(:countkeysinslot, 16383) >= 0) + + assert_raise(Redis::CommandError, 'ERR Invalid slot') do + redis.cluster(:countkeysinslot, -1) + end + + assert_raise(Redis::CommandError, 'ERR Invalid slot') do + redis.cluster(:countkeysinslot, 16384) + end + end + + def test_cluster_delslots + assert_raise(Redis::Cluster::OrchestrationCommandNotSupported, 'CLUSTER DELSLOTS command should be...') do + redis.cluster(:delslots, 0, 1, 2) + end + end + + def test_cluster_failover + assert_raise(Redis::Cluster::OrchestrationCommandNotSupported, 'CLUSTER FAILOVER command should be...') do + redis.cluster(:failover, 'FORCE') + end + end + + def test_cluster_forget + assert_raise(Redis::Cluster::OrchestrationCommandNotSupported, 'CLUSTER FORGET command should be...') do + redis.cluster(:forget, 'unknown-node-id') + end + end + + def test_cluster_getkeysinslot + assert_instance_of Array, redis.cluster(:getkeysinslot, 0, 3) + end + + def test_cluster_info + info = redis.cluster(:info) + + assert_equal '3', info.fetch('cluster_size') + end + + def test_cluster_keyslot + assert_equal Redis::Cluster::KeySlotConverter.convert('hogehoge'), redis.cluster(:keyslot, 'hogehoge') + assert_equal Redis::Cluster::KeySlotConverter.convert('12345'), redis.cluster(:keyslot, '12345') + assert_equal Redis::Cluster::KeySlotConverter.convert('foo'), redis.cluster(:keyslot, 'boo{foo}woo') + assert_equal Redis::Cluster::KeySlotConverter.convert('antirez.is.cool'), redis.cluster(:keyslot, 'antirez.is.cool') + assert_equal Redis::Cluster::KeySlotConverter.convert(''), redis.cluster(:keyslot, '') + end + + def test_cluster_meet + assert_raise(Redis::Cluster::OrchestrationCommandNotSupported, 'CLUSTER MEET command should be...') do + redis.cluster(:meet, '127.0.0.1', 11211) + end + end + + def test_cluster_nodes + cluster_nodes = redis.cluster(:nodes) + sample_node = cluster_nodes.first + + assert_equal 6, cluster_nodes.length + assert_equal true, sample_node.key?('node_id') + assert_equal true, sample_node.key?('ip_port') + assert_equal true, sample_node.key?('flags') + assert_equal true, sample_node.key?('master_node_id') + assert_equal true, sample_node.key?('ping_sent') + assert_equal true, sample_node.key?('pong_recv') + assert_equal true, sample_node.key?('config_epoch') + assert_equal true, sample_node.key?('link_state') + assert_equal true, sample_node.key?('slots') + end + + def test_cluster_replicate + assert_raise(Redis::Cluster::OrchestrationCommandNotSupported, 'CLUSTER REPLICATE command should be...') do + redis.cluster(:replicate) + end + end + + def test_cluster_reset + assert_raise(Redis::Cluster::OrchestrationCommandNotSupported, 'CLUSTER RESET command should be...') do + redis.cluster(:reset) + end + end + + def test_cluster_saveconfig + assert_equal 'OK', redis.cluster(:saveconfig) + end + + def test_cluster_set_config_epoch + assert_raise(Redis::Cluster::OrchestrationCommandNotSupported, 'CLUSTER SET-CONFIG-EPOCH command should be...') do + redis.cluster('set-config-epoch') + end + end + + def test_cluster_setslot + assert_raise(Redis::Cluster::OrchestrationCommandNotSupported, 'CLUSTER SETSLOT command should be...') do + redis.cluster(:setslot) + end + end + + def test_cluster_slaves + cluster_nodes = redis.cluster(:nodes) + + sample_master_node_id = cluster_nodes.find { |n| n.fetch('master_node_id') == '-' }.fetch('node_id') + sample_slave_node_id = cluster_nodes.find { |n| n.fetch('master_node_id') != '-' }.fetch('node_id') + + assert_equal 'slave', redis.cluster(:slaves, sample_master_node_id).first.fetch('flags').first + assert_raise(Redis::CommandError, 'ERR The specified node is not a master') do + redis.cluster(:slaves, sample_slave_node_id) + end + end + + def test_cluster_slots + slots = redis.cluster(:slots) + sample_slot = slots.first + + assert_equal 3, slots.length + assert_equal true, sample_slot.key?('start_slot') + assert_equal true, sample_slot.key?('end_slot') + assert_equal true, sample_slot.key?('master') + assert_equal true, sample_slot.fetch('master').key?('ip') + assert_equal true, sample_slot.fetch('master').key?('port') + assert_equal true, sample_slot.fetch('master').key?('node_id') + assert_equal true, sample_slot.key?('replicas') + assert_equal true, sample_slot.fetch('replicas').is_a?(Array) + assert_equal true, sample_slot.fetch('replicas').first.key?('ip') + assert_equal true, sample_slot.fetch('replicas').first.key?('port') + assert_equal true, sample_slot.fetch('replicas').first.key?('node_id') + end + + def test_readonly + assert_raise(Redis::Cluster::OrchestrationCommandNotSupported, 'READONLY command should be...') do + redis.readonly + end + end + + def test_readwrite + assert_raise(Redis::Cluster::OrchestrationCommandNotSupported, 'READWRITE command should be...') do + redis.readwrite + end + end +end diff --git a/test/cluster_commands_on_connection_test.rb b/test/cluster_commands_on_connection_test.rb new file mode 100644 index 000000000..5c50f43ad --- /dev/null +++ b/test/cluster_commands_on_connection_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_relative 'helper' + +# ruby -w -Itest test/cluster_commands_on_connection_test.rb +# @see https://redis.io/commands#connection +class TestClusterCommandsOnConnection < Test::Unit::TestCase + include Helper::Cluster + + def test_auth + redis_cluster_mock(auth: ->(*_) { '+OK' }) do |redis| + assert_equal 'OK', redis.auth('my-password-123') + end + end + + def test_echo + assert_equal 'hogehoge', redis.echo('hogehoge') + end + + def test_ping + assert_equal 'hogehoge', redis.ping('hogehoge') + end + + def test_quit + redis2 = build_another_client + assert_equal 'OK', redis2.quit + end + + def test_select + assert_raise(Redis::CommandError, 'ERR SELECT is not allowed in cluster mode') do + redis.select(1) + end + end + + def test_swapdb + assert_raise(Redis::CommandError, 'ERR SWAPDB is not allowed in cluster mode') do + redis.swapdb(1, 2) + end + end +end diff --git a/test/cluster_commands_on_geo_test.rb b/test/cluster_commands_on_geo_test.rb new file mode 100644 index 000000000..0c5b9dc42 --- /dev/null +++ b/test/cluster_commands_on_geo_test.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_relative 'helper' + +# ruby -w -Itest test/cluster_commands_on_geo_test.rb +# @see https://redis.io/commands#geo +class TestClusterCommandsOnGeo < Test::Unit::TestCase + include Helper::Cluster + + MIN_REDIS_VERSION = '3.2.0' + + def add_sicily + redis.geoadd('Sicily', + 13.361389, 38.115556, 'Palermo', + 15.087269, 37.502669, 'Catania') + end + + def test_geoadd + target_version(MIN_REDIS_VERSION) do + assert_equal 2, add_sicily + end + end + + def test_geohash + target_version(MIN_REDIS_VERSION) do + add_sicily + assert_equal %w[sqc8b49rny0 sqdtr74hyu0], redis.geohash('Sicily', %w[Palermo Catania]) + end + end + + def test_geopos + target_version(MIN_REDIS_VERSION) do + add_sicily + expected = [%w[13.36138933897018433 38.11555639549629859], + %w[15.08726745843887329 37.50266842333162032], + nil] + assert_equal expected, redis.geopos('Sicily', %w[Palermo Catania NonExisting]) + end + end + + def test_geodist + target_version(MIN_REDIS_VERSION) do + add_sicily + assert_equal '166274.1516', redis.geodist('Sicily', 'Palermo', 'Catania') + assert_equal '166.2742', redis.geodist('Sicily', 'Palermo', 'Catania', 'km') + assert_equal '103.3182', redis.geodist('Sicily', 'Palermo', 'Catania', 'mi') + end + end + + def test_georadius + target_version(MIN_REDIS_VERSION) do + add_sicily + + expected = [%w[Palermo 190.4424], %w[Catania 56.4413]] + assert_equal expected, redis.georadius('Sicily', 15, 37, 200, 'km', 'WITHDIST') + + expected = [['Palermo', %w[13.36138933897018433 38.11555639549629859]], + ['Catania', %w[15.08726745843887329 37.50266842333162032]]] + assert_equal expected, redis.georadius('Sicily', 15, 37, 200, 'km', 'WITHCOORD') + + expected = [['Palermo', '190.4424', %w[13.36138933897018433 38.11555639549629859]], + ['Catania', '56.4413', %w[15.08726745843887329 37.50266842333162032]]] + assert_equal expected, redis.georadius('Sicily', 15, 37, 200, 'km', 'WITHDIST', 'WITHCOORD') + end + end + + def test_georadiusbymember + target_version(MIN_REDIS_VERSION) do + redis.geoadd('Sicily', 13.583333, 37.316667, 'Agrigento') + add_sicily + assert_equal %w[Agrigento Palermo], redis.georadiusbymember('Sicily', 'Agrigento', 100, 'km') + end + end +end diff --git a/test/cluster_commands_on_hashes_test.rb b/test/cluster_commands_on_hashes_test.rb new file mode 100644 index 000000000..b60958b1d --- /dev/null +++ b/test/cluster_commands_on_hashes_test.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require_relative 'helper' +require_relative 'lint/hashes' + +# ruby -w -Itest test/cluster_commands_on_hashes_test.rb +# @see https://redis.io/commands#hash +class TestClusterCommandsOnHashes < Test::Unit::TestCase + include Helper::Cluster + include Lint::Hashes +end diff --git a/test/cluster_commands_on_hyper_log_log_test.rb b/test/cluster_commands_on_hyper_log_log_test.rb new file mode 100644 index 000000000..645500ca7 --- /dev/null +++ b/test/cluster_commands_on_hyper_log_log_test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require_relative 'helper' +require_relative 'lint/hyper_log_log' + +# ruby -w -Itest test/cluster_commands_on_hyper_log_log_test.rb +# @see https://redis.io/commands#hyperloglog +class TestClusterCommandsOnHyperLogLog < Test::Unit::TestCase + include Helper::Cluster + include Lint::HyperLogLog + + def test_pfmerge + assert_raise Redis::CommandError do + super + end + end +end diff --git a/test/cluster_commands_on_keys_test.rb b/test/cluster_commands_on_keys_test.rb new file mode 100644 index 000000000..12f5ea8cd --- /dev/null +++ b/test/cluster_commands_on_keys_test.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require_relative 'helper' + +# ruby -w -Itest test/cluster_commands_on_keys_test.rb +# @see https://redis.io/commands#generic +class TestClusterCommandsOnKeys < Test::Unit::TestCase + include Helper::Cluster + + def set_some_keys + redis.set('key1', 'Hello') + redis.set('key2', 'World') + + redis.set('{key}1', 'Hello') + redis.set('{key}2', 'World') + end + + def test_del + set_some_keys + + assert_raise(Redis::CommandError, "CROSSSLOT Keys in request don't hash to the same slot") do + redis.del('key1', 'key2') + end + + assert_equal 2, redis.del('{key}1', '{key}2') + end + + def test_migrate + redis.set('mykey', 1) + + assert_raise(Redis::CommandError, 'ERR Target instance replied with error: MOVED 14687 127.0.0.1:7002') do + # We cannot move between cluster nodes. + redis.migrate('mykey', host: '127.0.0.1', port: 7000) + end + + redis_cluster_mock(migrate: ->(*_) { '-IOERR error or timeout writing to target instance' }) do |redis| + assert_raise(Redis::CommandError, 'IOERR error or timeout writing to target instance') do + redis.migrate('mykey', host: '127.0.0.1', port: 11211) + end + end + + redis_cluster_mock(migrate: ->(*_) { '+OK' }) do |redis| + assert_equal 'OK', redis.migrate('mykey', host: '127.0.0.1', port: 6379) + end + end + + def test_object + redis.lpush('mylist', 'Hello World') + assert_equal 1, redis.object('refcount', 'mylist') + expected_encoding = version < '3.2.0' ? 'ziplist' : 'quicklist' + assert_equal expected_encoding, redis.object('encoding', 'mylist') + expected_instance_type = RUBY_VERSION < '2.4.0' ? Fixnum : Integer + assert_instance_of expected_instance_type, redis.object('idletime', 'mylist') + + redis.set('foo', 1000) + assert_equal 'int', redis.object('encoding', 'foo') + + redis.set('bar', '1000bar') + assert_equal 'embstr', redis.object('encoding', 'bar') + end + + def test_randomkey + set_some_keys + assert_true redis.randomkey.is_a?(String) + end + + def test_rename + set_some_keys + + assert_raise(Redis::CommandError, "CROSSSLOT Keys in request don't hash to the same slot") do + redis.rename('key1', 'key3') + end + + assert_equal 'OK', redis.rename('{key}1', '{key}3') + end + + def test_renamenx + set_some_keys + + assert_raise(Redis::CommandError, "CROSSSLOT Keys in request don't hash to the same slot") do + redis.renamenx('key1', 'key2') + end + + assert_equal false, redis.renamenx('{key}1', '{key}2') + end + + def test_sort + redis.lpush('mylist', 3) + redis.lpush('mylist', 1) + redis.lpush('mylist', 5) + redis.lpush('mylist', 2) + redis.lpush('mylist', 4) + assert_equal %w[1 2 3 4 5], redis.sort('mylist') + end + + def test_touch + target_version('3.2.1') do + set_some_keys + assert_equal 1, redis.touch('key1') + assert_equal 1, redis.touch('key2') + assert_equal 1, redis.touch('key1', 'key2') + assert_equal 2, redis.touch('{key}1', '{key}2') + end + end + + def test_unlink + target_version('4.0.0') do + set_some_keys + assert_raise(Redis::CommandError, "CROSSSLOT Keys in request don't hash to the same slot") do + redis.unlink('key1', 'key2', 'key3') + end + assert_equal 2, redis.unlink('{key}1', '{key}2', '{key}3') + end + end + + def test_wait + set_some_keys + assert_equal 1, redis.wait(1, 0) + end + + def test_scan + set_some_keys + + cursor = 0 + all_keys = [] + loop do + cursor, keys = redis.scan(cursor, match: '{key}*') + all_keys += keys + break if cursor == '0' + end + + assert_equal 2, all_keys.uniq.size + end +end diff --git a/test/cluster_commands_on_lists_test.rb b/test/cluster_commands_on_lists_test.rb new file mode 100644 index 000000000..e2794c13e --- /dev/null +++ b/test/cluster_commands_on_lists_test.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative 'helper' +require_relative 'lint/lists' + +# ruby -w -Itest test/cluster_commands_on_lists_test.rb +# @see https://redis.io/commands#list +class TestClusterCommandsOnLists < Test::Unit::TestCase + include Helper::Cluster + include Lint::Lists + + def test_rpoplpush + assert_raise(Redis::CommandError) { super } + end +end diff --git a/test/cluster_commands_on_pub_sub_test.rb b/test/cluster_commands_on_pub_sub_test.rb new file mode 100644 index 000000000..758f40873 --- /dev/null +++ b/test/cluster_commands_on_pub_sub_test.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require_relative 'helper' + +# ruby -w -Itest test/cluster_commands_on_pub_sub_test.rb +# @see https://redis.io/commands#pubsub +class TestClusterCommandsOnPubSub < Test::Unit::TestCase + include Helper::Cluster + + def test_publish_subscribe_unsubscribe_pubsub + sub_cnt = 0 + messages = {} + + wire = Wire.new do + redis.subscribe('channel1', 'channel2') do |on| + on.subscribe { |_c, t| sub_cnt = t } + on.unsubscribe { |_c, t| sub_cnt = t } + on.message do |c, msg| + messages[c] = msg + # FIXME: blocking occurs when `unsubscribe` method was called with channel arguments + redis.unsubscribe if messages.size == 2 + end + end + end + + Wire.pass until sub_cnt == 2 + + publisher = build_another_client + + assert_equal %w[channel1 channel2], publisher.pubsub(:channels) + assert_equal %w[channel1 channel2], publisher.pubsub(:channels, 'cha*') + assert_equal [], publisher.pubsub(:channels, 'chachacha*') + assert_equal({}, publisher.pubsub(:numsub)) + assert_equal({ 'channel1' => 1, 'channel2' => 1, 'channel3' => 0 }, + publisher.pubsub(:numsub, 'channel1', 'channel2', 'channel3')) + assert_equal 0, publisher.pubsub(:numpat) + + publisher.publish('channel1', 'one') + publisher.publish('channel2', 'two') + + wire.join + + assert_equal({ 'channel1' => 'one', 'channel2' => 'two' }, messages.sort.to_h) + + assert_equal [], publisher.pubsub(:channels) + assert_equal [], publisher.pubsub(:channels, 'cha*') + assert_equal [], publisher.pubsub(:channels, 'chachacha*') + assert_equal({}, publisher.pubsub(:numsub)) + assert_equal({ 'channel1' => 0, 'channel2' => 0, 'channel3' => 0 }, + publisher.pubsub(:numsub, 'channel1', 'channel2', 'channel3')) + assert_equal 0, publisher.pubsub(:numpat) + end + + def test_publish_psubscribe_punsubscribe_pubsub + sub_cnt = 0 + messages = {} + + wire = Wire.new do + redis.psubscribe('cha*', 'her*') do |on| + on.psubscribe { |_c, t| sub_cnt = t } + on.punsubscribe { |_c, t| sub_cnt = t } + on.pmessage do |_ptn, chn, msg| + messages[chn] = msg + # FIXME: blocking occurs when `unsubscribe` method was called with channel arguments + redis.punsubscribe if messages.size == 3 + end + end + end + + Wire.pass until sub_cnt == 2 + + publisher = build_another_client + + assert_equal [], publisher.pubsub(:channels) + assert_equal [], publisher.pubsub(:channels, 'cha*') + assert_equal [], publisher.pubsub(:channels, 'her*') + assert_equal [], publisher.pubsub(:channels, 'guc*') + assert_equal({}, publisher.pubsub(:numsub)) + assert_equal({ 'channel1' => 0, 'channel2' => 0, 'hermes3' => 0, 'gucci4' => 0 }, + publisher.pubsub(:numsub, 'channel1', 'channel2', 'hermes3', 'gucci4')) + assert_equal 2, publisher.pubsub(:numpat) + + publisher.publish('chanel1', 'one') + publisher.publish('chanel2', 'two') + publisher.publish('hermes3', 'three') + publisher.publish('gucci4', 'four') + + wire.join + + assert_equal({ 'chanel1' => 'one', 'chanel2' => 'two', 'hermes3' => 'three' }, messages.sort.to_h) + + assert_equal [], publisher.pubsub(:channels) + assert_equal [], publisher.pubsub(:channels, 'cha*') + assert_equal [], publisher.pubsub(:channels, 'her*') + assert_equal [], publisher.pubsub(:channels, 'guc*') + assert_equal({}, publisher.pubsub(:numsub)) + assert_equal({ 'channel1' => 0, 'channel2' => 0, 'hermes3' => 0, 'gucci4' => 0 }, + publisher.pubsub(:numsub, 'channel1', 'channel2', 'hermes3', 'gucci4')) + assert_equal 0, publisher.pubsub(:numpat) + end +end diff --git a/test/cluster_commands_on_scripting_test.rb b/test/cluster_commands_on_scripting_test.rb new file mode 100644 index 000000000..0bd76145e --- /dev/null +++ b/test/cluster_commands_on_scripting_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require_relative 'helper' + +# ruby -w -Itest test/cluster_commands_on_scripting_test.rb +# @see https://redis.io/commands#scripting +class TestClusterCommandsOnScripting < Test::Unit::TestCase + include Helper::Cluster + + def test_eval + script = 'return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}' + argv = %w[first second] + + keys = %w[key1 key2] + assert_raise(Redis::CommandError, "CROSSSLOT Keys in request don't hash to the same slot") do + redis.eval(script, keys: keys, argv: argv) + end + + keys = %w[{key}1 {key}2] + expected = %w[{key}1 {key}2 first second] + assert_equal expected, redis.eval(script, keys: keys, argv: argv) + end + + def test_evalsha + sha = redis.script(:load, 'return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}') + expected = %w[{key}1 {key}2 first second] + assert_equal expected, redis.evalsha(sha, keys: %w[{key}1 {key}2], argv: %w[first second]) + end + + def test_script_debug + target_version('3.2.0') do + assert_equal 'OK', redis.script(:debug, 'yes') + assert_equal 'OK', redis.script(:debug, 'no') + end + end + + def test_script_exists + sha = redis.script(:load, 'return 1') + assert_equal true, redis.script(:exists, sha) + assert_equal false, redis.script(:exists, 'unknownsha') + end + + def test_script_flush + assert_equal 'OK', redis.script(:flush) + end + + def test_script_kill + redis_cluster_mock(kill: -> { '+OK' }) do |redis| + assert_equal 'OK', redis.script(:kill) + end + end + + def test_script_load + assert_equal 'e0e1f9fabfc9d4800c877a703b823ac0578ff8db', redis.script(:load, 'return 1') + end +end diff --git a/test/cluster_commands_on_server_test.rb b/test/cluster_commands_on_server_test.rb new file mode 100644 index 000000000..e54252843 --- /dev/null +++ b/test/cluster_commands_on_server_test.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +require_relative 'helper' + +# ruby -w -Itest test/cluster_commands_on_server_test.rb +# @see https://redis.io/commands#server +class TestClusterCommandsOnServer < Test::Unit::TestCase + include Helper::Cluster + + def test_bgrewriteaof + assert_equal 'Background append only file rewriting started', redis.bgrewriteaof + end + + def test_bgsave + redis_cluster_mock(bgsave: ->(*_) { '+OK' }) do |redis| + assert_equal 'OK', redis.bgsave + end + + err_msg = 'ERR An AOF log rewriting in progress: '\ + "can't BGSAVE right now. "\ + 'Use BGSAVE SCHEDULE in order to schedule a BGSAVE whenever possible.' + + redis_cluster_mock(bgsave: ->(*_) { "-Error #{err_msg}" }) do |redis| + assert_raise(Redis::Cluster::CommandErrorCollection, 'Command error replied on any node') do + redis.bgsave + end + end + end + + def test_client_kill + redis_cluster_mock(client: ->(*_) { '-Error ERR No such client' }) do |redis| + assert_raise(Redis::CommandError, 'ERR No such client') do + redis.client(:kill, '127.0.0.1:6379') + end + end + + redis_cluster_mock(client: ->(*_) { '+OK' }) do |redis| + assert_equal 'OK', redis.client(:kill, '127.0.0.1:6379') + end + end + + def test_client_list + a_client_info = redis.client(:list).first + actual = a_client_info.keys.sort + expected = %w[addr age cmd db events fd flags id idle multi name obl oll omem psub qbuf qbuf-free sub] + assert_equal expected, actual + end + + def test_client_getname + redis.client(:setname, 'my-client-01') + assert_equal 'my-client-01', redis.client(:getname) + end + + def test_client_pause + assert_equal 'OK', redis.client(:pause, 0) + end + + def test_client_reply + target_version('3.2.0') do + assert_equal 'OK', redis.client(:reply, 'ON') + end + end + + def test_client_setname + assert_equal 'OK', redis.client(:setname, 'my-client-01') + end + + def test_command + assert_instance_of Array, redis.command + end + + def test_command_count + assert_true(redis.command(:count) > 0) + end + + def test_command_getkeys + assert_equal %w[a c e], redis.command(:getkeys, :mset, 'a', 'b', 'c', 'd', 'e', 'f') + end + + def test_command_info + expected = [ + ['get', 2, %w[readonly fast], 1, 1, 1], + ['set', -3, %w[write denyoom], 1, 1, 1], + ['eval', -3, %w[noscript movablekeys], 0, 0, 0] + ] + assert_equal expected, redis.command(:info, :get, :set, :eval) + end + + def test_config_get + expected_keys = if version < '3.2.0' + %w[hash-max-ziplist-entries list-max-ziplist-entries set-max-intset-entries zset-max-ziplist-entries] + else + %w[hash-max-ziplist-entries set-max-intset-entries zset-max-ziplist-entries] + end + + assert_equal expected_keys, redis.config(:get, '*max-*-entries*').keys.sort + end + + def test_config_rewrite + redis_cluster_mock(config: ->(*_) { '-Error ERR Rewriting config file: Permission denied' }) do |redis| + assert_raise(Redis::Cluster::CommandErrorCollection, 'Command error replied on any node') do + redis.config(:rewrite) + end + end + + redis_cluster_mock(config: ->(*_) { '+OK' }) do |redis| + assert_equal 'OK', redis.config(:rewrite) + end + end + + def test_config_set + assert_equal 'OK', redis.config(:set, 'hash-max-ziplist-entries', 512) + end + + def test_config_resetstat + assert_equal 'OK', redis.config(:resetstat) + end + + def test_config_db_size + 10.times { |i| redis.set("key#{i}", 1) } + assert_equal 10, redis.dbsize + end + + def test_debug_object + # DEBUG OBJECT is a debugging command that should not be used by clients. + end + + def test_debug_segfault + # DEBUG SEGFAULT performs an invalid memory access that crashes Redis. + # It is used to simulate bugs during the development. + end + + def test_flushall + assert_equal 'OK', redis.flushall + end + + def test_flushdb + assert_equal 'OK', redis.flushdb + end + + def test_info + assert_equal({ 'cluster_enabled' => '1' }, redis.info(:cluster)) + end + + def test_lastsave + assert_instance_of Array, redis.lastsave + end + + def test_memory_doctor + target_version('4.0.0') do + assert_instance_of String, redis.memory(:doctor) + end + end + + def test_memory_help + target_version('4.0.0') do + assert_instance_of Array, redis.memory(:help) + end + end + + def test_memory_malloc_stats + target_version('4.0.0') do + assert_instance_of String, redis.memory('malloc-stats') + end + end + + def test_memory_purge + target_version('4.0.0') do + assert_equal 'OK', redis.memory(:purge) + end + end + + def test_memory_stats + target_version('4.0.0') do + assert_instance_of Array, redis.memory(:stats) + end + end + + def test_memory_usage + target_version('4.0.0') do + redis.set('key1', 'Hello World') + assert_equal 61, redis.memory(:usage, 'key1') + end + end + + def test_monitor + # Add MONITOR command test + end + + def test_role + assert_equal %w[master master master], redis.role.map(&:first) + end + + def test_save + assert_equal 'OK', redis.save + end + + def test_shutdown + assert_raise(Redis::Cluster::OrchestrationCommandNotSupported, 'SHUTDOWN command should be...') do + redis.shutdown + end + end + + def test_slaveof + assert_raise(Redis::CommandError, 'ERR SLAVEOF not allowed in cluster mode.') do + redis.slaveof(:no, :one) + end + end + + def test_slowlog + assert_instance_of Array, redis.slowlog(:get, 1) + end + + def test_sync + # Internal command used for replication + end + + def test_time + assert_instance_of Array, redis.time + end +end diff --git a/test/cluster_commands_on_sets_test.rb b/test/cluster_commands_on_sets_test.rb new file mode 100644 index 000000000..fbfaeb077 --- /dev/null +++ b/test/cluster_commands_on_sets_test.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require_relative 'helper' +require_relative 'lint/sets' + +# ruby -w -Itest test/cluster_commands_on_sets_test.rb +# @see https://redis.io/commands#set +class TestClusterCommandsOnSets < Test::Unit::TestCase + include Helper::Cluster + include Lint::Sets + + def test_sdiff + assert_raise(Redis::CommandError) { super } + end + + def test_sdiffstore + assert_raise(Redis::CommandError) { super } + end + + def test_sinter + assert_raise(Redis::CommandError) { super } + end + + def test_sinterstore + assert_raise(Redis::CommandError) { super } + end + + def test_smove + assert_raise(Redis::CommandError) { super } + end + + def test_sunion + assert_raise(Redis::CommandError) { super } + end + + def test_sunionstore + assert_raise(Redis::CommandError) { super } + end +end diff --git a/test/cluster_commands_on_sorted_sets_test.rb b/test/cluster_commands_on_sorted_sets_test.rb new file mode 100644 index 000000000..9c4f2398c --- /dev/null +++ b/test/cluster_commands_on_sorted_sets_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative 'helper' +require_relative 'lint/sorted_sets' + +# ruby -w -Itest test/cluster_commands_on_sorted_sets_test.rb +# @see https://redis.io/commands#sorted_set +class TestClusterCommandsOnSortedSets < Test::Unit::TestCase + include Helper::Cluster + include Lint::SortedSets + + def test_zinterstore + assert_raise(Redis::CommandError) { super } + end + + def test_zinterstore_with_aggregate + assert_raise(Redis::CommandError) { super } + end + + def test_zinterstore_with_weights + assert_raise(Redis::CommandError) { super } + end + + def test_zunionstore + assert_raise(Redis::CommandError) { super } + end + + def test_zunionstore_with_aggregate + assert_raise(Redis::CommandError) { super } + end + + def test_zunionstore_with_weights + assert_raise(Redis::CommandError) { super } + end +end diff --git a/test/cluster_commands_on_streams_test.rb b/test/cluster_commands_on_streams_test.rb new file mode 100644 index 000000000..3f15ec9d9 --- /dev/null +++ b/test/cluster_commands_on_streams_test.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require_relative 'helper' + +# ruby -w -Itest test/cluster_commands_on_streams_test.rb +# @see https://redis.io/commands#stream +class TestClusterCommandsOnStreams < Test::Unit::TestCase + include Helper::Cluster + + MIN_REDIS_VERSION = '4.9.0' + ENTRY_ID_FORMAT = /\d+-\d+/ + + def setup + super + add_some_entries_to_streams_without_hashtag + add_some_entries_to_streams_with_hashtag + end + + def add_some_entries_to_streams_without_hashtag + target_version(MIN_REDIS_VERSION) do + redis.xadd('stream1', '*', 'name', 'John', 'surname', 'Connor') + redis.xadd('stream1', '*', 'name', 'Sarah', 'surname', 'Connor') + redis.xadd('stream1', '*', 'name', 'Miles', 'surname', 'Dyson') + redis.xadd('stream1', '*', 'name', 'Peter', 'surname', 'Silberman') + end + end + + def add_some_entries_to_streams_with_hashtag + target_version(MIN_REDIS_VERSION) do + redis.xadd('{stream}1', '*', 'name', 'John', 'surname', 'Connor') + redis.xadd('{stream}1', '*', 'name', 'Sarah', 'surname', 'Connor') + redis.xadd('{stream}1', '*', 'name', 'Miles', 'surname', 'Dyson') + redis.xadd('{stream}1', '*', 'name', 'Peter', 'surname', 'Silberman') + end + end + + def assert_stream_entry(actual, expected_name, expected_surname) + actual_key = actual.keys.first + actual_values = actual[actual_key] + + assert_match ENTRY_ID_FORMAT, actual_key + assert_equal expected_name, actual_values['name'] + assert_equal expected_surname, actual_values['surname'] + end + + def assert_stream_pending(actual, expected_size_of_group, expected_consumer_name, expected_size_of_consumer) + assert_equal expected_size_of_group, actual[:size] + assert_match ENTRY_ID_FORMAT, actual[:min_entry_id] + assert_match ENTRY_ID_FORMAT, actual[:max_entry_id] + assert_equal({ expected_consumer_name => expected_size_of_consumer }, actual[:consumers]) + end + + # TODO: Remove this helper method when we implement streams interfaces + def hashify_stream_entries(reply) + reply.map do |entry_id, values| + [entry_id, Hash[values.each_slice(2).to_a]] + end.to_h + end + + # TODO: Remove this helper method when we implement streams interfaces + def hashify_streams(reply) + reply.map do |stream_key, entries| + [stream_key, hashify_stream_entries(entries)] + end.to_h + end + + # TODO: Remove this helper method when we implement streams interfaces + def hashify_stream_pendings(reply) + { + size: reply.first, + min_entry_id: reply[1], + max_entry_id: reply[2], + consumers: Hash[reply[3]] + } + end + + def test_xadd + target_version(MIN_REDIS_VERSION) do + assert_match ENTRY_ID_FORMAT, redis.xadd('mystream', '*', 'type', 'T-800', 'model', '101') + assert_match ENTRY_ID_FORMAT, redis.xadd('my{stream}', '*', 'type', 'T-1000') + end + end + + def test_xrange + target_version(MIN_REDIS_VERSION) do + actual = redis.xrange('stream1', '-', '+', 'COUNT', 1) + actual = hashify_stream_entries(actual) # TODO: Remove this step when we implement streams interfaces + assert_stream_entry(actual, 'John', 'Connor') + + actual = redis.xrange('{stream}1', '-', '+', 'COUNT', 1) + actual = hashify_stream_entries(actual) # TODO: Remove this step when we implement streams interfaces + assert_stream_entry(actual, 'John', 'Connor') + end + end + + def test_xrevrange + target_version(MIN_REDIS_VERSION) do + actual = redis.xrevrange('stream1', '+', '-', 'COUNT', 1) + actual = hashify_stream_entries(actual) # TODO: Remove this step when we implement streams interfaces + assert_stream_entry(actual, 'Peter', 'Silberman') + + actual = redis.xrevrange('{stream}1', '+', '-', 'COUNT', 1) + actual = hashify_stream_entries(actual) # TODO: Remove this step when we implement streams interfaces + assert_stream_entry(actual, 'Peter', 'Silberman') + end + end + + def test_xlen + target_version(MIN_REDIS_VERSION) do + assert_equal 4, redis.xlen('stream1') + assert_equal 4, redis.xlen('{stream}1') + end + end + + def test_xread + target_version(MIN_REDIS_VERSION) do + # non blocking without hashtag + actual = redis.xread('COUNT', 1, 'STREAMS', 'stream1', 0) + actual = hashify_streams(actual) # TODO: Remove this step when we implement streams interfaces + assert_equal 'stream1', actual.keys.first + assert_stream_entry(actual['stream1'], 'John', 'Connor') + + # blocking without hashtag + actual = redis.xread('COUNT', 1, 'BLOCK', 1, 'STREAMS', 'stream1', 0) + actual = hashify_streams(actual) # TODO: Remove this step when we implement streams interfaces + assert_equal 'stream1', actual.keys.first + assert_stream_entry(actual['stream1'], 'John', 'Connor') + + # non blocking with hashtag + actual = redis.xread('COUNT', 1, 'STREAMS', '{stream}1', 0) + actual = hashify_streams(actual) # TODO: Remove this step when we implement streams interfaces + assert_equal '{stream}1', actual.keys.first + assert_stream_entry(actual['{stream}1'], 'John', 'Connor') + + # blocking with hashtag + actual = redis.xread('COUNT', 1, 'BLOCK', 1, 'STREAMS', '{stream}1', 0) + actual = hashify_streams(actual) # TODO: Remove this step when we implement streams interfaces + assert_equal '{stream}1', actual.keys.first + assert_stream_entry(actual['{stream}1'], 'John', 'Connor') + end + end + + def test_xreadgroup + target_version(MIN_REDIS_VERSION) do + # non blocking without hashtag + redis.xgroup('create', 'stream1', 'mygroup1', '$') + add_some_entries_to_streams_without_hashtag + actual = redis.xreadgroup('GROUP', 'mygroup1', 'T-1000', 'COUNT', 1, 'STREAMS', 'stream1', '>') + actual = hashify_streams(actual) # TODO: Remove this step when we implement streams interfaces + assert_equal 'stream1', actual.keys.first + assert_stream_entry(actual['stream1'], 'John', 'Connor') + + # blocking without hashtag + redis.xgroup('create', 'stream1', 'mygroup2', '$') + add_some_entries_to_streams_without_hashtag + actual = redis.xreadgroup('GROUP', 'mygroup2', 'T-800', 'COUNT', 1, 'BLOCK', 1, 'STREAMS', 'stream1', '>') + actual = hashify_streams(actual) # TODO: Remove this step when we implement streams interfaces + assert_equal 'stream1', actual.keys.first + assert_stream_entry(actual['stream1'], 'John', 'Connor') + + # non blocking with hashtag + redis.xgroup('create', '{stream}1', 'mygroup3', '$') + add_some_entries_to_streams_with_hashtag + actual = redis.xreadgroup('GROUP', 'mygroup3', 'T-1000', 'COUNT', 1, 'STREAMS', '{stream}1', '>') + actual = hashify_streams(actual) # TODO: Remove this step when we implement streams interfaces + assert_equal '{stream}1', actual.keys.first + assert_stream_entry(actual['{stream}1'], 'John', 'Connor') + + # blocking with hashtag + redis.xgroup('create', '{stream}1', 'mygroup4', '$') + add_some_entries_to_streams_with_hashtag + actual = redis.xreadgroup('GROUP', 'mygroup4', 'T-800', 'COUNT', 1, 'BLOCK', 1, 'STREAMS', '{stream}1', '>') + actual = hashify_streams(actual) # TODO: Remove this step when we implement streams interfaces + assert_equal '{stream}1', actual.keys.first + assert_stream_entry(actual['{stream}1'], 'John', 'Connor') + end + end + + def test_xpending + target_version(MIN_REDIS_VERSION) do + redis.xgroup('create', 'stream1', 'mygroup1', '$') + add_some_entries_to_streams_without_hashtag + redis.xreadgroup('GROUP', 'mygroup1', 'T-800', 'COUNT', 1, 'STREAMS', 'stream1', '>') + actual = redis.xpending('stream1', 'mygroup1') + actual = hashify_stream_pendings(actual) # TODO: Remove this step when we implement streams interfaces + assert_stream_pending(actual, 1, 'T-800', '1') + + redis.xgroup('create', '{stream}1', 'mygroup2', '$') + add_some_entries_to_streams_with_hashtag + redis.xreadgroup('GROUP', 'mygroup2', 'T-800', 'COUNT', 1, 'STREAMS', '{stream}1', '>') + actual = redis.xpending('{stream}1', 'mygroup2') + actual = hashify_stream_pendings(actual) # TODO: Remove this step when we implement streams interfaces + assert_stream_pending(actual, 1, 'T-800', '1') + end + end +end diff --git a/test/cluster_commands_on_strings_test.rb b/test/cluster_commands_on_strings_test.rb new file mode 100644 index 000000000..67bcb37d1 --- /dev/null +++ b/test/cluster_commands_on_strings_test.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative 'helper' +require_relative 'lint/strings' + +# ruby -w -Itest test/cluster_commands_on_strings_test.rb +# @see https://redis.io/commands#string +class TestClusterCommandsOnStrings < Test::Unit::TestCase + include Helper::Cluster + include Lint::Strings + + def mock(*args, &block) + redis_cluster_mock(*args, &block) + end +end diff --git a/test/cluster_commands_on_transactions_test.rb b/test/cluster_commands_on_transactions_test.rb new file mode 100644 index 000000000..f68d13015 --- /dev/null +++ b/test/cluster_commands_on_transactions_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative 'helper' + +# ruby -w -Itest test/cluster_commands_on_transactions_test.rb +# @see https://redis.io/commands#transactions +class TestClusterCommandsOnTransactions < Test::Unit::TestCase + include Helper::Cluster + + def test_discard + assert_raise(Redis::Cluster::AmbiguousNodeError) do + redis.discard + end + end + + def test_exec + assert_raise(Redis::Cluster::AmbiguousNodeError) do + redis.exec + end + end + + def test_multi + assert_raise(Redis::Cluster::AmbiguousNodeError) do + redis.multi + end + end + + def test_unwatch + assert_raise(Redis::Cluster::AmbiguousNodeError) do + redis.unwatch + end + end + + def test_watch + assert_raise(Redis::CommandError, "CROSSSLOT Keys in request don't hash to the same slot") do + redis.watch('key1', 'key2') + end + + assert_equal 'OK', redis.watch('{key}1', '{key}2') + end +end diff --git a/test/cluster_commands_on_value_types_test.rb b/test/cluster_commands_on_value_types_test.rb new file mode 100644 index 000000000..4903176a0 --- /dev/null +++ b/test/cluster_commands_on_value_types_test.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require_relative 'helper' +require_relative 'lint/value_types' + +# ruby -w -Itest test/cluster_commands_on_value_types_test.rb +class TestClusterCommandsOnValueTypes < Test::Unit::TestCase + include Helper::Cluster + include Lint::ValueTypes + + def test_move + assert_raise(Redis::CommandError) { super } + end +end diff --git a/test/commands_on_hashes_test.rb b/test/commands_on_hashes_test.rb index 3e0f7ba4e..683f8dbd6 100644 --- a/test/commands_on_hashes_test.rb +++ b/test/commands_on_hashes_test.rb @@ -1,19 +1,7 @@ -require_relative "helper" -require_relative "lint/hashes" +require_relative 'helper' +require_relative 'lint/hashes' class TestCommandsOnHashes < Test::Unit::TestCase - include Helper::Client include Lint::Hashes - - def test_mapped_hmget_in_a_pipeline_returns_hash - r.hset("foo", "f1", "s1") - r.hset("foo", "f2", "s2") - - result = r.pipelined do - r.mapped_hmget("foo", "f1", "f2") - end - - assert_equal result[0], { "f1" => "s1", "f2" => "s2" } - end end diff --git a/test/commands_on_hyper_log_log_test.rb b/test/commands_on_hyper_log_log_test.rb index 194e6d4c6..2e13ddac4 100644 --- a/test/commands_on_hyper_log_log_test.rb +++ b/test/commands_on_hyper_log_log_test.rb @@ -1,19 +1,7 @@ -require_relative "helper" -require_relative "lint/hyper_log_log" +require_relative 'helper' +require_relative 'lint/hyper_log_log' class TestCommandsOnHyperLogLog < Test::Unit::TestCase - include Helper::Client include Lint::HyperLogLog - - def test_pfmerge - target_version "2.8.9" do - r.pfadd "foo", "s1" - r.pfadd "bar", "s2" - - assert_equal true, r.pfmerge("res", "foo", "bar") - assert_equal 2, r.pfcount("res") - end - end - end diff --git a/test/commands_on_lists_test.rb b/test/commands_on_lists_test.rb index 5f286706f..e99914b9b 100644 --- a/test/commands_on_lists_test.rb +++ b/test/commands_on_lists_test.rb @@ -1,18 +1,7 @@ -require_relative "helper" -require_relative "lint/lists" +require_relative 'helper' +require_relative 'lint/lists' class TestCommandsOnLists < Test::Unit::TestCase - include Helper::Client include Lint::Lists - - def test_rpoplpush - r.rpush "foo", "s1" - r.rpush "foo", "s2" - - assert_equal "s2", r.rpoplpush("foo", "bar") - assert_equal ["s2"], r.lrange("bar", 0, -1) - assert_equal "s1", r.rpoplpush("foo", "bar") - assert_equal ["s1", "s2"], r.lrange("bar", 0, -1) - end end diff --git a/test/commands_on_sets_test.rb b/test/commands_on_sets_test.rb index cd186cd34..00346d300 100644 --- a/test/commands_on_sets_test.rb +++ b/test/commands_on_sets_test.rb @@ -1,75 +1,7 @@ -require_relative "helper" -require_relative "lint/sets" +require_relative 'helper' +require_relative 'lint/sets' class TestCommandsOnSets < Test::Unit::TestCase - include Helper::Client include Lint::Sets - - def test_smove - r.sadd "foo", "s1" - r.sadd "bar", "s2" - - assert r.smove("foo", "bar", "s1") - assert r.sismember("bar", "s1") - end - - def test_sinter - r.sadd "foo", "s1" - r.sadd "foo", "s2" - r.sadd "bar", "s2" - - assert_equal ["s2"], r.sinter("foo", "bar") - end - - def test_sinterstore - r.sadd "foo", "s1" - r.sadd "foo", "s2" - r.sadd "bar", "s2" - - r.sinterstore("baz", "foo", "bar") - - assert_equal ["s2"], r.smembers("baz") - end - - def test_sunion - r.sadd "foo", "s1" - r.sadd "foo", "s2" - r.sadd "bar", "s2" - r.sadd "bar", "s3" - - assert_equal ["s1", "s2", "s3"], r.sunion("foo", "bar").sort - end - - def test_sunionstore - r.sadd "foo", "s1" - r.sadd "foo", "s2" - r.sadd "bar", "s2" - r.sadd "bar", "s3" - - r.sunionstore("baz", "foo", "bar") - - assert_equal ["s1", "s2", "s3"], r.smembers("baz").sort - end - - def test_sdiff - r.sadd "foo", "s1" - r.sadd "foo", "s2" - r.sadd "bar", "s2" - r.sadd "bar", "s3" - - assert_equal ["s1"], r.sdiff("foo", "bar") - assert_equal ["s3"], r.sdiff("bar", "foo") - end - - def test_sdiffstore - r.sadd "foo", "s1" - r.sadd "foo", "s2" - r.sadd "bar", "s2" - r.sadd "bar", "s3" - - r.sdiffstore("baz", "foo", "bar") - - assert_equal ["s1"], r.smembers("baz") - end end diff --git a/test/commands_on_sorted_sets_test.rb b/test/commands_on_sorted_sets_test.rb index 9d30d22fe..3a57b9bc6 100644 --- a/test/commands_on_sorted_sets_test.rb +++ b/test/commands_on_sorted_sets_test.rb @@ -1,150 +1,7 @@ -require_relative "helper" -require_relative "lint/sorted_sets" +require_relative 'helper' +require_relative 'lint/sorted_sets' class TestCommandsOnSortedSets < Test::Unit::TestCase - include Helper::Client include Lint::SortedSets - - def test_zlexcount - target_version "2.8.9" do - r.zadd "foo", 0, "aaren" - r.zadd "foo", 0, "abagael" - r.zadd "foo", 0, "abby" - r.zadd "foo", 0, "abbygail" - - assert_equal 4, r.zlexcount("foo", "[a", "[a\xff") - assert_equal 4, r.zlexcount("foo", "[aa", "[ab\xff") - assert_equal 3, r.zlexcount("foo", "(aaren", "[ab\xff") - assert_equal 2, r.zlexcount("foo", "[aba", "(abbygail") - assert_equal 1, r.zlexcount("foo", "(aaren", "(abby") - end - end - - def test_zrangebylex - target_version "2.8.9" do - r.zadd "foo", 0, "aaren" - r.zadd "foo", 0, "abagael" - r.zadd "foo", 0, "abby" - r.zadd "foo", 0, "abbygail" - - assert_equal ["aaren", "abagael", "abby", "abbygail"], r.zrangebylex("foo", "[a", "[a\xff") - assert_equal ["aaren", "abagael"], r.zrangebylex("foo", "[a", "[a\xff", :limit => [0, 2]) - assert_equal ["abby", "abbygail"], r.zrangebylex("foo", "(abb", "(abb\xff") - assert_equal ["abbygail"], r.zrangebylex("foo", "(abby", "(abby\xff") - end - end - - def test_zrevrangebylex - target_version "2.9.9" do - r.zadd "foo", 0, "aaren" - r.zadd "foo", 0, "abagael" - r.zadd "foo", 0, "abby" - r.zadd "foo", 0, "abbygail" - - assert_equal ["abbygail", "abby", "abagael", "aaren"], r.zrevrangebylex("foo", "[a\xff", "[a") - assert_equal ["abbygail", "abby"], r.zrevrangebylex("foo", "[a\xff", "[a", :limit => [0, 2]) - assert_equal ["abbygail", "abby"], r.zrevrangebylex("foo", "(abb\xff", "(abb") - assert_equal ["abbygail"], r.zrevrangebylex("foo", "(abby\xff", "(abby") - end - end - - def test_zcount - r.zadd "foo", 1, "s1" - r.zadd "foo", 2, "s2" - r.zadd "foo", 3, "s3" - - assert_equal 2, r.zcount("foo", 2, 3) - end - - def test_zunionstore - r.zadd "foo", 1, "s1" - r.zadd "bar", 2, "s2" - r.zadd "foo", 3, "s3" - r.zadd "bar", 4, "s4" - - assert_equal 4, r.zunionstore("foobar", ["foo", "bar"]) - assert_equal ["s1", "s2", "s3", "s4"], r.zrange("foobar", 0, -1) - end - - def test_zunionstore_with_weights - r.zadd "foo", 1, "s1" - r.zadd "foo", 3, "s3" - r.zadd "bar", 20, "s2" - r.zadd "bar", 40, "s4" - - assert_equal 4, r.zunionstore("foobar", ["foo", "bar"]) - assert_equal ["s1", "s3", "s2", "s4"], r.zrange("foobar", 0, -1) - - assert_equal 4, r.zunionstore("foobar", ["foo", "bar"], :weights => [10, 1]) - assert_equal ["s1", "s2", "s3", "s4"], r.zrange("foobar", 0, -1) - end - - def test_zunionstore_with_aggregate - r.zadd "foo", 1, "s1" - r.zadd "foo", 2, "s2" - r.zadd "bar", 4, "s2" - r.zadd "bar", 3, "s3" - - assert_equal 3, r.zunionstore("foobar", ["foo", "bar"]) - assert_equal ["s1", "s3", "s2"], r.zrange("foobar", 0, -1) - - assert_equal 3, r.zunionstore("foobar", ["foo", "bar"], :aggregate => :min) - assert_equal ["s1", "s2", "s3"], r.zrange("foobar", 0, -1) - - assert_equal 3, r.zunionstore("foobar", ["foo", "bar"], :aggregate => :max) - assert_equal ["s1", "s3", "s2"], r.zrange("foobar", 0, -1) - end - - def test_zinterstore - r.zadd "foo", 1, "s1" - r.zadd "bar", 2, "s1" - r.zadd "foo", 3, "s3" - r.zadd "bar", 4, "s4" - - assert_equal 1, r.zinterstore("foobar", ["foo", "bar"]) - assert_equal ["s1"], r.zrange("foobar", 0, -1) - end - - def test_zinterstore_with_weights - r.zadd "foo", 1, "s1" - r.zadd "foo", 2, "s2" - r.zadd "foo", 3, "s3" - r.zadd "bar", 20, "s2" - r.zadd "bar", 30, "s3" - r.zadd "bar", 40, "s4" - - assert_equal 2, r.zinterstore("foobar", ["foo", "bar"]) - assert_equal ["s2", "s3"], r.zrange("foobar", 0, -1) - - assert_equal 2, r.zinterstore("foobar", ["foo", "bar"], :weights => [10, 1]) - assert_equal ["s2", "s3"], r.zrange("foobar", 0, -1) - - assert_equal 40.0, r.zscore("foobar", "s2") - assert_equal 60.0, r.zscore("foobar", "s3") - end - - def test_zinterstore_with_aggregate - r.zadd "foo", 1, "s1" - r.zadd "foo", 2, "s2" - r.zadd "foo", 3, "s3" - r.zadd "bar", 20, "s2" - r.zadd "bar", 30, "s3" - r.zadd "bar", 40, "s4" - - assert_equal 2, r.zinterstore("foobar", ["foo", "bar"]) - assert_equal ["s2", "s3"], r.zrange("foobar", 0, -1) - assert_equal 22.0, r.zscore("foobar", "s2") - assert_equal 33.0, r.zscore("foobar", "s3") - - assert_equal 2, r.zinterstore("foobar", ["foo", "bar"], :aggregate => :min) - assert_equal ["s2", "s3"], r.zrange("foobar", 0, -1) - assert_equal 2.0, r.zscore("foobar", "s2") - assert_equal 3.0, r.zscore("foobar", "s3") - - assert_equal 2, r.zinterstore("foobar", ["foo", "bar"], :aggregate => :max) - assert_equal ["s2", "s3"], r.zrange("foobar", 0, -1) - assert_equal 20.0, r.zscore("foobar", "s2") - assert_equal 30.0, r.zscore("foobar", "s3") - end end diff --git a/test/commands_on_strings_test.rb b/test/commands_on_strings_test.rb index 58fe7e510..07b898989 100644 --- a/test/commands_on_strings_test.rb +++ b/test/commands_on_strings_test.rb @@ -1,99 +1,7 @@ -require_relative "helper" -require_relative "lint/strings" +require_relative 'helper' +require_relative 'lint/strings' class TestCommandsOnStrings < Test::Unit::TestCase - include Helper::Client include Lint::Strings - - def test_mget - r.set("foo", "s1") - r.set("bar", "s2") - - assert_equal ["s1", "s2"] , r.mget("foo", "bar") - assert_equal ["s1", "s2", nil], r.mget("foo", "bar", "baz") - end - - def test_mget_mapped - r.set("foo", "s1") - r.set("bar", "s2") - - response = r.mapped_mget("foo", "bar") - - assert_equal "s1", response["foo"] - assert_equal "s2", response["bar"] - - response = r.mapped_mget("foo", "bar", "baz") - - assert_equal "s1", response["foo"] - assert_equal "s2", response["bar"] - assert_equal nil , response["baz"] - end - - def test_mapped_mget_in_a_pipeline_returns_hash - r.set("foo", "s1") - r.set("bar", "s2") - - result = r.pipelined do - r.mapped_mget("foo", "bar") - end - - assert_equal result[0], { "foo" => "s1", "bar" => "s2" } - end - - def test_mset - r.mset(:foo, "s1", :bar, "s2") - - assert_equal "s1", r.get("foo") - assert_equal "s2", r.get("bar") - end - - def test_mset_mapped - r.mapped_mset(:foo => "s1", :bar => "s2") - - assert_equal "s1", r.get("foo") - assert_equal "s2", r.get("bar") - end - - def test_msetnx - r.set("foo", "s1") - assert_equal false, r.msetnx(:foo, "s2", :bar, "s3") - assert_equal "s1", r.get("foo") - assert_equal nil, r.get("bar") - - r.del("foo") - assert_equal true, r.msetnx(:foo, "s2", :bar, "s3") - assert_equal "s2", r.get("foo") - assert_equal "s3", r.get("bar") - end - - def test_msetnx_mapped - r.set("foo", "s1") - assert_equal false, r.mapped_msetnx(:foo => "s2", :bar => "s3") - assert_equal "s1", r.get("foo") - assert_equal nil, r.get("bar") - - r.del("foo") - assert_equal true, r.mapped_msetnx(:foo => "s2", :bar => "s3") - assert_equal "s2", r.get("foo") - assert_equal "s3", r.get("bar") - end - - def test_bitop - with_external_encoding("UTF-8") do - target_version "2.5.10" do - r.set("foo", "a") - r.set("bar", "b") - - r.bitop(:and, "foo&bar", "foo", "bar") - assert_equal "\x60", r.get("foo&bar") - r.bitop(:or, "foo|bar", "foo", "bar") - assert_equal "\x63", r.get("foo|bar") - r.bitop(:xor, "foo^bar", "foo", "bar") - assert_equal "\x03", r.get("foo^bar") - r.bitop(:not, "~foo", "foo") - assert_equal "\x9E", r.get("~foo") - end - end - end end diff --git a/test/distributed_blocking_commands_test.rb b/test/distributed_blocking_commands_test.rb index f03f45b09..fa3b012dd 100644 --- a/test/distributed_blocking_commands_test.rb +++ b/test/distributed_blocking_commands_test.rb @@ -41,4 +41,12 @@ def test_brpoplpush_raises_with_old_prototype r.brpoplpush("foo", "bar", 0) end end + + def test_bzpopmin + # Not implemented yet + end + + def test_bzpopmax + # Not implemented yet + end end diff --git a/test/distributed_commands_on_hashes_test.rb b/test/distributed_commands_on_hashes_test.rb index 732fef64c..9ec2902d6 100644 --- a/test/distributed_commands_on_hashes_test.rb +++ b/test/distributed_commands_on_hashes_test.rb @@ -1,8 +1,21 @@ -require_relative "helper" -require_relative "lint/hashes" +require_relative 'helper' +require_relative 'lint/hashes' class TestDistributedCommandsOnHashes < Test::Unit::TestCase - include Helper::Distributed include Lint::Hashes + + def test_hscan + # Not implemented yet + end + + def test_hstrlen + # Not implemented yet + end + + def test_mapped_hmget_in_a_pipeline_returns_hash + assert_raise(Redis::Distributed::CannotDistribute) do + super + end + end end diff --git a/test/distributed_commands_on_hyper_log_log_test.rb b/test/distributed_commands_on_hyper_log_log_test.rb index a6b7110f7..b63cfb9e9 100644 --- a/test/distributed_commands_on_hyper_log_log_test.rb +++ b/test/distributed_commands_on_hyper_log_log_test.rb @@ -1,31 +1,26 @@ -require_relative "helper" -require_relative "lint/hyper_log_log" +require_relative 'helper' +require_relative 'lint/hyper_log_log' class TestDistributedCommandsOnHyperLogLog < Test::Unit::TestCase - include Helper::Distributed include Lint::HyperLogLog def test_pfmerge - target_version "2.8.9" do + target_version '2.8.9' do assert_raise Redis::Distributed::CannotDistribute do - r.pfadd "foo", "s1" - r.pfadd "bar", "s2" - - assert r.pfmerge("res", "foo", "bar") + super end end end def test_pfcount_multiple_keys_diff_nodes - target_version "2.8.9" do + target_version '2.8.9' do assert_raise Redis::Distributed::CannotDistribute do - r.pfadd "foo", "s1" - r.pfadd "bar", "s2" + r.pfadd 'foo', 's1' + r.pfadd 'bar', 's2' - assert r.pfcount("res", "foo", "bar") + assert r.pfcount('res', 'foo', 'bar') end end end - end diff --git a/test/distributed_commands_on_lists_test.rb b/test/distributed_commands_on_lists_test.rb index dd629bc2f..fa5caf92e 100644 --- a/test/distributed_commands_on_lists_test.rb +++ b/test/distributed_commands_on_lists_test.rb @@ -1,20 +1,19 @@ -require_relative "helper" -require_relative "lint/lists" +require_relative 'helper' +require_relative 'lint/lists' class TestDistributedCommandsOnLists < Test::Unit::TestCase - include Helper::Distributed include Lint::Lists def test_rpoplpush assert_raise Redis::Distributed::CannotDistribute do - r.rpoplpush("foo", "bar") + r.rpoplpush('foo', 'bar') end end def test_brpoplpush assert_raise Redis::Distributed::CannotDistribute do - r.brpoplpush("foo", "bar", :timeout => 1) + r.brpoplpush('foo', 'bar', timeout: 1) end end end diff --git a/test/distributed_commands_on_sets_test.rb b/test/distributed_commands_on_sets_test.rb index f30f49ba4..db9c66a87 100644 --- a/test/distributed_commands_on_sets_test.rb +++ b/test/distributed_commands_on_sets_test.rb @@ -1,106 +1,105 @@ -require_relative "helper" -require_relative "lint/sets" +require_relative 'helper' +require_relative 'lint/sets' class TestDistributedCommandsOnSets < Test::Unit::TestCase - include Helper::Distributed include Lint::Sets def test_smove assert_raise Redis::Distributed::CannotDistribute do - r.sadd "foo", "s1" - r.sadd "bar", "s2" + r.sadd 'foo', 's1' + r.sadd 'bar', 's2' - r.smove("foo", "bar", "s1") + r.smove('foo', 'bar', 's1') end end def test_sinter assert_raise Redis::Distributed::CannotDistribute do - r.sadd "foo", "s1" - r.sadd "foo", "s2" - r.sadd "bar", "s2" + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' - r.sinter("foo", "bar") + r.sinter('foo', 'bar') end end def test_sinterstore assert_raise Redis::Distributed::CannotDistribute do - r.sadd "foo", "s1" - r.sadd "foo", "s2" - r.sadd "bar", "s2" + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' - r.sinterstore("baz", "foo", "bar") + r.sinterstore('baz', 'foo', 'bar') end end def test_sunion assert_raise Redis::Distributed::CannotDistribute do - r.sadd "foo", "s1" - r.sadd "foo", "s2" - r.sadd "bar", "s2" - r.sadd "bar", "s3" + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + r.sadd 'bar', 's3' - r.sunion("foo", "bar") + r.sunion('foo', 'bar') end end def test_sunionstore assert_raise Redis::Distributed::CannotDistribute do - r.sadd "foo", "s1" - r.sadd "foo", "s2" - r.sadd "bar", "s2" - r.sadd "bar", "s3" + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + r.sadd 'bar', 's3' - r.sunionstore("baz", "foo", "bar") + r.sunionstore('baz', 'foo', 'bar') end end def test_sdiff assert_raise Redis::Distributed::CannotDistribute do - r.sadd "foo", "s1" - r.sadd "foo", "s2" - r.sadd "bar", "s2" - r.sadd "bar", "s3" + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + r.sadd 'bar', 's3' - r.sdiff("foo", "bar") + r.sdiff('foo', 'bar') end end def test_sdiffstore assert_raise Redis::Distributed::CannotDistribute do - r.sadd "foo", "s1" - r.sadd "foo", "s2" - r.sadd "bar", "s2" - r.sadd "bar", "s3" + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + r.sadd 'bar', 's3' - r.sdiffstore("baz", "foo", "bar") + r.sdiffstore('baz', 'foo', 'bar') end end def test_sscan assert_nothing_raised do - r.sadd "foo", "s1" - r.sadd "foo", "s2" - r.sadd "bar", "s2" - r.sadd "bar", "s3" + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + r.sadd 'bar', 's3' - cursor, vals = r.sscan "foo", 0 + cursor, vals = r.sscan 'foo', 0 assert_equal '0', cursor - assert_equal %w(s1 s2), vals.sort + assert_equal %w[s1 s2], vals.sort end end def test_sscan_each assert_nothing_raised do - r.sadd "foo", "s1" - r.sadd "foo", "s2" - r.sadd "bar", "s2" - r.sadd "bar", "s3" + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + r.sadd 'bar', 's3' - vals = r.sscan_each("foo").to_a - assert_equal %w(s1 s2), vals.sort + vals = r.sscan_each('foo').to_a + assert_equal %w[s1 s2], vals.sort end end end diff --git a/test/distributed_commands_on_sorted_sets_test.rb b/test/distributed_commands_on_sorted_sets_test.rb index ae23a6c83..3beb1c505 100644 --- a/test/distributed_commands_on_sorted_sets_test.rb +++ b/test/distributed_commands_on_sorted_sets_test.rb @@ -1,16 +1,59 @@ -require_relative "helper" -require_relative "lint/sorted_sets" +require_relative 'helper' +require_relative 'lint/sorted_sets' class TestDistributedCommandsOnSortedSets < Test::Unit::TestCase - include Helper::Distributed include Lint::SortedSets - def test_zcount - r.zadd "foo", 1, "s1" - r.zadd "foo", 2, "s2" - r.zadd "foo", 3, "s3" + def test_zinterstore + assert_raise(Redis::Distributed::CannotDistribute) { super } + end + + def test_zinterstore_with_aggregate + assert_raise(Redis::Distributed::CannotDistribute) { super } + end + + def test_zinterstore_with_weights + assert_raise(Redis::Distributed::CannotDistribute) { super } + end + + def test_zlexcount + # Not implemented yet + end + + def test_zpopmax + # Not implemented yet + end + + def test_zpopmin + # Not implemented yet + end + + def test_zrangebylex + # Not implemented yet + end + + def test_zremrangebylex + # Not implemented yet + end + + def test_zrevrangebylex + # Not implemented yet + end + + def test_zscan + # Not implemented yet + end + + def test_zunionstore + assert_raise(Redis::Distributed::CannotDistribute) { super } + end + + def test_zunionstore_with_aggregate + assert_raise(Redis::Distributed::CannotDistribute) { super } + end - assert_equal 2, r.zcount("foo", 2, 3) + def test_zunionstore_with_weights + assert_raise(Redis::Distributed::CannotDistribute) { super } end end diff --git a/test/distributed_commands_on_strings_test.rb b/test/distributed_commands_on_strings_test.rb index b8ad73c5a..e50ccf444 100644 --- a/test/distributed_commands_on_strings_test.rb +++ b/test/distributed_commands_on_strings_test.rb @@ -66,4 +66,14 @@ def test_bitop end end end + + def test_mapped_mget_in_a_pipeline_returns_hash + assert_raise Redis::Distributed::CannotDistribute do + super + end + end + + def test_bitfield + # Not implemented yet + end end diff --git a/test/helper.rb b/test/helper.rb index 4d3e1610d..7838bbbcb 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -12,39 +12,12 @@ require_relative "support/redis_mock" require_relative "support/connection/#{ENV["DRIVER"]}" +require_relative 'support/cluster/orchestrator' PORT = 6381 OPTIONS = {:port => PORT, :db => 15, :timeout => Float(ENV["TIMEOUT"] || 0.1)} NODES = ["redis://127.0.0.1:#{PORT}/15"] -def init(redis) - begin - redis.select 14 - redis.flushdb - redis.select 15 - redis.flushdb - redis - rescue Redis::CannotConnectError - puts <<-EOS - - Cannot connect to Redis. - - Make sure Redis is running on localhost, port #{PORT}. - This testing suite connects to the database 15. - - Try this once: - - $ make clean - - Then run the build again: - - $ make - - EOS - exit 1 - end -end - def driver(*drivers, &blk) if drivers.map(&:to_s).include?(ENV["DRIVER"]) class_eval(&blk) @@ -133,6 +106,32 @@ def teardown @redis.quit if @redis end + def init(redis) + redis.select 14 + redis.flushdb + redis.select 15 + redis.flushdb + redis + rescue Redis::CannotConnectError + puts <<-MSG + + Cannot connect to Redis. + + Make sure Redis is running on localhost, port #{PORT}. + This testing suite connects to the database 15. + + Try this once: + + $ make clean + + Then run the build again: + + $ make + + MSG + exit 1 + end + def redis_mock(commands, options = {}, &blk) RedisMock.start(commands, options) do |port| yield _new_client(options.merge(:port => port)) @@ -156,16 +155,16 @@ def target_version(target) yield end end + + def version + Version.new(redis.info['redis_version']) + end end module Client include Generic - def version - Version.new(redis.info["redis_version"]) - end - private def _format_options(options) @@ -198,4 +197,148 @@ 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 = (7000..7005).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) + .map { |k, v| [k.to_s.downcase, v] } + .to_h + else + {} + end + + commands[:cluster] = lambda { |subcommand, *args| + if cluster_subcommands.key?(subcommand) + cluster_subcommands[subcommand].call(*args) + else + case subcommand + 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) + trib.down + yield + ensure + trib.rebuild + trib.close + end + + def redis_cluster_failover + trib = ClusterOrchestrator.new(_default_nodes) + trib.failover + yield + ensure + 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) + 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], + logger: ::Logger.new(@log), + cluster: _default_nodes + }.merge(options) + end + + def _new_client(options = {}) + Redis.new(_format_options(options).merge(driver: ENV['DRIVER'])) + end + end end diff --git a/test/lint/blocking_commands.rb b/test/lint/blocking_commands.rb index 531e8d98f..29296aab1 100644 --- a/test/lint/blocking_commands.rb +++ b/test/lint/blocking_commands.rb @@ -1,14 +1,15 @@ module Lint - module BlockingCommands - def setup super - r.rpush("{zap}foo", "s1") - r.rpush("{zap}foo", "s2") - r.rpush("{zap}bar", "s1") - r.rpush("{zap}bar", "s2") + r.rpush('{zap}foo', 's1') + r.rpush('{zap}foo', 's2') + r.rpush('{zap}bar', 's1') + r.rpush('{zap}bar', 's2') + + r.zadd('{szap}foo', %w[0 a 1 b 2 c]) + r.zadd('{szap}bar', %w[0 c 1 d 2 e]) end def to_protocol(obj) @@ -18,27 +19,38 @@ def to_protocol(obj) when Array "*#{obj.length}\r\n" + obj.map { |e| to_protocol(e) }.join else - fail + raise end end def mock(options = {}, &blk) - commands = { - :blpop => lambda do |*args| - sleep options[:delay] if options.has_key?(:delay) + commands = build_mock_commands(options) + redis_mock(commands, &blk) + end + + def build_mock_commands(options = {}) + { + blpop: lambda do |*args| + sleep options[:delay] if options.key?(:delay) to_protocol([args.first, args.last]) end, - :brpop => lambda do |*args| - sleep options[:delay] if options.has_key?(:delay) + brpop: lambda do |*args| + sleep options[:delay] if options.key?(:delay) to_protocol([args.first, args.last]) end, - :brpoplpush => lambda do |*args| - sleep options[:delay] if options.has_key?(:delay) + brpoplpush: lambda do |*args| + sleep options[:delay] if options.key?(:delay) to_protocol(args.last) + end, + bzpopmax: lambda do |*args| + sleep options[:delay] if options.key?(:delay) + to_protocol([args.first, args.last]) + end, + bzpopmin: lambda do |*args| + sleep options[:delay] if options.key?(:delay) + to_protocol([args.first, args.last]) end } - - redis_mock(commands, &blk) end def test_blpop @@ -121,6 +133,18 @@ def test_brpoplpush_timeout_with_old_prototype end end + def test_bzpopmin + target_version('4.9.0') do + assert_equal %w[{szap}foo a 0], r.bzpopmin('{szap}foo', '{szap}bar', 0) + end + end + + def test_bzpopmax + target_version('4.9.0') do + assert_equal %w[{szap}foo c 2], r.bzpopmax('{szap}foo', '{szap}bar', 0) + end + end + driver(:ruby, :hiredis) do def test_blpop_socket_timeout mock(:delay => 1 + OPTIONS[:timeout] * 2) do |r| diff --git a/test/lint/hashes.rb b/test/lint/hashes.rb index 13e5d0c3c..6f942ca4d 100644 --- a/test/lint/hashes.rb +++ b/test/lint/hashes.rb @@ -144,6 +144,17 @@ def test_hmget_mapped assert({"f1" => "s1", "f2" => "s2"} == r.mapped_hmget("foo", "f1", "f2")) end + def test_mapped_hmget_in_a_pipeline_returns_hash + r.hset("foo", "f1", "s1") + r.hset("foo", "f2", "s2") + + result = r.pipelined do + r.mapped_hmget("foo", "f1", "f2") + end + + assert_equal result[0], { "f1" => "s1", "f2" => "s2" } + end + def test_hincrby r.hincrby("foo", "f1", 1) @@ -173,5 +184,20 @@ def test_hincrbyfloat assert_equal "1.9", r.hget("foo", "f1") end end + + def test_hstrlen + target_version('3.2.0') do + redis.hmset('foo', 'f1', 'HelloWorld', 'f2', 99, 'f3', -256) + assert_equal 10, r.hstrlen('foo', 'f1') + assert_equal 2, r.hstrlen('foo', 'f2') + assert_equal 4, r.hstrlen('foo', 'f3') + end + end + + def test_hscan + redis.hmset('foo', 'f1', 'Jack', 'f2', 33) + expected = ['0', [%w[f1 Jack], %w[f2 33]]] + assert_equal expected, redis.hscan('foo', 0) + end end end diff --git a/test/lint/hyper_log_log.rb b/test/lint/hyper_log_log.rb index 5472e22f5..f56fed8f6 100644 --- a/test/lint/hyper_log_log.rb +++ b/test/lint/hyper_log_log.rb @@ -55,6 +55,20 @@ def test_variadic_pfcount_expanded end end - end + def test_pfmerge + target_version '2.8.9' do + r.pfadd 'foo', 's1' + r.pfadd 'bar', 's2' + + assert_equal true, r.pfmerge('res', 'foo', 'bar') + assert_equal 2, r.pfcount('res') + end + end + def test_variadic_pfmerge_expanded + redis.pfadd('{1}foo', %w[foo bar zap a]) + redis.pfadd('{1}bar', %w[a b c foo]) + assert_equal true, redis.pfmerge('{1}baz', '{1}foo', '{1}bar') + end + end end diff --git a/test/lint/lists.rb b/test/lint/lists.rb index 3a230f675..397210fbb 100644 --- a/test/lint/lists.rb +++ b/test/lint/lists.rb @@ -139,5 +139,21 @@ def test_linsert r.linsert "foo", :anywhere, "s3", "s2" end end + + def test_rpoplpush + r.rpush 'foo', 's1' + r.rpush 'foo', 's2' + + assert_equal 's2', r.rpoplpush('foo', 'bar') + assert_equal ['s2'], r.lrange('bar', 0, -1) + assert_equal 's1', r.rpoplpush('foo', 'bar') + assert_equal %w[s1 s2], r.lrange('bar', 0, -1) + end + + def test_variadic_rpoplpush_expand + redis.rpush('{1}foo', %w[a b c]) + redis.rpush('{1}bar', %w[d e f]) + assert_equal 'c', redis.rpoplpush('{1}foo', '{1}bar') + end end end diff --git a/test/lint/sets.rb b/test/lint/sets.rb index f32b1de0e..780d5abb7 100644 --- a/test/lint/sets.rb +++ b/test/lint/sets.rb @@ -136,5 +136,147 @@ def test_srandmember_with_negative_count assert_equal 4, r.scard("foo") end + + def test_smove + r.sadd 'foo', 's1' + r.sadd 'bar', 's2' + + assert r.smove('foo', 'bar', 's1') + assert r.sismember('bar', 's1') + end + + def test_sinter + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + + assert_equal ['s2'], r.sinter('foo', 'bar') + end + + def test_variadic_smove_expand + r.sadd('{1}foo', 's1') + r.sadd('{1}foo', 's2') + r.sadd('{1}foo', 's3') + r.sadd('{1}bar', 's3') + r.sadd('{1}bar', 's4') + r.sadd('{1}bar', 's5') + assert_equal true, r.smove('{1}foo', '{1}bar', 's2') + end + + def test_variadic_sinter_expand + r.sadd('{1}foo', 's1') + r.sadd('{1}foo', 's2') + r.sadd('{1}foo', 's3') + r.sadd('{1}bar', 's3') + r.sadd('{1}bar', 's4') + r.sadd('{1}bar', 's5') + assert_equal %w[s3], r.sinter('{1}foo', '{1}bar') + end + + def test_sinterstore + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + + r.sinterstore('baz', 'foo', 'bar') + + assert_equal ['s2'], r.smembers('baz') + end + + def test_variadic_sinterstore_expand + r.sadd('{1}foo', 's1') + r.sadd('{1}foo', 's2') + r.sadd('{1}foo', 's3') + r.sadd('{1}bar', 's3') + r.sadd('{1}bar', 's4') + r.sadd('{1}bar', 's5') + assert_equal 1, r.sinterstore('{1}baz', '{1}foo', '{1}bar') + end + + def test_sunion + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + r.sadd 'bar', 's3' + + assert_equal %w[s1 s2 s3], r.sunion('foo', 'bar').sort + end + + def test_variadic_sunion_expand + r.sadd('{1}foo', 's1') + r.sadd('{1}foo', 's2') + r.sadd('{1}foo', 's3') + r.sadd('{1}bar', 's3') + r.sadd('{1}bar', 's4') + r.sadd('{1}bar', 's5') + assert_equal %w[s1 s2 s3 s4 s5], r.sunion('{1}foo', '{1}bar').sort + end + + def test_sunionstore + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + r.sadd 'bar', 's3' + + r.sunionstore('baz', 'foo', 'bar') + + assert_equal %w[s1 s2 s3], r.smembers('baz').sort + end + + def test_variadic_sunionstore_expand + r.sadd('{1}foo', 's1') + r.sadd('{1}foo', 's2') + r.sadd('{1}foo', 's3') + r.sadd('{1}bar', 's3') + r.sadd('{1}bar', 's4') + r.sadd('{1}bar', 's5') + assert_equal 5, r.sunionstore('{1}baz', '{1}foo', '{1}bar') + end + + def test_sdiff + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + r.sadd 'bar', 's3' + + assert_equal ['s1'], r.sdiff('foo', 'bar') + assert_equal ['s3'], r.sdiff('bar', 'foo') + end + + def test_variadic_sdiff_expand + r.sadd('{1}foo', 's1') + r.sadd('{1}foo', 's2') + r.sadd('{1}foo', 's3') + r.sadd('{1}bar', 's3') + r.sadd('{1}bar', 's4') + r.sadd('{1}bar', 's5') + assert_equal %w[s1 s2], r.sdiff('{1}foo', '{1}bar').sort + end + + def test_sdiffstore + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + r.sadd 'bar', 's3' + + r.sdiffstore('baz', 'foo', 'bar') + + assert_equal ['s1'], r.smembers('baz') + end + + def test_variadic_sdiffstore_expand + r.sadd('{1}foo', 's1') + r.sadd('{1}foo', 's2') + r.sadd('{1}foo', 's3') + r.sadd('{1}bar', 's3') + r.sadd('{1}bar', 's4') + r.sadd('{1}bar', 's5') + assert_equal 2, r.sdiffstore('{1}baz', '{1}foo', '{1}bar') + end + + def test_sscan + r.sadd('foo', %w[1 2 3 foo foobar feelsgood]) + assert_equal %w[0 feelsgood foo foobar], r.sscan('foo', 0, match: 'f*').flatten.sort + end end end diff --git a/test/lint/sorted_sets.rb b/test/lint/sorted_sets.rb index 9dc11e218..47bb7d4dd 100644 --- a/test/lint/sorted_sets.rb +++ b/test/lint/sorted_sets.rb @@ -41,7 +41,8 @@ def test_zadd assert_equal 11.0, r.zadd("foo", 10, "s1", :incr => true) assert_equal(-Infinity, r.zadd("bar", "-inf", "s1", :incr => true)) assert_equal(+Infinity, r.zadd("bar", "+inf", "s2", :incr => true)) - r.del "foo", "bar" + r.del 'foo' + r.del 'bar' # Incompatible options combination assert_raise(Redis::CommandError) { r.zadd("foo", 1, "s1", :xx => true, :nx => true) } @@ -104,7 +105,8 @@ def test_variadic_zadd assert_equal(-Infinity, r.zadd("bar", ["-inf", "s1"], :incr => true)) assert_equal(+Infinity, r.zadd("bar", ["+inf", "s2"], :incr => true)) assert_raise(Redis::CommandError) { r.zadd("foo", [1, "s1", 2, "s2"], :incr => true) } - r.del "foo", "bar" + r.del 'foo' + r.del 'bar' # Incompatible options combination assert_raise(Redis::CommandError) { r.zadd("foo", [1, "s1"], :xx => true, :nx => true) } @@ -312,5 +314,184 @@ def test_zremrangebyscore assert_equal 3, r.zremrangebyscore("foo", 2, 4) assert_equal ["s1"], r.zrange("foo", 0, -1) end + + def test_zpopmax + target_version('4.9.0') do + r.zadd('foo', %w[0 a 1 b 2 c]) + assert_equal %w[c 2], r.zpopmax('foo') + end + end + + def test_zpopmin + target_version('4.9.0') do + r.zadd('foo', %w[0 a 1 b 2 c]) + assert_equal %w[a 0], r.zpopmin('foo') + end + end + + def test_zremrangebylex + r.zadd('foo', %w[0 a 0 b 0 c 0 d 0 e 0 f 0 g]) + assert_equal 5, r.zremrangebylex('foo', '(b', '[g') + end + + def test_zlexcount + target_version '2.8.9' do + r.zadd 'foo', 0, 'aaren' + r.zadd 'foo', 0, 'abagael' + r.zadd 'foo', 0, 'abby' + r.zadd 'foo', 0, 'abbygail' + + assert_equal 4, r.zlexcount('foo', '[a', "[a\xff") + assert_equal 4, r.zlexcount('foo', '[aa', "[ab\xff") + assert_equal 3, r.zlexcount('foo', '(aaren', "[ab\xff") + assert_equal 2, r.zlexcount('foo', '[aba', '(abbygail') + assert_equal 1, r.zlexcount('foo', '(aaren', '(abby') + end + end + + def test_zrangebylex + target_version '2.8.9' do + r.zadd 'foo', 0, 'aaren' + r.zadd 'foo', 0, 'abagael' + r.zadd 'foo', 0, 'abby' + r.zadd 'foo', 0, 'abbygail' + + assert_equal %w[aaren abagael abby abbygail], r.zrangebylex('foo', '[a', "[a\xff") + assert_equal %w[aaren abagael], r.zrangebylex('foo', '[a', "[a\xff", limit: [0, 2]) + assert_equal %w[abby abbygail], r.zrangebylex('foo', '(abb', "(abb\xff") + assert_equal %w[abbygail], r.zrangebylex('foo', '(abby', "(abby\xff") + end + end + + def test_zrevrangebylex + target_version '2.9.9' do + r.zadd 'foo', 0, 'aaren' + r.zadd 'foo', 0, 'abagael' + r.zadd 'foo', 0, 'abby' + r.zadd 'foo', 0, 'abbygail' + + assert_equal %w[abbygail abby abagael aaren], r.zrevrangebylex('foo', "[a\xff", '[a') + assert_equal %w[abbygail abby], r.zrevrangebylex('foo', "[a\xff", '[a', limit: [0, 2]) + assert_equal %w[abbygail abby], r.zrevrangebylex('foo', "(abb\xff", '(abb') + assert_equal %w[abbygail], r.zrevrangebylex('foo', "(abby\xff", '(abby') + end + end + + def test_zcount + r.zadd 'foo', 1, 's1' + r.zadd 'foo', 2, 's2' + r.zadd 'foo', 3, 's3' + + assert_equal 2, r.zcount('foo', 2, 3) + end + + def test_zunionstore + r.zadd 'foo', 1, 's1' + r.zadd 'bar', 2, 's2' + r.zadd 'foo', 3, 's3' + r.zadd 'bar', 4, 's4' + + assert_equal 4, r.zunionstore('foobar', %w[foo bar]) + assert_equal %w[s1 s2 s3 s4], r.zrange('foobar', 0, -1) + end + + def test_zunionstore_with_weights + r.zadd 'foo', 1, 's1' + r.zadd 'foo', 3, 's3' + r.zadd 'bar', 20, 's2' + r.zadd 'bar', 40, 's4' + + assert_equal 4, r.zunionstore('foobar', %w[foo bar]) + assert_equal %w[s1 s3 s2 s4], r.zrange('foobar', 0, -1) + + assert_equal 4, r.zunionstore('foobar', %w[foo bar], weights: [10, 1]) + assert_equal %w[s1 s2 s3 s4], r.zrange('foobar', 0, -1) + end + + def test_zunionstore_with_aggregate + r.zadd 'foo', 1, 's1' + r.zadd 'foo', 2, 's2' + r.zadd 'bar', 4, 's2' + r.zadd 'bar', 3, 's3' + + assert_equal 3, r.zunionstore('foobar', %w[foo bar]) + assert_equal %w[s1 s3 s2], r.zrange('foobar', 0, -1) + + assert_equal 3, r.zunionstore('foobar', %w[foo bar], aggregate: :min) + assert_equal %w[s1 s2 s3], r.zrange('foobar', 0, -1) + + assert_equal 3, r.zunionstore('foobar', %w[foo bar], aggregate: :max) + assert_equal %w[s1 s3 s2], r.zrange('foobar', 0, -1) + end + + def test_zunionstore_expand + r.zadd('{1}foo', %w[0 a 1 b 2 c]) + r.zadd('{1}bar', %w[0 c 1 d 2 e]) + assert_equal 5, r.zunionstore('{1}baz', %w[{1}foo {1}bar]) + end + + def test_zinterstore + r.zadd 'foo', 1, 's1' + r.zadd 'bar', 2, 's1' + r.zadd 'foo', 3, 's3' + r.zadd 'bar', 4, 's4' + + assert_equal 1, r.zinterstore('foobar', %w[foo bar]) + assert_equal ['s1'], r.zrange('foobar', 0, -1) + end + + def test_zinterstore_with_weights + r.zadd 'foo', 1, 's1' + r.zadd 'foo', 2, 's2' + r.zadd 'foo', 3, 's3' + r.zadd 'bar', 20, 's2' + r.zadd 'bar', 30, 's3' + r.zadd 'bar', 40, 's4' + + assert_equal 2, r.zinterstore('foobar', %w[foo bar]) + assert_equal %w[s2 s3], r.zrange('foobar', 0, -1) + + assert_equal 2, r.zinterstore('foobar', %w[foo bar], weights: [10, 1]) + assert_equal %w[s2 s3], r.zrange('foobar', 0, -1) + + assert_equal 40.0, r.zscore('foobar', 's2') + assert_equal 60.0, r.zscore('foobar', 's3') + end + + def test_zinterstore_with_aggregate + r.zadd 'foo', 1, 's1' + r.zadd 'foo', 2, 's2' + r.zadd 'foo', 3, 's3' + r.zadd 'bar', 20, 's2' + r.zadd 'bar', 30, 's3' + r.zadd 'bar', 40, 's4' + + assert_equal 2, r.zinterstore('foobar', %w[foo bar]) + assert_equal %w[s2 s3], r.zrange('foobar', 0, -1) + assert_equal 22.0, r.zscore('foobar', 's2') + assert_equal 33.0, r.zscore('foobar', 's3') + + assert_equal 2, r.zinterstore('foobar', %w[foo bar], aggregate: :min) + assert_equal %w[s2 s3], r.zrange('foobar', 0, -1) + assert_equal 2.0, r.zscore('foobar', 's2') + assert_equal 3.0, r.zscore('foobar', 's3') + + assert_equal 2, r.zinterstore('foobar', %w[foo bar], aggregate: :max) + assert_equal %w[s2 s3], r.zrange('foobar', 0, -1) + assert_equal 20.0, r.zscore('foobar', 's2') + assert_equal 30.0, r.zscore('foobar', 's3') + end + + def test_zinterstore_expand + r.zadd '{1}foo', %w[0 s1 1 s2 2 s3] + r.zadd '{1}bar', %w[0 s3 1 s4 2 s5] + assert_equal 1, r.zinterstore('{1}baz', %w[{1}foo {1}bar], weights: [2.0, 3.0]) + end + + def test_zscan + r.zadd('foo', %w[0 a 1 b 2 c]) + expected = ['0', [['a', 0.0], ['b', 1.0], ['c', 2.0]]] + assert_equal expected, r.zscan('foo', 0) + end end end diff --git a/test/lint/strings.rb b/test/lint/strings.rb index e17eaf7fb..27da22070 100644 --- a/test/lint/strings.rb +++ b/test/lint/strings.rb @@ -1,6 +1,9 @@ module Lint module Strings + def mock(*args, &block) + redis_mock(*args, &block) + end def test_set_and_get r.set("foo", "s1") @@ -242,5 +245,104 @@ def test_strlen assert_equal 5, r.strlen("foo") end + + def test_bitfield + target_version('3.2.0') do + mock(bitfield: ->(*_) { "*2\r\n:1\r\n:0\r\n" }) do |redis| + assert_equal [1, 0], redis.bitfield('foo', 'INCRBY', 'i5', 100, 1, 'GET', 'u4', 0) + end + end + end + + def test_mget + r.set('{1}foo', 's1') + r.set('{1}bar', 's2') + + assert_equal %w[s1 s2], r.mget('{1}foo', '{1}bar') + assert_equal ['s1', 's2', nil], r.mget('{1}foo', '{1}bar', '{1}baz') + end + + def test_mget_mapped + r.set('{1}foo', 's1') + r.set('{1}bar', 's2') + + response = r.mapped_mget('{1}foo', '{1}bar') + + assert_equal 's1', response['{1}foo'] + assert_equal 's2', response['{1}bar'] + + response = r.mapped_mget('{1}foo', '{1}bar', '{1}baz') + + assert_equal 's1', response['{1}foo'] + assert_equal 's2', response['{1}bar'] + assert_equal nil, response['{1}baz'] + end + + def test_mapped_mget_in_a_pipeline_returns_hash + r.set('{1}foo', 's1') + r.set('{1}bar', 's2') + + result = r.pipelined do + r.mapped_mget('{1}foo', '{1}bar') + end + + assert_equal({ '{1}foo' => 's1', '{1}bar' => 's2' }, result[0]) + end + + def test_mset + r.mset('{1}foo', 's1', '{1}bar', 's2') + + assert_equal 's1', r.get('{1}foo') + assert_equal 's2', r.get('{1}bar') + end + + def test_mset_mapped + r.mapped_mset('{1}foo' => 's1', '{1}bar' => 's2') + + assert_equal 's1', r.get('{1}foo') + assert_equal 's2', r.get('{1}bar') + end + + def test_msetnx + r.set('{1}foo', 's1') + assert_equal false, r.msetnx('{1}foo', 's2', '{1}bar', 's3') + assert_equal 's1', r.get('{1}foo') + assert_equal nil, r.get('{1}bar') + + r.del('{1}foo') + assert_equal true, r.msetnx('{1}foo', 's2', '{1}bar', 's3') + assert_equal 's2', r.get('{1}foo') + assert_equal 's3', r.get('{1}bar') + end + + def test_msetnx_mapped + r.set('{1}foo', 's1') + assert_equal false, r.mapped_msetnx('{1}foo' => 's2', '{1}bar' => 's3') + assert_equal 's1', r.get('{1}foo') + assert_equal nil, r.get('{1}bar') + + r.del('{1}foo') + assert_equal true, r.mapped_msetnx('{1}foo' => 's2', '{1}bar' => 's3') + assert_equal 's2', r.get('{1}foo') + assert_equal 's3', r.get('{1}bar') + end + + def test_bitop + with_external_encoding('UTF-8') do + target_version '2.5.10' do + r.set('foo{1}', 'a') + r.set('bar{1}', 'b') + + r.bitop(:and, 'foo&bar{1}', 'foo{1}', 'bar{1}') + assert_equal "\x60", r.get('foo&bar{1}') + r.bitop(:or, 'foo|bar{1}', 'foo{1}', 'bar{1}') + assert_equal "\x63", r.get('foo|bar{1}') + r.bitop(:xor, 'foo^bar{1}', 'foo{1}', 'bar{1}') + assert_equal "\x03", r.get('foo^bar{1}') + r.bitop(:not, '~foo{1}', 'foo{1}') + assert_equal "\x9E", r.get('~foo{1}') + end + end + end end end diff --git a/test/support/cluster/orchestrator.rb b/test/support/cluster/orchestrator.rb new file mode 100644 index 000000000..0b172c1fc --- /dev/null +++ b/test/support/cluster/orchestrator.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +require_relative '../../../lib/redis' + +class ClusterOrchestrator + SLOT_SIZE = 16384 + + def initialize(node_addrs) + raise 'Redis Cluster requires at least 3 master nodes.' if node_addrs.size < 3 + @clients = node_addrs.map { |addr| Redis.new(url: addr) } + end + + def rebuild + flush_all_data(@clients) + reset_cluster(@clients) + assign_slots(@clients) + save_config_epoch(@clients) + meet_each_other(@clients) + wait_meeting(@clients) + replicate(@clients) + save_config(@clients) + wait_cluster_building(@clients) + sleep 3 + end + + def down + flush_all_data(@clients) + reset_cluster(@clients) + end + + def failover + take_slaves(@clients).last.cluster(:failover, :takeover) + sleep 3 + end + + def start_resharding(slot, src_node_key, dest_node_key) + node_map = hashify_node_map(@clients.first) + src_node_id = node_map.fetch(src_node_key) + src_client = find_client(@clients, src_node_key) + dest_node_id = node_map.fetch(dest_node_key) + dest_client = find_client(@clients, dest_node_key) + dest_host, dest_port = dest_node_key.split(':') + + dest_client.cluster(:setslot, slot, 'IMPORTING', src_node_id) + src_client.cluster(:setslot, slot, 'MIGRATING', dest_node_id) + + loop do + keys = src_client.cluster(:getkeysinslot, slot, 100) + break if keys.empty? + keys.each { |k| src_client.migrate(k, host: dest_host, port: dest_port) } + sleep 0.1 + end + end + + def finish_resharding(slot, dest_node_key) + node_map = hashify_node_map(@clients.first) + @clients.first.cluster(:setslot, slot, 'NODE', node_map.fetch(dest_node_key)) + end + + def close + @clients.each(&:quit) + end + + private + + def flush_all_data(clients) + clients.each do |c| + begin + c.flushall + rescue Redis::CommandError + # READONLY You can't write against a read only slave. + nil + end + end + end + + def reset_cluster(clients) + clients.each { |c| c.cluster(:reset) } + end + + def assign_slots(clients) + masters = take_masters(clients) + slot_slice = SLOT_SIZE / masters.size + mod = SLOT_SIZE % masters.size + slot_sizes = Array.new(masters.size, slot_slice) + mod.downto(1) { |i| slot_sizes[i] += 1 } + + slot_idx = 0 + masters.zip(slot_sizes).each do |c, s| + slot_range = slot_idx..slot_idx + s - 1 + c.cluster(:addslots, *slot_range.to_a) + slot_idx += s + end + end + + def save_config_epoch(clients) + clients.each_with_index do |c, i| + begin + c.cluster('set-config-epoch', i + 1) + rescue Redis::CommandError + # ERR Node config epoch is already non-zero + nil + end + end + end + + def meet_each_other(clients) + first_cliient = clients.first + target_info = first_cliient.connection + target_host = target_info.fetch(:host) + target_port = target_info.fetch(:port) + + clients.each do |client| + next if first_cliient.id == client.id + client.cluster(:meet, target_host, target_port) + end + end + + def wait_meeting(clients) + first_cliient = clients.first + size = clients.size + + loop do + info = hashify_cluster_info(first_cliient) + break if info['cluster_known_nodes'].to_i == size + sleep 0.1 + end + end + + def replicate(clients) + node_map = hashify_node_map(clients.first) + masters = take_masters(clients) + + take_slaves(clients).each_with_index do |slave, i| + master_info = masters[i].connection + master_host = master_info.fetch(:host) + master_port = master_info.fetch(:port) + + loop do + begin + master_node_id = node_map.fetch("#{master_host}:#{master_port}") + slave.cluster(:replicate, master_node_id) + rescue Redis::CommandError + # ERR Unknown node [key] + sleep 0.1 + node_map = hashify_node_map(clients.first) + next + end + + break + end + end + end + + def save_config(clients) + clients.each { |c| c.cluster(:saveconfig) } + end + + def wait_cluster_building(clients) + first_cliient = clients.first + + loop do + info = hashify_cluster_info(first_cliient) + break if info['cluster_state'] == 'ok' + sleep 0.1 + end + end + + def hashify_cluster_info(client) + client.cluster(:info).split("\r\n").map { |str| str.split(':') }.to_h + end + + def hashify_node_map(client) + client.cluster(:nodes) + .split("\n") + .map { |str| str.split(' ') } + .map { |arr| [arr[1].split('@').first, arr[0]] } + .to_h + end + + def take_masters(clients) + size = clients.size / 2 + return clients if size < 3 + clients.take(size) + end + + def take_slaves(clients) + size = clients.size / 2 + return [] if size < 3 + clients[size..size * 2] + end + + def find_client(clients, node_key) + clients.find do |cli| + con = cli.connection + node_key == "#{con.fetch(:host)}:#{con.fetch(:port)}" + end + end +end