├── .gitignore ├── .rspec ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── redis_cluster.rb └── redis_cluster │ ├── client.rb │ ├── configuration.rb │ ├── crc16.rb │ ├── errors.rb │ ├── node.rb │ ├── pool.rb │ ├── slot.rb │ └── version.rb ├── redis_cluster.gemspec └── spec ├── client_spec.rb ├── errors_spec.rb ├── node_spec.rb ├── pool_spec.rb ├── redis_cluster_spec.rb ├── slot_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | ## Specific to RubyMotion: 14 | .dat* 15 | .repl_history 16 | build/ 17 | 18 | ## Documentation cache and generated files: 19 | /.yardoc/ 20 | /_yardoc/ 21 | /doc/ 22 | /rdoc/ 23 | 24 | ## Environment normalization: 25 | /.bundle/ 26 | /vendor/bundle 27 | /lib/bundler/man/ 28 | 29 | # for a library or gem, you might want to ignore these files since the code is 30 | # intended to run in multiple environments; otherwise, check them in: 31 | # Gemfile.lock 32 | # .ruby-version 33 | # .ruby-gemset 34 | 35 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 36 | .rvmrc 37 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -f d 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.4.0 4 | - 2.3.3 5 | - 2.2 6 | - 2.1 7 | - 2.0 8 | - jruby 9 | before_install: 10 | - gem update bundler 11 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating 6 | documentation, submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in this project a harassment-free 9 | experience for everyone, regardless of level of experience, gender, gender 10 | identity and expression, sexual orientation, disability, personal appearance, 11 | body size, race, ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | * The use of sexualized language or imagery 16 | * Personal attacks 17 | * Trolling or insulting/derogatory comments 18 | * Public or private harassment 19 | * Publishing other's private information, such as physical or electronic 20 | addresses, without explicit permission 21 | * Other unethical or unprofessional conduct 22 | 23 | Project maintainers have the right and responsibility to remove, edit, or 24 | reject comments, commits, code, wiki edits, issues, and other contributions 25 | that are not aligned to this Code of Conduct, or to ban temporarily or 26 | permanently any contributor for other behaviors that they deem inappropriate, 27 | threatening, offensive, or harmful. 28 | 29 | By adopting this Code of Conduct, project maintainers commit themselves to 30 | fairly and consistently applying these principles to every aspect of managing 31 | this project. Project maintainers who do not follow or enforce the Code of 32 | Conduct may be permanently removed from the project team. 33 | 34 | This code of conduct applies both within project spaces and in public spaces 35 | when an individual is representing the project or its community. 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 38 | reported by contacting a project maintainer at wangzhichao@caishuo.com. All 39 | complaints will be reviewed and investigated and will result in a response that 40 | is deemed necessary and appropriate to the circumstances. Maintainers are 41 | obligated to maintain confidentiality with regard to the reporter of an 42 | incident. 43 | 44 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 45 | version 1.3.0, available at 46 | [http://contributor-covenant.org/version/1/3/0/][version] 47 | 48 | [homepage]: http://contributor-covenant.org 49 | [version]: http://contributor-covenant.org/version/1/3/0/ -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in redis_cluster.gemspec 4 | gemspec 5 | 6 | group :development do 7 | platforms :mri do 8 | if RUBY_VERSION >= "2.0.0" 9 | gem "pry-byebug" 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 sam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RedisCluster 2 | 3 | ![travis ci](https://travis-ci.org/zhchsf/redis_cluster.svg?branch=master) 4 | 5 | Support: Ruby 2.0+ 6 | 7 | Redis Cluster is a Redis configuration that allows data to be automatically 8 | sharded across a number of different nodes. You can find its main documentation 9 | at https://redis.io/topics/cluster-tutorial. 10 | 11 | [`redis-rb`](https://github.com/redis/redis-rb), the most common Redis gem for 12 | Ruby, doesn't offer Redis Cluster support. This gem works in conjunction with 13 | redis-rb to add the missing functionality. It's based on [antirez's prototype 14 | reference implementation](https://github.com/antirez/redis-rb-cluster) (which 15 | is not maintained). 16 | 17 | ## Installation 18 | 19 | Add it to your `Gemfile`: 20 | 21 | ```ruby 22 | gem 'redis_cluster' 23 | ``` 24 | 25 | ## Usage 26 | 27 | Initialize `RedisCluster` with an array of Redis Cluster host nodes: 28 | 29 | ```ruby 30 | rs = RedisCluster.new([ 31 | {host: '127.0.0.1', port: 7000}, 32 | {host: '127.0.0.1', port: 7001} 33 | ]) 34 | rs.set "test", 1 35 | rs.get "test" 36 | ``` 37 | 38 | The library will issue the `CLUSTER SLOTS` command to configured hosts it it 39 | receives a `MOVED` response, so it's safe to configure it with only a subset of 40 | the total nodes in the cluster. 41 | 42 | ### Other options 43 | 44 | Most options are forwarded onto underlying `Redis` clients. If for example 45 | `masterauth` and `requirepass` are enabled, the password can be set like this: 46 | 47 | ```ruby 48 | RedisCluster.new(hosts, password: 'password') 49 | ``` 50 | 51 | ### Standalone Redis 52 | 53 | If initialized with a host hash instead of an array, the library will assume 54 | that it's operating on a standalone Redis, and cluster functionality will be 55 | disabled: 56 | 57 | ```ruby 58 | rs = RedisCluster.new({host: '127.0.0.1', port: 7000}) 59 | ``` 60 | 61 | When configured with an array of hosts the library normally requires that they 62 | be part of a Redis Cluster, but that check can be disabled by setting 63 | `force_cluster: false`. This may be useful for development or test environments 64 | where a full cluster isn't available, but where a standalone Redis will do just 65 | as well. 66 | 67 | ```ruby 68 | rs = RedisCluster.new([ 69 | {host: '127.0.0.1', port: 7000}, 70 | ], force_cluster: false) 71 | ``` 72 | 73 | ### Logging 74 | 75 | A logger can be specified with the `logger` option. It should be compatible 76 | with the interface of Ruby's `Logger` from the standard library. 77 | 78 | ```ruby 79 | require 'logger' 80 | logger = Logger.new(STDOUT) 81 | logger.level = Logger::WARN 82 | RedisCluster.new(hosts, logger: logger) 83 | ``` 84 | 85 | ### `KEYS` 86 | 87 | The `KEYS` command will scan all nodes: 88 | 89 | ```ruby 90 | rs.keys 'test*' 91 | ``` 92 | 93 | ### Pipelining, `MULTI` 94 | 95 | There is limited support for pipelining and `MULTI`: 96 | 97 | ```ruby 98 | rs.pipelined do 99 | rs.set "{foo}one", 1 100 | rs.set "{foo}two", 2 101 | end 102 | ``` 103 | 104 | Note that all keys used in a pipeline must map to the same Redis node. This is 105 | possible through the use of Redis Cluster "hash tags" where only the section of 106 | a key name wrapped in `{}` when calculating a key's hash. 107 | 108 | #### `EVAL`, `EVALSHA`, `SCRIPT` 109 | 110 | `EVAL` and `EVALSHA` must only rely on keys that map to a single slot (again, 111 | possible with hash tags). `KEYS` should be used to retrieve keys in Lua 112 | scripts. 113 | 114 | ```ruby 115 | rs.eval "return redis.call('get', KEYS[1]) + ARGV[1]", [:test], [3] 116 | rs.evalsha '727fc2fb7c0f11ec134d998654e3dadaacf31a97', [:test], [5] 117 | 118 | # Even if a Lua script doesn't need any keys or argvs, you'll still need to 119 | specify a dummy key. 120 | rs.eval "return 'hello redis!'", [:foo] 121 | ``` 122 | 123 | `SCRIPT` commands will run on all nodes: 124 | 125 | ```ruby 126 | # script commands will run on all nodes 127 | rs.script :load, "return redis.call('get', KEYS[1])" 128 | rs.script :exists, '4e6d8fc8bb01276962cce5371fa795a7763657ae' 129 | rs.script :flush 130 | ``` 131 | 132 | ## Development 133 | 134 | Clone the repository and then install dependencies: 135 | 136 | ```sh 137 | bin/setup 138 | ``` 139 | 140 | Run tests: 141 | 142 | ```sh 143 | rake spec 144 | ``` 145 | 146 | `bin/console` will bring up an interactive prompt for other experimentation. 147 | 148 | ### Releases 149 | 150 | To release a new version, update the version number in `version.rb` and run 151 | `bundle exec rake release`. This will create a Git tag for the version, push 152 | Git commits and tags to GitHub, and push the `.gem` file to Rubygems. 153 | 154 | The gem can be installed locally with `bundle exec rake install`. 155 | 156 | ### Benchmark test 157 | 158 | ```ruby 159 | Benchmark.bm do |x| 160 | x.report do 161 | 1.upto(100_000).each do |i| 162 | redis.set "test#{i}", i 163 | end 164 | end 165 | x.report do 166 | 1.upto(100_000).each do |i| 167 | redis.get "test#{i}" 168 | end 169 | end 170 | x.report do 171 | 1.upto(100_000).each do |i| 172 | redis.del "test#{i}" 173 | end 174 | end 175 | end 176 | ``` 177 | 178 | ## Contributing 179 | 180 | Bug reports and pull requests are welcome on GitHub. This project is intended 181 | to be a safe, welcoming space for collaboration, and contributors are expected 182 | to adhere to the [Contributor Covenant](http://contributor-covenant.org) code 183 | of conduct. 184 | 185 | ## License 186 | 187 | The gem is available as open source under the terms of the [MIT 188 | License](http://opensource.org/licenses/MIT). 189 | 190 | 193 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "redis_cluster" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/redis_cluster.rb: -------------------------------------------------------------------------------- 1 | require "redis_cluster/version" 2 | require "redis" 3 | 4 | module RedisCluster 5 | 6 | class << self 7 | 8 | # startup_hosts examples: 9 | # [{host: 'xxx', port: 'xxx'}, {host: 'xxx', port: 'xxx'}, ...] 10 | # global_configs: 11 | # options for redis: password, ... 12 | def new(startup_hosts, global_configs = {}) 13 | @client = Client.new(startup_hosts, global_configs) 14 | end 15 | 16 | end 17 | 18 | end 19 | 20 | require "redis_cluster/configuration" 21 | require "redis_cluster/client" 22 | require "redis_cluster/node" 23 | require "redis_cluster/pool" 24 | require "redis_cluster/slot" 25 | require "redis_cluster/crc16" 26 | require "redis_cluster/errors" 27 | -------------------------------------------------------------------------------- /lib/redis_cluster/client.rb: -------------------------------------------------------------------------------- 1 | require 'thread' 2 | 3 | module RedisCluster 4 | 5 | class Client 6 | 7 | def initialize(hosts, configs = {}) 8 | @hosts = hosts.dup 9 | @initial_hosts = hosts.dup 10 | 11 | # Extract configuration options relevant to Redis Cluster. 12 | 13 | # force_cluster defaults to true to match the client's behavior before 14 | # the option existed 15 | @force_cluster = configs.delete(:force_cluster) { |_key| true } 16 | 17 | # An optional logger. Should respond like the standard Ruby `Logger`: 18 | # 19 | # http://ruby-doc.org/stdlib-2.4.0/libdoc/logger/rdoc/Logger.html 20 | @logger = configs.delete(:logger) { |_key| nil } 21 | 22 | # The number of times to retry when it detects a failure that looks like 23 | # it might be intermittent. 24 | # 25 | # It might be worth setting this to `0` if you'd like full visibility 26 | # around what kinds of errors are occurring. Possibly in conjunction with 27 | # your own out-of-library retry loop and/or circuit breaker. 28 | @retry_count = configs.delete(:retry_count) { |_key| 2 } 29 | 30 | # Any leftover configuration goes through to the pool and onto individual 31 | # Redis clients. 32 | @pool = Pool.new(configs) 33 | @mutex = Mutex.new 34 | 35 | reload_pool_nodes 36 | end 37 | 38 | def execute(method, args, &block) 39 | asking = false 40 | retried = false 41 | 42 | # Note that there are two levels of retry loops here. 43 | # 44 | # The first is for intermittent failures like "host unreachable" or 45 | # timeouts. These are retried a number of times equal to @retry_count. 46 | # 47 | # The second is when it receives an `ASK` or `MOVED` error response from 48 | # Redis. In this case the client will complete re-enter its execution 49 | # loop and retry the command after any necessary prework (if `MOVED`, it 50 | # will attempt to reload the node pool first). This will only ever be 51 | # retried one time (see notes below). This loop uses Ruby's `retry` 52 | # syntax for blocks, so keep an eye out for that in the code below. 53 | # 54 | # It's worth noting that if these conditions ever combine, you could see 55 | # more network attempts than @retry_count. An initial execution attempt 56 | # might fail intermittently a couple times before sending a `MOVED`. The 57 | # client will then attempt to reload the node pool, an operation which is 58 | # also retried for intermittent failures. It could then return to the 59 | # main execution and fail another couple of times intermittently. This 60 | # should be an extreme edge case, but it's worth considering if you're 61 | # running at large scale. 62 | begin 63 | retry_intermittent_loop do |attempt| 64 | # Getting an error while executing may be an indication that we've 65 | # lost the node that we were talking to and in that case it makes 66 | # sense to try a different node and maybe reload our node pool (if 67 | # the new node issues a `MOVE`). 68 | try_random_node = attempt > 0 69 | 70 | return @pool.execute(method, args, {asking: asking, random_node: try_random_node}, &block) 71 | end 72 | rescue Redis::CommandError => e 73 | unless @logger.nil? 74 | @logger.error("redis_cluster: Received error: #{e}") 75 | end 76 | 77 | # This is a special condition to protect against a misbehaving library 78 | # or server. After we've gotten one ASK or MOVED and retried once, 79 | # we'll never do so a second time. Receiving two of any operations in a 80 | # row is probably indicative of a problem and we don't want to get 81 | # stuck in an infinite retry loop. 82 | raise if retried 83 | retried = true 84 | 85 | err_code = e.to_s.split.first 86 | case err_code 87 | when 'ASK' 88 | unless @logger.nil? 89 | @logger.info("redis_cluster: Received ASK; retrying operation (#{e})") 90 | end 91 | 92 | asking = true 93 | retry 94 | 95 | when 'MOVED' 96 | unless @logger.nil? 97 | @logger.info("redis_cluster: Received MOVED; retrying operation (#{e})") 98 | end 99 | 100 | # `MOVED` indicates a permanent redirect which means that our slot 101 | # mappings are stale: reload them then try what we were doing again 102 | reload_pool_nodes 103 | retry 104 | 105 | else 106 | raise 107 | end 108 | end 109 | end 110 | 111 | Configuration.method_names.each do |method_name| 112 | define_method method_name do |*args, &block| 113 | execute(method_name, args, &block) 114 | end 115 | end 116 | 117 | def method_missing(method, *args, &block) 118 | execute(method, args, &block) 119 | end 120 | 121 | # Add default argument to keys to match redis client interface 122 | def keys(glob = "*", &block) 123 | execute("keys", [glob], &block) 124 | end 125 | 126 | # Closes all open connections and reloads the client pool. 127 | # 128 | # Normally host information from the last time the node pool was reloaded 129 | # is used, but if the `use_initial_hosts` is set to `true`, then the client 130 | # is completely refreshed and the hosts that were specified when creating 131 | # it originally are set instead. 132 | def reconnect(options = {}) 133 | use_initial_hosts = options.fetch(:use_initial_hosts, false) 134 | 135 | @hosts = @initial_hosts.dup if use_initial_hosts 136 | 137 | @mutex.synchronize do 138 | @pool.nodes.each{|node| node.connection.close} 139 | @pool.nodes.clear 140 | reload_pool_nodes_unsync 141 | end 142 | end 143 | 144 | private 145 | 146 | # Adds only a single node to the client pool and sets it result for the 147 | # entire space of slots. This is useful when running either a standalone 148 | # Redis or a single-node Redis Cluster. 149 | def create_single_node_pool 150 | host = @hosts 151 | if host.is_a?(Array) 152 | if host.length > 1 153 | raise ArgumentError, "Can only create single node pool for single host" 154 | end 155 | 156 | # Flatten the configured host so that we can easily add it to the 157 | # client pool. 158 | host = host.first 159 | end 160 | 161 | @pool.add_node!(host, [(0..Configuration::HASH_SLOTS)]) 162 | 163 | unless @logger.nil? 164 | @logger.info("redis_cluster: Initialized single node pool: #{host}") 165 | end 166 | end 167 | 168 | def create_multi_node_pool 169 | unless @hosts.is_a?(Array) 170 | raise ArgumentError, "Can only create multi-node pool for multiple hosts" 171 | end 172 | 173 | begin 174 | retry_intermittent_loop do |attempt| 175 | # Try a random host from our seed pool. 176 | options = @hosts.sample 177 | 178 | redis = Node.redis(@pool.global_configs.merge(options)) 179 | slots_mapping = redis.cluster("slots").group_by{|x| x[2]} 180 | @pool.delete_except!(slots_mapping.keys) 181 | slots_mapping.each do |host, infos| 182 | slots_ranges = infos.map {|x| x[0]..x[1] } 183 | @pool.add_node!({host: host[0], port: host[1]}, slots_ranges) 184 | end 185 | end 186 | rescue Redis::CommandError => e 187 | unless @logger.nil? 188 | @logger.error("redis_cluster: Received error: #{e}") 189 | end 190 | 191 | if e.message =~ /cluster\ support\ disabled$/ && !@force_cluster 192 | # We're running outside of cluster-mode -- just create a single-node 193 | # pool and move on. The exception is if we've been asked for force 194 | # Redis Cluster, in which case we assume this is a configuration 195 | # problem and maybe raise an error. 196 | create_single_node_pool 197 | return 198 | end 199 | 200 | raise 201 | end 202 | 203 | unless @logger.nil? 204 | mappings = @pool.nodes.map{|node| "#{node.slots} -> #{node.options}"} 205 | @logger.info("redis_cluster: Initialized multi-node pool: #{mappings}") 206 | end 207 | end 208 | 209 | # Reloads the client node pool by requesting new information with `CLUSTER 210 | # SLOTS` or just adding a node directly if running on standalone. Clients 211 | # are "upserted" so that we don't necessarily drop clients that are still 212 | # relevant. 213 | def reload_pool_nodes 214 | @mutex.synchronize do 215 | reload_pool_nodes_unsync 216 | end 217 | end 218 | 219 | # The same as `#reload_pool_nodes`, but doesn't attempt to synchronize on 220 | # the mutex. Use this only if you've already got a lock on it. 221 | def reload_pool_nodes_unsync 222 | if @hosts.is_a?(Array) 223 | create_multi_node_pool 224 | refresh_startup_nodes 225 | else 226 | create_single_node_pool 227 | end 228 | end 229 | 230 | # Refreshes the contents of @hosts based on the hosts currently in 231 | # the client pool. This is useful because we may have been told about new 232 | # hosts after running `CLUSTER SLOTS`. 233 | def refresh_startup_nodes 234 | @pool.nodes.each {|node| @hosts.push(node.host_hash) } 235 | @hosts.uniq! 236 | end 237 | 238 | # Retries an operation @retry_count times for intermittent connection 239 | # errors. After exhausting retries, the error that was received on the last 240 | # attempt is raised to the user. 241 | def retry_intermittent_loop 242 | last_error = nil 243 | 244 | for attempt in 0..(@retry_count) do 245 | begin 246 | yield(attempt) 247 | 248 | # Fall through on any success. 249 | return 250 | rescue Errno::EACCES, Redis::TimeoutError, Redis::CannotConnectError => e 251 | last_error = e 252 | 253 | unless @logger.nil? 254 | @logger.error("redis_cluster: Received error: #{e} retries_left=#{@retry_count - attempt}") 255 | end 256 | end 257 | end 258 | 259 | # If we ran out of retries (the maximum number may have been set to 0), 260 | # surface any error that was thrown back to the caller. We'd otherwise 261 | # suppress the error, which would return something quite unexpected. 262 | raise last_error 263 | end 264 | 265 | end # end client 266 | 267 | end 268 | -------------------------------------------------------------------------------- /lib/redis_cluster/configuration.rb: -------------------------------------------------------------------------------- 1 | module RedisCluster 2 | 3 | class Configuration 4 | HASH_SLOTS = 16384 5 | DEFAULT_TIMEOUT = 1 6 | 7 | SUPPORT_SINGLE_NODE_METHODS = %w( 8 | persist expire expireat ttl pexpire pexpireat pttl dump restore del exists 9 | move type decr decrby incr incrby incrbyfloat set setex psetex setnx get 10 | setrange getrange setbit getbit append bitcount bitpos getset strlen [] 11 | []= llen lpush lpushx rpush rpushx lpop rpop blpop brpop lindex linsert 12 | lrange lrem lset ltrim scard sadd srem spop srandmember sismember smembers 13 | zcard zadd zincrby zrem zscore zrange zrevrange zrank zrevrank 14 | zremrangebyrank zrangebyscore zrevrangebyscore zremrangebyscore zcount 15 | hlen hset hsetnx hmset mapped_hmset hget hmget mapped_hmget hdel hexists 16 | hincrby hincrbyfloat hkeys hvals hgetall publish pfadd 17 | ).freeze 18 | 19 | SUPPORT_MULTI_NODE_METHODS = %w(keys script multi pipelined scan).freeze 20 | 21 | def self.method_names 22 | SUPPORT_SINGLE_NODE_METHODS + SUPPORT_MULTI_NODE_METHODS 23 | end 24 | end 25 | 26 | end 27 | -------------------------------------------------------------------------------- /lib/redis_cluster/crc16.rb: -------------------------------------------------------------------------------- 1 | # From: antirez's redis-rb-cluster 2 | # 3 | # Copyright (C) 2013 Salvatore Sanfilippo 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | # 24 | # ----------------------------------------------------------------------------- 25 | # 26 | # This is the CRC16 algorithm used by Redis Cluster to hash keys. 27 | # Implementation according to CCITT standards. 28 | # 29 | # This is actually the XMODEM CRC 16 algorithm, using the 30 | # following parameters: 31 | # 32 | # Name : "XMODEM", also known as "ZMODEM", "CRC-16/ACORN" 33 | # Width : 16 bit 34 | # Poly : 1021 (That is actually x^16 + x^12 + x^5 + 1) 35 | # Initialization : 0000 36 | # Reflect Input byte : False 37 | # Reflect Output CRC : False 38 | # Xor constant to output CRC : 0000 39 | # Output for "123456789" : 31C3 40 | 41 | module RedisCluster 42 | 43 | module CRC16 44 | 45 | def self.crc16(bytes) 46 | crc = 0 47 | bytes.each_byte{|b| 48 | crc = ((crc<<8) & 0xffff) ^ XMODEMCRC16Lookup[((crc>>8)^b) & 0xff] 49 | } 50 | crc 51 | end 52 | 53 | XMODEMCRC16Lookup = [ 54 | 0x0000,0x1021,0x2042,0x3063,0x4084,0x50a5,0x60c6,0x70e7, 55 | 0x8108,0x9129,0xa14a,0xb16b,0xc18c,0xd1ad,0xe1ce,0xf1ef, 56 | 0x1231,0x0210,0x3273,0x2252,0x52b5,0x4294,0x72f7,0x62d6, 57 | 0x9339,0x8318,0xb37b,0xa35a,0xd3bd,0xc39c,0xf3ff,0xe3de, 58 | 0x2462,0x3443,0x0420,0x1401,0x64e6,0x74c7,0x44a4,0x5485, 59 | 0xa56a,0xb54b,0x8528,0x9509,0xe5ee,0xf5cf,0xc5ac,0xd58d, 60 | 0x3653,0x2672,0x1611,0x0630,0x76d7,0x66f6,0x5695,0x46b4, 61 | 0xb75b,0xa77a,0x9719,0x8738,0xf7df,0xe7fe,0xd79d,0xc7bc, 62 | 0x48c4,0x58e5,0x6886,0x78a7,0x0840,0x1861,0x2802,0x3823, 63 | 0xc9cc,0xd9ed,0xe98e,0xf9af,0x8948,0x9969,0xa90a,0xb92b, 64 | 0x5af5,0x4ad4,0x7ab7,0x6a96,0x1a71,0x0a50,0x3a33,0x2a12, 65 | 0xdbfd,0xcbdc,0xfbbf,0xeb9e,0x9b79,0x8b58,0xbb3b,0xab1a, 66 | 0x6ca6,0x7c87,0x4ce4,0x5cc5,0x2c22,0x3c03,0x0c60,0x1c41, 67 | 0xedae,0xfd8f,0xcdec,0xddcd,0xad2a,0xbd0b,0x8d68,0x9d49, 68 | 0x7e97,0x6eb6,0x5ed5,0x4ef4,0x3e13,0x2e32,0x1e51,0x0e70, 69 | 0xff9f,0xefbe,0xdfdd,0xcffc,0xbf1b,0xaf3a,0x9f59,0x8f78, 70 | 0x9188,0x81a9,0xb1ca,0xa1eb,0xd10c,0xc12d,0xf14e,0xe16f, 71 | 0x1080,0x00a1,0x30c2,0x20e3,0x5004,0x4025,0x7046,0x6067, 72 | 0x83b9,0x9398,0xa3fb,0xb3da,0xc33d,0xd31c,0xe37f,0xf35e, 73 | 0x02b1,0x1290,0x22f3,0x32d2,0x4235,0x5214,0x6277,0x7256, 74 | 0xb5ea,0xa5cb,0x95a8,0x8589,0xf56e,0xe54f,0xd52c,0xc50d, 75 | 0x34e2,0x24c3,0x14a0,0x0481,0x7466,0x6447,0x5424,0x4405, 76 | 0xa7db,0xb7fa,0x8799,0x97b8,0xe75f,0xf77e,0xc71d,0xd73c, 77 | 0x26d3,0x36f2,0x0691,0x16b0,0x6657,0x7676,0x4615,0x5634, 78 | 0xd94c,0xc96d,0xf90e,0xe92f,0x99c8,0x89e9,0xb98a,0xa9ab, 79 | 0x5844,0x4865,0x7806,0x6827,0x18c0,0x08e1,0x3882,0x28a3, 80 | 0xcb7d,0xdb5c,0xeb3f,0xfb1e,0x8bf9,0x9bd8,0xabbb,0xbb9a, 81 | 0x4a75,0x5a54,0x6a37,0x7a16,0x0af1,0x1ad0,0x2ab3,0x3a92, 82 | 0xfd2e,0xed0f,0xdd6c,0xcd4d,0xbdaa,0xad8b,0x9de8,0x8dc9, 83 | 0x7c26,0x6c07,0x5c64,0x4c45,0x3ca2,0x2c83,0x1ce0,0x0cc1, 84 | 0xef1f,0xff3e,0xcf5d,0xdf7c,0xaf9b,0xbfba,0x8fd9,0x9ff8, 85 | 0x6e17,0x7e36,0x4e55,0x5e74,0x2e93,0x3eb2,0x0ed1,0x1ef0 86 | ].freeze 87 | 88 | end # end CRC16 89 | 90 | end 91 | -------------------------------------------------------------------------------- /lib/redis_cluster/errors.rb: -------------------------------------------------------------------------------- 1 | module RedisCluster 2 | class CommandNotSupportedError < StandardError 3 | def initialize(command) 4 | super("Command #{command} is not supported for Redis Cluster") 5 | end 6 | end 7 | 8 | class KeysNotAtSameSlotError < StandardError 9 | def initialize(keys) 10 | super("Keys must map to the same Redis Cluster slot when using " \ 11 | "EVAL/EVALSHA. Consider using Redis Cluster 'hash tags' (see " \ 12 | "documentation). Keys: #{keys}") 13 | end 14 | end 15 | 16 | class KeysNotSpecifiedError < StandardError 17 | def initialize(command) 18 | super("Keys must be specified for command #{command}") 19 | end 20 | end 21 | 22 | # These error classes were renamed. These aliases are here for backwards 23 | # compatibility. 24 | KeyNotAppointError = KeysNotSpecifiedError 25 | NotSupportError = CommandNotSupportedError 26 | end 27 | -------------------------------------------------------------------------------- /lib/redis_cluster/node.rb: -------------------------------------------------------------------------------- 1 | module RedisCluster 2 | 3 | class Node 4 | attr_accessor :options 5 | 6 | # slots is a range array: [1..100, 300..500] 7 | attr_accessor :slots 8 | 9 | # 10 | # basic requires: 11 | # {host: xxx.xxx.xx.xx, port: xxx} 12 | # redis cluster don't support select db, use default 0 13 | # 14 | def initialize(opts) 15 | @options = opts 16 | @slots = [] 17 | end 18 | 19 | def name 20 | "#{@options[:host]}:#{@options[:port]}" 21 | end 22 | 23 | def host_hash 24 | {host: @options[:host], port: @options[:port]} 25 | end 26 | 27 | def has_slot?(slot) 28 | slots.any? {|range| range.include? slot } 29 | end 30 | 31 | def asking 32 | execute(:asking) 33 | end 34 | 35 | def execute(method, args, &block) 36 | connection.public_send(method, *args, &block) 37 | end 38 | 39 | def connection 40 | @connection ||= self.class.redis(options) 41 | end 42 | 43 | def self.redis(options) 44 | default_options = {timeout: Configuration::DEFAULT_TIMEOUT, driver: 'hiredis'.freeze} 45 | ::Redis.new(default_options.merge(options)) 46 | end 47 | 48 | end # end Node 49 | 50 | end 51 | -------------------------------------------------------------------------------- /lib/redis_cluster/pool.rb: -------------------------------------------------------------------------------- 1 | module RedisCluster 2 | 3 | class Pool 4 | attr_reader :nodes, :global_configs 5 | 6 | def initialize(global_configs = {}) 7 | @nodes = [] 8 | @global_configs = global_configs 9 | end 10 | 11 | # TODO: type check 12 | def add_node!(node_options, slots) 13 | new_node = Node.new(global_configs.merge(node_options)) 14 | node = @nodes.find { |n| n.name == new_node.name } || new_node 15 | node.slots = slots 16 | @nodes.push(node).uniq! 17 | end 18 | 19 | def delete_except!(master_hosts) 20 | names = master_hosts.map { |host, port| "#{host}:#{port}" } 21 | @nodes.delete_if { |n| !names.include?(n.name) } 22 | end 23 | 24 | # other_options: 25 | # asking 26 | # random_node 27 | def execute(method, args, other_options, &block) 28 | return send(method, args, &block) if Configuration::SUPPORT_MULTI_NODE_METHODS.include?(method.to_s) 29 | 30 | key = key_by_command(method, args) 31 | raise CommandNotSupportedError.new(method.upcase) if key.nil? 32 | 33 | node = other_options[:random_node] ? random_node : node_by(key) 34 | node.asking if other_options[:asking] 35 | node.execute(method, args, &block) 36 | end 37 | 38 | def keys(args, &block) 39 | glob = args.first 40 | on_each_node(:keys, glob).flatten 41 | end 42 | 43 | def script(args, &block) 44 | on_each_node(:script, *args).flatten 45 | end 46 | 47 | # Now mutli & pipelined conmand must control keys at same slot yourself 48 | # You can use hash tag: '{foo}1' 49 | def multi(args, &block) 50 | random_node.execute :multi, args, &block 51 | end 52 | 53 | def pipelined(args, &block) 54 | random_node.execute :pipelined, args, &block 55 | end 56 | 57 | # Implements scan across all nodes in the pool. 58 | # Cursors will behave strangely if the node list changes during iteration. 59 | def scan(args) 60 | scan_cursor = args.first 61 | options = args[1] || {} 62 | node_cursor, node_index = decode_scan_cursor(scan_cursor) 63 | next_node_cursor, result = @nodes[node_index].execute("scan", [node_cursor, options]) 64 | [encode_next_scan_cursor(next_node_cursor, node_index), result] 65 | end 66 | 67 | private 68 | 69 | def encode_next_scan_cursor(next_node_cursor, node_index) 70 | if next_node_cursor == '0' 71 | # '0' indicates the end of iteration. Advance the node index and 72 | # start at the '0' position on the next node. If this was the last node, 73 | # loop around and return '0' to indicate iteration is done. 74 | ((node_index + 1) % @nodes.size) 75 | else 76 | ((next_node_cursor.to_i * @nodes.size) + node_index) 77 | end.to_s # Cursors are strings 78 | end 79 | 80 | def decode_scan_cursor(cursor) 81 | node_cursor, node_index = cursor.to_i.divmod(@nodes.size) 82 | [node_cursor.to_s, node_index] # Cursors are strings. 83 | end 84 | 85 | def node_by(key) 86 | slot = Slot.slot_by(key) 87 | @nodes.find { |node| node.has_slot?(slot) } 88 | end 89 | 90 | def random_node 91 | @nodes.sample 92 | end 93 | 94 | def key_by_command(method, args) 95 | case method.to_s.downcase 96 | when 'info', 'exec', 'slaveof', 'config', 'shutdown' 97 | nil 98 | when 'eval', 'evalsha' 99 | if args[1].nil? || args[1].empty? 100 | raise KeysNotSpecifiedError.new(method.upcase) 101 | end 102 | 103 | unless Slot.at_one?(args[1]) 104 | raise KeysNotAtSameSlotError.new(args[1]) 105 | end 106 | 107 | return args[1][0] 108 | else 109 | return args.first 110 | end 111 | end 112 | 113 | def on_each_node(method, *args) 114 | @nodes.map do |node| 115 | node.execute(method, args) 116 | end 117 | end 118 | 119 | end # end pool 120 | 121 | end 122 | -------------------------------------------------------------------------------- /lib/redis_cluster/slot.rb: -------------------------------------------------------------------------------- 1 | module RedisCluster 2 | 3 | class Slot 4 | KEY_PATTERN = /\{([^\}]*)\}/ 5 | 6 | # hash tag key "{xxx}ooo" will calculate "xxx" for slot 7 | # if key is "{}dddd", calculate "{}dddd" for slot 8 | def self.slot_by(key) 9 | key = key.to_s 10 | KEY_PATTERN =~ key 11 | key = $1 if $1 && !$1.empty? 12 | CRC16.crc16(key) % Configuration::HASH_SLOTS 13 | end 14 | 15 | # check if keys at same slot 16 | def self.at_one?(keys) 17 | keys.map { |k| slot_by(k) }.uniq.size == 1 18 | end 19 | 20 | end # end Slot 21 | 22 | end 23 | -------------------------------------------------------------------------------- /lib/redis_cluster/version.rb: -------------------------------------------------------------------------------- 1 | module RedisCluster 2 | VERSION = "0.3.2" 3 | end 4 | -------------------------------------------------------------------------------- /redis_cluster.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'redis_cluster/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "redis_cluster" 8 | spec.version = RedisCluster::VERSION 9 | spec.authors = ["wangzc"] 10 | spec.email = ["zhchsf@gmail.com"] 11 | 12 | spec.summary = %q{redis cluster client} 13 | spec.description = %q{redis cluster client} 14 | spec.homepage = "http://www.iruby.com.cn" 15 | spec.license = "MIT" 16 | 17 | # Prevent pushing this gem to RubyGems.org by setting 'allowed_push_host', or 18 | # delete this section to allow pushing this gem to any host. 19 | if spec.respond_to?(:metadata) 20 | spec.metadata['allowed_push_host'] = "https://rubygems.org" 21 | else 22 | raise "RubyGems 2.0 or newer is required to protect against public gem pushes." 23 | end 24 | 25 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 26 | spec.bindir = "exe" 27 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 28 | spec.require_paths = ["lib"] 29 | 30 | spec.add_dependency "redis", "~> 3.2" 31 | spec.add_dependency "hiredis", "~> 0.6" 32 | spec.add_development_dependency "bundler", "~> 1.11" 33 | spec.add_development_dependency "rake", "~> 10.0" 34 | spec.add_development_dependency "rspec", "~> 3.0" 35 | spec.add_development_dependency "pry" 36 | end 37 | -------------------------------------------------------------------------------- /spec/client_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "client" do 4 | let(:pool) {@redis.instance_variable_get("@pool")} 5 | let(:pool_nodes) {pool.nodes} 6 | let(:pool_hosts) {pool_nodes.map{|n| n.host_hash[:host]}} 7 | let(:pool_ports) {pool_nodes.map{|n| n.host_hash[:port]}} 8 | 9 | before do 10 | cluster_nodes = [ 11 | [1000, 5460, ["127.0.0.1", 7003], ["127.0.0.1", 7000]], 12 | [0, 999, ["127.0.0.1", 7006], ["127.0.0.1", 7007]], 13 | [10923, 16383, ["127.0.0.1", 7002], ["127.0.0.1", 7005]], 14 | [5461, 10922, ["127.0.0.1", 7004], ["127.0.0.1", 7001]] 15 | ] 16 | allow_any_instance_of(Redis).to receive(:cluster).and_return(cluster_nodes) 17 | 18 | hosts = [{host: '127.0.0.1', port: '7000'}] 19 | @redis = RedisCluster::Client.new(hosts) 20 | end 21 | 22 | context "standalone Redis" do 23 | it "initializes single node pool" do 24 | # Note that unlike many examples, this `host` is a Hash instead of an 25 | # array which directly indicates to the gem that we want a standalone 26 | # Redis. 27 | host = {host: '127.0.0.1', port: '7000'} 28 | 29 | @redis = RedisCluster::Client.new(host) 30 | expect(pool_hosts).to eq(["127.0.0.1"]) 31 | expect(pool_ports).to eq(["7000"]) 32 | end 33 | end 34 | 35 | context "nodes auto detect" do 36 | it "will get host 127.0.0.1 in pool" do 37 | expect(pool_hosts).to include "127.0.0.1" 38 | end 39 | 40 | it "will get master port 7003 7006 7002 7004" do 41 | [7003, 7006, 7002, 7004].each do |port| 42 | expect(pool_ports).to include port 43 | end 44 | end 45 | end 46 | 47 | context "failover" do 48 | before :each do 49 | key_slot_map = {a: 15495, b: 3300, c: 7365, d: 11298, e: 15363, f: 3168} 50 | @value = "ok wang" 51 | 52 | cluster_nodes = [ 53 | [1000, 5460, ["127.0.0.1", 7003], ["127.0.0.1", 7000]], 54 | [0, 999, ["127.0.0.1", 7006], ["127.0.0.1", 7007]], 55 | [15001, 16383, ["127.0.0.1", 7006], ["127.0.0.1", 7007]], 56 | [10923, 15000, ["127.0.0.1", 7002], ["127.0.0.1", 7005]], 57 | [5461, 10922, ["127.0.0.1", 7004], ["127.0.0.1", 7001]] 58 | ] 59 | allow_any_instance_of(Redis).to receive(:cluster).and_return(cluster_nodes) 60 | 61 | redis_7002 = double("7002") 62 | allow(redis_7002).to receive(:get).and_raise(Redis::CommandError.new("MOVED 15495 127.0.0.1:7006")) 63 | @redis.instance_variable_get("@pool").nodes.find {|node| node.instance_variable_get("@options")[:port] == 7002 }.instance_variable_set("@connection", redis_7002) 64 | 65 | redis_7006 = double("7006") 66 | allow(redis_7006).to receive(:get).and_return(@value) 67 | @redis.instance_variable_get("@pool").nodes.find {|node| node.instance_variable_get("@options")[:port] == 7006 }.instance_variable_set("@connection", redis_7006) 68 | end 69 | 70 | it "redetect nodes and get right redis value" do 71 | expect(@redis.get("a")).to eq @value 72 | 73 | node_7006 = pool_nodes.find {|node| node.instance_variable_get("@options")[:port] == 7006 } 74 | expect(node_7006.has_slot? 15495).to be_truthy 75 | end 76 | end 77 | 78 | describe "multi nodes command" do 79 | context "keys with 'test*'" do 80 | before :each do 81 | [[7002, []], [7003, ['test111', 'test222']], [7004, []], [7006, ['test333']]].each do |port , values| 82 | redis_obj = double(port) 83 | allow(redis_obj).to receive(:keys).and_return(values) 84 | pool_nodes.find {|node| node.instance_variable_get("@options")[:port] == port }.instance_variable_set("@connection", redis_obj) 85 | end 86 | @keys = @redis.keys "test*" 87 | end 88 | 89 | it "has 3 keys" do 90 | expect(@keys.length).to eq 3 91 | end 92 | 93 | it "include all node keys" do 94 | ['test111', 'test222', 'test333'].each do |key| 95 | expect(@keys).to include key 96 | end 97 | end 98 | end 99 | end 100 | 101 | describe "errors" do 102 | context "node cluster support disabled" do 103 | before do 104 | error = Redis::CommandError.new('ERR This instance has cluster support disabled') 105 | allow_any_instance_of(Redis).to receive(:cluster).and_raise(error) 106 | end 107 | 108 | it "raise Redis::CommandError" do 109 | hosts = [{host: '127.0.0.1', port: '7000'}] 110 | expect{ RedisCluster::Client.new(hosts) }.to raise_error Redis::CommandError 111 | end 112 | 113 | it "initializes single node pool when force_cluster is false" do 114 | hosts = [{host: '127.0.0.1', port: '7000'}] 115 | @redis = RedisCluster::Client.new(hosts, force_cluster: false) 116 | expect(pool_hosts).to eq(["127.0.0.1"]) 117 | expect(pool_ports).to eq(["7000"]) 118 | end 119 | 120 | it "retries intermittent errors" do 121 | cluster_nodes = [ 122 | [0, RedisCluster::Configuration::HASH_SLOTS, ["127.0.0.1", 7000]], 123 | ] 124 | allow_any_instance_of(Redis).to receive(:cluster).and_return(cluster_nodes) 125 | 126 | hosts = [{host: '127.0.0.1', port: '7000'}] 127 | @redis = RedisCluster::Client.new(hosts, retry_count: 1) 128 | 129 | num_invocations = 0 130 | redis_double = double("Redis connection") 131 | allow(redis_double).to receive(:get) do 132 | num_invocations += 1 133 | raise Redis::CannotConnectError if num_invocations == 1 134 | "b" 135 | end 136 | @redis.instance_variable_get("@pool").nodes. 137 | each {|node| node.instance_variable_set(:@connection, redis_double)} 138 | 139 | expect(@redis.get("a")).to eq("b") 140 | end 141 | 142 | it "reraises errors to user after running out of retries" do 143 | cluster_nodes = [ 144 | [0, RedisCluster::Configuration::HASH_SLOTS, ["127.0.0.1", 7000]], 145 | ] 146 | allow_any_instance_of(Redis).to receive(:cluster).and_return(cluster_nodes) 147 | 148 | hosts = [{host: '127.0.0.1', port: '7000'}] 149 | @redis = RedisCluster::Client.new(hosts, retry_count: 0) 150 | 151 | redis_double = double("Redis connection") 152 | allow(redis_double).to receive(:get).and_raise(Redis::CannotConnectError) 153 | @redis.instance_variable_get("@pool").nodes. 154 | each {|node| node.instance_variable_set(:@connection, redis_double)} 155 | 156 | expect{ @redis.get("a") }.to raise_error Redis::CannotConnectError 157 | end 158 | end 159 | end 160 | 161 | describe "#reconnect" do 162 | it "reconnects clients" do 163 | # Expect every connection to receive a close 164 | pool_nodes.each do |node| 165 | expect(node.connection).to receive(:close) 166 | end 167 | 168 | @redis.reconnect 169 | 170 | # When reconnecting, the client will reuse the hosts that it received 171 | # from `CLUSTER SLOTS`. We therefore expect the full range of ports 172 | # instead of just the ones that we configured originally. 173 | [7003, 7006, 7002, 7004].each do |port| 174 | expect(pool_ports).to include port 175 | end 176 | end 177 | 178 | it "reconnects clients and uses original hosts configuration" do 179 | # Expect every connection to receive a close 180 | pool_nodes.each do |node| 181 | expect(node.connection).to receive(:close) 182 | end 183 | 184 | # Currently the client is already loaded with the full set of hosts that 185 | # stubbed in the global `before` block. Here we restub `Redis` to only 186 | # return a single host instead. The client will get this result when it 187 | # reconnects, and that allows us to verify that it's indeed doing a new 188 | # lookup instead of reusing its previously existing set of hosts. 189 | cluster_nodes = [ 190 | [0, RedisCluster::Configuration::HASH_SLOTS, ["127.0.0.1", "7000"]], 191 | ] 192 | allow_any_instance_of(Redis).to receive(:cluster).and_return(cluster_nodes) 193 | 194 | @redis.reconnect(use_initial_hosts: true) 195 | 196 | # When reconnecting, the client will reuse the hosts that it received 197 | # from `CLUSTER SLOTS`. We therefore expect the full range of ports 198 | # instead of just the ones that we configured originally. 199 | expect(pool_hosts).to eq(["127.0.0.1"]) 200 | expect(pool_ports).to eq(["7000"]) 201 | end 202 | end 203 | 204 | describe "keys" do 205 | it "supports a default argument" do 206 | allow(pool).to receive(:execute).with("keys", ["*"], {asking: false, random_node: false}).and_return(["abc", "def"]) 207 | result = @redis.keys 208 | expect(result).to eq(["abc", "def"]) 209 | end 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /spec/errors_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | # These tests are extremely trivial (purposely so because it gives us a little 4 | # more flexibility around changing error messages), but are here to demonstrate 5 | # that these exceptions can be initialized without error and also shows their 6 | # basic usage. 7 | describe "errors" do 8 | describe RedisCluster::CommandNotSupportedError do 9 | it "initializes" do 10 | RedisCluster::CommandNotSupportedError.new("GET") 11 | end 12 | end 13 | 14 | describe RedisCluster::KeysNotAtSameSlotError do 15 | it "initializes" do 16 | RedisCluster::KeysNotAtSameSlotError.new(["foo", "bar"]) 17 | end 18 | end 19 | 20 | describe RedisCluster::KeysNotSpecifiedError do 21 | it "initializes" do 22 | RedisCluster::KeysNotSpecifiedError.new("GET") 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/node_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "node" do 4 | subject { RedisCluster::Node.new(host: '127.0.0.1', port: 6379) } 5 | 6 | shared_examples "slots include" do |slot| 7 | it "has slot #{slot}" do 8 | expect(subject.has_slot? slot).to be_truthy 9 | end 10 | end 11 | 12 | shared_examples "slots exclude" do |slot| 13 | it "has not slot #{slot}" do 14 | expect(subject.has_slot? slot).to_not be_truthy 15 | end 16 | end 17 | 18 | context "basic infos" do 19 | 20 | it "have a name" do 21 | expect(subject.name).to eq "127.0.0.1:6379" 22 | end 23 | 24 | end 25 | 26 | context "slots" do 27 | before :each do 28 | subject.slots = [1..100, 200..300] 29 | end 30 | 31 | it_behaves_like "slots include", 10 32 | 33 | it_behaves_like "slots include", 290 34 | 35 | it_behaves_like "slots exclude", 110 36 | end 37 | 38 | context "redis connection" do 39 | it "has a redis connection" do 40 | expect(subject.connection.class.name).to eq "Redis" 41 | end 42 | 43 | it "should allow the default options to be overridden" do 44 | other_node = RedisCluster::Node.new(host: '127.0.0.1', port: 6379, timeout: 20, driver: 'ruby') 45 | 46 | connection_options = other_node.connection.instance_variable_get("@options") 47 | 48 | expect(connection_options[:timeout]).to eq(20) 49 | expect(connection_options[:driver]).to eq('ruby') 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/pool_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "pool" do 4 | before :each do 5 | @pool = RedisCluster::Pool.new 6 | end 7 | 8 | shared_examples "slots include" do |slot| 9 | it "has slot #{slot}" do 10 | expect( @pool.nodes.any? {|n| n.has_slot? slot} ).to be_truthy 11 | end 12 | end 13 | 14 | shared_examples "slots exclude" do |slot| 15 | it "has not slot #{slot}" do 16 | expect( @pool.nodes.any? {|n| n.has_slot? slot} ).to_not be_truthy 17 | end 18 | end 19 | 20 | describe "nodes operations" do 21 | let(:node_size) { @pool.nodes.size } 22 | 23 | before :each do 24 | nodes = [ 25 | [{host: '127.0.0.1', port: 7000}, [1..1000]], 26 | [{host: '127.0.0.1', port: 7001}, [1001..2000]] 27 | ] 28 | nodes.each do |node, slots| 29 | @pool.add_node!(node, slots) 30 | end 31 | end 32 | 33 | describe "#add_node!" do 34 | context "when add exist host and same slots" do 35 | before :each do 36 | @pool.add_node!({host: '127.0.0.1', port: 7000}, [1..1000]) 37 | end 38 | 39 | it "has 2 nodes" do 40 | expect(node_size).to eq 2 41 | end 42 | 43 | it_behaves_like "slots include", 888 44 | 45 | it_behaves_like "slots exclude", 8888 46 | end 47 | 48 | context "when add exist host but more slots" do 49 | before :each do 50 | @pool.add_node!({host: '127.0.0.1', port: 7000}, [1..1000, 2001..3001]) 51 | end 52 | 53 | it "has 2 nodes" do 54 | expect(node_size).to eq 2 55 | end 56 | 57 | it_behaves_like "slots include", 111 58 | 59 | it_behaves_like "slots include", 2111 60 | 61 | it_behaves_like "slots include", 1888 62 | 63 | it_behaves_like "slots exclude", 3888 64 | end 65 | 66 | context "when add new host" do 67 | before :each do 68 | @pool.add_node!({host: '127.0.0.1', port: 7002}, [4001..5000]) 69 | end 70 | 71 | it "has 3 nodes" do 72 | expect(node_size).to eq 3 73 | end 74 | 75 | it_behaves_like "slots include", 5000 76 | 77 | it_behaves_like "slots exclude", 5555 78 | end 79 | end 80 | 81 | describe "#delete_except!" do 82 | before :each do 83 | now_master_hosts = [['127.0.0.1', 7000], ['127.0.0.1', 7003]] 84 | @pool.delete_except!(now_master_hosts) 85 | end 86 | 87 | it "has 1 nodes" do 88 | expect(node_size).to eq 1 89 | end 90 | 91 | it_behaves_like "slots include", 888 92 | 93 | it_behaves_like "slots exclude", 1888 94 | end 95 | 96 | describe "scan" do 97 | # First iteration, finds one element from the first node and reports there is more to scan... 98 | it "returns an expanded cursor for the same node when scan returns a nonzero value" do 99 | allow(@pool.nodes.first).to receive(:execute).with("scan", ["0", {}]).and_return(["2", ["abc"]]) 100 | 101 | cursor, keys = @pool.execute(:scan, ["0", {}], {}) 102 | 103 | # There are two nodes in the pool, so the scan cursor is doubled... 104 | expect(cursor).to eq "4" 105 | expect(keys).to eq ["abc"] 106 | end 107 | 108 | # Second iteration, using the cursor from before, returns the last element from the first node... 109 | it "returns a cursor that points to the next node when scan returns a zero value" do 110 | allow(@pool.nodes.first).to receive(:execute).with("scan", ["2", {}]).and_return(["0", ["def"]]) 111 | 112 | cursor, keys = @pool.execute(:scan, ["4", {}], {}) 113 | 114 | # Hitting the zero element on the second pool. 115 | expect(cursor).to eq "1" 116 | expect(keys).to eq ["def"] 117 | end 118 | 119 | # Third iteration, using the previously returned cursor. Returns an element from the second node and reports more to scan 120 | it "selects the correct node based on the passed cursor" do 121 | allow(@pool.nodes.last).to receive(:execute).with("scan", ["0", {}]).and_return(["1", ["ghi"]]) 122 | 123 | cursor, keys = @pool.execute(:scan, ["1", {}], {}) 124 | 125 | # Hitting the zero element on the second pool. 126 | expect(cursor).to eq "3" 127 | expect(keys).to eq ["ghi"] 128 | end 129 | 130 | # Third iteration, using the previously returned cursor. Returns the last element from the second node 131 | it "reports zero when the last element on the last node is scanned" do 132 | allow(@pool.nodes.last).to receive(:execute).with("scan", ["1", {}]).and_return(["0", ["jkl"]]) 133 | 134 | cursor, keys = @pool.execute(:scan, ["3", {}], {}) 135 | 136 | # Hitting the zero element on the second pool. 137 | expect(cursor).to eq "0" 138 | expect(keys).to eq ["jkl"] 139 | end 140 | end 141 | end 142 | 143 | end 144 | -------------------------------------------------------------------------------- /spec/redis_cluster_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RedisCluster do 4 | it 'has a version number' do 5 | expect(RedisCluster::VERSION).not_to be nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/slot_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "slot calculater" do 4 | context "when has { and } in the key" do 5 | it "would be equal with key 'test' and '{test}xxxx' " do 6 | r1 = RedisCluster::Slot.slot_by("test") 7 | r2 = RedisCluster::Slot.slot_by("{test}xxxx") 8 | expect(r1).to eq r2 9 | end 10 | 11 | it "would not equal with key '{test}xxx' and '{tes}t}xxx' " do 12 | r1 = RedisCluster::Slot.slot_by("{test}xxx") 13 | r2 = RedisCluster::Slot.slot_by("{tes}t}xxx") 14 | expect(r1).to_not eq r2 15 | end 16 | 17 | it "would use all for key when blank between {}" do 18 | r1 = RedisCluster::Slot.slot_by("{}xxx") 19 | r2 = RedisCluster::Slot.slot_by("") 20 | expect(r1).to_not eq r2 21 | end 22 | end 23 | end -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require_relative "../lib/redis_cluster" 3 | #require 'pry' 4 | --------------------------------------------------------------------------------