diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..5ace4600a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 000000000..226a27d8f --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,246 @@ +--- +name: Test +on: + push: + branches: + - "*" + pull_request: + branches: + - "*" +jobs: + lint: + name: Rubocop + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.2" + bundler-cache: true + - name: Lint + run: bundle exec rubocop + + rubies: + name: Ruby + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + ruby: ["3.2", "3.1", "3.0", "2.7", "2.6", "2.5", "jruby-9.3.6.0"] + runs-on: ubuntu-latest + env: + LOW_TIMEOUT: "0.01" + REDIS_BRANCH: "7.2" + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Print environment variables + run: | + echo "TIMEOUT=${TIMEOUT}" + echo "LOW_TIMEOUT=${LOW_TIMEOUT}" + echo "DRIVER=${DRIVER}" + echo "REDIS_BRANCH=${REDIS_BRANCH}" + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Cache local temporary directory + uses: actions/cache@v4 + with: + path: tmp + key: "local-tmp-redis-7.0-on-ubuntu-latest" + - name: Booting up Redis + run: make start + - name: Test + run: bundle exec rake test:redis test:distributed + - name: Shutting down Redis + run: make stop + + truffle: + name: TruffleRuby + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + suite: ["redis", "distributed"] + runs-on: ubuntu-latest + env: + LOW_TIMEOUT: "0.01" + REDIS_BRANCH: "7.2" + TRUFFLERUBYOPT: "--engine.Mode=latency" + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Print environment variables + run: | + echo "TIMEOUT=${TIMEOUT}" + echo "LOW_TIMEOUT=${LOW_TIMEOUT}" + echo "DRIVER=${DRIVER}" + echo "REDIS_BRANCH=${REDIS_BRANCH}" + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: truffleruby + bundler-cache: true + - name: Cache local temporary directory + uses: actions/cache@v4 + with: + path: tmp + key: "local-tmp-redis-7.0-on-ubuntu-latest" + - name: Booting up Redis + run: make start + - name: Test + run: bundle exec rake test:${{ matrix.suite }} + - name: Shutting down Redis + run: make stop + + drivers: + name: Driver + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + driver: ["hiredis"] + runs-on: ubuntu-latest + env: + LOW_TIMEOUT: "0.01" + DRIVER: ${{ matrix.driver }} + REDIS_BRANCH: "7.2" + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Print environment variables + run: | + echo "TIMEOUT=${TIMEOUT}" + echo "LOW_TIMEOUT=${LOW_TIMEOUT}" + echo "DRIVER=${DRIVER}" + echo "REDIS_BRANCH=${REDIS_BRANCH}" + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "2.5" + bundler-cache: true + - name: Cache local temporary directory + uses: actions/cache@v4 + with: + path: tmp + key: "local-tmp-redis-7.0-on-ubuntu-latest" + - name: Booting up Redis + run: make start + - name: Test + run: bundle exec rake test:redis test:distributed + - name: Shutting down Redis + run: make stop + + redises: + name: Redis + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + redis: ["7.0", "6.2", "6.0", "5.0"] + runs-on: ubuntu-latest + env: + LOW_TIMEOUT: "0.14" + REDIS_BRANCH: ${{ matrix.redis }} + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Print environment variables + run: | + echo "TIMEOUT=${TIMEOUT}" + echo "LOW_TIMEOUT=${LOW_TIMEOUT}" + echo "DRIVER=${DRIVER}" + echo "REDIS_BRANCH=${REDIS_BRANCH}" + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "2.5" + bundler-cache: true + - name: Cache local temporary directory + uses: actions/cache@v4 + with: + path: tmp + key: "local-tmp-redis-${{ matrix.redis }}-on-ubuntu-latest" + - name: Booting up Redis + run: make start + - name: Test + run: bundle exec rake test:redis test:distributed + - name: Shutting down Redis + run: make stop + + sentinel: + name: Sentinel + timeout-minutes: 15 + strategy: + fail-fast: false + runs-on: ubuntu-latest + env: + LOW_TIMEOUT: "0.14" + REDIS_BRANCH: "7.0" + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Print environment variables + run: | + echo "TIMEOUT=${TIMEOUT}" + echo "LOW_TIMEOUT=${LOW_TIMEOUT}" + echo "DRIVER=${DRIVER}" + echo "REDIS_BRANCH=${REDIS_BRANCH}" + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "2.5" + bundler-cache: true + - name: Cache local temporary directory + uses: actions/cache@v4 + with: + path: tmp + key: "local-tmp-redis-7.0-on-ubuntu-latest" + - name: Booting up Redis + run: make start_sentinel wait_for_sentinel + - name: Test + run: bundle exec rake test:sentinel + - name: Shutting down Redis + run: make stop_all + + cluster: + name: Cluster + timeout-minutes: 15 + strategy: + fail-fast: false + runs-on: ubuntu-latest + env: + TIMEOUT: "15" + LOW_TIMEOUT: "0.14" + REDIS_BRANCH: "7.2" + BUNDLE_GEMFILE: cluster/Gemfile + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Print environment variables + run: | + echo "TIMEOUT=${TIMEOUT}" + echo "LOW_TIMEOUT=${LOW_TIMEOUT}" + echo "DRIVER=${DRIVER}" + echo "REDIS_BRANCH=${REDIS_BRANCH}" + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "2.7" + bundler-cache: true + - name: Cache local temporary directory + uses: actions/cache@v4 + with: + path: tmp + key: "local-tmp-redis-7.0-on-ubuntu-latest" + - name: Booting up Redis + run: make start start_cluster create_cluster + - name: Test + run: bundle exec rake test:cluster + - name: Shutting down Redis + run: make stop_all diff --git a/.gitignore b/.gitignore index 9f5963972..19ff57df0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ Gemfile.lock /tmp/ /.idea /.yardoc +/.bundle +/cluster/.bundle /coverage/* /doc/ /examples/sentinel/sentinel.conf @@ -14,3 +16,8 @@ Gemfile.lock /redis/* /test/db /test/test.conf +appendonly.aof +appendonlydir +temp-rewriteaof-*.aof +.history + diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 000000000..bc508cf25 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,160 @@ +inherit_from: .rubocop_todo.yml + +AllCops: + TargetRubyVersion: 2.5 + +Layout/LineLength: + Max: 120 + Exclude: + - 'test/**/*' + +Layout/CaseIndentation: + EnforcedStyle: end + +Lint/RescueException: + Enabled: false + +Lint/SuppressedException: + Enabled: false + +Lint/AssignmentInCondition: + Enabled: false + +Lint/UnifiedInteger: + Enabled: false + +Lint/UnderscorePrefixedVariableName: + Enabled: false + +Lint/MissingSuper: + Enabled: false + +Metrics/ClassLength: + Enabled: false + +Metrics/CyclomaticComplexity: + Enabled: false + +Metrics/AbcSize: + Enabled: false + +Metrics/BlockLength: + Enabled: false + +Metrics/MethodLength: + Enabled: false + +Metrics/ModuleLength: + Enabled: false + +Metrics/ParameterLists: + Enabled: false + +Metrics/PerceivedComplexity: + Enabled: false + +Style/PercentLiteralDelimiters: + Enabled: false + +Style/TrailingCommaInArrayLiteral: + Enabled: false + +Style/TrailingCommaInArguments: + Enabled: false + +Style/ParallelAssignment: + Enabled: false + +Style/NumericPredicate: + Enabled: false + +Style/IfUnlessModifier: + Enabled: false + +Style/MutableConstant: + Enabled: false # false positives + +Style/SignalException: + Exclude: + - 'lib/redis/connection/synchrony.rb' + +Style/StringLiterals: + Enabled: false + +Style/DoubleNegation: + Enabled: false + +Style/MultipleComparison: + Enabled: false + +Style/GuardClause: + Enabled: false + +Style/Semicolon: + Enabled: false + +Style/Documentation: + Enabled: false + +Style/FormatStringToken: + Enabled: false + +Style/FormatString: + Enabled: false + +Style/RescueStandardError: + Enabled: false + +Style/WordArray: + Enabled: false + +Lint/NonLocalExitFromIterator: + Enabled: false + +Layout/EndAlignment: + EnforcedStyleAlignWith: variable + +Layout/ElseAlignment: + Enabled: false + +Layout/RescueEnsureAlignment: + Enabled: false + +Naming/HeredocDelimiterNaming: + Enabled: false + +Naming/VariableNumber: + Enabled: false + +Naming/FileName: + Enabled: false + +Naming/RescuedExceptionsVariableName: + Enabled: false + +Naming/AccessorMethodName: + Exclude: + - lib/redis/connection/ruby.rb + +Naming/MethodParameterName: + Enabled: false + +Metrics/BlockNesting: + Enabled: false + +Style/HashTransformValues: + Enabled: false + +Style/TrailingCommaInHashLiteral: + Enabled: false + +Style/SymbolProc: + Exclude: + - 'test/**/*' + +Bundler/OrderedGems: + Enabled: false + +Gemspec/RequiredRubyVersion: + Exclude: + - cluster/redis-clustering.gemspec diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 000000000..85908a89a --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,38 @@ +# This configuration was generated by +# `rubocop --auto-gen-config` +# on 2022-01-08 23:15:30 UTC using RuboCop version 1.11.0. +# The point is for the user to remove these configuration records +# one by one as the offenses are removed from the code base. +# Note that changes in the inspected code, or installation of new +# versions of RuboCop, may require this file to be generated again. + +# Offense count: 2 +Lint/HashCompareByIdentity: + Exclude: + - 'lib/redis.rb' + +# Offense count: 1 +# Cop supports --auto-correct. +Lint/RedundantStringCoercion: + Exclude: + - 'examples/consistency.rb' + +# Offense count: 2 +# Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers. +# SupportedStyles: snake_case, normalcase, non_integer +# AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339 +Naming/VariableNumber: + Exclude: + - 'test/remote_server_control_commands_test.rb' + +# Offense count: 6 +# Configuration parameters: AllowedMethods. +# AllowedMethods: respond_to_missing? +Style/OptionalBooleanParameter: + Exclude: + - 'lib/redis.rb' + - 'lib/redis/client.rb' + - 'lib/redis/cluster.rb' + - 'lib/redis/cluster/node.rb' + - 'lib/redis/cluster/slot.rb' + - 'lib/redis/pipeline.rb' diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 438d89596..000000000 --- a/.travis.yml +++ /dev/null @@ -1,89 +0,0 @@ -language: ruby - -rvm: - - 1.8.7 - - 1.9.3 - - 2.0 - - 2.1 - - 2.2 - - 2.3.0 - - jruby-18mode - - jruby-19mode - - jruby-9.0.5.0 - - rbx-2 - -gemfile: ".travis/Gemfile" - -sudo: false - -env: - global: - - VERBOSE=true - - TIMEOUT=1 - matrix: - - conn=ruby REDIS_BRANCH=3.0 - - conn=ruby REDIS_BRANCH=3.2 - - conn=hiredis REDIS_BRANCH=3.0 - - conn=hiredis REDIS_BRANCH=3.2 - - conn=synchrony REDIS_BRANCH=3.0 - - conn=synchrony REDIS_BRANCH=3.2 - - conn=ruby REDIS_BRANCH=unstable - -branches: - only: - - master - -matrix: - exclude: - # hiredis - - rvm: jruby-18mode - gemfile: .travis/Gemfile - env: conn=hiredis REDIS_BRANCH=3.0 - - rvm: jruby-18mode - gemfile: .travis/Gemfile - env: conn=hiredis REDIS_BRANCH=3.2 - - rvm: jruby-19mode - gemfile: .travis/Gemfile - env: conn=hiredis REDIS_BRANCH=3.0 - - rvm: jruby-19mode - gemfile: .travis/Gemfile - env: conn=hiredis REDIS_BRANCH=3.2 - - rvm: jruby-9.0.5.0 - gemfile: .travis/Gemfile - env: conn=hiredis REDIS_BRANCH=3.0 - - rvm: jruby-9.0.5.0 - gemfile: .travis/Gemfile - env: conn=hiredis REDIS_BRANCH=3.2 - - # synchrony - - rvm: 1.8.7 - gemfile: .travis/Gemfile - env: conn=synchrony REDIS_BRANCH=3.0 - - rvm: 1.8.7 - gemfile: .travis/Gemfile - env: conn=synchrony REDIS_BRANCH=3.2 - - rvm: jruby-18mode - gemfile: .travis/Gemfile - env: conn=synchrony REDIS_BRANCH=3.0 - - rvm: jruby-18mode - gemfile: .travis/Gemfile - env: conn=synchrony REDIS_BRANCH=3.2 - - rvm: jruby-19mode - gemfile: .travis/Gemfile - env: conn=synchrony REDIS_BRANCH=3.0 - - rvm: jruby-19mode - gemfile: .travis/Gemfile - env: conn=synchrony REDIS_BRANCH=3.2 - - rvm: jruby-9.0.5.0 - gemfile: .travis/Gemfile - env: conn=synchrony REDIS_BRANCH=3.0 - - rvm: jruby-9.0.5.0 - gemfile: .travis/Gemfile - env: conn=synchrony REDIS_BRANCH=3.2 - allow_failures: - - rvm: rbx-2 - -notifications: - irc: - - irc.freenode.net#redis-rb - email: false diff --git a/.travis/Gemfile b/.travis/Gemfile deleted file mode 100644 index 3fd116321..000000000 --- a/.travis/Gemfile +++ /dev/null @@ -1,11 +0,0 @@ -source "https://rubygems.org" - -gemspec :path => "../" - -case ENV["conn"] -when "hiredis" - gem "hiredis" -when "synchrony" - gem "hiredis" - gem "em-synchrony" -end diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e10f7ca2..184381e2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,304 @@ -# 4.x (unreleased) +# Unreleased -## Planned breaking changes: -* `Redis#client` will no longer expose the underlying `Redis::Client`; - it has not yet been determined how 4.0 will expose the underlying - functionality, but we will make every attempt to provide a final minor - release of 3.x that provides the new interfaces in order to facilitate - a smooth transition. +# 5.0.8 -* Ruby 1.8.7 (and the 1.8 modes of JRuby and Rubinius) will no longer be - supported; 1.8.x entered end-of-life in June of 2012 and stopped receiving - security updates in June of 2013; continuing to support it would prevent - the use of newer features of Ruby. +- Fix `Redis#without_reconnect` for sentinel clients. Fix #1212. +- Add `sentinel_username`, `sentinel_password` for sentinel clients. Bump `redis-client` to `>=0.17.0`. See #1213 + +# 5.0.7 + +- Fix compatibility with `redis-client 0.15.0` when using Redis Sentinel. Fix #1209. + +# 5.0.6 + +- Wait for an extra `config.read_timeout` in blocking commands rather than an arbitrary 100ms. See #1175. +- Treat ReadOnlyError as ConnectionError. See #1168. + +# 5.0.5 + +- Fix automatic disconnection when the process was forked. See #1157. + +# 5.0.4 + +- Cast `ttl` argument to integer in `expire`, `setex` and a few others. + +# 5.0.3 + +- Add `OutOfMemoryError` as a subclass of `CommandError` + +# 5.0.2 + +- Fix `Redis#close` to properly reset the fork protection check. + +# 5.0.1 + +- Added a fake `Redis::Connections.drivers` method to be compatible with older sidekiq versions. + +# 5.0.0 + +- Default client timeout decreased from 5 seconds to 1 second. +- Eagerly and strictly cast Integer and Float parameters. +- Allow to call `subscribe`, `unsubscribe`, `psubscribe` and `punsubscribe` from a subscribed client. See #1131. +- Use `MD5` for hashing server nodes in `Redis::Distributed`. This should improve keys distribution among servers. See #1089. +- Changed `sadd` and `srem` to now always return an Integer. +- Added `sadd?` and `srem?` which always return a Boolean. +- Added support for `IDLE` paramter in `xpending`. +- Cluster support has been moved to a `redis-clustering` companion gem. +- `select` no longer record the current database. If the client has to reconnect after `select` was used, it will reconnect to the original database. +- Better support Float timeout in blocking commands. See #977. +- `Redis.new` will now raise an error if provided unknown options. +- Removed positional timeout in blocking commands (`BLPOP`, etc). Timeout now must be passed as an option: `r.blpop("key", timeout: 2.5)` +- Removed `logger` option. +- Removed `reconnect_delay_max` and `reconnect_delay`, you can pass precise sleep durations to `reconnect_attempts` instead. +- Require Ruby 2.5+. +- Removed the deprecated `queue` and `commit` methods. Use `pipelined` instead. +- Removed the deprecated `Redis::Future#==`. +- Removed the deprecated `pipelined` and `multi` signature. Commands now MUST be called on the block argument, not the original redis instance. +- Removed `Redis.current`. You shouldn't assume there is a single global Redis connection, use a connection pool instead, + and libaries using Redis should accept a Redis instance (or connection pool) as a config. E.g. `MyLibrary.redis = Redis.new(...)`. +- Removed the `synchrony` driver. +- Removed `Redis.exists_returns_integer`, it's now always enabled. + +# 4.8.1 + +* Automatically reconnect after fork regardless of `reconnect_attempts` + +# 4.8.0 + +* Introduce `sadd?` and `srem?` as boolean returning versions of `sadd` and `srem`. +* Deprecate `sadd` and `srem` returning a boolean when called with a single argument. + To enable the redis 5.0 behavior you can set `Redis.sadd_returns_boolean = false`. +* Deprecate passing `timeout` as a positional argument in blocking commands (`brpop`, `blop`, etc). + +# 4.7.1 + +* Gracefully handle OpenSSL 3.0 EOF Errors (`OpenSSL::SSL::SSLError: SSL_read: unexpected eof while reading`). See #1106 + This happens frequently on heroku-22. + +# 4.7.0 + +* Support single endpoint architecture with SSL/TLS in cluster mode. See #1086. +* `zrem` and `zadd` act as noop when provided an empty list of keys. See #1097. +* Support IPv6 URLs. +* Add `Redis#with` for better compatibility with `connection_pool` usage. +* Fix the block form of `multi` called inside `pipelined`. Previously the `MUTLI/EXEC` wouldn't be sent. See #1073. + +# 4.6.0 + +* Deprecate `Redis.current`. +* Deprecate calling commands on `Redis` inside `Redis#pipelined`. See #1059. + ```ruby + redis.pipelined do + redis.get("key") + end + ``` + + should be replaced by: + + ```ruby + redis.pipelined do |pipeline| + pipeline.get("key") + end + ``` +* Deprecate calling commands on `Redis` inside `Redis#multi`. See #1059. + ```ruby + redis.multi do + redis.get("key") + end + ``` + + should be replaced by: + + ```ruby + redis.multi do |transaction| + transaction.get("key") + end + ``` +* Deprecate `Redis#queue` and `Redis#commit`. See #1059. + +* Fix `zpopmax` and `zpopmin` when called inside a pipeline. See #1055. +* `Redis#synchronize` is now private like it should always have been. + +* Add `Redis.silence_deprecations=` to turn off deprecation warnings. + If you don't wish to see warnings yet, you can set `Redis.silence_deprecations = true`. + It is however heavily recommended to fix them instead when possible. +* Add `Redis.raise_deprecations=` to turn deprecation warnings into errors. + This makes it easier to identitify the source of deprecated APIs usage. + It is recommended to set `Redis.raise_deprecations = true` in development and test environments. +* Add new options to ZRANGE. See #1053. +* Add ZRANGESTORE command. See #1053. +* Add SCAN support for `Redis::Cluster`. See #1049. +* Add COPY command. See #1053. See #1048. +* Add ZDIFFSTORE command. See #1046. +* Add ZDIFF command. See #1044. +* Add ZUNION command. See #1042. +* Add HRANDFIELD command. See #1040. + +# 4.5.1 + +* Restore the accidential auth behavior of redis-rb 4.3.0 with a warning. If provided with the `default` user's password, but a wrong username, + redis-rb will first try to connect as the provided user, but then will fallback to connect as the `default` user with the provided password. + This behavior is deprecated and will be removed in Redis 4.6.0. Fix #1038. + +# 4.5.0 + +* Handle parts of the command using incompatible encodings. See #1037. +* Add GET option to SET command. See #1036. +* Add ZRANDMEMBER command. See #1035. +* Add LMOVE/BLMOVE commands. See #1034. +* Add ZMSCORE command. See #1032. +* Add LT/GT options to ZADD. See #1033. +* Add SMISMEMBER command. See #1031. +* Add EXAT/PXAT options to SET. See #1028. +* Add GETDEL/GETEX commands. See #1024. +* `Redis#exists` now returns an Integer by default, as warned since 4.2.0. The old behavior can be restored with `Redis.exists_returns_integer = false`. +* Fix Redis < 6 detection during connect. See #1025. +* Fix fetching command details in Redis cluster when the first node is unhealthy. See #1026. + +# 4.4.0 + +* Redis cluster: fix cross-slot validation in pipelines. Fix ##1019. +* Add support for `XAUTOCLAIM`. See #1018. +* Properly issue `READONLY` when reconnecting to replicas. Fix #1017. +* Make `del` a noop if passed an empty list of keys. See #998. +* Add support for `ZINTER`. See #995. + +# 4.3.1 + +* Fix password authentication against redis server 5 and older. + +# 4.3.0 + +* Add the TYPE argument to scan and scan_each. See #985. +* Support AUTH command for ACL. See #967. + +# 4.2.5 + +* Optimize the ruby connector write buffering. See #964. + +# 4.2.4 + +* Fix bytesize calculations in the ruby connector, and work on a copy of the buffer. Fix #961, #962. + +# 4.2.3 + +* Use io/wait instead of IO.select in the ruby connector. See #960. +* Use exception free non blocking IOs in the ruby connector. See #926. +* Prevent corruption of the client when an interrupt happen during inside a pipeline block. See #945. + +# 4.2.2 + +* Fix `WATCH` support for `Redis::Distributed`. See #941. +* Fix handling of empty stream responses. See #905, #929. + +# 4.2.1 + +* Fix `exists?` returning an actual boolean when called with multiple keys. See #918. +* Setting `Redis.exists_returns_integer = false` disables warning message about new behaviour. See #920. + +# 4.2.0 + +* Convert commands to accept keyword arguments rather than option hashes. This both help catching typos, and reduce needless allocations. +* Deprecate the synchrony driver. It will be removed in 5.0 and hopefully maintained as a separate gem. See #915. +* Make `Redis#exists` variadic, will return an Integer if called with multiple keys. +* Add `Redis#exists?` to get a Boolean if any of the keys exists. +* `Redis#exists` when called with a single key will warn that future versions will return an Integer. + Set `Redis.exists_returns_integer = true` to opt-in to the new behavior. +* Support `keepttl` ooption in `set`. See #913. +* Optimized initialization of Redis::Cluster. See #912. +* Accept sentinel options even with string key. See #599. +* Verify TLS connections by default. See #900. +* Make `Redis#hset` variadic. It now returns an integer, not a boolean. See #910. + +# 4.1.4 + +* Alias `Redis#disconnect` as `#close`. See #901. +* Handle clusters with multiple slot ranges. See #894. +* Fix password authentication to a redis cluster. See #889. +* Handle recursive MOVED responses. See #882. +* Increase buffer size in the ruby connector. See #880. +* Fix thread safety of `Redis.queue`. See #878. +* Deprecate `Redis::Future#==` as it's likely to be a mistake. See #876. +* Support `KEEPTTL` option for SET command. See #913. + +# 4.1.3 + +* Fix the client hanging forever when connecting with SSL to a non-SSL server. See #835. + +# 4.1.2 + +* Fix several authentication problems with sentinel. See #850 and #856. +* Explicitly drop Ruby 2.2 support. + + +# 4.1.1 + +* Fix error handling in multi blocks. See #754. +* Fix geoadd to accept arrays like georadius and georadiusbymember. See #841. +* Fix georadius command failing when long == lat. See #841. +* Fix timeout error in xread block: 0. See #837. +* Fix incompatibility issue with redis-objects. See #834. +* Properly handle Errno::EADDRNOTAVAIL on connect. +* Fix password authentication to sentinel instances. See #813. + +# 4.1.0 + +* Add Redis Cluster support. See #716. +* Add streams support. See #799 and #811. +* Add ZPOP* support. See #812. +* Fix issues with integer-like objects as BPOP timeout + +# 4.0.3 + +* Fix raising command error for first command in pipeline. See #788. +* Fix the gemspec to stop exposing a `build` executable. See #785. +* Add `:reconnect_delay` and `:reconnect_delay_max` options. See #778. + +# 4.0.2 + +* Added `Redis#unlink`. See #766. + +* `Redis.new` now accept a custom connector via `:connector`. See #591. + +* `Redis#multi` no longer perform empty transactions. See #747. + +* `Redis#hdel` now accepts hash keys as multiple arguments like `#del`. See #755. + +* Allow to skip SSL verification. See #745. + +* Add Geo commands: `geoadd`, `geohash`, `georadius`, `georadiusbymember`, `geopos`, `geodist`. See #730. + +# 4.0.1 + +* `Redis::Distributed` now supports `mget` and `mapped_mget`. See #687. + +* `Redis::Distributed` now supports `sscan` and `sscan_each`. See #572. + +* `Redis#connection` returns a hash with connection information. + You shouldn't need to call `Redis#_client`, ever. + +* `Redis#flushdb` and `Redis#flushall` now support the `:async` option. See #706. + + +# 4.0 + +* Removed `Redis.connect`. Use `Redis.new`. + +* Removed `Redis#[]` and `Redis#[]=` aliases. + +* Added support for `CLIENT` commands. The lower-level client can be + accessed via `Redis#_client`. + +* Dropped official support for Ruby < 2.2.2. + +# 3.3.5 + +* Fixed Ruby 1.8 compatibility after backporting `Redis#connection`. See #719. + +# 3.3.4 (yanked) + +* `Redis#connection` returns a hash with connection information. + You shouldn't need to call `Redis#_client`, ever. # 3.3.3 @@ -18,7 +306,7 @@ # 3.3.2 -* Added support for SPOP with COUNT. See #628. +* Added support for `SPOP` with COUNT. See #628. * Fixed connection glitches when using SSL. See #644. diff --git a/Gemfile b/Gemfile index 73d38bc2e..00615dee1 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,12 @@ -# encoding: utf-8 +# frozen_string_literal: true + source 'https://rubygems.org' gemspec + +gem 'minitest' +gem 'rake' +gem 'rubocop', '~> 1.25.1' +gem 'mocha' + +gem 'hiredis-client' diff --git a/README.md b/README.md index 138bf9c15..99f141101 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,17 @@ -# redis-rb [![Build Status][travis-image]][travis-link] [![Inline docs][inchpages-image]][inchpages-link] +# redis-rb [![Build Status][gh-actions-image]][gh-actions-link] [![Inline docs][rdoc-master-image]][rdoc-master-link] -[travis-image]: https://secure.travis-ci.org/redis/redis-rb.png?branch=master -[travis-link]: http://travis-ci.org/redis/redis-rb -[travis-home]: http://travis-ci.org/ -[inchpages-image]: http://inch-ci.org/github/redis/redis-rb.png -[inchpages-link]: http://inch-ci.org/github/redis/redis-rb +A Ruby client that tries to match [Redis][redis-home]' API one-to-one, while still providing an idiomatic interface. -A Ruby client library for [Redis][redis-home]. - -[redis-home]: http://redis.io - -A Ruby client that tries to match Redis' API one-to-one, while still -providing an idiomatic interface. It features thread-safety, client-side -sharding, pipelining, and an obsession for performance. - -## Upgrading from 2.x to 3.0 - -Please refer to the [CHANGELOG][changelog-3.0.0] for a summary of the -most important changes, as well as a full list of changes. - -[changelog-3.0.0]: https://github.com/redis/redis-rb/blob/master/CHANGELOG.md#300 +See [RubyDoc.info][rubydoc] for the API docs of the latest published gem. ## Getting started -To install **redis-rb**, run the following command: - -``` - gem install redis -``` - -Or if you are using **bundler**, add +Install with: ``` - gem 'redis', '~>3.2' +$ gem install redis ``` -to your `Gemfile`, and run `bundle install` - -As of version 2.0 this client only targets Redis version 2.0 and higher. -You can use an older version of this client if you need to interface -with a Redis instance older than 2.0, but this is no longer supported. - You can connect to Redis by instantiating the `Redis` class: ```ruby @@ -54,31 +25,34 @@ listening on `localhost`, port 6379. If you need to connect to a remote server or a different port, try: ```ruby -redis = Redis.new(:host => "10.0.1.1", :port => 6380, :db => 15) +redis = Redis.new(host: "10.0.1.1", port: 6380, db: 15) ``` You can also specify connection options as a [`redis://` URL][redis-url]: ```ruby -redis = Redis.new(:url => "redis://:p4ssw0rd@10.0.1.1:6380/15") +redis = Redis.new(url: "redis://:p4ssw0rd@10.0.1.1:6380/15") ``` -[redis-url]: http://www.iana.org/assignments/uri-schemes/prov/redis - -By default, the client will try to read the `REDIS_URL` environment variable -and use that as URL to connect to. The above statement is therefore equivalent -to setting this environment variable and calling `Redis.new` without arguments. +The client expects passwords with special chracters to be URL-encoded (i.e. +`CGI.escape(password)`). To connect to Redis listening on a Unix socket, try: ```ruby -redis = Redis.new(:path => "/tmp/redis.sock") +redis = Redis.new(path: "/tmp/redis.sock") ``` To connect to a password protected Redis instance, use: ```ruby -redis = Redis.new(:password => "mysecret") +redis = Redis.new(password: "mysecret") +``` + +To connect a Redis instance using [ACL](https://redis.io/topics/acl), use: + +```ruby +redis = Redis.new(username: 'myname', password: 'mysecret') ``` The Redis class exports methods that are named identical to the commands @@ -86,8 +60,6 @@ they execute. The arguments these methods accept are often identical to the arguments specified on the [Redis website][redis-commands]. For instance, the `SET` and `GET` commands can be called like this: -[redis-commands]: http://redis.io/commands - ```ruby redis.set("mykey", "hello world") # => "OK" @@ -96,24 +68,42 @@ redis.get("mykey") # => "hello world" ``` -All commands, their arguments and return values are documented, and -available on [rdoc.info][rdoc]. +All commands, their arguments, and return values are documented and +available on [RubyDoc.info][rubydoc]. + +## Connection Pooling and Thread safety -[rdoc]: http://rdoc.info/github/redis/redis-rb/ +The client does not provide connection pooling. Each `Redis` instance +has one and only one connection to the server, and use of this connection +is protected by a mutex. + +As such it is heavilly recommended to use the [`connection_pool` gem](https://github.com/mperham/connection_pool), e.g.: + +```ruby +module MyApp + def self.redis + @redis ||= ConnectionPool::Wrapper.new do + Redis.new(url: ENV["REDIS_URL"]) + end + end +end + +MyApp.redis.incr("some-counter") +``` ## Sentinel support -The client is able to perform automatic failovers by using [Redis +The client is able to perform automatic failover by using [Redis Sentinel](http://redis.io/topics/sentinel). Make sure to run Redis 2.8+ if you want to use this feature. To connect using Sentinel, use: ```ruby -SENTINELS = [{:host => "127.0.0.1", :port => 26380}, - {:host => "127.0.0.1", :port => 26381}] +SENTINELS = [{ host: "127.0.0.1", port: 26380 }, + { host: "127.0.0.1", port: 26381 }] -redis = Redis.new(:url => "redis://mymaster", :sentinels => SENTINELS, :role => :master) +redis = Redis.new(name: "mymaster", sentinels: SENTINELS, role: :master) ``` * The master name identifies a group of Redis instances composed of a master @@ -130,21 +120,45 @@ but a few so that if one is down the client will try the next one. The client is able to remember the last Sentinel that was able to reply correctly and will use it for the next requests. -## Storing objects +To [authenticate](https://redis.io/docs/management/sentinel/#configuring-sentinel-instances-with-authentication) Sentinel itself, you can specify the `sentinel_username` and `sentinel_password`. Exclude the `sentinel_username` option if you're using password-only authentication. + +```ruby +SENTINELS = [{ host: '127.0.0.1', port: 26380}, + { host: '127.0.0.1', port: 26381}] + +redis = Redis.new(name: 'mymaster', sentinels: SENTINELS, sentinel_username: 'appuser', sentinel_password: 'mysecret', role: :master) +``` -Redis only stores strings as values. If you want to store an object, you -can use a serialization mechanism such as JSON: +If you specify a username and/or password at the top level for your main Redis instance, Sentinel *will not* using thouse credentials ```ruby -require "json" +# Use 'mysecret' to authenticate against the mymaster instance, but skip authentication for the sentinels: +SENTINELS = [{ host: '127.0.0.1', port: 26380 }, + { host: '127.0.0.1', port: 26381 }] + +redis = Redis.new(name: 'mymaster', sentinels: SENTINELS, role: :master, password: 'mysecret') +``` + +So you have to provide Sentinel credential and Redis explictly even they are the same + +```ruby +# Use 'mysecret' to authenticate against the mymaster instance and sentinel +SENTINELS = [{ host: '127.0.0.1', port: 26380 }, + { host: '127.0.0.1', port: 26381 }] + +redis = Redis.new(name: 'mymaster', sentinels: SENTINELS, role: :master, password: 'mysecret', sentinel_password: 'mysecret') +``` -redis.set "foo", [1, 2, 3].to_json -# => OK +Also the `name`, `password`, `username` and `db` for Redis instance can be passed as an url: -JSON.parse(redis.get("foo")) -# => [1, 2, 3] +```ruby +redis = Redis.new(url: "redis://appuser:mysecret@mymaster/10", sentinels: SENTINELS, role: :master) ``` +## Cluster support + +[Clustering](https://redis.io/topics/cluster-spec). is supported via the [`redis-clustering` gem](cluster/). + ## Pipelining When multiple commands are executed sequentially, but are not dependent, @@ -159,13 +173,24 @@ commands to Redis and gathers their replies. These replies are returned by the `#pipelined` method. ```ruby -redis.pipelined do - redis.set "foo", "bar" - redis.incr "baz" +redis.pipelined do |pipeline| + pipeline.set "foo", "bar" + pipeline.incr "baz" end # => ["OK", 1] ``` +Commands must be called on the yielded objects. If you call methods +on the original client objects from inside a pipeline, they will be sent immediately: + +```ruby +redis.pipelined do |pipeline| + pipeline.set "foo", "bar" + redis.incr "baz" # => 1 +end +# => ["OK"] +``` + ### Executing commands atomically You can use `MULTI/EXEC` to run a number of commands in an atomic @@ -175,9 +200,9 @@ the regular pipeline, the replies to the commands are returned by the `#multi` method. ```ruby -redis.multi do - redis.set "foo", "bar" - redis.incr "baz" +redis.multi do |transaction| + transaction.set "foo", "bar" + transaction.incr "baz" end # => ["OK", 1] ``` @@ -185,21 +210,22 @@ end ### Futures Replies to commands in a pipeline can be accessed via the *futures* they -emit (since redis-rb 3.0). All calls inside a pipeline block return a +emit. All calls on the pipeline object return a `Future` object, which responds to the `#value` method. When the pipeline has successfully executed, all futures are assigned their respective replies and can be used. ```ruby -redis.pipelined do - @set = redis.set "foo", "bar" - @incr = redis.incr "baz" +set = incr = nil +redis.pipelined do |pipeline| + set = pipeline.set "foo", "bar" + incr = pipeline.incr "baz" end -@set.value +set.value # => "OK" -@incr.value +incr.value # => 1 ``` @@ -211,7 +237,7 @@ it can't connect to the server a `Redis::CannotConnectError` error will be raise ```ruby begin redis.ping -rescue Exception => e +rescue Redis::BaseError => e e.inspect # => # @@ -246,6 +272,7 @@ All timeout values are specified in seconds. When using pub/sub, you can subscribe to a channel using a timeout as well: ```ruby +redis = Redis.new(reconnect_attempts: 0) redis.subscribe_with_timeout(5, "news") do |on| on.message do |channel, message| # ... @@ -255,14 +282,41 @@ end If no message is received after 5 seconds, the client will unsubscribe. +## Reconnections -## SSL/TLS Support +**By default**, this gem will only **retry a connection once** and then fail, but +the client allows you to configure how many `reconnect_attempts` it should +complete before declaring a connection as failed. + +```ruby +Redis.new(reconnect_attempts: 0) +Redis.new(reconnect_attempts: 3) +``` + +If you wish to wait between reconnection attempts, you can instead pass a list +of durations: + +```ruby +Redis.new(reconnect_attempts: [ + 0, # retry immediately + 0.25, # retry a second time after 250ms + 1, # retry a third and final time after another 1s +]) +``` + +If you wish to disable reconnection only for some commands, you can use +`disable_reconnection`: + +```ruby +redis.get("some-key") # this may be retried +redis.disable_reconnection do + redis.incr("some-counter") # this won't be retried. +end +``` -This library supports natively terminating client side SSL/TLS connections -when talking to Redis via a server-side proxy such as [stunnel], [hitch], -or [ghostunnel]. +## SSL/TLS Support -To enable SSL support, pass the `:ssl => :true` option when configuring the +To enable SSL support, pass the `:ssl => true` option when configuring the Redis client, or pass in `:url => "rediss://..."` (like HTTPS for Redis). You will also need to pass in an `:ssl_params => { ... }` hash used to configure the `OpenSSL::SSL::SSLContext` object used for the connection: @@ -295,13 +349,7 @@ redis = Redis.new( ) ``` -[stunnel]: https://www.stunnel.org/ -[hitch]: https://hitch-tls.org/ -[ghostunnel]: https://github.com/square/ghostunnel -[OpenSSL::SSL::SSLContext documentation]: http://ruby-doc.org/stdlib-2.3.0/libdoc/openssl/rdoc/OpenSSL/SSL/SSLContext.html - -*NOTE:* SSL is only supported by the default "Ruby" driver - +[OpenSSL::SSL::SSLContext documentation]: http://ruby-doc.org/stdlib-2.5.0/libdoc/openssl/rdoc/OpenSSL/SSL/SSLContext.html ## Expert-Mode Options @@ -315,17 +363,9 @@ redis = Redis.new( Improper use of `inherit_socket` will result in corrupted and/or incorrect responses. -## Alternate drivers +## hiredis binding By default, redis-rb uses Ruby's socket library to talk with Redis. -To use an alternative connection driver it should be specified as option -when instantiating the client object. These instructions are only valid -for **redis-rb 3.0**. For instructions on how to use alternate drivers from -**redis-rb 2.2**, please refer to an [older README][readme-2.2.2]. - -[readme-2.2.2]: https://github.com/redis/redis-rb/blob/v2.2.2/README.md - -### hiredis The hiredis driver uses the connection facility of hiredis-rb. In turn, hiredis-rb is a binding to the official hiredis client library. It @@ -335,76 +375,56 @@ extension, JRuby is not supported (by default). It is best to use hiredis when you have large replies (for example: `LRANGE`, `SMEMBERS`, `ZRANGE`, etc.) and/or use big pipelines. -In your Gemfile, include hiredis: +In your Gemfile, include `hiredis-client`: ```ruby -gem "redis", "~> 3.0.1" -gem "hiredis", "~> 0.4.5" +gem "redis" +gem "hiredis-client" ``` -When instantiating the client object, specify hiredis: +If your application doesn't call `Bundler.require`, you may have +to require it explictly: ```ruby -redis = Redis.new(:driver => :hiredis) -``` +require "hiredis-client" +```` -### synchrony +This makes the hiredis driver the default. -The synchrony driver adds support for [em-synchrony][em-synchrony]. -This makes redis-rb work with EventMachine's asynchronous I/O, while not -changing the exposed API. The hiredis gem needs to be available as -well, because the synchrony driver uses hiredis for parsing the Redis -protocol. - -[em-synchrony]: https://github.com/igrigorik/em-synchrony - -In your Gemfile, include em-synchrony and hiredis: +If you want to be certain hiredis is being used, when instantiating +the client object, specify hiredis: ```ruby -gem "redis", "~> 3.0.1" -gem "hiredis", "~> 0.4.5" -gem "em-synchrony" -``` - -When instantiating the client object, specify synchrony: - -```ruby -redis = Redis.new(:driver => :synchrony) +redis = Redis.new(driver: :hiredis) ``` ## Testing -This library is tested using [Travis][travis-home], where it is tested -against the following interpreters and drivers: +This library is tested against recent Ruby and Redis versions. +Check [Github Actions][gh-actions-link] for the exact versions supported. + +## See Also -* MRI 1.8.7 (drivers: ruby, hiredis) -* MRI 1.9.3 (drivers: ruby, hiredis, synchrony) -* MRI 2.0 (drivers: ruby, hiredis, synchrony) -* MRI 2.1 (drivers: ruby, hiredis, synchrony) -* MRI 2.2 (drivers: ruby, hiredis, synchrony) -* MRI 2.3 (drivers: ruby, hiredis, synchrony) -* JRuby 1.7 (1.8 mode) (drivers: ruby) -* JRuby 1.7 (1.9 mode) (drivers: ruby) +- [async-redis](https://github.com/socketry/async-redis) — An [async](https://github.com/socketry/async) compatible Redis client. ## Contributors -(ordered chronologically with more than 5 commits, see `git shortlog -sn` for -all contributors) - -* Ezra Zygmuntowicz -* Taylor Weibley -* Matthew Clark -* Brian McKinney -* Luca Guidi -* Salvatore Sanfilippo -* Chris Wanstrath -* Damian Janowski -* Michel Martens -* Nick Quaranto -* Pieter Noordhuis -* Ilya Grigorik +Several people contributed to redis-rb, but we would like to especially +mention Ezra Zygmuntowicz. Ezra introduced the Ruby community to many +new cool technologies, like Redis. He wrote the first version of this +client and evangelized Redis in Rubyland. Thank you, Ezra. ## Contributing [Fork the project](https://github.com/redis/redis-rb) and send pull -requests. You can also ask for help at `#redis-rb` on Freenode. +requests. + + +[rdoc-master-image]: https://img.shields.io/badge/docs-rdoc.info-blue.svg +[rdoc-master-link]: https://rubydoc.info/github/redis/redis-rb +[redis-commands]: https://redis.io/commands +[redis-home]: https://redis.io +[redis-url]: https://www.iana.org/assignments/uri-schemes/prov/redis +[gh-actions-image]: https://github.com/redis/redis-rb/workflows/Test/badge.svg +[gh-actions-link]: https://github.com/redis/redis-rb/actions +[rubydoc]: https://rubydoc.info/gems/redis diff --git a/Rakefile b/Rakefile index 2d9e137d8..15ff13e31 100644 --- a/Rakefile +++ b/Rakefile @@ -1,87 +1,34 @@ -require "rake/testtask" +# frozen_string_literal: true -ENV["REDIS_BRANCH"] ||= "unstable" +require 'bundler/gem_tasks' +Bundler::GemHelper.install_tasks(dir: "cluster", name: "redis-clustering") -REDIS_DIR = File.expand_path(File.join("..", "test"), __FILE__) -REDIS_CNF = File.join(REDIS_DIR, "test.conf") -REDIS_CNF_TEMPLATE = File.join(REDIS_DIR, "test.conf.erb") -REDIS_PID = File.join(REDIS_DIR, "db", "redis.pid") -REDIS_LOG = File.join(REDIS_DIR, "db", "redis.log") -REDIS_SOCKET = File.join(REDIS_DIR, "db", "redis.sock") -BINARY = "tmp/redis-#{ENV["REDIS_BRANCH"]}/src/redis-server" +require 'rake/testtask' -task :default => :run - -desc "Run tests and manage server start/stop" -task :run => [:start, :test, :stop] - -desc "Start the Redis server" -task :start => [BINARY, REDIS_CNF] do - sh "#{BINARY} --version" - - redis_running = \ - begin - File.exists?(REDIS_PID) && Process.kill(0, File.read(REDIS_PID).to_i) - rescue Errno::ESRCH - FileUtils.rm REDIS_PID - false - end - - unless redis_running - unless system("#{BINARY} #{REDIS_CNF}") - abort "could not start redis-server" +namespace :test do + groups = %i(redis distributed sentinel) + groups.each do |group| + Rake::TestTask.new(group) do |t| + t.libs << "test" + t.libs << "lib" + t.test_files = FileList["test/#{group}/**/*_test.rb"] + t.options = '-v' if ENV['CI'] || ENV['VERBOSE'] end end - at_exit do - Rake::Task["stop"].invoke + lost_tests = Dir["test/**/*_test.rb"] - groups.map { |g| Dir["test/#{g}/**/*_test.rb"] }.flatten + unless lost_tests.empty? + abort "The following test files are in no group:\n#{lost_tests.join("\n")}" end -end -desc "Stop the Redis server" -task :stop do - if File.exists?(REDIS_PID) - Process.kill "INT", File.read(REDIS_PID).to_i - FileUtils.rm REDIS_PID + Rake::TestTask.new(:cluster) do |t| + t.libs << "cluster/test" << "test" + t.libs << "cluster/lib" << "lib" + t.test_files = FileList["cluster/test/**/*_test.rb"] + t.options = '-v' if ENV['CI'] || ENV['VERBOSE'] end end -desc "Clean up testing artifacts" -task :clean do - FileUtils.rm_f(BINARY) - FileUtils.rm_f(REDIS_CNF) -end - -file BINARY do - branch = ENV.fetch("REDIS_BRANCH") +task test: ["test:redis", "test:distributed", "test:sentinel", "test:cluster"] - sh <<-SH - mkdir -p tmp; - cd tmp; - rm -rf redis-#{branch}; - wget https://github.com/antirez/redis/archive/#{branch}.tar.gz -O #{branch}.tar.gz; - tar xf #{branch}.tar.gz; - cd redis-#{branch}; - make - SH -end - -file REDIS_CNF => [REDIS_CNF_TEMPLATE, __FILE__] do |t| - require 'erb' - - erb = t.prerequisites[0] - template = File.read(erb) - - File.open(REDIS_CNF, 'w') do |file| - file.puts "\# This file was auto-generated at #{Time.now}", - "\# from (#{erb})", - "\#" - conf = ERB.new(template).result - file << conf - end -end - -Rake::TestTask.new do |t| - t.options = "-v" if $VERBOSE - t.test_files = FileList["test/*_test.rb"] -end +task default: :test diff --git a/benchmarking/logging.rb b/benchmarking/logging.rb deleted file mode 100644 index 353e91bd0..000000000 --- a/benchmarking/logging.rb +++ /dev/null @@ -1,71 +0,0 @@ -# Run with -# -# $ ruby -Ilib benchmarking/logging.rb -# - -begin - require "bench" -rescue LoadError - $stderr.puts "`gem install bench` and try again." - exit 1 -end - -require "redis" -require "logger" - -def log(level, namespace = nil) - logger = (namespace || Kernel).const_get(:Logger).new("/dev/null") - logger.level = (namespace || Logger).const_get(level) - logger -end - -def stress(redis) - redis.flushdb - - n = (ARGV.shift || 2000).to_i - - n.times do |i| - key = "foo:#{i}" - redis.set key, i - redis.get key - end -end - -default = Redis.new - -logging_redises = [ - Redis.new(:logger => log(:DEBUG)), - Redis.new(:logger => log(:INFO)), -] - -begin - require "log4r" - - logging_redises += [ - Redis.new(:logger => log(:DEBUG, Log4r)), - Redis.new(:logger => log(:INFO, Log4r)), - ] -rescue LoadError - $stderr.puts "Log4r not installed. `gem install log4r` if you want to compare it against Ruby's Logger (spoiler: it's much faster)." -end - -benchmark "Default options (no logger)" do - stress(default) -end - -logging_redises.each do |redis| - logger = redis.client.logger - - case logger - when Logger - level = Logger::SEV_LABEL[logger.level] - when Log4r::Logger - level = logger.levels[logger.level] - end - - benchmark "#{logger.class} on #{level}" do - stress(redis) - end -end - -run 10 diff --git a/benchmarking/pipeline.rb b/benchmarking/pipeline.rb deleted file mode 100644 index ecc98ba2d..000000000 --- a/benchmarking/pipeline.rb +++ /dev/null @@ -1,51 +0,0 @@ -require "benchmark" - -$:.push File.join(File.dirname(__FILE__), 'lib') - -require 'redis' - -ITERATIONS = 10000 - -@r = Redis.new - -Benchmark.bmbm do |benchmark| - benchmark.report("set") do - @r.flushdb - - ITERATIONS.times do |i| - @r.set("foo#{i}", "Hello world!") - @r.get("foo#{i}") - end - end - - benchmark.report("set (pipelined)") do - @r.flushdb - - @r.pipelined do - ITERATIONS.times do |i| - @r.set("foo#{i}", "Hello world!") - @r.get("foo#{i}") - end - end - end - - benchmark.report("lpush+ltrim") do - @r.flushdb - - ITERATIONS.times do |i| - @r.lpush "lpush#{i}", i - @r.ltrim "ltrim#{i}", 0, 30 - end - end - - benchmark.report("lpush+ltrim (pipelined)") do - @r.flushdb - - @r.pipelined do - ITERATIONS.times do |i| - @r.lpush "lpush#{i}", i - @r.ltrim "ltrim#{i}", 0, 30 - end - end - end -end diff --git a/benchmarking/speed.rb b/benchmarking/speed.rb deleted file mode 100644 index 3780bffb6..000000000 --- a/benchmarking/speed.rb +++ /dev/null @@ -1,21 +0,0 @@ -# Run with -# -# $ ruby -Ilib benchmarking/speed.rb -# - -require "benchmark" -require "redis" - -r = Redis.new -n = (ARGV.shift || 20000).to_i - -elapsed = Benchmark.realtime do - # n sets, n gets - n.times do |i| - key = "foo#{i}" - r[key] = key * 10 - r[key] - end -end - -puts '%.2f Kops' % (2 * n / 1000 / elapsed) diff --git a/benchmarking/suite.rb b/benchmarking/suite.rb deleted file mode 100644 index 55fd1d660..000000000 --- a/benchmarking/suite.rb +++ /dev/null @@ -1,24 +0,0 @@ -require 'fileutils' - -def run_in_background(command) - fork { system command } -end - -def with_all_segments(&block) - 0.upto(9) do |segment_number| - block_size = 100000 - start_index = segment_number * block_size - end_index = start_index + block_size - 1 - block.call(start_index, end_index) - end -end - -#with_all_segments do |start_index, end_index| -# puts "Initializing keys from #{start_index} to #{end_index}" -# system "ruby worker.rb initialize #{start_index} #{end_index} 0" -#end - -with_all_segments do |start_index, end_index| - run_in_background "ruby worker.rb write #{start_index} #{end_index} 10" - run_in_background "ruby worker.rb read #{start_index} #{end_index} 1" -end diff --git a/benchmarking/worker.rb b/benchmarking/worker.rb deleted file mode 100644 index 836d03b06..000000000 --- a/benchmarking/worker.rb +++ /dev/null @@ -1,71 +0,0 @@ -BENCHMARK_ROOT = File.dirname(__FILE__) -REDIS_ROOT = File.join(BENCHMARK_ROOT, "..", "lib") - -$: << REDIS_ROOT -require 'redis' -require 'benchmark' - -def show_usage - puts <<-EOL - Usage: worker.rb [read:write] - EOL -end - -def shift_from_argv - value = ARGV.shift - unless value - show_usage - exit -1 - end - value -end - -operation = shift_from_argv.to_sym -start_index = shift_from_argv.to_i -end_index = shift_from_argv.to_i -sleep_msec = shift_from_argv.to_i -sleep_duration = sleep_msec/1000.0 - -redis = Redis.new - -case operation - when :initialize - - start_index.upto(end_index) do |i| - redis[i] = 0 - end - - when :clear - - start_index.upto(end_index) do |i| - redis.delete(i) - end - - when :read, :write - - puts "Starting to #{operation} at segment #{end_index + 1}" - - loop do - t1 = Time.now - start_index.upto(end_index) do |i| - case operation - when :read - redis.get(i) - when :write - redis.incr(i) - else - raise "Unknown operation: #{operation}" - end - sleep sleep_duration - end - t2 = Time.now - - requests_processed = end_index - start_index - time = t2 - t1 - puts "#{t2.strftime("%H:%M")} [segment #{end_index + 1}] : Processed #{requests_processed} requests in #{time} seconds - #{(requests_processed/time).round} requests/sec" - end - - else - raise "Unknown operation: #{operation}" -end - diff --git a/bin/build b/bin/build new file mode 100755 index 000000000..925a91059 --- /dev/null +++ b/bin/build @@ -0,0 +1,77 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +TARBALL = ARGV[0] + +require 'digest/sha1' +require 'English' +require 'fileutils' + +class Builder + TARBALL_CACHE_EXPIRATION = 60 * 10 + + def initialize(redis_branch, tmp_dir) + @redis_branch = redis_branch + @tmp_dir = tmp_dir + @build_dir = File.join(@tmp_dir, "cache", "redis-#{redis_branch}") + end + + def run + download_tarball_if_needed + if old_checkum != checksum + build + update_checksum + end + 0 + end + + private + + def download_tarball_if_needed + return if File.exist?(tarball_path) && File.mtime(tarball_path) > Time.now - TARBALL_CACHE_EXPIRATION + + command!('wget', '-q', tarball_url, '-O', tarball_path) + end + + def tarball_path + File.join(@tmp_dir, "redis-#{@redis_branch}.tar.gz") + end + + def tarball_url + "https://github.com/antirez/redis/archive/#{@redis_branch}.tar.gz" + end + + def build + FileUtils.rm_rf(@build_dir) + FileUtils.mkdir_p(@build_dir) + command!('tar', 'xf', tarball_path, '-C', File.expand_path('../', @build_dir)) + Dir.chdir(@build_dir) do + command!('make') + end + end + + def update_checksum + File.write(checksum_path, checksum) + end + + def old_checkum + File.read(checksum_path) + rescue Errno::ENOENT + nil + end + + def checksum_path + File.join(@build_dir, 'build.checksum') + end + + def checksum + @checksum ||= Digest::SHA1.file(tarball_path).hexdigest + end + + def command!(*args) + puts "$ #{args.join(' ')}" + raise "Command failed with status #{$CHILD_STATUS.exitstatus}" unless system(*args) + end +end + +exit Builder.new(ARGV[0], ARGV[1]).run diff --git a/bin/cluster_creator b/bin/cluster_creator new file mode 100755 index 000000000..5e9761fa7 --- /dev/null +++ b/bin/cluster_creator @@ -0,0 +1,13 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +puts ARGV.join(" ") +require 'bundler/setup' + +$LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) +require_relative '../cluster/test/support/orchestrator' + +urls = ARGV.map { |host_port| "redis://#{host_port}" } +orchestrator = ClusterOrchestrator.new(urls, timeout: 3.0) +orchestrator.rebuild +orchestrator.close diff --git a/bin/console b/bin/console new file mode 100755 index 000000000..d4220d089 --- /dev/null +++ b/bin/console @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) +require 'redis' + +require 'irb' +IRB.start diff --git a/cluster/CHANGELOG.md b/cluster/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/cluster/Gemfile b/cluster/Gemfile new file mode 100644 index 000000000..ff5d5832a --- /dev/null +++ b/cluster/Gemfile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gemspec + +gem 'redis', path: File.expand_path("..", __dir__) +gem 'minitest' +gem 'rake' +gem 'rubocop', '~> 1.25.1' +gem 'mocha' diff --git a/cluster/LICENSE b/cluster/LICENSE new file mode 100644 index 000000000..5e648fa9d --- /dev/null +++ b/cluster/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2009 Ezra Zygmuntowicz + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/cluster/README.md b/cluster/README.md new file mode 100644 index 000000000..d47222077 --- /dev/null +++ b/cluster/README.md @@ -0,0 +1,77 @@ +# Redis::Cluster + +## Getting started + +Install with: + +``` +$ gem install redis-clustering +``` + +You can connect to Redis by instantiating the `Redis::Cluster` class: + +```ruby +require "redis-clustering" + +redis = Redis::Cluster.new(nodes: (7000..7005).map { |port| "redis://127.0.0.1:#{port}" }) +``` + +NB: Both `redis_cluster` and `redis-cluster` are unrelated and abandoned gems. + +```ruby +# Nodes can be passed to the client as an array of connection URLs. +nodes = (7000..7005).map { |port| "redis://127.0.0.1:#{port}" } +redis = Redis::Cluster.new(nodes: nodes) + +# You can also specify the options as a Hash. The options are the same as for a single server connection. +(7000..7005).map { |port| { host: '127.0.0.1', port: port } } +``` + +You can also specify only a subset of the nodes, and the client will discover the missing ones using the [CLUSTER NODES](https://redis.io/commands/cluster-nodes) command. + +```ruby +Redis::Cluster.new(nodes: %w[redis://127.0.0.1:7000]) +``` + +If you want [the connection to be able to read from any replica](https://redis.io/commands/readonly), you must pass the `replica: true`. Note that this connection won't be usable to write keys. + +```ruby +Redis::Cluster.new(nodes: nodes, replica: true) +``` + +Also, you can specify the `:replica_affinity` option if you want to prevent accessing cross availability zones. + +```ruby +Redis::Cluster.new(nodes: nodes, replica: true, replica_affinity: :latency) +``` + +The calling code is responsible for [avoiding cross slot commands](https://redis.io/topics/cluster-spec#keys-distribution-model). + +```ruby +redis = Redis::Cluster.new(nodes: %w[redis://127.0.0.1:7000]) + +redis.mget('key1', 'key2') +#=> Redis::CommandError (CROSSSLOT Keys in request don't hash to the same slot) + +redis.mget('{key}1', '{key}2') +#=> [nil, nil] +``` + +* The client automatically reconnects after a failover occurred, but the caller is responsible for handling errors while it is happening. +* The client support permanent node failures, and will reroute requests to promoted slaves. +* The client supports `MOVED` and `ASK` redirections transparently. + +## Cluster mode with SSL/TLS +Since Redis can return FQDN of nodes in reply to client since `7.*` with CLUSTER commands, we can use cluster feature with SSL/TLS connection like this: + +```ruby +Redis::Cluster.new(nodes: %w[rediss://foo.example.com:6379]) +``` + +On the other hand, in Redis versions prior to `6.*`, you can specify options like the following if cluster mode is enabled and client has to connect to nodes via single endpoint with SSL/TLS. + +```ruby +Redis::Cluster.new(nodes: %w[rediss://foo-endpoint.example.com:6379], fixed_hostname: 'foo-endpoint.example.com') +``` + +In case of the above architecture, if you don't pass the `fixed_hostname` option to the client and servers return IP addresses of nodes, the client may fail to verify certificates. diff --git a/cluster/lib/redis-clustering.rb b/cluster/lib/redis-clustering.rb new file mode 100644 index 000000000..d03439440 --- /dev/null +++ b/cluster/lib/redis-clustering.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "redis/cluster" diff --git a/cluster/lib/redis/cluster.rb b/cluster/lib/redis/cluster.rb new file mode 100644 index 000000000..58c775f56 --- /dev/null +++ b/cluster/lib/redis/cluster.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "redis" + +class Redis + class Cluster < ::Redis + # Raised when client connected to redis as cluster mode + # and failed to fetch cluster state information by commands. + class InitialSetupError < BaseError + end + + # Raised when client connected to redis as cluster mode + # and some cluster subcommands were called. + class OrchestrationCommandNotSupported < BaseError + def initialize(command, subcommand = '') + str = [command, subcommand].map(&:to_s).reject(&:empty?).join(' ').upcase + msg = "#{str} command should be used with care "\ + 'only by applications orchestrating Redis Cluster, like redis-trib, '\ + 'and the command if used out of the right context can leave the cluster '\ + 'in a wrong state or cause data loss.' + super(msg) + end + end + + # Raised when error occurs on any node of cluster. + class CommandErrorCollection < BaseError + attr_reader :errors + + # @param errors [Hash{String => Redis::CommandError}] + # @param error_message [String] + def initialize(errors, error_message = 'Command errors were replied on any node') + @errors = errors + super(error_message) + end + end + + # Raised when cluster client can't select node. + class AmbiguousNodeError < BaseError + end + + class TransactionConsistencyError < BaseError + end + + class NodeMightBeDown < BaseError + end + + def connection + raise NotImplementedError, "Redis::Cluster doesn't implement #connection" + end + + # Create a new client instance + # + # @param [Hash] options + # @option options [Float] :timeout (5.0) timeout in seconds + # @option options [Float] :connect_timeout (same as timeout) timeout for initial connect in seconds + # @option options [Symbol] :driver Driver to use, currently supported: `:ruby`, `:hiredis` + # @option options [Integer, Array] :reconnect_attempts Number of attempts trying to connect, + # or a list of sleep duration between attempts. + # @option options [Boolean] :inherit_socket (false) Whether to use socket in forked process or not + # @option options [Array String, Integer}>] :nodes List of cluster nodes to contact + # @option options [Boolean] :replica Whether to use readonly replica nodes in Redis Cluster or not + # @option options [Symbol] :replica_affinity scale reading strategy, currently supported: `:random`, `:latency` + # @option options [String] :fixed_hostname Specify a FQDN if cluster mode enabled and + # client has to connect nodes via single endpoint with SSL/TLS + # @option options [Class] :connector Class of custom connector + # + # @return [Redis::Cluster] a new client instance + def initialize(*) # rubocop:disable Lint/UselessMethodDefinition + super + end + ruby2_keywords :initialize if respond_to?(:ruby2_keywords, true) + + # Sends `CLUSTER *` command to random node and returns its reply. + # + # @see https://redis.io/commands#cluster Reference of cluster command + # + # @param subcommand [String, Symbol] the subcommand of cluster command + # e.g. `:slots`, `:nodes`, `:slaves`, `:info` + # + # @return [Object] depends on the subcommand + def cluster(subcommand, *args) + subcommand = subcommand.to_s.downcase + block = case subcommand + when 'slots' + HashifyClusterSlots + when 'nodes' + HashifyClusterNodes + when 'slaves' + HashifyClusterSlaves + when 'info' + HashifyInfo + else + Noop + end + + send_command([:cluster, subcommand] + args, &block) + end + + private + + def initialize_client(options) + cluster_config = RedisClient.cluster(**options, protocol: 2, client_implementation: ::Redis::Cluster::Client) + cluster_config.new_client + end + end +end + +require "redis/cluster/client" diff --git a/cluster/lib/redis/cluster/client.rb b/cluster/lib/redis/cluster/client.rb new file mode 100644 index 000000000..a14845c2b --- /dev/null +++ b/cluster/lib/redis/cluster/client.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'redis-cluster-client' + +class Redis + class Cluster + class Client < RedisClient::Cluster + ERROR_MAPPING = ::Redis::Client::ERROR_MAPPING.merge( + RedisClient::Cluster::InitialSetupError => Redis::Cluster::InitialSetupError, + RedisClient::Cluster::OrchestrationCommandNotSupported => Redis::Cluster::OrchestrationCommandNotSupported, + RedisClient::Cluster::AmbiguousNodeError => Redis::Cluster::AmbiguousNodeError, + RedisClient::Cluster::ErrorCollection => Redis::Cluster::CommandErrorCollection, + RedisClient::Cluster::Transaction::ConsistencyError => Redis::Cluster::TransactionConsistencyError, + RedisClient::Cluster::NodeMightBeDown => Redis::Cluster::NodeMightBeDown, + ) + + class << self + def config(**kwargs) + super(protocol: 2, **kwargs) + end + + def sentinel(**kwargs) + super(protocol: 2, **kwargs) + end + + def translate_error!(error, mapping: ERROR_MAPPING) + case error + when RedisClient::Cluster::ErrorCollection + error.errors.each do |_node, node_error| + if node_error.is_a?(RedisClient::AuthenticationError) + raise mapping.fetch(node_error.class), node_error.message, node_error.backtrace + end + end + + remapped_node_errors = error.errors.map do |node_key, node_error| + remapped = mapping.fetch(node_error.class, node_error.class).new(node_error.message) + remapped.set_backtrace node_error.backtrace + [node_key, remapped] + end.to_h + + raise(Redis::Cluster::CommandErrorCollection.new(remapped_node_errors, error.message).tap do |remapped| + remapped.set_backtrace error.backtrace + end) + else + Redis::Client.translate_error!(error, mapping: mapping) + end + end + end + + def initialize(*) + handle_errors { super } + end + ruby2_keywords :initialize if respond_to?(:ruby2_keywords, true) + + def id + @router.node_keys.join(' ') + end + + def server_url + @router.node_keys + end + + def connected? + true + end + + def disable_reconnection + yield # TODO: do we need this, is it doable? + end + + def timeout + config.read_timeout + end + + def db + 0 + end + + undef_method :call + undef_method :call_once + undef_method :call_once_v + undef_method :blocking_call + + def call_v(command, &block) + handle_errors { super(command, &block) } + end + + def blocking_call_v(timeout, command, &block) + timeout += self.timeout if timeout && timeout > 0 + handle_errors { super(timeout, command, &block) } + end + + def pipelined(&block) + handle_errors { super(&block) } + end + + def multi(watch: nil, &block) + handle_errors { super(watch: watch, &block) } + end + + private + + def handle_errors + yield + rescue ::RedisClient::Error => error + Redis::Cluster::Client.translate_error!(error) + end + end + end +end diff --git a/cluster/lib/redis/cluster/version.rb b/cluster/lib/redis/cluster/version.rb new file mode 100644 index 000000000..1e5507a70 --- /dev/null +++ b/cluster/lib/redis/cluster/version.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "redis/version" + +class Redis + class Cluster + VERSION = Redis::VERSION + end +end diff --git a/cluster/redis-clustering.gemspec b/cluster/redis-clustering.gemspec new file mode 100644 index 000000000..af1dea33a --- /dev/null +++ b/cluster/redis-clustering.gemspec @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require_relative "../lib/redis/version" + +Gem::Specification.new do |s| + s.name = "redis-clustering" + + s.version = Redis::VERSION + + github_root = "https://github.com/redis/redis-rb" + s.homepage = "#{github_root}/blob/master/cluster" + + s.summary = "A Ruby client library for Redis Cluster" + + s.description = <<-EOS + A Ruby client that tries to match Redis' Cluster API one-to-one, while still + providing an idiomatic interface. + EOS + + s.license = "MIT" + + s.authors = [ + "Ezra Zygmuntowicz", + "Taylor Weibley", + "Matthew Clark", + "Brian McKinney", + "Salvatore Sanfilippo", + "Luca Guidi", + "Michel Martens", + "Damian Janowski", + "Pieter Noordhuis" + ] + + s.email = ["redis-db@googlegroups.com"] + + s.metadata = { + "bug_tracker_uri" => "#{github_root}/issues", + "changelog_uri" => "#{s.homepage}/CHANGELOG.md", + "documentation_uri" => "https://www.rubydoc.info/gems/redis/#{s.version}", + "homepage_uri" => s.homepage, + "source_code_uri" => "#{github_root}/tree/v#{s.version}/cluster" + } + + s.files = Dir["CHANGELOG.md", "LICENSE", "README.md", "lib/**/*"] + s.executables = `git ls-files -- exe/*`.split("\n").map { |f| File.basename(f) } + + s.required_ruby_version = '>= 2.7.0' + + s.add_runtime_dependency('redis', s.version) + s.add_runtime_dependency('redis-cluster-client', '>= 0.7.0') +end diff --git a/cluster/test/blocking_commands_test.rb b/cluster/test/blocking_commands_test.rb new file mode 100644 index 000000000..6adc02656 --- /dev/null +++ b/cluster/test/blocking_commands_test.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_blocking_commands_test.rb +class TestClusterBlockingCommands < Minitest::Test + include Helper::Cluster + include Lint::BlockingCommands + + def mock(options = {}, &blk) + commands = build_mock_commands(options) + redis_cluster_mock(commands, { timeout: LOW_TIMEOUT, concurrent: true }, &blk) + end +end diff --git a/cluster/test/client_internals_test.rb b/cluster/test/client_internals_test.rb new file mode 100644 index 000000000..2f5d660d5 --- /dev/null +++ b/cluster/test/client_internals_test.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_client_internals_test.rb +class TestClusterClientInternals < Minitest::Test + 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_raises(Redis::CommandError) do + redis.not_yet_implemented_command('boo', 'foo') + end + end + + def test_connected? + assert_equal true, redis.connected? + end + + def test_close + redis.close + end + + def test_disconnect! + redis.disconnect! + end + + def test_asking + assert_equal 'OK', redis.asking + end + + def test_id + expected = '127.0.0.1:16380 '\ + '127.0.0.1:16381 '\ + '127.0.0.1:16382' + assert_equal expected, redis.id + end + + def test_inspect + expected = "#' + + assert_equal expected, redis.inspect + end + + def test_acl_auth_success + target_version "6.0.0" do + with_acl do |username, password| + r = _new_client(nodes: DEFAULT_PORTS.map { |port| "redis://#{username}:#{password}@#{DEFAULT_HOST}:#{port}" }) + assert_equal('PONG', r.ping) + end + end + end + + def test_acl_auth_failure + target_version "6.0.0" do + with_acl do |username, _| + assert_raises(Redis::Cluster::InitialSetupError) do + _new_client(nodes: DEFAULT_PORTS.map { |port| "redis://#{username}:wrongpassword@#{DEFAULT_HOST}:#{port}" }) + end + end + end + end +end diff --git a/cluster/test/client_pipelining_test.rb b/cluster/test/client_pipelining_test.rb new file mode 100644 index 000000000..4116c9a53 --- /dev/null +++ b/cluster/test/client_pipelining_test.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_client_pipelining_test.rb +class TestClusterClientPipelining < Minitest::Test + 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_equal 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 + result = redis.pipelined do |pipeline| + pipeline.set(:a, 1) + pipeline.set(:b, 2) + pipeline.set(:c, 3) + pipeline.set(:d, 4) + pipeline.set(:e, 5) + pipeline.set(:f, 6) + end + assert_equal ["OK"] * 6, result + + result = redis.pipelined do |pipeline| + pipeline.get(:a) + pipeline.get(:b) + pipeline.get(:c) + pipeline.get(:d) + pipeline.get(:e) + pipeline.get(:f) + end + assert_equal 1.upto(6).map(&:to_s), result + end + + def test_pipeline_unmapped_errors_are_bubbled_up + ex = Class.new(StandardError) + assert_raises(ex) do + redis.pipelined do |_pipe| + raise ex, "boom" + end + end + end + + def test_pipeline_error_subclasses_are_mapped + ex = Class.new(RedisClient::ConnectionError) + assert_raises(Redis::ConnectionError) do + redis.pipelined do |_pipe| + raise ex, "tick tock" + end + end + end +end diff --git a/cluster/test/client_replicas_test.rb b/cluster/test/client_replicas_test.rb new file mode 100644 index 000000000..e6926d52e --- /dev/null +++ b/cluster/test/client_replicas_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_client_replicas_test.rb +class TestClusterClientReplicas < Minitest::Test + 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 + + r.wait(1, TIMEOUT.to_i * 1000) + + 100.times do |i| + assert_equal i.to_s, r.get("key#{i}") + end + end +end diff --git a/cluster/test/client_transactions_test.rb b/cluster/test/client_transactions_test.rb new file mode 100644 index 000000000..b60a8bc4d --- /dev/null +++ b/cluster/test/client_transactions_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_client_transactions_test.rb +class TestClusterClientTransactions < Minitest::Test + include Helper::Cluster + + def test_cluster_client_does_support_transaction_by_single_key + actual = redis.multi do |r| + r.set('counter', '0') + r.incr('counter') + r.incr('counter') + end + + assert_equal(['OK', 1, 2], actual) + assert_equal('2', redis.get('counter')) + end + + def test_cluster_client_does_support_transaction_by_hashtag + actual = redis.multi do |r| + r.mset('{key}1', 1, '{key}2', 2) + r.mset('{key}3', 3, '{key}4', 4) + end + + assert_equal(%w[OK OK], actual) + assert_equal(%w[1 2 3 4], redis.mget('{key}1', '{key}2', '{key}3', '{key}4')) + end + + def test_cluster_client_does_not_support_transaction_by_multiple_keys + assert_raises(Redis::Cluster::TransactionConsistencyError) do + redis.multi do |r| + r.set('key1', 1) + r.set('key2', 2) + r.set('key3', 3) + r.set('key4', 4) + end + end + + assert_raises(Redis::Cluster::TransactionConsistencyError) do + redis.multi do |r| + r.mset('key1', 1, 'key2', 2) + r.mset('key3', 3, 'key4', 4) + end + end + + (1..4).each do |i| + assert_nil(redis.get("key#{i}")) + end + end +end diff --git a/cluster/test/commands_on_cluster_test.rb b/cluster/test/commands_on_cluster_test.rb new file mode 100644 index 000000000..a6fea1bbd --- /dev/null +++ b/cluster/test/commands_on_cluster_test.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_commands_on_cluster_test.rb +# @see https://redis.io/commands#cluster +class TestClusterCommandsOnCluster < Minitest::Test + include Helper::Cluster + + def test_cluster_addslots + assert_raises(Redis::Cluster::OrchestrationCommandNotSupported, 'CLUSTER ADDSLOTS command should be...') do + redis.cluster(:addslots, 0, 1, 2) + end + end + + def test_cluster_count_failure_reports + assert_raises(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_equal true, (redis.cluster('count-failure-reports', node_id) >= 0) + end + + def test_cluster_countkeysinslot + assert_equal true, (redis.cluster(:countkeysinslot, 0) >= 0) + assert_equal true, (redis.cluster(:countkeysinslot, 16_383) >= 0) + + assert_raises(Redis::CommandError, 'ERR Invalid slot') do + redis.cluster(:countkeysinslot, -1) + end + + assert_raises(Redis::CommandError, 'ERR Invalid slot') do + redis.cluster(:countkeysinslot, 16_384) + end + end + + def test_cluster_delslots + assert_raises(Redis::Cluster::OrchestrationCommandNotSupported, 'CLUSTER DELSLOTS command should be...') do + redis.cluster(:delslots, 0, 1, 2) + end + end + + def test_cluster_failover + assert_raises(Redis::Cluster::OrchestrationCommandNotSupported, 'CLUSTER FAILOVER command should be...') do + redis.cluster(:failover, 'FORCE') + end + end + + def test_cluster_forget + assert_raises(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_meet + assert_raises(Redis::Cluster::OrchestrationCommandNotSupported, 'CLUSTER MEET command should be...') do + redis.cluster(:meet, '127.0.0.1', 11_211) + 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_raises(Redis::Cluster::OrchestrationCommandNotSupported, 'CLUSTER REPLICATE command should be...') do + redis.cluster(:replicate) + end + end + + def test_cluster_reset + assert_raises(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_raises(Redis::Cluster::OrchestrationCommandNotSupported, 'CLUSTER SET-CONFIG-EPOCH command should be...') do + redis.cluster('set-config-epoch') + end + end + + def test_cluster_setslot + assert_raises(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_raises(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) + sample_slot.fetch('replicas').each do |replica| + assert_equal true, replica.key?('ip') + assert_equal true, replica.key?('port') + assert_equal true, replica.key?('node_id') + end + end + + def test_readonly + assert_raises(Redis::Cluster::OrchestrationCommandNotSupported, 'READONLY command should be...') do + redis.readonly + end + end + + def test_readwrite + assert_raises(Redis::Cluster::OrchestrationCommandNotSupported, 'READWRITE command should be...') do + redis.readwrite + end + end +end diff --git a/cluster/test/commands_on_connection_test.rb b/cluster/test/commands_on_connection_test.rb new file mode 100644 index 000000000..6ed7f43f2 --- /dev/null +++ b/cluster/test/commands_on_connection_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "helper" +require 'lint/authentication' + +# ruby -w -Itest test/cluster_commands_on_connection_test.rb +# @see https://redis.io/commands#connection +class TestClusterCommandsOnConnection < Minitest::Test + include Helper::Cluster + include Lint::Authentication + + 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_raises(Redis::CommandError, 'ERR SELECT is not allowed in cluster mode') do + redis.select(1) + end + end + + def test_swapdb + assert_raises(Redis::CommandError, 'ERR SWAPDB is not allowed in cluster mode') do + redis.swapdb(1, 2) + end + end + + def mock(*args, &block) + redis_cluster_mock(*args, &block) + end +end diff --git a/cluster/test/commands_on_geo_test.rb b/cluster/test/commands_on_geo_test.rb new file mode 100644 index 000000000..6dc94a967 --- /dev/null +++ b/cluster/test/commands_on_geo_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_commands_on_geo_test.rb +# @see https://redis.io/commands#geo +class TestClusterCommandsOnGeo < Minitest::Test + include Helper::Cluster + + def add_sicily + redis.geoadd('Sicily', + 13.361389, 38.115556, 'Palermo', + 15.087269, 37.502669, 'Catania') + end + + def test_geoadd + assert_equal 2, add_sicily + end + + def test_geohash + add_sicily + assert_equal %w[sqc8b49rny0 sqdtr74hyu0], redis.geohash('Sicily', %w[Palermo Catania]) + end + + def test_geopos + 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 + + def test_geodist + 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 + + def test_georadius + 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 + + def test_georadiusbymember + redis.geoadd('Sicily', 13.583333, 37.316667, 'Agrigento') + add_sicily + assert_equal %w[Agrigento Palermo], redis.georadiusbymember('Sicily', 'Agrigento', 100, 'km') + end +end diff --git a/cluster/test/commands_on_hashes_test.rb b/cluster/test/commands_on_hashes_test.rb new file mode 100644 index 000000000..e1c2b7e1e --- /dev/null +++ b/cluster/test/commands_on_hashes_test.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_commands_on_hashes_test.rb +# @see https://redis.io/commands#hash +class TestClusterCommandsOnHashes < Minitest::Test + include Helper::Cluster + include Lint::Hashes +end diff --git a/cluster/test/commands_on_hyper_log_log_test.rb b/cluster/test/commands_on_hyper_log_log_test.rb new file mode 100644 index 000000000..d2f6e6f02 --- /dev/null +++ b/cluster/test/commands_on_hyper_log_log_test.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_commands_on_hyper_log_log_test.rb +# @see https://redis.io/commands#hyperloglog +class TestClusterCommandsOnHyperLogLog < Minitest::Test + include Helper::Cluster + include Lint::HyperLogLog + + def test_pfmerge + assert_raises Redis::CommandError do + super + end + end +end diff --git a/cluster/test/commands_on_keys_test.rb b/cluster/test/commands_on_keys_test.rb new file mode 100644 index 000000000..6227e5b46 --- /dev/null +++ b/cluster/test/commands_on_keys_test.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_commands_on_keys_test.rb +# @see https://redis.io/commands#generic +class TestClusterCommandsOnKeys < Minitest::Test + 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_raises(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_raises(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_raises(Redis::CommandError, 'IOERR error or timeout writing to target instance') do + redis.migrate('mykey', host: '127.0.0.1', port: 11_211) + 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') + assert(redis.object('idletime', 'mylist') >= 0) + + 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_equal true, redis.randomkey.is_a?(String) + end + + def test_rename + set_some_keys + + assert_raises(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_raises(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 + set_some_keys + assert_equal 1, redis.touch('key1') + assert_equal 1, redis.touch('key2') + if version < '6' + assert_equal 1, redis.touch('key1', 'key2') + else + assert_raises(Redis::CommandError, "CROSSSLOT Keys in request don't hash to the same slot") do + redis.touch('key1', 'key2') + end + end + assert_equal 2, redis.touch('{key}1', '{key}2') + end + + def test_unlink + set_some_keys + assert_raises(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 + + def test_wait + set_some_keys + assert_equal 3, redis.wait(1, TIMEOUT.to_i * 1000) + 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 + + def test_scan_each + require 'securerandom' + + 1000.times do |n| + redis.set("test-#{::SecureRandom.uuid}", n) + end + + 1000.times do |n| + redis.set("random-#{::SecureRandom.uuid}", n) + end + + keys_result = redis.keys('test-*') + scan_result = redis.scan_each(match: 'test-*').to_a + assert_equal(keys_result.size, 1000) + assert_equal(scan_result.size, 1000) + assert_equal(scan_result.sort, keys_result.sort) + end +end diff --git a/cluster/test/commands_on_lists_test.rb b/cluster/test/commands_on_lists_test.rb new file mode 100644 index 000000000..dcd8ede9b --- /dev/null +++ b/cluster/test/commands_on_lists_test.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_commands_on_lists_test.rb +# @see https://redis.io/commands#list +class TestClusterCommandsOnLists < Minitest::Test + include Helper::Cluster + include Lint::Lists + + def test_lmove + target_version "6.2" do + assert_raises(Redis::CommandError) { super } + end + end + + def test_rpoplpush + assert_raises(Redis::CommandError) { super } + end +end diff --git a/cluster/test/commands_on_pub_sub_test.rb b/cluster/test/commands_on_pub_sub_test.rb new file mode 100644 index 000000000..107f35a7b --- /dev/null +++ b/cluster/test/commands_on_pub_sub_test.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_commands_on_pub_sub_test.rb +# @see https://redis.io/commands#pubsub +class TestClusterCommandsOnPubSub < Minitest::Test + include Helper::Cluster + + def test_publish_subscribe_unsubscribe_pubsub + sub_cnt = 0 + messages = {} + + thread = Thread.new do + redis.subscribe('channel1', 'channel2') do |on| + on.subscribe { sub_cnt += 1 } + on.message do |c, msg| + messages[c] = msg + redis.unsubscribe if messages.size == 2 + end + end + end + + Thread.pass until sub_cnt == 2 + + publisher = build_another_client + + assert_equal %w[channel1 channel2], publisher.pubsub(:channels, 'channel*') + assert_equal({ 'channel1' => 1, 'channel2' => 1, 'channel3' => 0 }, + publisher.pubsub(:numsub, 'channel1', 'channel2', 'channel3')) + + publisher.publish('channel1', 'one') + publisher.publish('channel2', 'two') + publisher.publish('channel3', 'three') + + thread.join + + assert_equal(2, messages.size) + assert_equal('one', messages['channel1']) + assert_equal('two', messages['channel2']) + end + + def test_publish_psubscribe_punsubscribe_pubsub + sub_cnt = 0 + messages = {} + + thread = Thread.new do + redis.psubscribe('guc*', 'her*') do |on| + on.psubscribe { sub_cnt += 1 } + on.pmessage do |_ptn, c, msg| + messages[c] = msg + redis.punsubscribe if messages.size == 2 + end + end + end + + Thread.pass until sub_cnt == 2 + + publisher = build_another_client + + assert_equal 2, publisher.pubsub(:numpat) + + publisher.publish('burberry1', 'one') + publisher.publish('gucci2', 'two') + publisher.publish('hermes3', 'three') + + thread.join + + assert_equal(2, messages.size) + assert_equal('two', messages['gucci2']) + assert_equal('three', messages['hermes3']) + end + + def test_spublish_ssubscribe_sunsubscribe_pubsub + omit_version('7.0.0') + + sub_cnt = 0 + messages = {} + + thread = Thread.new do + redis.ssubscribe('channel1', 'channel2') do |on| + on.ssubscribe { sub_cnt += 1 } + on.smessage do |c, msg| + messages[c] = msg + redis.sunsubscribe if messages.size == 2 + end + end + end + + Thread.pass until sub_cnt == 2 + + publisher = build_another_client + + assert_equal %w[channel1 channel2], publisher.pubsub(:shardchannels, 'channel*') + assert_equal({ 'channel1' => 1, 'channel2' => 1, 'channel3' => 0 }, + publisher.pubsub(:shardnumsub, 'channel1', 'channel2', 'channel3')) + + publisher.spublish('channel1', 'one') + publisher.spublish('channel2', 'two') + publisher.spublish('channel3', 'three') + + thread.join + + assert_equal(2, messages.size) + assert_equal('one', messages['channel1']) + assert_equal('two', messages['channel2']) + end +end diff --git a/cluster/test/commands_on_scripting_test.rb b/cluster/test/commands_on_scripting_test.rb new file mode 100644 index 000000000..b0af1dda0 --- /dev/null +++ b/cluster/test/commands_on_scripting_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_commands_on_scripting_test.rb +# @see https://redis.io/commands#scripting +class TestClusterCommandsOnScripting < Minitest::Test + 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_raises(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 + assert_equal 'OK', redis.script(:debug, 'yes') + assert_equal 'OK', redis.script(:debug, 'no') + 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/cluster/test/commands_on_server_test.rb b/cluster/test/commands_on_server_test.rb new file mode 100644 index 000000000..3fc36a7a1 --- /dev/null +++ b/cluster/test/commands_on_server_test.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_commands_on_server_test.rb +# @see https://redis.io/commands#server +class TestClusterCommandsOnServer < Minitest::Test + 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| + err = assert_raises(Redis::Cluster::CommandErrorCollection, 'Command error replied on any node') do + redis.bgsave + end + assert_includes err.message, err_msg + assert_kind_of Redis::CommandError, err.errors.values.first + end + end + + def test_client_kill + redis_cluster_mock(client: ->(*_) { '-Error ERR No such client' }) do |redis| + assert_raises(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 + assert_instance_of Hash, a_client_info + assert_includes a_client_info, 'addr' + 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 + assert_equal 'OK', redis.client(:reply, 'ON') + 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_equal 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], + ] + assert_equal(expected, redis.command(:info, :get, :set).map { |c| c.first(6) }) + end + + def test_config_get + assert_equal ['hash-max-ziplist-entries'], redis.config(:get, 'hash-max-ziplist-entrie*').keys.sort + end + + def test_config_rewrite + redis_cluster_mock(config: ->(*_) { '-Error ERR Rewriting config file: Permission denied' }) do |redis| + assert_raises(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 + assert_instance_of String, redis.memory(:doctor) + end + + def test_memory_help + assert_instance_of Array, redis.memory(:help) + end + + def test_memory_malloc_stats + assert_instance_of String, redis.memory('malloc-stats') + end + + def test_memory_purge + assert_equal 'OK', redis.memory(:purge) + end + + def test_memory_stats + assert_instance_of Array, redis.memory(:stats) + end + + def test_memory_usage + redis.set('key1', 'Hello World') + assert_operator redis.memory(:usage, 'key1'), :>, 0 + 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_raises(Redis::Cluster::OrchestrationCommandNotSupported, 'SHUTDOWN command should be...') do + redis.shutdown + end + end + + def test_slaveof + assert_raises(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/cluster/test/commands_on_sets_test.rb b/cluster/test/commands_on_sets_test.rb new file mode 100644 index 000000000..72fda5a1c --- /dev/null +++ b/cluster/test/commands_on_sets_test.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_commands_on_sets_test.rb +# @see https://redis.io/commands#set +class TestClusterCommandsOnSets < Minitest::Test + include Helper::Cluster + include Lint::Sets + + def test_sdiff + assert_raises(Redis::CommandError) { super } + end + + def test_sdiffstore + assert_raises(Redis::CommandError) { super } + end + + def test_sinter + assert_raises(Redis::CommandError) { super } + end + + def test_sinterstore + assert_raises(Redis::CommandError) { super } + end + + def test_smove + assert_raises(Redis::CommandError) { super } + end + + def test_sunion + assert_raises(Redis::CommandError) { super } + end + + def test_sunionstore + assert_raises(Redis::CommandError) { super } + end +end diff --git a/cluster/test/commands_on_sorted_sets_test.rb b/cluster/test/commands_on_sorted_sets_test.rb new file mode 100644 index 000000000..c282c2273 --- /dev/null +++ b/cluster/test/commands_on_sorted_sets_test.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_commands_on_sorted_sets_test.rb +# @see https://redis.io/commands#sorted_set +class TestClusterCommandsOnSortedSets < Minitest::Test + include Helper::Cluster + include Lint::SortedSets + + def test_zrangestore + assert_raises(Redis::CommandError) { super } + end + + def test_zinter + assert_raises(Redis::CommandError) { super } + end + + def test_zinter_with_aggregate + assert_raises(Redis::CommandError) { super } + end + + def test_zinter_with_weights + assert_raises(Redis::CommandError) { super } + end + + def test_zinterstore + assert_raises(Redis::CommandError) { super } + end + + def test_zinterstore_with_aggregate + assert_raises(Redis::CommandError) { super } + end + + def test_zinterstore_with_weights + assert_raises(Redis::CommandError) { super } + end + + def test_zunion + assert_raises(Redis::CommandError) { super } + end + + def test_zunion_with_aggregate + assert_raises(Redis::CommandError) { super } + end + + def test_zunion_with_weights + assert_raises(Redis::CommandError) { super } + end + + def test_zunionstore + assert_raises(Redis::CommandError) { super } + end + + def test_zunionstore_with_aggregate + assert_raises(Redis::CommandError) { super } + end + + def test_zunionstore_with_weights + assert_raises(Redis::CommandError) { super } + end + + def test_zdiff + assert_raises(Redis::CommandError) { super } + end + + def test_zdiffstore + assert_raises(Redis::CommandError) { super } + end +end diff --git a/cluster/test/commands_on_streams_test.rb b/cluster/test/commands_on_streams_test.rb new file mode 100644 index 000000000..f0c7595a3 --- /dev/null +++ b/cluster/test/commands_on_streams_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_commands_on_streams_test.rb +# @see https://redis.io/commands#stream +class TestClusterCommandsOnStreams < Minitest::Test + include Helper::Cluster + include Lint::Streams + + def test_xread_with_multiple_keys + err_msg = "CROSSSLOT Keys in request don't hash to the same slot" + assert_raises(Redis::CommandError, err_msg) { super } + end + + def test_xread_with_multiple_keys_and_hash_tags + redis.xadd('{s}1', { f: 'v01' }, id: '0-1') + redis.xadd('{s}1', { f: 'v02' }, id: '0-2') + redis.xadd('{s}2', { f: 'v11' }, id: '1-1') + redis.xadd('{s}2', { f: 'v12' }, id: '1-2') + + actual = redis.xread(%w[{s}1 {s}2], %w[0-1 1-1]) + + assert_equal %w(0-2), actual['{s}1'].map(&:first) + assert_equal(%w(v02), actual['{s}1'].map { |i| i.last['f'] }) + + assert_equal %w(1-2), actual['{s}2'].map(&:first) + assert_equal(%w(v12), actual['{s}2'].map { |i| i.last['f'] }) + end + + def test_xreadgroup_with_multiple_keys + err_msg = "CROSSSLOT Keys in request don't hash to the same slot" + assert_raises(Redis::CommandError, err_msg) { super } + end + + def test_xreadgroup_with_multiple_keys_and_hash_tags + redis.xadd('{s}1', { f: 'v01' }, id: '0-1') + redis.xgroup(:create, '{s}1', 'g1', '$') + redis.xadd('{s}2', { f: 'v11' }, id: '1-1') + redis.xgroup(:create, '{s}2', 'g1', '$') + redis.xadd('{s}1', { f: 'v02' }, id: '0-2') + redis.xadd('{s}2', { f: 'v12' }, id: '1-2') + + actual = redis.xreadgroup('g1', 'c1', %w[{s}1 {s}2], %w[> >]) + + assert_equal %w(0-2), actual['{s}1'].map(&:first) + assert_equal(%w(v02), actual['{s}1'].map { |i| i.last['f'] }) + + assert_equal %w(1-2), actual['{s}2'].map(&:first) + assert_equal(%w(v12), actual['{s}2'].map { |i| i.last['f'] }) + end +end diff --git a/cluster/test/commands_on_strings_test.rb b/cluster/test/commands_on_strings_test.rb new file mode 100644 index 000000000..dac0972fa --- /dev/null +++ b/cluster/test/commands_on_strings_test.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_commands_on_strings_test.rb +# @see https://redis.io/commands#string +class TestClusterCommandsOnStrings < Minitest::Test + include Helper::Cluster + include Lint::Strings + + def mock(*args, &block) + redis_cluster_mock(*args, &block) + end +end diff --git a/cluster/test/commands_on_transactions_test.rb b/cluster/test/commands_on_transactions_test.rb new file mode 100644 index 000000000..7935ba4a8 --- /dev/null +++ b/cluster/test/commands_on_transactions_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_commands_on_transactions_test.rb +# @see https://redis.io/commands#transactions +class TestClusterCommandsOnTransactions < Minitest::Test + include Helper::Cluster + + def test_discard + assert_raises(Redis::Cluster::AmbiguousNodeError) do + redis.discard + end + end + + def test_exec + assert_raises(Redis::Cluster::AmbiguousNodeError) do + redis.exec + end + end + + def test_multi + assert_raises(LocalJumpError) do + redis.multi + end + + assert_raises(ArgumentError) do + redis.multi {} + end + + assert_equal([1], redis.multi { |r| r.incr('counter') }) + end + + def test_unwatch + assert_raises(Redis::Cluster::AmbiguousNodeError) do + redis.unwatch + end + end + + def test_watch + assert_raises(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/cluster/test/commands_on_value_types_test.rb b/cluster/test/commands_on_value_types_test.rb new file mode 100644 index 000000000..8665a570e --- /dev/null +++ b/cluster/test/commands_on_value_types_test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/cluster_commands_on_value_types_test.rb +class TestClusterCommandsOnValueTypes < Minitest::Test + include Helper::Cluster + include Lint::ValueTypes + + def test_move + assert_raises(Redis::CommandError) { super } + end + + def test_copy + assert_raises(Redis::CommandError) { super } + end +end diff --git a/cluster/test/helper.rb b/cluster/test/helper.rb new file mode 100644 index 000000000..ff7b97943 --- /dev/null +++ b/cluster/test/helper.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require_relative "../../test/helper" +$LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) + +require "redis-clustering" +require_relative 'support/orchestrator' + +module Helper + module Cluster + include Generic + + DEFAULT_HOST = '127.0.0.1' + DEFAULT_PORTS = (16_380..16_385).freeze + + ClusterSlotsRawReply = lambda { |host, port| + # @see https://redis.io/topics/protocol + <<-REPLY.delete(' ') + *1\r + *4\r + :0\r + :16383\r + *3\r + $#{host.size}\r + #{host}\r + :#{port}\r + $40\r + 649fa246273043021a05f547a79478597d3f1dc5\r + *3\r + $#{host.size}\r + #{host}\r + :#{port}\r + $40\r + 649fa246273043021a05f547a79478597d3f1dc5\r + REPLY + } + + ClusterNodesRawReply = lambda { |host, port| + line = "649fa246273043021a05f547a79478597d3f1dc5 #{host}:#{port}@17000 "\ + 'myself,master - 0 1530797742000 1 connected 0-16383' + "$#{line.size}\r\n#{line}\r\n" + } + + def init(redis) + redis.flushall + redis + rescue Redis::CannotConnectError + puts <<-MSG + + Cannot connect to Redis Cluster. + + Make sure Redis is running on localhost, port #{DEFAULT_PORTS}. + + Try this once: + + $ make stop_cluster + + Then run the build again: + + $ make + + MSG + exit! 1 + end + + def build_another_client(options = {}) + _new_client(options) + end + + def redis_cluster_mock(commands, options = {}) + host = DEFAULT_HOST + port = nil + + cluster_subcommands = if commands.key?(:cluster) + commands.delete(:cluster) + .to_h { |k, v| [k.to_s.downcase, v] } + else + {} + end + + commands[:cluster] = lambda { |subcommand, *args| + subcommand = subcommand.downcase + if cluster_subcommands.key?(subcommand) + cluster_subcommands[subcommand].call(*args) + else + case subcommand.downcase + when 'slots' then ClusterSlotsRawReply.call(host, port) + when 'nodes' then ClusterNodesRawReply.call(host, port) + else '+OK' + end + end + } + + commands[:command] = ->(*_) { "*0\r\n" } + + RedisMock.start(commands, options) do |po| + port = po + scheme = options[:ssl] ? 'rediss' : 'redis' + nodes = %W[#{scheme}://#{host}:#{port}] + yield _new_client(options.merge(nodes: nodes)) + end + end + + def redis_cluster_down + trib = ClusterOrchestrator.new(_default_nodes, timeout: TIMEOUT) + trib.down + yield + ensure + trib.rebuild + trib.close + end + + def redis_cluster_failover + trib = ClusterOrchestrator.new(_default_nodes, timeout: TIMEOUT) + trib.failover + yield + ensure + trib.rebuild + trib.close + end + + def redis_cluster_fail_master + trib = ClusterOrchestrator.new(_default_nodes, timeout: TIMEOUT) + trib.fail_serving_master + yield + ensure + trib.restart_cluster_nodes + trib.rebuild + trib.close + end + + # @param slot [Integer] + # @param src [String] : + # @param dest [String] : + def redis_cluster_resharding(slot, src:, dest:) + trib = ClusterOrchestrator.new(_default_nodes, timeout: TIMEOUT) + trib.start_resharding(slot, src, dest) + yield + trib.finish_resharding(slot, dest) + ensure + trib.rebuild + trib.close + end + + private + + def _default_nodes(host: DEFAULT_HOST, ports: DEFAULT_PORTS) + ports.map { |port| "redis://#{host}:#{port}" } + end + + def _format_options(options) + { + timeout: OPTIONS[:timeout], + nodes: _default_nodes + }.merge(options) + end + + def _new_client(options = {}) + Redis::Cluster.new(_format_options(options).merge(driver: ENV['DRIVER'])) + end + end +end diff --git a/cluster/test/support/orchestrator.rb b/cluster/test/support/orchestrator.rb new file mode 100644 index 000000000..fd52dec35 --- /dev/null +++ b/cluster/test/support/orchestrator.rb @@ -0,0 +1,297 @@ +# frozen_string_literal: true + +require 'redis' + +class ClusterOrchestrator + SLOT_SIZE = 16_384 + + def initialize(node_addrs, timeout: 30.0) + raise 'Redis Cluster requires at least 3 master nodes.' if node_addrs.size < 3 + + @clients = node_addrs.map do |addr| + Redis.new(url: addr, timeout: timeout, reconnect_attempts: [0, 0.5, 1, 1.5]) + end + @timeout = timeout + end + + def restart_cluster_nodes + system('make', '--no-print-directory', 'start_cluster', out: File::NULL, err: File::NULL) + 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) + wait_replication(@clients) + wait_cluster_recovering(@clients) + end + + def down + flush_all_data(@clients) + reset_cluster(@clients) + end + + def fail_serving_master + master, slave = take_replication_pairs(@clients) + master.shutdown + attempt_count = 1 + max_attempts = 500 + attempt_count.step(max_attempts) do |i| + return if slave.role == 'master' || i >= max_attempts + + attempt_count += 1 + sleep 0.1 + end + end + + def failover + master, slave = take_replication_pairs(@clients) + wait_replication_delay(@clients, @timeout) + slave.cluster(:failover, :takeover) + wait_failover(to_node_key(master), to_node_key(slave), @clients) + wait_replication_delay(@clients, @timeout) + wait_cluster_recovering(@clients) + end + + def start_resharding(slot, src_node_key, dest_node_key, slice_size: 10) + 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) + + keys_count = src_client.cluster(:countkeysinslot, slot) + loop do + break if keys_count <= 0 + + keys = src_client.cluster(:getkeysinslot, slot, slice_size) + break if keys.empty? + + keys.each do |k| + src_client.migrate(k, host: dest_host, port: dest_port) + rescue Redis::CommandError => err + raise unless err.message.start_with?('IOERR') + + src_client.migrate(k, host: dest_host, port: dest_port, replace: true) # retry once + ensure + keys_count -= 1 + end + 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| + c.flushall(async: true) + rescue Redis::CommandError + # READONLY You can't write against a read only slave. + nil + 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| + c.cluster('set-config-epoch', i + 1) + rescue Redis::CommandError + # ERR Node config epoch is already non-zero + nil + end + end + + def meet_each_other(clients) + first_client = clients.first + target_info = first_client.connection + target_host = target_info.fetch(:host) + target_port = target_info.fetch(:port) + + clients.each do |client| + next if first_client.id == client.id + + client.cluster(:meet, target_host, target_port) + end + end + + def wait_meeting(clients, max_attempts: 60) + size = clients.size.to_s + + wait_for_state(clients, max_attempts) do |client| + info = hashify_cluster_info(client) + info['cluster_known_nodes'] == size + 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, max_attempts: 60) + wait_for_state(clients, max_attempts) do |client| + info = hashify_cluster_info(client) + info['cluster_state'] == 'ok' + end + end + + def wait_replication(clients, max_attempts: 60) + wait_for_state(clients, max_attempts) do |client| + flags = hashify_cluster_node_flags(client) + flags.values.select { |f| f == 'slave' }.size == 3 + end + end + + def wait_failover(master_key, slave_key, clients, max_attempts: 60) + wait_for_state(clients, max_attempts) do |client| + flags = hashify_cluster_node_flags(client) + flags[master_key] == 'slave' && flags[slave_key] == 'master' + end + end + + def wait_replication_delay(clients, timeout_sec) + timeout_msec = timeout_sec.to_i * 1000 + wait_for_state(clients, clients.size + 1) do |client| + client.wait(1, timeout_msec) if client.role.first == 'master' + true + end + end + + def wait_cluster_recovering(clients, max_attempts: 60) + key = 0 + wait_for_state(clients, max_attempts) do |client| + client.get(key) if client.role.first == 'master' + true + rescue Redis::CommandError => err + if err.message.start_with?('CLUSTERDOWN') + false + elsif err.message.start_with?('MOVED') + key += 1 + false + else + true + end + end + end + + def wait_for_state(clients, max_attempts) + attempt_count = 1 + clients.each do |client| + attempt_count.step(max_attempts) do |i| + break if i >= max_attempts + + attempt_count += 1 + break if yield(client) + + sleep 0.1 + end + end + end + + def hashify_cluster_info(client) + client.cluster(:info).split("\r\n").map { |str| str.split(':') }.to_h + end + + def hashify_cluster_node_flags(client) + client.cluster(:nodes) + .split("\n") + .map { |str| str.split(' ') } + .map { |arr| [arr[1].split('@').first, (arr[2].split(',') & %w[master slave]).first] } + .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 take_replication_pairs(clients) + [take_masters(clients).last, take_slaves(clients).last] + end + + def find_client(clients, node_key) + clients.find { |cli| node_key == to_node_key(cli) } + end + + def to_node_key(client) + con = client.connection + "#{con.fetch(:host)}:#{con.fetch(:port)}" + end +end diff --git a/examples/basic.rb b/examples/basic.rb index 7800cf794..4c8906bba 100644 --- a/examples/basic.rb +++ b/examples/basic.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'redis' r = Redis.new @@ -6,7 +8,7 @@ puts -p'set foo to "bar"' +p 'set foo to "bar"' r['foo'] = 'bar' puts diff --git a/examples/consistency.rb b/examples/consistency.rb index df34e1ab9..3c54eaf5f 100644 --- a/examples/consistency.rb +++ b/examples/consistency.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # This file implements a simple consistency test for Redis-rb (or any other # Redis environment if you pass a different client object) where a client # writes to the database using INCR in order to increment keys, but actively @@ -16,10 +18,10 @@ # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: -# +# # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -# +# # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND @@ -31,84 +33,83 @@ require 'redis' class ConsistencyTester - def initialize(redis) - @r = redis - @working_set = 10000 - @keyspace = 100000 - @writes = 0 - @reads = 0 - @failed_writes = 0 - @failed_reads = 0 - @lost_writes = 0 - @not_ack_writes = 0 - @delay = 0 - @cached = {} # We take our view of data stored in the DB. - @prefix = [Process.pid.to_s,Time.now.usec,@r.object_id,""].join("|") - @errtime = {} - end + def initialize(redis) + @r = redis + @working_set = 10_000 + @keyspace = 100_000 + @writes = 0 + @reads = 0 + @failed_writes = 0 + @failed_reads = 0 + @lost_writes = 0 + @not_ack_writes = 0 + @delay = 0 + @cached = {} # We take our view of data stored in the DB. + @prefix = [Process.pid.to_s, Time.now.usec, @r.object_id, ""].join("|") + @errtime = {} + end - def genkey - # Write more often to a small subset of keys - ks = rand() > 0.5 ? @keyspace : @working_set - @prefix+"key_"+rand(ks).to_s - end + def genkey + # Write more often to a small subset of keys + ks = rand > 0.5 ? @keyspace : @working_set + "#{@prefix}key_#{rand(ks).to_s}" + end - def check_consistency(key,value) - expected = @cached[key] - return if !expected # We lack info about previous state. - if expected > value - @lost_writes += expected-value - elsif expected < value - @not_ack_writes += value-expected - end - end + def check_consistency(key, value) + expected = @cached[key] + return unless expected # We lack info about previous state. - def puterr(msg) - if !@errtime[msg] || Time.now.to_i != @errtime[msg] - puts msg - end - @errtime[msg] = Time.now.to_i + if expected > value + @lost_writes += expected - value + elsif expected < value + @not_ack_writes += value - expected end + end + + def puterr(msg) + puts msg if !@errtime[msg] || Time.now.to_i != @errtime[msg] + @errtime[msg] = Time.now.to_i + end + + def test + last_report = Time.now.to_i + loop do + # Read + key = genkey + begin + val = @r.get(key) + check_consistency(key, val.to_i) + @reads += 1 + rescue => e + puterr "Reading: #{e.class}: #{e.message} (#{e.backtrace.first})" + @failed_reads += 1 + end - def test - last_report = Time.now.to_i - while true - # Read - key = genkey - begin - val = @r.get(key) - check_consistency(key,val.to_i) - @reads += 1 - rescue => e - puterr "Reading: #{e.class}: #{e.message} (#{e.backtrace.first})" - @failed_reads += 1 - end + # Write + begin + @cached[key] = @r.incr(key).to_i + @writes += 1 + rescue => e + puterr "Writing: #{e.class}: #{e.message} (#{e.backtrace.first})" + @failed_writes += 1 + end - # Write - begin - @cached[key] = @r.incr(key).to_i - @writes += 1 - rescue => e - puterr "Writing: #{e.class}: #{e.message} (#{e.backtrace.first})" - @failed_writes += 1 - end + # Report + sleep @delay + next unless Time.now.to_i != last_report - # Report - sleep @delay - if Time.now.to_i != last_report - report = "#{@reads} R (#{@failed_reads} err) | " + - "#{@writes} W (#{@failed_writes} err) | " - report += "#{@lost_writes} lost | " if @lost_writes > 0 - report += "#{@not_ack_writes} noack | " if @not_ack_writes > 0 - last_report = Time.now.to_i - puts report - end - end + report = "#{@reads} R (#{@failed_reads} err) | " \ + "#{@writes} W (#{@failed_writes} err) | " + report += "#{@lost_writes} lost | " if @lost_writes > 0 + report += "#{@not_ack_writes} noack | " if @not_ack_writes > 0 + last_report = Time.now.to_i + puts report end + end end -Sentinels = [{:host => "127.0.0.1", :port => 26379}, - {:host => "127.0.0.1", :port => 26380}] -r = Redis.new(:url => "redis://master1", :sentinels => Sentinels, :role => :master) +SENTINELS = [{ host: "127.0.0.1", port: 26_379 }, + { host: "127.0.0.1", port: 26_380 }].freeze +r = Redis.new(url: "redis://master1", sentinels: SENTINELS, role: :master) tester = ConsistencyTester.new(r) tester.test diff --git a/examples/dist_redis.rb b/examples/dist_redis.rb index fe1c5da72..6a8878420 100644 --- a/examples/dist_redis.rb +++ b/examples/dist_redis.rb @@ -1,7 +1,14 @@ +# frozen_string_literal: true + require "redis" require "redis/distributed" -r = Redis::Distributed.new %w[redis://localhost:6379 redis://localhost:6380 redis://localhost:6381 redis://localhost:6382] +r = Redis::Distributed.new %w[ + redis://localhost:6379 + redis://localhost:6380 + redis://localhost:6381 + redis://localhost:6382 +] r.flushdb @@ -37,7 +44,7 @@ puts "key distribution:" r.ring.nodes.each do |node| - p [node.client, node.keys("*")] + p [node.client(:getname), node.keys("*")] end r.flushdb p r.keys('*') diff --git a/examples/incr-decr.rb b/examples/incr-decr.rb index 98ff9d15c..d226f3dfe 100644 --- a/examples/incr-decr.rb +++ b/examples/incr-decr.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'redis' r = Redis.new diff --git a/examples/list.rb b/examples/list.rb index b2f25cbaa..06e460c40 100644 --- a/examples/list.rb +++ b/examples/list.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rubygems' require 'redis' diff --git a/examples/pubsub.rb b/examples/pubsub.rb index 9da15065d..8b9d09712 100644 --- a/examples/pubsub.rb +++ b/examples/pubsub.rb @@ -1,13 +1,15 @@ +# frozen_string_literal: true + require "redis" -puts <<-EOS -To play with this example use redis-cli from another terminal, like this: +puts <<~EOS + To play with this example use redis-cli from another terminal, like this: - $ redis-cli publish one hello + $ redis-cli publish one hello -Finally force the example to exit sending the 'exit' message with: + Finally force the example to exit sending the 'exit' message with: - $ redis-cli publish two exit + $ redis-cli publish two exit EOS diff --git a/examples/sentinel.rb b/examples/sentinel.rb index 62fc5a4c4..884b77b96 100644 --- a/examples/sentinel.rb +++ b/examples/sentinel.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'redis' # This example creates a master-slave setup with a sentinel, then connects to @@ -13,29 +15,29 @@ at_exit do begin - Process.kill(:INT, $redises) + Process.kill(:INT, @redises) rescue Errno::ESRCH end Process.waitall end -$redises = spawn("examples/sentinel/start") +@redises = spawn("examples/sentinel/start") -Sentinels = [{:host => "127.0.0.1", :port => 26379}, - {:host => "127.0.0.1", :port => 26380}] -r = Redis.new(:url => "redis://master1", :sentinels => Sentinels, :role => :master) +SENTINELS = [{ host: "127.0.0.1", port: 26_379 }, + { host: "127.0.0.1", port: 26_380 }].freeze +r = Redis.new(url: "redis://master1", sentinels: SENTINELS, role: :master) # Set keys into a loop. # # The example traps errors so that you can actually try to failover while # running the script to see redis-rb reconfiguring. -(0..1000000).each{|i| - begin - r.set(i,i) - $stdout.write("SET (#{i} times)\n") if i % 100 == 0 - rescue => e - $stdout.write("E") - end - sleep(0.01) -} +(0..1_000_000).each do |i| + begin + r.set(i, i) + $stdout.write("SET (#{i} times)\n") if i % 100 == 0 + rescue + $stdout.write("E") + end + sleep(0.01) +end diff --git a/examples/sentinel/start b/examples/sentinel/start index 46567e120..547d63bb5 100755 --- a/examples/sentinel/start +++ b/examples/sentinel/start @@ -1,4 +1,5 @@ #! /usr/bin/env ruby +# frozen_string_literal: true # This is a helper script used together with examples/sentinel.rb # It runs two Redis masters, two slaves for each of them, and two sentinels. @@ -8,42 +9,40 @@ require "fileutils" -$pids = [] +pids = [] at_exit do - $pids.each do |pid| - begin - Process.kill(:INT, pid) - rescue Errno::ESRCH - end + pids.each do |pid| + Process.kill(:INT, pid) + rescue Errno::ESRCH end Process.waitall end -base = File.expand_path(File.dirname(__FILE__)) +base = __dir__ # Masters -$pids << spawn("redis-server --port 6380 --loglevel warning") -$pids << spawn("redis-server --port 6381 --loglevel warning") +pids << spawn("redis-server --port 6380 --loglevel warning") +pids << spawn("redis-server --port 6381 --loglevel warning") # Slaves of Master 1 -$pids << spawn("redis-server --port 63800 --slaveof 127.0.0.1 6380 --loglevel warning") -$pids << spawn("redis-server --port 63801 --slaveof 127.0.0.1 6380 --loglevel warning") +pids << spawn("redis-server --port 63800 --slaveof 127.0.0.1 6380 --loglevel warning") +pids << spawn("redis-server --port 63801 --slaveof 127.0.0.1 6380 --loglevel warning") # Slaves of Master 2 -$pids << spawn("redis-server --port 63810 --slaveof 127.0.0.1 6381 --loglevel warning") -$pids << spawn("redis-server --port 63811 --slaveof 127.0.0.1 6381 --loglevel warning") +pids << spawn("redis-server --port 63810 --slaveof 127.0.0.1 6381 --loglevel warning") +pids << spawn("redis-server --port 63811 --slaveof 127.0.0.1 6381 --loglevel warning") FileUtils.cp(File.join(base, "sentinel.conf"), "tmp/sentinel1.conf") FileUtils.cp(File.join(base, "sentinel.conf"), "tmp/sentinel2.conf") # Sentinels -$pids << spawn("redis-server tmp/sentinel1.conf --sentinel --port 26379") -$pids << spawn("redis-server tmp/sentinel2.conf --sentinel --port 26380") +pids << spawn("redis-server tmp/sentinel1.conf --sentinel --port 26379") +pids << spawn("redis-server tmp/sentinel2.conf --sentinel --port 26380") sleep 30 -Process.kill(:KILL, $pids[0]) +Process.kill(:KILL, pids[0]) Process.waitall diff --git a/examples/sets.rb b/examples/sets.rb index 31c49c38c..8446a51db 100644 --- a/examples/sets.rb +++ b/examples/sets.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rubygems' require 'redis' diff --git a/examples/unicorn/config.ru b/examples/unicorn/config.ru index ede557351..d49d56904 100644 --- a/examples/unicorn/config.ru +++ b/examples/unicorn/config.ru @@ -1,3 +1,5 @@ -run lambda { |env| - [200, {"Content-Type" => "text/plain"}, [Redis.current.randomkey]] +# frozen_string_literal: true + +run lambda { |_env| + [200, { "Content-Type" => "text/plain" }, [MyApp.redis.randomkey]] } diff --git a/examples/unicorn/unicorn.rb b/examples/unicorn/unicorn.rb index 957d3840e..a47f1a0fb 100644 --- a/examples/unicorn/unicorn.rb +++ b/examples/unicorn/unicorn.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "redis" worker_processes 3 @@ -15,6 +17,6 @@ # Thus we need to connect to Redis after forking the # worker processes. -after_fork do |server, worker| - Redis.current.disconnect! +after_fork do |_server, _worker| + MyApp.redis.disconnect! end diff --git a/lib/redis.rb b/lib/redis.rb index c61d4838e..c6b8f7003 100644 --- a/lib/redis.rb +++ b/lib/redis.rb @@ -1,2778 +1,193 @@ +# frozen_string_literal: true + require "monitor" require "redis/errors" +require "redis/commands" class Redis + BASE_PATH = __dir__ + Deprecated = Class.new(StandardError) - def self.deprecate(message, trace = caller[0]) - $stderr.puts "\n#{message} (in #{trace})" - end - - attr :client + class << self + attr_accessor :silence_deprecations, :raise_deprecations - # @deprecated The preferred way to create a new client object is using `#new`. - # This method does not actually establish a connection to Redis, - # in contrary to what you might expect. - def self.connect(options = {}) - new(options) + def deprecate!(message) + unless silence_deprecations + if raise_deprecations + raise Deprecated, message + else + ::Kernel.warn(message) + end + end + end end - def self.current - @current ||= Redis.new + # soft-deprecated + # We added this back for older sidekiq releases + module Connection + class << self + def drivers + [RedisClient.default_driver] + end + end end - def self.current=(redis) - @current = redis - end + include Commands - include MonitorMixin + SERVER_URL_OPTIONS = %i(url host port path).freeze # Create a new client instance # # @param [Hash] options - # @option options [String] :url (value of the environment variable REDIS_URL) a Redis URL, for a TCP connection: `redis://:[password]@[hostname]:[port]/[db]` (password, port and database are optional), for a unix socket connection: `unix://[path to Redis socket]`. This overrides all other options. + # @option options [String] :url (value of the environment variable REDIS_URL) a Redis URL, for a TCP connection: + # `redis://:[password]@[hostname]:[port]/[db]` (password, port and database are optional), for a unix socket + # connection: `unix://[path to Redis socket]`. This overrides all other options. # @option options [String] :host ("127.0.0.1") server hostname - # @option options [Fixnum] :port (6379) server port + # @option options [Integer] :port (6379) server port # @option options [String] :path path to server socket (overrides host and port) - # @option options [Float] :timeout (5.0) timeout in seconds + # @option options [Float] :timeout (1.0) timeout in seconds # @option options [Float] :connect_timeout (same as timeout) timeout for initial connect in seconds + # @option options [String] :username Username to authenticate against server # @option options [String] :password Password to authenticate against server - # @option options [Fixnum] :db (0) Database to select after initial connect - # @option options [Symbol] :driver Driver to use, currently supported: `:ruby`, `:hiredis`, `:synchrony` - # @option options [String] :id ID for the client connection, assigns name to current connection by sending `CLIENT SETNAME` - # @option options [Hash, Fixnum] :tcp_keepalive Keepalive values, if Fixnum `intvl` and `probe` are calculated based on the value, if Hash `time`, `intvl` and `probes` can be specified as a Fixnum - # @option options [Fixnum] :reconnect_attempts Number of attempts trying to connect + # @option options [Integer] :db (0) Database to select after connect and on reconnects + # @option options [Symbol] :driver Driver to use, currently supported: `:ruby`, `:hiredis` + # @option options [String] :id ID for the client connection, assigns name to current connection by sending + # `CLIENT SETNAME` + # @option options [Integer, Array] :reconnect_attempts Number of attempts trying to connect, + # or a list of sleep duration between attempts. # @option options [Boolean] :inherit_socket (false) Whether to use socket in forked process or not + # @option options [String] :name The name of the server group to connect to. # @option options [Array] :sentinels List of sentinels to contact - # @option options [Symbol] :role (:master) Role to fetch via Sentinel, either `:master` or `:slave` # # @return [Redis] a new client instance def initialize(options = {}) + @monitor = Monitor.new @options = options.dup - @original_client = @client = Client.new(options) - @queue = Hash.new { |h, k| h[k] = [] } - - super() # Monitor#initialize - end - - def synchronize - mon_synchronize { yield(@client) } - end - - # Run code with the client reconnecting - def with_reconnect(val=true, &blk) - synchronize do |client| - client.with_reconnect(val, &blk) + @options[:reconnect_attempts] = 1 unless @options.key?(:reconnect_attempts) + if ENV["REDIS_URL"] && SERVER_URL_OPTIONS.none? { |o| @options.key?(o) } + @options[:url] = ENV["REDIS_URL"] end + inherit_socket = @options.delete(:inherit_socket) + @subscription_client = nil + + @client = initialize_client(@options) + @client.inherit_socket! if inherit_socket end # Run code without the client reconnecting - def without_reconnect(&blk) - with_reconnect(false, &blk) + def without_reconnect(&block) + @client.disable_reconnection(&block) end # Test whether or not the client is connected def connected? - @original_client.connected? + @client.connected? || @subscription_client&.connected? end # Disconnect the client as quickly and silently as possible. def close - @original_client.disconnect + @client.close + @subscription_client&.close end alias disconnect! close - # Sends a command to Redis and returns its reply. - # - # Replies are converted to Ruby objects according to the RESP protocol, so - # you can expect a Ruby array, integer or nil when Redis sends one. Higher - # level transformations, such as converting an array of pairs into a Ruby - # hash, are up to consumers. - # - # Redis error replies are raised as Ruby exceptions. - def call(*command) - synchronize do |client| - client.call(command) - end - end - - # Queues a command for pipelining. - # - # Commands in the queue are executed with the Redis#commit method. - # - # See http://redis.io/topics/pipelining for more details. - # - def queue(*command) - @queue[Thread.current.object_id] << command - end - - # Sends all commands in the queue. - # - # See http://redis.io/topics/pipelining for more details. - # - def commit - synchronize do |client| - begin - client.call_pipelined(@queue[Thread.current.object_id]) - ensure - @queue.delete(Thread.current.object_id) - end - end - end - - # Authenticate to the server. - # - # @param [String] password must match the password specified in the - # `requirepass` directive in the configuration file - # @return [String] `OK` - def auth(password) - synchronize do |client| - client.call([:auth, password]) - end - end - - # Change the selected database for the current connection. - # - # @param [Fixnum] db zero-based index of the DB to use (0 to 15) - # @return [String] `OK` - def select(db) - synchronize do |client| - client.db = db - client.call([:select, db]) - end - end - - # Ping the server. - # - # @return [String] `PONG` - def ping - synchronize do |client| - client.call([:ping]) - end - end - - # Echo the given string. - # - # @param [String] value - # @return [String] - def echo(value) - synchronize do |client| - client.call([:echo, value]) - end - end - - # Close the connection. - # - # @return [String] `OK` - def quit - synchronize do |client| - begin - client.call([:quit]) - rescue ConnectionError - ensure - client.disconnect - end - end - end - - # Asynchronously rewrite the append-only file. - # - # @return [String] `OK` - def bgrewriteaof - synchronize do |client| - client.call([:bgrewriteaof]) - end + def with + yield self end - # Asynchronously save the dataset to disk. - # - # @return [String] `OK` - def bgsave - synchronize do |client| - client.call([:bgsave]) - end + def _client + @client end - # Get or set server configuration parameters. - # - # @param [Symbol] action e.g. `:get`, `:set`, `:resetstat` - # @return [String, Hash] string reply, or hash when retrieving more than one - # property with `CONFIG GET` - def config(action, *args) + def pipelined synchronize do |client| - client.call([:config, action] + args) do |reply| - if reply.kind_of?(Array) && action == :get - Hashify.call(reply) - else - reply - end + client.pipelined do |raw_pipeline| + yield PipelinedConnection.new(raw_pipeline) end end end - # Return the number of keys in the selected database. - # - # @return [Fixnum] - def dbsize - synchronize do |client| - client.call([:dbsize]) - end - end - - def debug(*args) - synchronize do |client| - client.call([:debug] + args) - end - end - - # Remove all keys from all databases. - # - # @return [String] `OK` - def flushall - synchronize do |client| - client.call([:flushall]) - end + def id + @client.id || @client.server_url end - # Remove all keys from the current database. - # - # @return [String] `OK` - def flushdb - synchronize do |client| - client.call([:flushdb]) - end + def inspect + "#" end - # Get information and statistics about the server. - # - # @param [String, Symbol] cmd e.g. "commandstats" - # @return [Hash] - 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] - - if cmd && cmd.to_s == "commandstats" - # Extract nested hashes for INFO COMMANDSTATS - reply = Hash[reply.map do |k, v| - v = v.split(",").map { |e| e.split("=") } - [k[/^cmdstat_(.*)$/, 1], Hash[v]] - end] - end - end - - reply - end - end + def dup + self.class.new(@options) end - # Get the UNIX time stamp of the last successful save to disk. - # - # @return [Fixnum] - def lastsave - synchronize do |client| - client.call([:lastsave]) - end + def connection + { + host: @client.host, + port: @client.port, + db: @client.db, + id: id, + location: "#{@client.host}:#{@client.port}" + } end - # Listen for all requests received by the server in real time. - # - # There is no way to interrupt this command. - # - # @yield a block to be called for every line of output - # @yieldparam [String] line timestamp and command that was executed - def monitor(&block) - synchronize do |client| - client.call_loop([:monitor], &block) - end - end + private - # Synchronously save the dataset to disk. - # - # @return [String] - def save - synchronize do |client| - client.call([:save]) + def initialize_client(options) + if options.key?(:cluster) + raise "Redis Cluster support was moved to the `redis-clustering` gem." end - end - # Synchronously save the dataset to disk and then shut down the server. - def shutdown - synchronize do |client| - client.with_reconnect(false) do - begin - client.call([:shutdown]) - rescue ConnectionError - # This means Redis has probably exited. - nil - end - end + if options.key?(:sentinels) + Client.sentinel(**options).new_client + else + Client.config(**options).new_client end end - # Make the server a slave of another instance, or promote it as master. - def slaveof(host, port) - synchronize do |client| - client.call([:slaveof, host, port]) - end + def synchronize + @monitor.synchronize { yield(@client) } end - # Interact with the slowlog (get, len, reset) - # - # @param [String] subcommand e.g. `get`, `len`, `reset` - # @param [Fixnum] length maximum number of entries to return - # @return [Array, Fixnum, String] depends on subcommand - def slowlog(subcommand, length=nil) - synchronize do |client| - args = [:slowlog, subcommand] - args << length if length - client.call args + def send_command(command, &block) + @monitor.synchronize do + @client.call_v(command, &block) end + rescue ::RedisClient::Error => error + Client.translate_error!(error) end - # Internal command used for replication. - def sync - synchronize do |client| - client.call([:sync]) + def send_blocking_command(command, timeout, &block) + @monitor.synchronize do + @client.blocking_call_v(timeout, command, &block) end end - # Return the server time. - # - # @example - # r.time # => [ 1333093196, 606806 ] - # - # @return [Array] tuple of seconds since UNIX epoch and - # microseconds in the current second - def time - synchronize do |client| - client.call([:time]) do |reply| - reply.map(&:to_i) if reply + def _subscription(method, timeout, channels, block) + if block + if @subscription_client + raise SubscriptionError, "This client is already subscribed" end - end - end - - # Remove the expiration from a key. - # - # @param [String] key - # @return [Boolean] whether the timeout was removed or not - def persist(key) - synchronize do |client| - client.call([:persist, key], &Boolify) - end - end - - # Set a key's time to live in seconds. - # - # @param [String] key - # @param [Fixnum] seconds time to live - # @return [Boolean] whether the timeout was set or not - def expire(key, seconds) - synchronize do |client| - client.call([:expire, key, seconds], &Boolify) - end - end - - # Set the expiration for a key as a UNIX timestamp. - # - # @param [String] key - # @param [Fixnum] unix_time expiry time specified as a UNIX timestamp - # @return [Boolean] whether the timeout was set or not - def expireat(key, unix_time) - synchronize do |client| - client.call([:expireat, key, unix_time], &Boolify) - end - end - - # Get the time to live (in seconds) for a key. - # - # @param [String] key - # @return [Fixnum] remaining time to live in seconds. - # - # In Redis 2.6 or older the command returns -1 if the key does not exist or if - # the key exist but has no associated expire. - # - # Starting with Redis 2.8 the return value in case of error changed: - # - # - The command returns -2 if the key does not exist. - # - The command returns -1 if the key exists but has no associated expire. - def ttl(key) - synchronize do |client| - client.call([:ttl, key]) - end - end - - # Set a key's time to live in milliseconds. - # - # @param [String] key - # @param [Fixnum] milliseconds time to live - # @return [Boolean] whether the timeout was set or not - def pexpire(key, milliseconds) - synchronize do |client| - client.call([:pexpire, key, milliseconds], &Boolify) - end - end - - # Set the expiration for a key as number of milliseconds from UNIX Epoch. - # - # @param [String] key - # @param [Fixnum] ms_unix_time expiry time specified as number of milliseconds from UNIX Epoch. - # @return [Boolean] whether the timeout was set or not - def pexpireat(key, ms_unix_time) - synchronize do |client| - client.call([:pexpireat, key, ms_unix_time], &Boolify) - end - end - - # Get the time to live (in milliseconds) for a key. - # - # @param [String] key - # @return [Fixnum] remaining time to live in milliseconds - # In Redis 2.6 or older the command returns -1 if the key does not exist or if - # the key exist but has no associated expire. - # - # Starting with Redis 2.8 the return value in case of error changed: - # - # - The command returns -2 if the key does not exist. - # - The command returns -1 if the key exists but has no associated expire. - def pttl(key) - synchronize do |client| - client.call([:pttl, key]) - end - end - - # Return a serialized version of the value stored at a key. - # - # @param [String] key - # @return [String] serialized_value - def dump(key) - synchronize do |client| - client.call([:dump, key]) - end - end - - # Create a key using the serialized value, previously obtained using DUMP. - # - # @param [String] key - # @param [String] ttl - # @param [String] serialized_value - # @return [String] `"OK"` - def restore(key, ttl, serialized_value) - synchronize do |client| - client.call([:restore, key, ttl, serialized_value]) - end - end - - # Transfer a key from the connected instance to another instance. - # - # @param [String] key - # @param [Hash] options - # - `:host => String`: host of instance to migrate to - # - `:port => Integer`: port of instance to migrate to - # - `:db => Integer`: database to migrate to (default: same as source) - # - `:timeout => Integer`: timeout (default: same as connection timeout) - # @return [String] `"OK"` - def migrate(key, options) - host = options[:host] || raise(RuntimeError, ":host not specified") - port = options[:port] || raise(RuntimeError, ":port not specified") - db = (options[:db] || client.db).to_i - timeout = (options[:timeout] || client.timeout).to_i - - synchronize do |client| - client.call([:migrate, host, port, key, db, timeout]) - end - end - - # Delete one or more keys. - # - # @param [String, Array] keys - # @return [Fixnum] number of keys that were deleted - def del(*keys) - synchronize do |client| - client.call([:del] + keys) - end - end - - # Determine if a key exists. - # - # @param [String] key - # @return [Boolean] - def exists(key) - synchronize do |client| - client.call([:exists, key], &Boolify) - end - end - # Find all keys matching the given pattern. - # - # @param [String] pattern - # @return [Array] - def keys(pattern = "*") - synchronize do |client| - client.call([:keys, pattern]) do |reply| - if reply.kind_of?(String) - reply.split(" ") + begin + @subscription_client = SubscribedClient.new(@client.pubsub) + if timeout > 0 + @subscription_client.send(method, timeout, *channels, &block) else - reply + @subscription_client.send(method, *channels, &block) end + ensure + @subscription_client = nil end - end - end - - # Move a key to another database. - # - # @example Move a key to another database - # redis.set "foo", "bar" - # # => "OK" - # redis.move "foo", 2 - # # => true - # redis.exists "foo" - # # => false - # redis.select 2 - # # => "OK" - # redis.exists "foo" - # # => true - # redis.get "foo" - # # => "bar" - # - # @param [String] key - # @param [Fixnum] db - # @return [Boolean] whether the key was moved or not - def move(key, db) - synchronize do |client| - client.call([:move, key, db], &Boolify) - end - end - - def object(*args) - synchronize do |client| - client.call([:object] + args) - end - end - - # Return a random key from the keyspace. - # - # @return [String] - def randomkey - synchronize do |client| - client.call([:randomkey]) - end - end - - # Rename a key. If the new key already exists it is overwritten. - # - # @param [String] old_name - # @param [String] new_name - # @return [String] `OK` - def rename(old_name, new_name) - synchronize do |client| - client.call([:rename, old_name, new_name]) - end - end - - # Rename a key, only if the new key does not exist. - # - # @param [String] old_name - # @param [String] new_name - # @return [Boolean] whether the key was renamed or not - def renamenx(old_name, new_name) - synchronize do |client| - client.call([:renamenx, old_name, new_name], &Boolify) - end - end - - # Sort the elements in a list, set or sorted set. - # - # @example Retrieve the first 2 elements from an alphabetically sorted "list" - # redis.sort("list", :order => "alpha", :limit => [0, 2]) - # # => ["a", "b"] - # @example Store an alphabetically descending list in "target" - # redis.sort("list", :order => "desc alpha", :store => "target") - # # => 26 - # - # @param [String] key - # @param [Hash] options - # - `:by => String`: use external key to sort elements by - # - `:limit => [offset, count]`: skip `offset` elements, return a maximum - # of `count` elements - # - `:get => [String, Array]`: single key or array of keys to - # retrieve per element in the result - # - `:order => String`: combination of `ASC`, `DESC` and optionally `ALPHA` - # - `:store => String`: key to store the result at - # - # @return [Array, Array>, Fixnum] - # - when `:get` is not specified, or holds a single element, an array of elements - # - when `:get` is specified, and holds more than one element, an array of - # elements where every element is an array with the result for every - # element specified in `:get` - # - when `:store` is specified, the number of elements in the stored result - def sort(key, options = {}) - args = [] - - by = options[:by] - args.concat(["BY", by]) if by - - limit = options[:limit] - args.concat(["LIMIT"] + limit) if limit - - get = Array(options[:get]) - args.concat(["GET"].product(get).flatten) unless get.empty? - - order = options[:order] - args.concat(order.split(" ")) if order - - store = options[:store] - args.concat(["STORE", store]) if store - - synchronize do |client| - client.call([:sort, key] + args) do |reply| - if get.size > 1 && !store - if reply - reply.each_slice(get.size).to_a - end - else - reply - end + else + unless @subscription_client + raise SubscriptionError, "This client is not subscribed" end - end - end - - # Determine the type stored at key. - # - # @param [String] key - # @return [String] `string`, `list`, `set`, `zset`, `hash` or `none` - def type(key) - synchronize do |client| - client.call([:type, key]) - end - end - # Decrement the integer value of a key by one. - # - # @example - # redis.decr("value") - # # => 4 - # - # @param [String] key - # @return [Fixnum] value after decrementing it - def decr(key) - synchronize do |client| - client.call([:decr, key]) + @subscription_client.call_v([method].concat(channels)) end end - - # Decrement the integer value of a key by the given number. - # - # @example - # redis.decrby("value", 5) - # # => 0 - # - # @param [String] key - # @param [Fixnum] decrement - # @return [Fixnum] value after decrementing it - def decrby(key, decrement) - synchronize do |client| - client.call([:decrby, key, decrement]) - end - end - - # Increment the integer value of a key by one. - # - # @example - # redis.incr("value") - # # => 6 - # - # @param [String] key - # @return [Fixnum] value after incrementing it - def incr(key) - synchronize do |client| - client.call([:incr, key]) - end - end - - # Increment the integer value of a key by the given integer number. - # - # @example - # redis.incrby("value", 5) - # # => 10 - # - # @param [String] key - # @param [Fixnum] increment - # @return [Fixnum] value after incrementing it - def incrby(key, increment) - synchronize do |client| - client.call([:incrby, key, increment]) - end - end - - # Increment the numeric value of a key by the given float number. - # - # @example - # redis.incrbyfloat("value", 1.23) - # # => 1.23 - # - # @param [String] key - # @param [Float] increment - # @return [Float] value after incrementing it - def incrbyfloat(key, increment) - synchronize do |client| - client.call([:incrbyfloat, key, increment], &Floatify) - end - end - - # Set the string value of a key. - # - # @param [String] key - # @param [String] value - # @param [Hash] options - # - `:ex => Fixnum`: Set the specified expire time, in seconds. - # - `:px => Fixnum`: Set the specified expire time, in milliseconds. - # - `:nx => true`: Only set the key if it does not already exist. - # - `:xx => true`: Only set the key if it already exist. - # @return [String, Boolean] `"OK"` or true, false if `:nx => true` or `:xx => true` - def set(key, value, options = {}) - args = [] - - ex = options[:ex] - args.concat(["EX", ex]) if ex - - px = options[:px] - args.concat(["PX", px]) if px - - nx = options[:nx] - args.concat(["NX"]) if nx - - xx = options[:xx] - args.concat(["XX"]) if xx - - synchronize do |client| - if nx || xx - client.call([:set, key, value.to_s] + args, &BoolifySet) - else - client.call([:set, key, value.to_s] + args) - end - end - end - - alias :[]= :set - - # Set the time to live in seconds of a key. - # - # @param [String] key - # @param [Fixnum] ttl - # @param [String] value - # @return [String] `"OK"` - def setex(key, ttl, value) - synchronize do |client| - client.call([:setex, key, ttl, value.to_s]) - end - end - - # Set the time to live in milliseconds of a key. - # - # @param [String] key - # @param [Fixnum] ttl - # @param [String] value - # @return [String] `"OK"` - def psetex(key, ttl, value) - synchronize do |client| - client.call([:psetex, key, ttl, value.to_s]) - end - end - - # Set the value of a key, only if the key does not exist. - # - # @param [String] key - # @param [String] value - # @return [Boolean] whether the key was set or not - def setnx(key, value) - synchronize do |client| - client.call([:setnx, key, value.to_s], &Boolify) - end - end - - # Set one or more values. - # - # @example - # redis.mset("key1", "v1", "key2", "v2") - # # => "OK" - # - # @param [Array] args array of keys and values - # @return [String] `"OK"` - # - # @see #mapped_mset - def mset(*args) - synchronize do |client| - client.call([:mset] + args) - end - end - - # Set one or more values. - # - # @example - # redis.mapped_mset({ "f1" => "v1", "f2" => "v2" }) - # # => "OK" - # - # @param [Hash] hash keys mapping to values - # @return [String] `"OK"` - # - # @see #mset - def mapped_mset(hash) - mset(hash.to_a.flatten) - end - - # Set one or more values, only if none of the keys exist. - # - # @example - # redis.msetnx("key1", "v1", "key2", "v2") - # # => true - # - # @param [Array] args array of keys and values - # @return [Boolean] whether or not all values were set - # - # @see #mapped_msetnx - def msetnx(*args) - synchronize do |client| - client.call([:msetnx] + args, &Boolify) - end - end - - # Set one or more values, only if none of the keys exist. - # - # @example - # redis.mapped_msetnx({ "key1" => "v1", "key2" => "v2" }) - # # => true - # - # @param [Hash] hash keys mapping to values - # @return [Boolean] whether or not all values were set - # - # @see #msetnx - def mapped_msetnx(hash) - msetnx(hash.to_a.flatten) - end - - # Get the value of a key. - # - # @param [String] key - # @return [String] - def get(key) - synchronize do |client| - client.call([:get, key]) - end - end - - alias :[] :get - - # Get the values of all the given keys. - # - # @example - # redis.mget("key1", "key1") - # # => ["v1", "v2"] - # - # @param [Array] keys - # @return [Array] an array of values for the specified keys - # - # @see #mapped_mget - def mget(*keys, &blk) - synchronize do |client| - client.call([:mget] + keys, &blk) - end - end - - # Get the values of all the given keys. - # - # @example - # redis.mapped_mget("key1", "key2") - # # => { "key1" => "v1", "key2" => "v2" } - # - # @param [Array] keys array of keys - # @return [Hash] a hash mapping the specified keys to their values - # - # @see #mget - def mapped_mget(*keys) - mget(*keys) do |reply| - if reply.kind_of?(Array) - Hash[keys.zip(reply)] - else - reply - end - end - end - - # Overwrite part of a string at key starting at the specified offset. - # - # @param [String] key - # @param [Fixnum] offset byte offset - # @param [String] value - # @return [Fixnum] length of the string after it was modified - def setrange(key, offset, value) - synchronize do |client| - client.call([:setrange, key, offset, value.to_s]) - end - end - - # Get a substring of the string stored at a key. - # - # @param [String] key - # @param [Fixnum] start zero-based start offset - # @param [Fixnum] stop zero-based end offset. Use -1 for representing - # the end of the string - # @return [Fixnum] `0` or `1` - def getrange(key, start, stop) - synchronize do |client| - client.call([:getrange, key, start, stop]) - end - end - - # Sets or clears the bit at offset in the string value stored at key. - # - # @param [String] key - # @param [Fixnum] offset bit offset - # @param [Fixnum] value bit value `0` or `1` - # @return [Fixnum] the original bit value stored at `offset` - def setbit(key, offset, value) - synchronize do |client| - client.call([:setbit, key, offset, value]) - end - end - - # Returns the bit value at offset in the string value stored at key. - # - # @param [String] key - # @param [Fixnum] offset bit offset - # @return [Fixnum] `0` or `1` - def getbit(key, offset) - synchronize do |client| - client.call([:getbit, key, offset]) - end - end - - # Append a value to a key. - # - # @param [String] key - # @param [String] value value to append - # @return [Fixnum] length of the string after appending - def append(key, value) - synchronize do |client| - client.call([:append, key, value]) - end - end - - # Count the number of set bits in a range of the string value stored at key. - # - # @param [String] key - # @param [Fixnum] start start index - # @param [Fixnum] stop stop index - # @return [Fixnum] the number of bits set to 1 - def bitcount(key, start = 0, stop = -1) - synchronize do |client| - client.call([:bitcount, key, start, stop]) - end - end - - # Perform a bitwise operation between strings and store the resulting string in a key. - # - # @param [String] operation e.g. `and`, `or`, `xor`, `not` - # @param [String] destkey destination key - # @param [String, Array] keys one or more source keys to perform `operation` - # @return [Fixnum] the length of the string stored in `destkey` - def bitop(operation, destkey, *keys) - synchronize do |client| - client.call([:bitop, operation, destkey] + keys) - end - end - - # Return the position of the first bit set to 1 or 0 in a string. - # - # @param [String] key - # @param [Fixnum] bit whether to look for the first 1 or 0 bit - # @param [Fixnum] start start index - # @param [Fixnum] stop stop index - # @return [Fixnum] the position of the first 1/0 bit. - # -1 if looking for 1 and it is not found or start and stop are given. - def bitpos(key, bit, start=nil, stop=nil) - if stop and not start - raise(ArgumentError, 'stop parameter specified without start parameter') - end - - synchronize do |client| - command = [:bitpos, key, bit] - command << start if start - command << stop if stop - client.call(command) - end - end - - # Set the string value of a key and return its old value. - # - # @param [String] key - # @param [String] value value to replace the current value with - # @return [String] the old value stored in the key, or `nil` if the key - # did not exist - def getset(key, value) - synchronize do |client| - client.call([:getset, key, value.to_s]) - end - end - - # Get the length of the value stored in a key. - # - # @param [String] key - # @return [Fixnum] the length of the value stored in the key, or 0 - # if the key does not exist - def strlen(key) - synchronize do |client| - client.call([:strlen, key]) - end - end - - # Get the length of a list. - # - # @param [String] key - # @return [Fixnum] - def llen(key) - synchronize do |client| - client.call([:llen, key]) - end - end - - # Prepend one or more values to a list, creating the list if it doesn't exist - # - # @param [String] key - # @param [String, Array] value string value, or array of string values to push - # @return [Fixnum] the length of the list after the push operation - def lpush(key, value) - synchronize do |client| - client.call([:lpush, key, value]) - end - end - - # Prepend a value to a list, only if the list exists. - # - # @param [String] key - # @param [String] value - # @return [Fixnum] the length of the list after the push operation - def lpushx(key, value) - synchronize do |client| - client.call([:lpushx, key, value]) - end - end - - # Append one or more values to a list, creating the list if it doesn't exist - # - # @param [String] key - # @param [String] value - # @return [Fixnum] the length of the list after the push operation - def rpush(key, value) - synchronize do |client| - client.call([:rpush, key, value]) - end - end - - # Append a value to a list, only if the list exists. - # - # @param [String] key - # @param [String] value - # @return [Fixnum] the length of the list after the push operation - def rpushx(key, value) - synchronize do |client| - client.call([:rpushx, key, value]) - end - end - - # Remove and get the first element in a list. - # - # @param [String] key - # @return [String] - def lpop(key) - synchronize do |client| - client.call([:lpop, key]) - end - end - - # Remove and get the last element in a list. - # - # @param [String] key - # @return [String] - def rpop(key) - synchronize do |client| - client.call([:rpop, key]) - end - end - - # Remove the last element in a list, append it to another list and return it. - # - # @param [String] source source key - # @param [String] destination destination key - # @return [nil, String] the element, or nil when the source key does not exist - def rpoplpush(source, destination) - synchronize do |client| - client.call([:rpoplpush, source, destination]) - end - end - - def _bpop(cmd, args) - options = {} - - case args.last - when Hash - options = args.pop - when Integer - # Issue deprecation notice in obnoxious mode... - options[:timeout] = args.pop - end - - if args.size > 1 - # Issue deprecation notice in obnoxious mode... - end - - keys = args.flatten - timeout = options[:timeout] || 0 - - synchronize do |client| - command = [cmd, keys, timeout] - timeout += client.timeout if timeout > 0 - client.call_with_timeout(command, timeout) - end - end - - # Remove and get the first element in a list, or block until one is available. - # - # @example With timeout - # list, element = redis.blpop("list", :timeout => 5) - # # => nil on timeout - # # => ["list", "element"] on success - # @example Without timeout - # list, element = redis.blpop("list") - # # => ["list", "element"] - # @example Blocking pop on multiple lists - # list, element = redis.blpop(["list", "another_list"]) - # # => ["list", "element"] - # - # @param [String, Array] keys one or more keys to perform the - # blocking pop on - # @param [Hash] options - # - `:timeout => Fixnum`: timeout in seconds, defaults to no timeout - # - # @return [nil, [String, String]] - # - `nil` when the operation timed out - # - tuple of the list that was popped from and element was popped otherwise - def blpop(*args) - _bpop(:blpop, args) - end - - # Remove and get the last element in a list, or block until one is available. - # - # @param [String, Array] keys one or more keys to perform the - # blocking pop on - # @param [Hash] options - # - `:timeout => Fixnum`: timeout in seconds, defaults to no timeout - # - # @return [nil, [String, String]] - # - `nil` when the operation timed out - # - tuple of the list that was popped from and element was popped otherwise - # - # @see #blpop - def brpop(*args) - _bpop(:brpop, args) - end - - # Pop a value from a list, push it to another list and return it; or block - # until one is available. - # - # @param [String] source source key - # @param [String] destination destination key - # @param [Hash] options - # - `:timeout => Fixnum`: timeout in seconds, defaults to no timeout - # - # @return [nil, String] - # - `nil` when the operation timed out - # - the element was popped and pushed otherwise - def brpoplpush(source, destination, options = {}) - case options - when Integer - # Issue deprecation notice in obnoxious mode... - options = { :timeout => options } - end - - timeout = options[:timeout] || 0 - - synchronize do |client| - command = [:brpoplpush, source, destination, timeout] - timeout += client.timeout if timeout > 0 - client.call_with_timeout(command, timeout) - end - end - - # Get an element from a list by its index. - # - # @param [String] key - # @param [Fixnum] index - # @return [String] - def lindex(key, index) - synchronize do |client| - client.call([:lindex, key, index]) - end - end - - # Insert an element before or after another element in a list. - # - # @param [String] key - # @param [String, Symbol] where `BEFORE` or `AFTER` - # @param [String] pivot reference element - # @param [String] value - # @return [Fixnum] length of the list after the insert operation, or `-1` - # when the element `pivot` was not found - def linsert(key, where, pivot, value) - synchronize do |client| - client.call([:linsert, key, where, pivot, value]) - end - end - - # Get a range of elements from a list. - # - # @param [String] key - # @param [Fixnum] start start index - # @param [Fixnum] stop stop index - # @return [Array] - def lrange(key, start, stop) - synchronize do |client| - client.call([:lrange, key, start, stop]) - end - end - - # Remove elements from a list. - # - # @param [String] key - # @param [Fixnum] count number of elements to remove. Use a positive - # value to remove the first `count` occurrences of `value`. A negative - # value to remove the last `count` occurrences of `value`. Or zero, to - # remove all occurrences of `value` from the list. - # @param [String] value - # @return [Fixnum] the number of removed elements - def lrem(key, count, value) - synchronize do |client| - client.call([:lrem, key, count, value]) - end - end - - # Set the value of an element in a list by its index. - # - # @param [String] key - # @param [Fixnum] index - # @param [String] value - # @return [String] `OK` - def lset(key, index, value) - synchronize do |client| - client.call([:lset, key, index, value]) - end - end - - # Trim a list to the specified range. - # - # @param [String] key - # @param [Fixnum] start start index - # @param [Fixnum] stop stop index - # @return [String] `OK` - def ltrim(key, start, stop) - synchronize do |client| - client.call([:ltrim, key, start, stop]) - end - end - - # Get the number of members in a set. - # - # @param [String] key - # @return [Fixnum] - def scard(key) - synchronize do |client| - client.call([:scard, key]) - end - end - - # Add one or more members to a set. - # - # @param [String] key - # @param [String, Array] member one member, or array of members - # @return [Boolean, Fixnum] `Boolean` when a single member is specified, - # holding whether or not adding the member succeeded, or `Fixnum` when an - # array of members is specified, holding the number of members that were - # successfully added - def sadd(key, member) - synchronize do |client| - client.call([:sadd, key, member]) do |reply| - if member.is_a? Array - # Variadic: return integer - reply - else - # Single argument: return boolean - Boolify.call(reply) - end - end - end - end - - # Remove one or more members from a set. - # - # @param [String] key - # @param [String, Array] member one member, or array of members - # @return [Boolean, Fixnum] `Boolean` when a single member is specified, - # holding whether or not removing the member succeeded, or `Fixnum` when an - # array of members is specified, holding the number of members that were - # successfully removed - def srem(key, member) - synchronize do |client| - client.call([:srem, key, member]) do |reply| - if member.is_a? Array - # Variadic: return integer - reply - else - # Single argument: return boolean - Boolify.call(reply) - end - end - end - end - - # Remove and return one or more random member from a set. - # - # @param [String] key - # @return [String] - # @param [Fixnum] count - def spop(key, count = nil) - synchronize do |client| - if count.nil? - client.call([:spop, key]) - else - client.call([:spop, key, count]) - end - end - end - - # Get one or more random members from a set. - # - # @param [String] key - # @param [Fixnum] count - # @return [String] - def srandmember(key, count = nil) - synchronize do |client| - if count.nil? - client.call([:srandmember, key]) - else - client.call([:srandmember, key, count]) - end - end - end - - # Move a member from one set to another. - # - # @param [String] source source key - # @param [String] destination destination key - # @param [String] member member to move from `source` to `destination` - # @return [Boolean] - def smove(source, destination, member) - synchronize do |client| - client.call([:smove, source, destination, member], &Boolify) - end - end - - # Determine if a given value is a member of a set. - # - # @param [String] key - # @param [String] member - # @return [Boolean] - def sismember(key, member) - synchronize do |client| - client.call([:sismember, key, member], &Boolify) - end - end - - # Get all the members in a set. - # - # @param [String] key - # @return [Array] - def smembers(key) - synchronize do |client| - client.call([:smembers, key]) - end - end - - # Subtract multiple sets. - # - # @param [String, Array] keys keys pointing to sets to subtract - # @return [Array] members in the difference - def sdiff(*keys) - synchronize do |client| - client.call([:sdiff] + keys) - end - end - - # Subtract multiple sets and store the resulting set in a key. - # - # @param [String] destination destination key - # @param [String, Array] keys keys pointing to sets to subtract - # @return [Fixnum] number of elements in the resulting set - def sdiffstore(destination, *keys) - synchronize do |client| - client.call([:sdiffstore, destination] + keys) - end - end - - # Intersect multiple sets. - # - # @param [String, Array] keys keys pointing to sets to intersect - # @return [Array] members in the intersection - def sinter(*keys) - synchronize do |client| - client.call([:sinter] + keys) - end - end - - # Intersect multiple sets and store the resulting set in a key. - # - # @param [String] destination destination key - # @param [String, Array] keys keys pointing to sets to intersect - # @return [Fixnum] number of elements in the resulting set - def sinterstore(destination, *keys) - synchronize do |client| - client.call([:sinterstore, destination] + keys) - end - end - - # Add multiple sets. - # - # @param [String, Array] keys keys pointing to sets to unify - # @return [Array] members in the union - def sunion(*keys) - synchronize do |client| - client.call([:sunion] + keys) - end - end - - # Add multiple sets and store the resulting set in a key. - # - # @param [String] destination destination key - # @param [String, Array] keys keys pointing to sets to unify - # @return [Fixnum] number of elements in the resulting set - def sunionstore(destination, *keys) - synchronize do |client| - client.call([:sunionstore, destination] + keys) - end - end - - # Get the number of members in a sorted set. - # - # @example - # redis.zcard("zset") - # # => 4 - # - # @param [String] key - # @return [Fixnum] - def zcard(key) - synchronize do |client| - client.call([:zcard, key]) - end - end - - # Add one or more members to a sorted set, or update the score for members - # that already exist. - # - # @example Add a single `[score, member]` pair to a sorted set - # redis.zadd("zset", 32.0, "member") - # @example Add an array of `[score, member]` pairs to a sorted set - # redis.zadd("zset", [[32.0, "a"], [64.0, "b"]]) - # - # @param [String] key - # @param [[Float, String], Array<[Float, String]>] args - # - a single `[score, member]` pair - # - an array of `[score, member]` pairs - # @param [Hash] options - # - `:xx => true`: Only update elements that already exist (never - # add elements) - # - `:nx => true`: Don't update already existing elements (always - # add new elements) - # - `:ch => true`: Modify the return value from the number of new - # elements added, to the total number of elements changed (CH is an - # abbreviation of changed); changed elements are new elements added - # and elements already existing for which the score was updated - # - `:incr => true`: When this option is specified ZADD acts like - # ZINCRBY; only one score-element pair can be specified in this mode - # - # @return [Boolean, Fixnum, Float] - # - `Boolean` when a single pair is specified, holding whether or not it was - # **added** to the sorted set. - # - `Fixnum` when an array of pairs is specified, holding the number of - # pairs that were **added** to the sorted set. - # - `Float` when option :incr is specified, holding the score of the member - # after incrementing it. - def zadd(key, *args) #, options - zadd_options = [] - if args.last.is_a?(Hash) - options = args.pop - - nx = options[:nx] - zadd_options << "NX" if nx - - xx = options[:xx] - zadd_options << "XX" if xx - - ch = options[:ch] - zadd_options << "CH" if ch - - incr = options[:incr] - zadd_options << "INCR" if incr - end - - synchronize do |client| - if args.size == 1 && args[0].is_a?(Array) - # Variadic: return float if INCR, integer if !INCR - client.call([:zadd, key] + zadd_options + args[0], &(incr ? Floatify : nil)) - elsif args.size == 2 - # Single pair: return float if INCR, boolean if !INCR - client.call([:zadd, key] + zadd_options + args, &(incr ? Floatify : Boolify)) - else - raise ArgumentError, "wrong number of arguments" - end - end - end - - # Increment the score of a member in a sorted set. - # - # @example - # redis.zincrby("zset", 32.0, "a") - # # => 64.0 - # - # @param [String] key - # @param [Float] increment - # @param [String] member - # @return [Float] score of the member after incrementing it - def zincrby(key, increment, member) - synchronize do |client| - client.call([:zincrby, key, increment, member], &Floatify) - end - end - - # Remove one or more members from a sorted set. - # - # @example Remove a single member from a sorted set - # redis.zrem("zset", "a") - # @example Remove an array of members from a sorted set - # redis.zrem("zset", ["a", "b"]) - # - # @param [String] key - # @param [String, Array] member - # - a single member - # - an array of members - # - # @return [Boolean, Fixnum] - # - `Boolean` when a single member is specified, holding whether or not it - # was removed from the sorted set - # - `Fixnum` when an array of pairs is specified, holding the number of - # members that were removed to the sorted set - def zrem(key, member) - synchronize do |client| - client.call([:zrem, key, member]) do |reply| - if member.is_a? Array - # Variadic: return integer - reply - else - # Single argument: return boolean - Boolify.call(reply) - end - end - end - end - - # Get the score associated with the given member in a sorted set. - # - # @example Get the score for member "a" - # redis.zscore("zset", "a") - # # => 32.0 - # - # @param [String] key - # @param [String] member - # @return [Float] score of the member - def zscore(key, member) - synchronize do |client| - client.call([:zscore, key, member], &Floatify) - end - end - - # Return a range of members in a sorted set, by index. - # - # @example Retrieve all members from a sorted set - # redis.zrange("zset", 0, -1) - # # => ["a", "b"] - # @example Retrieve all members and their scores from a sorted set - # redis.zrange("zset", 0, -1, :with_scores => true) - # # => [["a", 32.0], ["b", 64.0]] - # - # @param [String] key - # @param [Fixnum] start start index - # @param [Fixnum] stop stop index - # @param [Hash] options - # - `:with_scores => true`: include scores in output - # - # @return [Array, Array<[String, Float]>] - # - when `:with_scores` is not specified, an array of members - # - when `:with_scores` is specified, an array with `[member, score]` pairs - def zrange(key, start, stop, options = {}) - args = [] - - with_scores = options[:with_scores] || options[:withscores] - - if with_scores - args << "WITHSCORES" - block = FloatifyPairs - end - - synchronize do |client| - client.call([:zrange, key, start, stop] + args, &block) - end - end - - # Return a range of members in a sorted set, by index, with scores ordered - # from high to low. - # - # @example Retrieve all members from a sorted set - # redis.zrevrange("zset", 0, -1) - # # => ["b", "a"] - # @example Retrieve all members and their scores from a sorted set - # redis.zrevrange("zset", 0, -1, :with_scores => true) - # # => [["b", 64.0], ["a", 32.0]] - # - # @see #zrange - def zrevrange(key, start, stop, options = {}) - args = [] - - with_scores = options[:with_scores] || options[:withscores] - - if with_scores - args << "WITHSCORES" - block = FloatifyPairs - end - - synchronize do |client| - client.call([:zrevrange, key, start, stop] + args, &block) - end - end - - # Determine the index of a member in a sorted set. - # - # @param [String] key - # @param [String] member - # @return [Fixnum] - def zrank(key, member) - synchronize do |client| - client.call([:zrank, key, member]) - end - end - - # Determine the index of a member in a sorted set, with scores ordered from - # high to low. - # - # @param [String] key - # @param [String] member - # @return [Fixnum] - def zrevrank(key, member) - synchronize do |client| - client.call([:zrevrank, key, member]) - end - end - - # Remove all members in a sorted set within the given indexes. - # - # @example Remove first 5 members - # redis.zremrangebyrank("zset", 0, 4) - # # => 5 - # @example Remove last 5 members - # redis.zremrangebyrank("zset", -5, -1) - # # => 5 - # - # @param [String] key - # @param [Fixnum] start start index - # @param [Fixnum] stop stop index - # @return [Fixnum] number of members that were removed - def zremrangebyrank(key, start, stop) - synchronize do |client| - client.call([:zremrangebyrank, key, start, stop]) - end - end - - # Return a range of members with the same score in a sorted set, by lexicographical ordering - # - # @example Retrieve members matching a - # redis.zrangebylex("zset", "[a", "[a\xff") - # # => ["aaren", "aarika", "abagael", "abby"] - # @example Retrieve the first 2 members matching a - # redis.zrangebylex("zset", "[a", "[a\xff", :limit => [0, 2]) - # # => ["aaren", "aarika"] - # - # @param [String] key - # @param [String] min - # - inclusive minimum is specified by prefixing `(` - # - exclusive minimum is specified by prefixing `[` - # @param [String] max - # - inclusive maximum is specified by prefixing `(` - # - exclusive maximum is specified by prefixing `[` - # @param [Hash] options - # - `:limit => [offset, count]`: skip `offset` members, return a maximum of - # `count` members - # - # @return [Array, Array<[String, Float]>] - def zrangebylex(key, min, max, options = {}) - args = [] - - limit = options[:limit] - args.concat(["LIMIT"] + limit) if limit - - synchronize do |client| - client.call([:zrangebylex, key, min, max] + args) - end - end - - # Return a range of members with the same score in a sorted set, by reversed lexicographical ordering. - # Apart from the reversed ordering, #zrevrangebylex is similar to #zrangebylex. - # - # @example Retrieve members matching a - # redis.zrevrangebylex("zset", "[a", "[a\xff") - # # => ["abbygail", "abby", "abagael", "aaren"] - # @example Retrieve the last 2 members matching a - # redis.zrevrangebylex("zset", "[a", "[a\xff", :limit => [0, 2]) - # # => ["abbygail", "abby"] - # - # @see #zrangebylex - def zrevrangebylex(key, max, min, options = {}) - args = [] - - limit = options[:limit] - args.concat(["LIMIT"] + limit) if limit - - synchronize do |client| - client.call([:zrevrangebylex, key, max, min] + args) - end - end - - # Return a range of members in a sorted set, by score. - # - # @example Retrieve members with score `>= 5` and `< 100` - # redis.zrangebyscore("zset", "5", "(100") - # # => ["a", "b"] - # @example Retrieve the first 2 members with score `>= 0` - # redis.zrangebyscore("zset", "0", "+inf", :limit => [0, 2]) - # # => ["a", "b"] - # @example Retrieve members and their scores with scores `> 5` - # redis.zrangebyscore("zset", "(5", "+inf", :with_scores => true) - # # => [["a", 32.0], ["b", 64.0]] - # - # @param [String] key - # @param [String] min - # - inclusive minimum score is specified verbatim - # - exclusive minimum score is specified by prefixing `(` - # @param [String] max - # - inclusive maximum score is specified verbatim - # - exclusive maximum score is specified by prefixing `(` - # @param [Hash] options - # - `:with_scores => true`: include scores in output - # - `:limit => [offset, count]`: skip `offset` members, return a maximum of - # `count` members - # - # @return [Array, Array<[String, Float]>] - # - when `:with_scores` is not specified, an array of members - # - when `:with_scores` is specified, an array with `[member, score]` pairs - def zrangebyscore(key, min, max, options = {}) - args = [] - - with_scores = options[:with_scores] || options[:withscores] - - if with_scores - args << "WITHSCORES" - block = FloatifyPairs - end - - limit = options[:limit] - args.concat(["LIMIT"] + limit) if limit - - synchronize do |client| - client.call([:zrangebyscore, key, min, max] + args, &block) - end - end - - # Return a range of members in a sorted set, by score, with scores ordered - # from high to low. - # - # @example Retrieve members with score `< 100` and `>= 5` - # redis.zrevrangebyscore("zset", "(100", "5") - # # => ["b", "a"] - # @example Retrieve the first 2 members with score `<= 0` - # redis.zrevrangebyscore("zset", "0", "-inf", :limit => [0, 2]) - # # => ["b", "a"] - # @example Retrieve members and their scores with scores `> 5` - # redis.zrevrangebyscore("zset", "+inf", "(5", :with_scores => true) - # # => [["b", 64.0], ["a", 32.0]] - # - # @see #zrangebyscore - def zrevrangebyscore(key, max, min, options = {}) - args = [] - - with_scores = options[:with_scores] || options[:withscores] - - if with_scores - args << ["WITHSCORES"] - block = FloatifyPairs - end - - limit = options[:limit] - args.concat(["LIMIT"] + limit) if limit - - synchronize do |client| - client.call([:zrevrangebyscore, key, max, min] + args, &block) - end - end - - # Remove all members in a sorted set within the given scores. - # - # @example Remove members with score `>= 5` and `< 100` - # redis.zremrangebyscore("zset", "5", "(100") - # # => 2 - # @example Remove members with scores `> 5` - # redis.zremrangebyscore("zset", "(5", "+inf") - # # => 2 - # - # @param [String] key - # @param [String] min - # - inclusive minimum score is specified verbatim - # - exclusive minimum score is specified by prefixing `(` - # @param [String] max - # - inclusive maximum score is specified verbatim - # - exclusive maximum score is specified by prefixing `(` - # @return [Fixnum] number of members that were removed - def zremrangebyscore(key, min, max) - synchronize do |client| - client.call([:zremrangebyscore, key, min, max]) - end - end - - # Count the members in a sorted set with scores within the given values. - # - # @example Count members with score `>= 5` and `< 100` - # redis.zcount("zset", "5", "(100") - # # => 2 - # @example Count members with scores `> 5` - # redis.zcount("zset", "(5", "+inf") - # # => 2 - # - # @param [String] key - # @param [String] min - # - inclusive minimum score is specified verbatim - # - exclusive minimum score is specified by prefixing `(` - # @param [String] max - # - inclusive maximum score is specified verbatim - # - exclusive maximum score is specified by prefixing `(` - # @return [Fixnum] number of members in within the specified range - def zcount(key, min, max) - synchronize do |client| - client.call([:zcount, key, min, max]) - end - end - - # Intersect multiple sorted sets and store the resulting sorted set in a new - # key. - # - # @example Compute the intersection of `2*zsetA` with `1*zsetB`, summing their scores - # redis.zinterstore("zsetC", ["zsetA", "zsetB"], :weights => [2.0, 1.0], :aggregate => "sum") - # # => 4 - # - # @param [String] destination destination key - # @param [Array] keys source keys - # @param [Hash] options - # - `:weights => [Float, Float, ...]`: weights to associate with source - # sorted sets - # - `:aggregate => String`: aggregate function to use (sum, min, max, ...) - # @return [Fixnum] number of elements in the resulting sorted set - def zinterstore(destination, keys, options = {}) - args = [] - - weights = options[:weights] - args.concat(["WEIGHTS"] + weights) if weights - - aggregate = options[:aggregate] - args.concat(["AGGREGATE", aggregate]) if aggregate - - synchronize do |client| - client.call([:zinterstore, destination, keys.size] + keys + args) - end - end - - # Add multiple sorted sets and store the resulting sorted set in a new key. - # - # @example Compute the union of `2*zsetA` with `1*zsetB`, summing their scores - # redis.zunionstore("zsetC", ["zsetA", "zsetB"], :weights => [2.0, 1.0], :aggregate => "sum") - # # => 8 - # - # @param [String] destination destination key - # @param [Array] keys source keys - # @param [Hash] options - # - `:weights => [Float, Float, ...]`: weights to associate with source - # sorted sets - # - `:aggregate => String`: aggregate function to use (sum, min, max, ...) - # @return [Fixnum] number of elements in the resulting sorted set - def zunionstore(destination, keys, options = {}) - args = [] - - weights = options[:weights] - args.concat(["WEIGHTS"] + weights) if weights - - aggregate = options[:aggregate] - args.concat(["AGGREGATE", aggregate]) if aggregate - - synchronize do |client| - client.call([:zunionstore, destination, keys.size] + keys + args) - end - end - - # Get the number of fields in a hash. - # - # @param [String] key - # @return [Fixnum] number of fields in the hash - def hlen(key) - synchronize do |client| - client.call([:hlen, key]) - end - end - - # Set the string value of a hash field. - # - # @param [String] key - # @param [String] field - # @param [String] value - # @return [Boolean] whether or not the field was **added** to the hash - def hset(key, field, value) - synchronize do |client| - client.call([:hset, key, field, value], &Boolify) - end - end - - # Set the value of a hash field, only if the field does not exist. - # - # @param [String] key - # @param [String] field - # @param [String] value - # @return [Boolean] whether or not the field was **added** to the hash - def hsetnx(key, field, value) - synchronize do |client| - client.call([:hsetnx, key, field, value], &Boolify) - end - end - - # Set one or more hash values. - # - # @example - # redis.hmset("hash", "f1", "v1", "f2", "v2") - # # => "OK" - # - # @param [String] key - # @param [Array] attrs array of fields and values - # @return [String] `"OK"` - # - # @see #mapped_hmset - def hmset(key, *attrs) - synchronize do |client| - client.call([:hmset, key] + attrs) - end - end - - # Set one or more hash values. - # - # @example - # redis.mapped_hmset("hash", { "f1" => "v1", "f2" => "v2" }) - # # => "OK" - # - # @param [String] key - # @param [Hash] hash a non-empty hash with fields mapping to values - # @return [String] `"OK"` - # - # @see #hmset - def mapped_hmset(key, hash) - hmset(key, hash.to_a.flatten) - end - - # Get the value of a hash field. - # - # @param [String] key - # @param [String] field - # @return [String] - def hget(key, field) - synchronize do |client| - client.call([:hget, key, field]) - end - end - - # Get the values of all the given hash fields. - # - # @example - # redis.hmget("hash", "f1", "f2") - # # => ["v1", "v2"] - # - # @param [String] key - # @param [Array] fields array of fields - # @return [Array] an array of values for the specified fields - # - # @see #mapped_hmget - def hmget(key, *fields, &blk) - synchronize do |client| - client.call([:hmget, key] + fields, &blk) - end - end - - # Get the values of all the given hash fields. - # - # @example - # redis.mapped_hmget("hash", "f1", "f2") - # # => { "f1" => "v1", "f2" => "v2" } - # - # @param [String] key - # @param [Array] fields array of fields - # @return [Hash] a hash mapping the specified fields to their values - # - # @see #hmget - def mapped_hmget(key, *fields) - hmget(key, *fields) do |reply| - if reply.kind_of?(Array) - Hash[fields.zip(reply)] - else - reply - end - end - end - - # Delete one or more hash fields. - # - # @param [String] key - # @param [String, Array] field - # @return [Fixnum] the number of fields that were removed from the hash - def hdel(key, field) - synchronize do |client| - client.call([:hdel, key, field]) - end - end - - # Determine if a hash field exists. - # - # @param [String] key - # @param [String] field - # @return [Boolean] whether or not the field exists in the hash - def hexists(key, field) - synchronize do |client| - client.call([:hexists, key, field], &Boolify) - end - end - - # Increment the integer value of a hash field by the given integer number. - # - # @param [String] key - # @param [String] field - # @param [Fixnum] increment - # @return [Fixnum] value of the field after incrementing it - def hincrby(key, field, increment) - synchronize do |client| - client.call([:hincrby, key, field, increment]) - end - end - - # Increment the numeric value of a hash field by the given float number. - # - # @param [String] key - # @param [String] field - # @param [Float] increment - # @return [Float] value of the field after incrementing it - def hincrbyfloat(key, field, increment) - synchronize do |client| - client.call([:hincrbyfloat, key, field, increment], &Floatify) - end - end - - # Get all the fields in a hash. - # - # @param [String] key - # @return [Array] - def hkeys(key) - synchronize do |client| - client.call([:hkeys, key]) - end - end - - # Get all the values in a hash. - # - # @param [String] key - # @return [Array] - def hvals(key) - synchronize do |client| - client.call([:hvals, key]) - end - end - - # Get all the fields and values in a hash. - # - # @param [String] key - # @return [Hash] - def hgetall(key) - synchronize do |client| - client.call([:hgetall, key], &Hashify) - end - end - - # Post a message to a channel. - def publish(channel, message) - synchronize do |client| - client.call([:publish, channel, message]) - end - end - - def subscribed? - synchronize do |client| - client.kind_of? SubscribedClient - end - end - - # Listen for messages published to the given channels. - def subscribe(*channels, &block) - synchronize do |client| - _subscription(:subscribe, 0, channels, block) - end - end - - # Listen for messages published to the given channels. Throw a timeout error if there is no messages for a timeout period. - def subscribe_with_timeout(timeout, *channels, &block) - synchronize do |client| - _subscription(:subscribe_with_timeout, timeout, channels, block) - end - end - - # Stop listening for messages posted to the given channels. - def unsubscribe(*channels) - synchronize do |client| - raise RuntimeError, "Can't unsubscribe if not subscribed." unless subscribed? - client.unsubscribe(*channels) - end - end - - # Listen for messages published to channels matching the given patterns. - def psubscribe(*channels, &block) - synchronize do |client| - _subscription(:psubscribe, 0, channels, block) - end - end - - # Listen for messages published to channels matching the given patterns. Throw a timeout error if there is no messages for a timeout period. - def psubscribe_with_timeout(timeout, *channels, &block) - synchronize do |client| - _subscription(:psubscribe_with_timeout, timeout, channels, block) - end - end - - # Stop listening for messages posted to channels matching the given patterns. - def punsubscribe(*channels) - synchronize do |client| - raise RuntimeError, "Can't unsubscribe if not subscribed." unless subscribed? - client.punsubscribe(*channels) - end - end - - # Inspect the state of the Pub/Sub subsystem. - # Possible subcommands: channels, numsub, numpat. - def pubsub(subcommand, *args) - synchronize do |client| - client.call([:pubsub, subcommand] + args) - end - end - - # Watch the given keys to determine execution of the MULTI/EXEC block. - # - # Using a block is optional, but is necessary for thread-safety. - # - # An `#unwatch` is automatically issued if an exception is raised within the - # block that is a subclass of StandardError and is not a ConnectionError. - # - # @example With a block - # redis.watch("key") do - # if redis.get("key") == "some value" - # redis.multi do |multi| - # multi.set("key", "other value") - # multi.incr("counter") - # end - # else - # redis.unwatch - # end - # end - # # => ["OK", 6] - # - # @example Without a block - # redis.watch("key") - # # => "OK" - # - # @param [String, Array] keys one or more keys to watch - # @return [Object] if using a block, returns the return value of the block - # @return [String] if not using a block, returns `OK` - # - # @see #unwatch - # @see #multi - def watch(*keys) - synchronize do |client| - res = client.call([:watch] + keys) - - if block_given? - begin - yield(self) - rescue ConnectionError - raise - rescue StandardError - unwatch - raise - end - else - res - end - end - end - - # Forget about all watched keys. - # - # @return [String] `OK` - # - # @see #watch - # @see #multi - def unwatch - synchronize do |client| - client.call([:unwatch]) - end - end - - def pipelined - synchronize do |client| - begin - original, @client = @client, Pipeline.new - yield(self) - original.call_pipeline(@client) - ensure - @client = original - end - end - end - - # Mark the start of a transaction block. - # - # Passing a block is optional. - # - # @example With a block - # redis.multi do |multi| - # multi.set("key", "value") - # multi.incr("counter") - # end # => ["OK", 6] - # - # @example Without a block - # redis.multi - # # => "OK" - # redis.set("key", "value") - # # => "QUEUED" - # redis.incr("counter") - # # => "QUEUED" - # redis.exec - # # => ["OK", 6] - # - # @yield [multi] the commands that are called inside this block are cached - # and written to the server upon returning from it - # @yieldparam [Redis] multi `self` - # - # @return [String, Array<...>] - # - when a block is not given, `OK` - # - when a block is given, an array with replies - # - # @see #watch - # @see #unwatch - def multi - synchronize do |client| - if !block_given? - client.call([:multi]) - else - begin - pipeline = Pipeline::Multi.new - original, @client = @client, pipeline - yield(self) - original.call_pipeline(pipeline) - ensure - @client = original - end - end - end - end - - # Execute all commands issued after MULTI. - # - # Only call this method when `#multi` was called **without** a block. - # - # @return [nil, Array<...>] - # - when commands were not executed, `nil` - # - when commands were executed, an array with their replies - # - # @see #multi - # @see #discard - def exec - synchronize do |client| - client.call([:exec]) - end - end - - # Discard all commands issued after MULTI. - # - # Only call this method when `#multi` was called **without** a block. - # - # @return [String] `"OK"` - # - # @see #multi - # @see #exec - def discard - synchronize do |client| - client.call([:discard]) - end - end - - # Control remote script registry. - # - # @example Load a script - # sha = redis.script(:load, "return 1") - # # => - # @example Check if a script exists - # redis.script(:exists, sha) - # # => true - # @example Check if multiple scripts exist - # redis.script(:exists, [sha, other_sha]) - # # => [true, false] - # @example Flush the script registry - # redis.script(:flush) - # # => "OK" - # @example Kill a running script - # redis.script(:kill) - # # => "OK" - # - # @param [String] subcommand e.g. `exists`, `flush`, `load`, `kill` - # @param [Array] args depends on subcommand - # @return [String, Boolean, Array, ...] depends on subcommand - # - # @see #eval - # @see #evalsha - def script(subcommand, *args) - subcommand = subcommand.to_s.downcase - - if subcommand == "exists" - synchronize do |client| - arg = args.first - - client.call([:script, :exists, arg]) do |reply| - reply = reply.map { |r| Boolify.call(r) } - - if arg.is_a?(Array) - reply - else - reply.first - end - end - end - else - synchronize do |client| - client.call([:script, subcommand] + args) - end - end - end - - def _eval(cmd, args) - script = args.shift - options = args.pop if args.last.is_a?(Hash) - options ||= {} - - keys = args.shift || options[:keys] || [] - argv = args.shift || options[:argv] || [] - - synchronize do |client| - client.call([cmd, script, keys.length] + keys + argv) - end - end - - # Evaluate Lua script. - # - # @example EVAL without KEYS nor ARGV - # redis.eval("return 1") - # # => 1 - # @example EVAL with KEYS and ARGV as array arguments - # redis.eval("return { KEYS, ARGV }", ["k1", "k2"], ["a1", "a2"]) - # # => [["k1", "k2"], ["a1", "a2"]] - # @example EVAL with KEYS and ARGV in a hash argument - # redis.eval("return { KEYS, ARGV }", :keys => ["k1", "k2"], :argv => ["a1", "a2"]) - # # => [["k1", "k2"], ["a1", "a2"]] - # - # @param [Array] keys optional array with keys to pass to the script - # @param [Array] argv optional array with arguments to pass to the script - # @param [Hash] options - # - `:keys => Array`: optional array with keys to pass to the script - # - `:argv => Array`: optional array with arguments to pass to the script - # @return depends on the script - # - # @see #script - # @see #evalsha - def eval(*args) - _eval(:eval, args) - end - - # Evaluate Lua script by its SHA. - # - # @example EVALSHA without KEYS nor ARGV - # redis.evalsha(sha) - # # => - # @example EVALSHA with KEYS and ARGV as array arguments - # redis.evalsha(sha, ["k1", "k2"], ["a1", "a2"]) - # # => - # @example EVALSHA with KEYS and ARGV in a hash argument - # redis.evalsha(sha, :keys => ["k1", "k2"], :argv => ["a1", "a2"]) - # # => - # - # @param [Array] keys optional array with keys to pass to the script - # @param [Array] argv optional array with arguments to pass to the script - # @param [Hash] options - # - `:keys => Array`: optional array with keys to pass to the script - # - `:argv => Array`: optional array with arguments to pass to the script - # @return depends on the script - # - # @see #script - # @see #eval - def evalsha(*args) - _eval(:evalsha, args) - end - - def _scan(command, cursor, args, options = {}, &block) - # SSCAN/ZSCAN/HSCAN already prepend the key to +args+. - - args << cursor - - if match = options[:match] - args.concat(["MATCH", match]) - end - - if count = options[:count] - args.concat(["COUNT", count]) - end - - synchronize do |client| - client.call([command] + args, &block) - end - end - - # Scan the keyspace - # - # @example Retrieve the first batch of keys - # redis.scan(0) - # # => ["4", ["key:21", "key:47", "key:42"]] - # @example Retrieve a batch of keys matching a pattern - # redis.scan(4, :match => "key:1?") - # # => ["92", ["key:13", "key:18"]] - # - # @param [String, Integer] cursor the cursor of the iteration - # @param [Hash] options - # - `:match => String`: only return keys matching the pattern - # - `:count => Integer`: return count keys at most per iteration - # - # @return [String, Array] the next cursor and all found keys - def scan(cursor, options={}) - _scan(:scan, cursor, [], options) - end - - # Scan the keyspace - # - # @example Retrieve all of the keys (with possible duplicates) - # redis.scan_each.to_a - # # => ["key:21", "key:47", "key:42"] - # @example Execute block for each key matching a pattern - # redis.scan_each(:match => "key:1?") {|key| puts key} - # # => key:13 - # # => key:18 - # - # @param [Hash] options - # - `:match => String`: only return keys matching the pattern - # - `:count => Integer`: return count keys at most per iteration - # - # @return [Enumerator] an enumerator for all found keys - def scan_each(options={}, &block) - return to_enum(:scan_each, options) unless block_given? - cursor = 0 - loop do - cursor, keys = scan(cursor, options) - keys.each(&block) - break if cursor == "0" - end - end - - # Scan a hash - # - # @example Retrieve the first batch of key/value pairs in a hash - # redis.hscan("hash", 0) - # - # @param [String, Integer] cursor the cursor of the iteration - # @param [Hash] options - # - `:match => String`: only return keys matching the pattern - # - `:count => Integer`: return count keys at most per iteration - # - # @return [String, Array<[String, String]>] the next cursor and all found keys - def hscan(key, cursor, options={}) - _scan(:hscan, cursor, [key], options) do |reply| - [reply[0], reply[1].each_slice(2).to_a] - end - end - - # Scan a hash - # - # @example Retrieve all of the key/value pairs in a hash - # redis.hscan_each("hash").to_a - # # => [["key70", "70"], ["key80", "80"]] - # - # @param [Hash] options - # - `:match => String`: only return keys matching the pattern - # - `:count => Integer`: return count keys at most per iteration - # - # @return [Enumerator] an enumerator for all found keys - def hscan_each(key, options={}, &block) - return to_enum(:hscan_each, key, options) unless block_given? - cursor = 0 - loop do - cursor, values = hscan(key, cursor, options) - values.each(&block) - break if cursor == "0" - end - end - - # Scan a sorted set - # - # @example Retrieve the first batch of key/value pairs in a hash - # redis.zscan("zset", 0) - # - # @param [String, Integer] cursor the cursor of the iteration - # @param [Hash] options - # - `:match => String`: only return keys matching the pattern - # - `:count => Integer`: return count keys at most per iteration - # - # @return [String, Array<[String, Float]>] the next cursor and all found - # members and scores - def zscan(key, cursor, options={}) - _scan(:zscan, cursor, [key], options) do |reply| - [reply[0], FloatifyPairs.call(reply[1])] - end - end - - # Scan a sorted set - # - # @example Retrieve all of the members/scores in a sorted set - # redis.zscan_each("zset").to_a - # # => [["key70", "70"], ["key80", "80"]] - # - # @param [Hash] options - # - `:match => String`: only return keys matching the pattern - # - `:count => Integer`: return count keys at most per iteration - # - # @return [Enumerator] an enumerator for all found scores and members - def zscan_each(key, options={}, &block) - return to_enum(:zscan_each, key, options) unless block_given? - cursor = 0 - loop do - cursor, values = zscan(key, cursor, options) - values.each(&block) - break if cursor == "0" - end - end - - # Scan a set - # - # @example Retrieve the first batch of keys in a set - # redis.sscan("set", 0) - # - # @param [String, Integer] cursor the cursor of the iteration - # @param [Hash] options - # - `:match => String`: only return keys matching the pattern - # - `:count => Integer`: return count keys at most per iteration - # - # @return [String, Array] the next cursor and all found members - def sscan(key, cursor, options={}) - _scan(:sscan, cursor, [key], options) - end - - # Scan a set - # - # @example Retrieve all of the keys in a set - # redis.sscan_each("set").to_a - # # => ["key1", "key2", "key3"] - # - # @param [Hash] options - # - `:match => String`: only return keys matching the pattern - # - `:count => Integer`: return count keys at most per iteration - # - # @return [Enumerator] an enumerator for all keys in the set - def sscan_each(key, options={}, &block) - return to_enum(:sscan_each, key, options) unless block_given? - cursor = 0 - loop do - cursor, keys = sscan(key, cursor, options) - keys.each(&block) - break if cursor == "0" - end - end - - # Add one or more members to a HyperLogLog structure. - # - # @param [String] key - # @param [String, Array] member one member, or array of members - # @return [Boolean] true if at least 1 HyperLogLog internal register was altered. false otherwise. - def pfadd(key, member) - synchronize do |client| - client.call([:pfadd, key, member], &Boolify) - end - end - - # Get the approximate cardinality of members added to HyperLogLog structure. - # - # If called with multiple keys, returns the approximate cardinality of the - # union of the HyperLogLogs contained in the keys. - # - # @param [String, Array] keys - # @return [Fixnum] - def pfcount(*keys) - synchronize do |client| - client.call([:pfcount] + keys) - end - end - - # Merge multiple HyperLogLog values into an unique value that will approximate the cardinality of the union of - # the observed Sets of the source HyperLogLog structures. - # - # @param [String] dest_key destination key - # @param [String, Array] source_key source key, or array of keys - # @return [Boolean] - def pfmerge(dest_key, *source_key) - synchronize do |client| - client.call([:pfmerge, dest_key, *source_key], &BoolifySet) - end - end - - # Interact with the sentinel command (masters, master, slaves, failover) - # - # @param [String] subcommand e.g. `masters`, `master`, `slaves` - # @param [Array] args depends on subcommand - # @return [Array, Hash, String] depends on subcommand - def sentinel(subcommand, *args) - subcommand = subcommand.to_s.downcase - synchronize do |client| - client.call([:sentinel, subcommand] + args) do |reply| - case subcommand - when "get-master-addr-by-name" - reply - else - if reply.kind_of?(Array) - if reply[0].kind_of?(Array) - reply.map(&Hashify) - else - Hashify.call(reply) - end - else - reply - end - end - end - end - end - - def id - @original_client.id - end - - def inspect - "#" - end - - def dup - self.class.new(@options) - end - - def method_missing(command, *args) - synchronize do |client| - client.call([command] + args) - end - end - -private - - # Commands returning 1 for true and 0 for false may be executed in a pipeline - # where the method call will return nil. Propagate the nil instead of falsely - # returning false. - Boolify = - lambda { |value| - value == 1 if value - } - - BoolifySet = - lambda { |value| - if value && "OK" == value - true - else - false - end - } - - Hashify = - lambda { |array| - hash = Hash.new - array.each_slice(2) do |field, value| - hash[field] = value - end - hash - } - - Floatify = - lambda { |str| - if str - if (inf = str.match(/^(-)?inf/i)) - (inf[1] ? -1.0 : 1.0) / 0.0 - else - Float(str) - end - end - } - - FloatifyPairs = - lambda { |array| - if array - array.each_slice(2).map do |member, score| - [member, Floatify.call(score)] - end - end - } - - def _subscription(method, timeout, channels, block) - return @client.call([method] + channels) if subscribed? - - begin - original, @client = @client, SubscribedClient.new(@client) - if timeout > 0 - @client.send(method, timeout, *channels, &block) - else - @client.send(method, *channels, &block) - end - ensure - @client = original - end - end - end require "redis/version" -require "redis/connection" require "redis/client" require "redis/pipeline" require "redis/subscribe" diff --git a/lib/redis/client.rb b/lib/redis/client.rb index 31be2de33..38f3273ae 100644 --- a/lib/redis/client.rb +++ b/lib/redis/client.rb @@ -1,590 +1,124 @@ -require "redis/errors" -require "socket" -require "cgi" +# frozen_string_literal: true -class Redis - class Client +require 'redis-client' - DEFAULTS = { - :url => lambda { ENV["REDIS_URL"] }, - :scheme => "redis", - :host => "127.0.0.1", - :port => 6379, - :path => nil, - :timeout => 5.0, - :password => nil, - :db => 0, - :driver => nil, - :id => nil, - :tcp_keepalive => 0, - :reconnect_attempts => 1, - :inherit_socket => false +class Redis + class Client < ::RedisClient + ERROR_MAPPING = { + RedisClient::ConnectionError => Redis::ConnectionError, + RedisClient::CommandError => Redis::CommandError, + RedisClient::ReadTimeoutError => Redis::TimeoutError, + RedisClient::CannotConnectError => Redis::CannotConnectError, + RedisClient::AuthenticationError => Redis::CannotConnectError, + RedisClient::FailoverError => Redis::CannotConnectError, + RedisClient::PermissionError => Redis::PermissionError, + RedisClient::WrongTypeError => Redis::WrongTypeError, + RedisClient::ReadOnlyError => Redis::ReadOnlyError, + RedisClient::ProtocolError => Redis::ProtocolError, + RedisClient::OutOfMemoryError => Redis::OutOfMemoryError, } - def options - Marshal.load(Marshal.dump(@options)) - end - - def scheme - @options[:scheme] - end - - def host - @options[:host] - end - - def port - @options[:port] - end - - def path - @options[:path] - end - - def read_timeout - @options[:read_timeout] - end - - def connect_timeout - @options[:connect_timeout] - end - - def timeout - @options[:read_timeout] - end - - def password - @options[:password] - end - - def db - @options[:db] - end - - def db=(db) - @options[:db] = db.to_i - end - - def driver - @options[:driver] - end - - def inherit_socket? - @options[:inherit_socket] - end - - attr_accessor :logger - attr_reader :connection - attr_reader :command_map - - def initialize(options = {}) - @options = _parse_options(options) - @reconnect = true - @logger = @options[:logger] - @connection = nil - @command_map = {} - - @pending_reads = 0 - - if options.include?(:sentinels) - @connector = Connector::Sentinel.new(@options) - else - @connector = Connector.new(@options) - end - end - - def connect - @pid = Process.pid - - # Don't try to reconnect when the connection is fresh - with_reconnect(false) do - establish_connection - call [:auth, password] if password - call [:select, db] if db != 0 - call [:client, :setname, @options[:id]] if @options[:id] - @connector.check(self) - end - - self - end - - def id - @options[:id] || "redis://#{location}/#{db}" - end - - def location - path || "#{host}:#{port}" - end - - def call(command) - reply = process([command]) { read } - raise reply if reply.is_a?(CommandError) - - if block_given? - yield reply - else - reply + class << self + def config(**kwargs) + super(protocol: 2, **kwargs) end - end - def call_loop(command, timeout = 0) - error = nil - - result = with_socket_timeout(timeout) do - process([command]) do - loop do - reply = read - if reply.is_a?(CommandError) - error = reply - break - else - yield reply - end - end - end + def sentinel(**kwargs) + super(protocol: 2, **kwargs, client_implementation: ::RedisClient) end - # Raise error when previous block broke out of the loop. - raise error if error - - # Result is set to the value that the provided block used to break. - result - end - - def call_pipeline(pipeline) - with_reconnect pipeline.with_reconnect? do - begin - pipeline.finish(call_pipelined(pipeline.commands)).tap do - self.db = pipeline.db if pipeline.db - end - rescue ConnectionError => e - return nil if pipeline.shutdown? - # Assume the pipeline was sent in one piece, but execution of - # SHUTDOWN caused none of the replies for commands that were executed - # prior to it from coming back around. - raise e - end + def translate_error!(error, mapping: ERROR_MAPPING) + redis_error = translate_error_class(error.class, mapping: mapping) + raise redis_error, error.message, error.backtrace end - end - - def call_pipelined(commands) - return [] if commands.empty? - # The method #ensure_connected (called from #process) reconnects once on - # I/O errors. To make an effort in making sure that commands are not - # executed more than once, only allow reconnection before the first reply - # has been read. When an error occurs after the first reply has been - # read, retrying would re-execute the entire pipeline, thus re-issuing - # already successfully executed commands. To circumvent this, don't retry - # after the first reply has been read successfully. + private - result = Array.new(commands.size) - reconnect = @reconnect - - begin - exception = nil - - process(commands) do - result[0] = read - - @reconnect = false - - (commands.size - 1).times do |i| - reply = read - result[i + 1] = reply - exception = reply if exception.nil? && reply.is_a?(CommandError) - end + def translate_error_class(error_class, mapping: ERROR_MAPPING) + mapping.fetch(error_class) + rescue IndexError + if (client_error = error_class.ancestors.find { |a| mapping[a] }) + mapping[error_class] = mapping[client_error] + else + raise end - - raise exception if exception - ensure - @reconnect = reconnect - end - - result - end - - def call_with_timeout(command, timeout, &blk) - with_socket_timeout(timeout) do - call(command, &blk) end - rescue ConnectionError - retry end - def call_without_timeout(command, &blk) - call_with_timeout(command, 0, &blk) + def id + config.id end - def process(commands) - logging(commands) do - ensure_connected do - commands.each do |command| - if command_map[command.first] - command = command.dup - command[0] = command_map[command.first] - end - - write(command) - end - - yield if block_given? - end - end + def server_url + config.server_url end - def connected? - !! (connection && connection.connected?) + def timeout + config.read_timeout end - def disconnect - connection.disconnect if connected? + def db + config.db end - def reconnect - disconnect - connect + def host + config.host unless config.path end - def io - yield - rescue TimeoutError => e1 - # Add a message to the exception without destroying the original stack - e2 = TimeoutError.new("Connection timed out") - e2.set_backtrace(e1.backtrace) - raise e2 - rescue Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED, Errno::EBADF, Errno::EINVAL => e - raise ConnectionError, "Connection lost (%s)" % [e.class.name.split("::").last] + def port + config.port unless config.path end - def read - io do - value = connection.read - @pending_reads -= 1 - value - end + def path + config.path end - def write(command) - io do - @pending_reads += 1 - connection.write(command) - end + def username + config.username end - def with_socket_timeout(timeout) - connect unless connected? - - begin - connection.timeout = timeout - yield - ensure - connection.timeout = self.timeout if connected? - end - end - - def without_socket_timeout(&blk) - with_socket_timeout(0, &blk) + def password + config.password end - def with_reconnect(val=true) - begin - original, @reconnect = @reconnect, val - yield - ensure - @reconnect = original - end - end + undef_method :call + undef_method :call_once + undef_method :call_once_v + undef_method :blocking_call - def without_reconnect(&blk) - with_reconnect(false, &blk) + def call_v(command, &block) + super(command, &block) + rescue ::RedisClient::Error => error + Client.translate_error!(error) end - protected - - def logging(commands) - return yield unless @logger && @logger.debug? - - begin - commands.each do |name, *args| - logged_args = args.map do |a| - case - when a.respond_to?(:inspect) then a.inspect - when a.respond_to?(:to_s) then a.to_s - else - # handle poorly-behaved descendants of BasicObject - klass = a.instance_exec { (class << self; self end).superclass } - "\#<#{klass}:#{a.__id__}>" - end - end - @logger.debug("[Redis] command=#{name.to_s.upcase} args=#{logged_args.join(' ')}") - end - - t1 = Time.now - yield - ensure - @logger.debug("[Redis] call_time=%0.2f ms" % ((Time.now - t1) * 1000)) if t1 + def blocking_call_v(timeout, command, &block) + if timeout && timeout > 0 + # Can't use the command timeout argument as the connection timeout + # otherwise it would be very racy. So we add the regular read_timeout on top + # to account for the network delay. + timeout += config.read_timeout end - end - - def establish_connection - server = @connector.resolve.dup - - @options[:host] = server[:host] - @options[:port] = Integer(server[:port]) if server.include?(:port) - @connection = @options[:driver].connect(@options) - @pending_reads = 0 - rescue TimeoutError, - Errno::ECONNREFUSED, - Errno::EHOSTDOWN, - Errno::EHOSTUNREACH, - Errno::ENETUNREACH, - Errno::ETIMEDOUT - - raise CannotConnectError, "Error connecting to Redis on #{location} (#{$!.class})" + super(timeout, command, &block) + rescue ::RedisClient::Error => error + Client.translate_error!(error) end - def ensure_connected - disconnect if @pending_reads > 0 - - attempts = 0 - - begin - attempts += 1 - - if connected? - unless inherit_socket? || Process.pid == @pid - raise InheritedError, - "Tried to use a connection from a child process without reconnecting. " + - "You need to reconnect to Redis after forking " + - "or set :inherit_socket to true." - end - else - connect - end - - yield - rescue BaseConnectionError - disconnect - - if attempts <= @options[:reconnect_attempts] && @reconnect - retry - else - raise - end - rescue Exception - disconnect - raise - end + def pipelined + super + rescue ::RedisClient::Error => error + Client.translate_error!(error) end - def _parse_options(options) - return options if options[:_parsed] - - defaults = DEFAULTS.dup - options = options.dup - - defaults.keys.each do |key| - # Fill in defaults if needed - if defaults[key].respond_to?(:call) - defaults[key] = defaults[key].call - end - - # Symbolize only keys that are needed - options[key] = options[key.to_s] if options.has_key?(key.to_s) - end - - url = options[:url] || defaults[:url] - - # Override defaults from URL if given - if url - require "uri" - - uri = URI(url) - - if uri.scheme == "unix" - defaults[:path] = uri.path - elsif uri.scheme == "redis" || uri.scheme == "rediss" - defaults[:scheme] = uri.scheme - defaults[:host] = uri.host if uri.host - defaults[:port] = uri.port if uri.port - defaults[:password] = CGI.unescape(uri.password) if uri.password - defaults[:db] = uri.path[1..-1].to_i if uri.path - defaults[:role] = :master - else - raise ArgumentError, "invalid uri scheme '#{uri.scheme}'" - end - - defaults[:ssl] = true if uri.scheme == "rediss" - end - - # Use default when option is not specified or nil - defaults.keys.each do |key| - options[key] = defaults[key] if options[key].nil? - end - - if options[:path] - # Unix socket - options[:scheme] = "unix" - options.delete(:host) - options.delete(:port) - else - # TCP socket - options[:host] = options[:host].to_s - options[:port] = options[:port].to_i - end - - if options.has_key?(:timeout) - options[:connect_timeout] ||= options[:timeout] - options[:read_timeout] ||= options[:timeout] - options[:write_timeout] ||= options[:timeout] - end - - options[:connect_timeout] = Float(options[:connect_timeout]) - options[:read_timeout] = Float(options[:read_timeout]) - options[:write_timeout] = Float(options[:write_timeout]) - - options[:db] = options[:db].to_i - options[:driver] = _parse_driver(options[:driver]) || Connection.drivers.last - - case options[:tcp_keepalive] - when Hash - [:time, :intvl, :probes].each do |key| - unless options[:tcp_keepalive][key].is_a?(Integer) - raise "Expected the #{key.inspect} key in :tcp_keepalive to be an Integer" - end - end - - when Integer - if options[:tcp_keepalive] >= 60 - options[:tcp_keepalive] = {:time => options[:tcp_keepalive] - 20, :intvl => 10, :probes => 2} - - elsif options[:tcp_keepalive] >= 30 - options[:tcp_keepalive] = {:time => options[:tcp_keepalive] - 10, :intvl => 5, :probes => 2} - - elsif options[:tcp_keepalive] >= 5 - options[:tcp_keepalive] = {:time => options[:tcp_keepalive] - 2, :intvl => 2, :probes => 1} - end - end - - options[:_parsed] = true - - options + def multi(watch: nil) + super + rescue ::RedisClient::Error => error + Client.translate_error!(error) end - def _parse_driver(driver) - driver = driver.to_s if driver.is_a?(Symbol) - - if driver.kind_of?(String) - begin - require "redis/connection/#{driver}" - driver = Connection.const_get(driver.capitalize) - rescue LoadError, NameError - raise RuntimeError, "Cannot load driver #{driver.inspect}" - end - end - - driver - end - - class Connector - def initialize(options) - @options = options.dup - end - - def resolve - @options - end - - def check(client) - end - - class Sentinel < Connector - def initialize(options) - super(options) - - @options[:password] = DEFAULTS.fetch(:password) - @options[:db] = DEFAULTS.fetch(:db) - - @sentinels = @options.delete(:sentinels).dup - @role = @options.fetch(:role, "master").to_s - @master = @options[:host] - end - - def check(client) - # Check the instance is really of the role we are looking for. - # We can't assume the command is supported since it was introduced - # recently and this client should work with old stuff. - begin - role = client.call([:role])[0] - rescue Redis::CommandError - # Assume the test is passed if we can't get a reply from ROLE... - role = @role - end - - if role != @role - client.disconnect - raise ConnectionError, "Instance role mismatch. Expected #{@role}, got #{role}." - end - end - - def resolve - result = case @role - when "master" - resolve_master - when "slave" - resolve_slave - else - raise ArgumentError, "Unknown instance role #{@role}" - end - - result || (raise ConnectionError, "Unable to fetch #{@role} via Sentinel.") - end - - def sentinel_detect - @sentinels.each do |sentinel| - client = Client.new(@options.merge({ - :host => sentinel[:host], - :port => sentinel[:port], - :reconnect_attempts => 0, - })) - - begin - if result = yield(client) - # This sentinel responded. Make sure we ask it first next time. - @sentinels.delete(sentinel) - @sentinels.unshift(sentinel) - - return result - end - rescue BaseConnectionError - ensure - client.disconnect - end - end - - raise CannotConnectError, "No sentinels available." - end - - def resolve_master - sentinel_detect do |client| - if reply = client.call(["sentinel", "get-master-addr-by-name", @master]) - {:host => reply[0], :port => reply[1]} - end - end - end - - def resolve_slave - sentinel_detect do |client| - if reply = client.call(["sentinel", "slaves", @master]) - slave = Hash[*reply.sample] - - {:host => slave.fetch("ip"), :port => slave.fetch("port")} - end - end - end - end + def inherit_socket! + @inherit_socket = true end end end diff --git a/lib/redis/commands.rb b/lib/redis/commands.rb new file mode 100644 index 000000000..ddf264d95 --- /dev/null +++ b/lib/redis/commands.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +require "redis/commands/bitmaps" +require "redis/commands/cluster" +require "redis/commands/connection" +require "redis/commands/geo" +require "redis/commands/hashes" +require "redis/commands/hyper_log_log" +require "redis/commands/keys" +require "redis/commands/lists" +require "redis/commands/pubsub" +require "redis/commands/scripting" +require "redis/commands/server" +require "redis/commands/sets" +require "redis/commands/sorted_sets" +require "redis/commands/streams" +require "redis/commands/strings" +require "redis/commands/transactions" + +class Redis + module Commands + include Bitmaps + include Cluster + include Connection + include Geo + include Hashes + include HyperLogLog + include Keys + include Lists + include Pubsub + include Scripting + include Server + include Sets + include SortedSets + include Streams + include Strings + include Transactions + + # Commands returning 1 for true and 0 for false may be executed in a pipeline + # where the method call will return nil. Propagate the nil instead of falsely + # returning false. + Boolify = lambda { |value| + value != 0 unless value.nil? + } + + BoolifySet = lambda { |value| + case value + when "OK" + true + when nil + false + else + value + end + } + + Hashify = lambda { |value| + if value.respond_to?(:each_slice) + value.each_slice(2).to_h + else + value + end + } + + Pairify = lambda { |value| + if value.respond_to?(:each_slice) + value.each_slice(2).to_a + else + value + end + } + + Floatify = lambda { |value| + case value + when "inf" + Float::INFINITY + when "-inf" + -Float::INFINITY + when String + Float(value) + else + value + end + } + + FloatifyPairs = lambda { |value| + return value unless value.respond_to?(:each_slice) + + value.each_slice(2).map do |member, score| + [member, Floatify.call(score)] + end + } + + HashifyInfo = lambda { |reply| + lines = reply.split("\r\n").grep_v(/^(#|$)/) + lines.map! { |line| line.split(':', 2) } + lines.compact! + lines.to_h + } + + HashifyStreams = lambda { |reply| + case reply + when nil + {} + else + reply.map { |key, entries| [key, HashifyStreamEntries.call(entries)] }.to_h + end + } + + EMPTY_STREAM_RESPONSE = [nil].freeze + private_constant :EMPTY_STREAM_RESPONSE + + HashifyStreamEntries = lambda { |reply| + reply.compact.map do |entry_id, values| + [entry_id, values&.each_slice(2)&.to_h] + end + } + + HashifyStreamAutoclaim = lambda { |reply| + { + 'next' => reply[0], + 'entries' => reply[1].compact.map do |entry, values| + [entry, values.each_slice(2)&.to_h] + end + } + } + + HashifyStreamAutoclaimJustId = lambda { |reply| + { + 'next' => reply[0], + 'entries' => reply[1] + } + } + + HashifyStreamPendings = lambda { |reply| + { + 'size' => reply[0], + 'min_entry_id' => reply[1], + 'max_entry_id' => reply[2], + 'consumers' => reply[3].nil? ? {} : reply[3].to_h + } + } + + HashifyStreamPendingDetails = lambda { |reply| + reply.map do |arr| + { + 'entry_id' => arr[0], + 'consumer' => arr[1], + 'elapsed' => arr[2], + 'count' => arr[3] + } + end + } + + 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 } + + # Sends a command to Redis and returns its reply. + # + # Replies are converted to Ruby objects according to the RESP protocol, so + # you can expect a Ruby array, integer or nil when Redis sends one. Higher + # level transformations, such as converting an array of pairs into a Ruby + # hash, are up to consumers. + # + # Redis error replies are raised as Ruby exceptions. + def call(*command) + send_command(command) + end + + # Interact with the sentinel command (masters, master, slaves, failover) + # + # @param [String] subcommand e.g. `masters`, `master`, `slaves` + # @param [Array] args depends on subcommand + # @return [Array, Hash, String] depends on subcommand + def sentinel(subcommand, *args) + subcommand = subcommand.to_s.downcase + send_command([:sentinel, subcommand] + args) do |reply| + case subcommand + when "get-master-addr-by-name" + reply + else + if reply.is_a?(Array) + if reply[0].is_a?(Array) + reply.map(&Hashify) + else + Hashify.call(reply) + end + else + reply + end + end + end + end + + private + + def method_missing(*command) # rubocop:disable Style/MissingRespondToMissing + send_command(command) + end + end +end diff --git a/lib/redis/commands/bitmaps.rb b/lib/redis/commands/bitmaps.rb new file mode 100644 index 000000000..c9f172825 --- /dev/null +++ b/lib/redis/commands/bitmaps.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +class Redis + module Commands + module Bitmaps + # Sets or clears the bit at offset in the string value stored at key. + # + # @param [String] key + # @param [Integer] offset bit offset + # @param [Integer] value bit value `0` or `1` + # @return [Integer] the original bit value stored at `offset` + def setbit(key, offset, value) + send_command([:setbit, key, offset, value]) + end + + # Returns the bit value at offset in the string value stored at key. + # + # @param [String] key + # @param [Integer] offset bit offset + # @return [Integer] `0` or `1` + def getbit(key, offset) + send_command([:getbit, key, offset]) + end + + # Count the number of set bits in a range of the string value stored at key. + # + # @param [String] key + # @param [Integer] start start index + # @param [Integer] stop stop index + # @param [String, Symbol] scale the scale of the offset range + # e.g. 'BYTE' - interpreted as a range of bytes, 'BIT' - interpreted as a range of bits + # @return [Integer] the number of bits set to 1 + def bitcount(key, start = 0, stop = -1, scale: nil) + command = [:bitcount, key, start, stop] + command << scale if scale + send_command(command) + end + + # Perform a bitwise operation between strings and store the resulting string in a key. + # + # @param [String] operation e.g. `and`, `or`, `xor`, `not` + # @param [String] destkey destination key + # @param [String, Array] keys one or more source keys to perform `operation` + # @return [Integer] the length of the string stored in `destkey` + def bitop(operation, destkey, *keys) + keys.flatten!(1) + command = [:bitop, operation, destkey] + command.concat(keys) + send_command(command) + end + + # Return the position of the first bit set to 1 or 0 in a string. + # + # @param [String] key + # @param [Integer] bit whether to look for the first 1 or 0 bit + # @param [Integer] start start index + # @param [Integer] stop stop index + # @param [String, Symbol] scale the scale of the offset range + # e.g. 'BYTE' - interpreted as a range of bytes, 'BIT' - interpreted as a range of bits + # @return [Integer] the position of the first 1/0 bit. + # -1 if looking for 1 and it is not found or start and stop are given. + def bitpos(key, bit, start = nil, stop = nil, scale: nil) + raise(ArgumentError, 'stop parameter specified without start parameter') if stop && !start + + command = [:bitpos, key, bit] + command << start if start + command << stop if stop + command << scale if scale + send_command(command) + end + end + end +end diff --git a/lib/redis/commands/cluster.rb b/lib/redis/commands/cluster.rb new file mode 100644 index 000000000..390b49746 --- /dev/null +++ b/lib/redis/commands/cluster.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class Redis + module Commands + module Cluster + # 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) + send_command([:cluster, subcommand] + args) + 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 + send_command(%i[asking]) + end + end + end +end diff --git a/lib/redis/commands/connection.rb b/lib/redis/commands/connection.rb new file mode 100644 index 000000000..50c39e8e7 --- /dev/null +++ b/lib/redis/commands/connection.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class Redis + module Commands + module Connection + # Authenticate to the server. + # + # @param [Array] args includes both username and password + # or only password + # @return [String] `OK` + # @see https://redis.io/commands/auth AUTH command + def auth(*args) + send_command([:auth, *args]) + end + + # Ping the server. + # + # @param [optional, String] message + # @return [String] `PONG` + def ping(message = nil) + send_command([:ping, message].compact) + end + + # Echo the given string. + # + # @param [String] value + # @return [String] + def echo(value) + send_command([:echo, value]) + end + + # Change the selected database for the current connection. + # + # @param [Integer] db zero-based index of the DB to use (0 to 15) + # @return [String] `OK` + def select(db) + send_command([:select, db]) + end + + # Close the connection. + # + # @return [String] `OK` + def quit + synchronize do |client| + client.call_v([:quit]) + rescue ConnectionError + ensure + client.close + end + end + end + end +end diff --git a/lib/redis/commands/geo.rb b/lib/redis/commands/geo.rb new file mode 100644 index 000000000..fe0d211ce --- /dev/null +++ b/lib/redis/commands/geo.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +class Redis + module Commands + module Geo + # Adds the specified geospatial items (latitude, longitude, name) to the specified key + # + # @param [String] key + # @param [Array] member arguemnts for member or members: longitude, latitude, name + # @return [Integer] number of elements added to the sorted set + def geoadd(key, *member) + send_command([:geoadd, key, *member]) + end + + # Returns geohash string representing position for specified members of the specified key. + # + # @param [String] key + # @param [String, Array] member one member or array of members + # @return [Array] returns array containg geohash string if member is present, nil otherwise + def geohash(key, member) + send_command([:geohash, key, member]) + end + + # Query a sorted set representing a geospatial index to fetch members matching a + # given maximum distance from a point + # + # @param [Array] args key, longitude, latitude, radius, unit(m|km|ft|mi) + # @param ['asc', 'desc'] sort sort returned items from the nearest to the farthest + # or the farthest to the nearest relative to the center + # @param [Integer] count limit the results to the first N matching items + # @param ['WITHDIST', 'WITHCOORD', 'WITHHASH'] options to return additional information + # @return [Array] may be changed with `options` + def georadius(*args, **geoptions) + geoarguments = _geoarguments(*args, **geoptions) + + send_command([:georadius, *geoarguments]) + end + + # Query a sorted set representing a geospatial index to fetch members matching a + # given maximum distance from an already existing member + # + # @param [Array] args key, member, radius, unit(m|km|ft|mi) + # @param ['asc', 'desc'] sort sort returned items from the nearest to the farthest or the farthest + # to the nearest relative to the center + # @param [Integer] count limit the results to the first N matching items + # @param ['WITHDIST', 'WITHCOORD', 'WITHHASH'] options to return additional information + # @return [Array] may be changed with `options` + def georadiusbymember(*args, **geoptions) + geoarguments = _geoarguments(*args, **geoptions) + + send_command([:georadiusbymember, *geoarguments]) + end + + # Returns longitude and latitude of members of a geospatial index + # + # @param [String] key + # @param [String, Array] member one member or array of members + # @return [Array, nil>] returns array of elements, where each + # element is either array of longitude and latitude or nil + def geopos(key, member) + send_command([:geopos, key, member]) + end + + # Returns the distance between two members of a geospatial index + # + # @param [String ]key + # @param [Array] members + # @param ['m', 'km', 'mi', 'ft'] unit + # @return [String, nil] returns distance in spefied unit if both members present, nil otherwise. + def geodist(key, member1, member2, unit = 'm') + send_command([:geodist, key, member1, member2, unit]) + end + + private + + def _geoarguments(*args, options: nil, sort: nil, count: nil) + args << sort if sort + args << 'COUNT' << Integer(count) if count + args << options if options + args + end + end + end +end diff --git a/lib/redis/commands/hashes.rb b/lib/redis/commands/hashes.rb new file mode 100644 index 000000000..a9a505afe --- /dev/null +++ b/lib/redis/commands/hashes.rb @@ -0,0 +1,254 @@ +# frozen_string_literal: true + +class Redis + module Commands + module Hashes + # Get the number of fields in a hash. + # + # @param [String] key + # @return [Integer] number of fields in the hash + def hlen(key) + send_command([:hlen, key]) + end + + # Set one or more hash values. + # + # @example + # redis.hset("hash", "f1", "v1", "f2", "v2") # => 2 + # redis.hset("hash", { "f1" => "v1", "f2" => "v2" }) # => 2 + # + # @param [String] key + # @param [Array | Hash] attrs array or hash of fields and values + # @return [Integer] The number of fields that were added to the hash + def hset(key, *attrs) + attrs = attrs.first.flatten if attrs.size == 1 && attrs.first.is_a?(Hash) + + send_command([:hset, key, *attrs]) + end + + # Set the value of a hash field, only if the field does not exist. + # + # @param [String] key + # @param [String] field + # @param [String] value + # @return [Boolean] whether or not the field was **added** to the hash + def hsetnx(key, field, value) + send_command([:hsetnx, key, field, value], &Boolify) + end + + # Set one or more hash values. + # + # @example + # redis.hmset("hash", "f1", "v1", "f2", "v2") + # # => "OK" + # + # @param [String] key + # @param [Array] attrs array of fields and values + # @return [String] `"OK"` + # + # @see #mapped_hmset + def hmset(key, *attrs) + send_command([:hmset, key] + attrs) + end + + # Set one or more hash values. + # + # @example + # redis.mapped_hmset("hash", { "f1" => "v1", "f2" => "v2" }) + # # => "OK" + # + # @param [String] key + # @param [Hash] hash a non-empty hash with fields mapping to values + # @return [String] `"OK"` + # + # @see #hmset + def mapped_hmset(key, hash) + hmset(key, hash.flatten) + end + + # Get the value of a hash field. + # + # @param [String] key + # @param [String] field + # @return [String] + def hget(key, field) + send_command([:hget, key, field]) + end + + # Get the values of all the given hash fields. + # + # @example + # redis.hmget("hash", "f1", "f2") + # # => ["v1", "v2"] + # + # @param [String] key + # @param [Array] fields array of fields + # @return [Array] an array of values for the specified fields + # + # @see #mapped_hmget + def hmget(key, *fields, &blk) + fields.flatten!(1) + send_command([:hmget, key].concat(fields), &blk) + end + + # Get the values of all the given hash fields. + # + # @example + # redis.mapped_hmget("hash", "f1", "f2") + # # => { "f1" => "v1", "f2" => "v2" } + # + # @param [String] key + # @param [Array] fields array of fields + # @return [Hash] a hash mapping the specified fields to their values + # + # @see #hmget + def mapped_hmget(key, *fields) + fields.flatten!(1) + hmget(key, fields) do |reply| + if reply.is_a?(Array) + Hash[fields.zip(reply)] + else + reply + end + end + end + + # Get one or more random fields from a hash. + # + # @example Get one random field + # redis.hrandfield("hash") + # # => "f1" + # @example Get multiple random fields + # redis.hrandfield("hash", 2) + # # => ["f1, "f2"] + # @example Get multiple random fields with values + # redis.hrandfield("hash", 2, with_values: true) + # # => [["f1", "s1"], ["f2", "s2"]] + # + # @param [String] key + # @param [Integer] count + # @param [Hash] options + # - `:with_values => true`: include values in output + # + # @return [nil, String, Array, Array<[String, Float]>] + # - when `key` does not exist, `nil` + # - when `count` is not specified, a field name + # - when `count` is specified and `:with_values` is not specified, an array of field names + # - when `:with_values` is specified, an array with `[field, value]` pairs + def hrandfield(key, count = nil, withvalues: false, with_values: withvalues) + if with_values && count.nil? + raise ArgumentError, "count argument must be specified" + end + + args = [:hrandfield, key] + args << count if count + args << "WITHVALUES" if with_values + + parser = Pairify if with_values + send_command(args, &parser) + end + + # Delete one or more hash fields. + # + # @param [String] key + # @param [String, Array] field + # @return [Integer] the number of fields that were removed from the hash + def hdel(key, *fields) + fields.flatten!(1) + send_command([:hdel, key].concat(fields)) + end + + # Determine if a hash field exists. + # + # @param [String] key + # @param [String] field + # @return [Boolean] whether or not the field exists in the hash + def hexists(key, field) + send_command([:hexists, key, field], &Boolify) + end + + # Increment the integer value of a hash field by the given integer number. + # + # @param [String] key + # @param [String] field + # @param [Integer] increment + # @return [Integer] value of the field after incrementing it + def hincrby(key, field, increment) + send_command([:hincrby, key, field, Integer(increment)]) + end + + # Increment the numeric value of a hash field by the given float number. + # + # @param [String] key + # @param [String] field + # @param [Float] increment + # @return [Float] value of the field after incrementing it + def hincrbyfloat(key, field, increment) + send_command([:hincrbyfloat, key, field, Float(increment)], &Floatify) + end + + # Get all the fields in a hash. + # + # @param [String] key + # @return [Array] + def hkeys(key) + send_command([:hkeys, key]) + end + + # Get all the values in a hash. + # + # @param [String] key + # @return [Array] + def hvals(key) + send_command([:hvals, key]) + end + + # Get all the fields and values in a hash. + # + # @param [String] key + # @return [Hash] + def hgetall(key) + send_command([:hgetall, key], &Hashify) + end + + # Scan a hash + # + # @example Retrieve the first batch of key/value pairs in a hash + # redis.hscan("hash", 0) + # + # @param [String, Integer] cursor the cursor of the iteration + # @param [Hash] options + # - `:match => String`: only return keys matching the pattern + # - `:count => Integer`: return count keys at most per iteration + # + # @return [String, Array<[String, String]>] the next cursor and all found keys + def hscan(key, cursor, **options) + _scan(:hscan, cursor, [key], **options) do |reply| + [reply[0], reply[1].each_slice(2).to_a] + end + end + + # Scan a hash + # + # @example Retrieve all of the key/value pairs in a hash + # redis.hscan_each("hash").to_a + # # => [["key70", "70"], ["key80", "80"]] + # + # @param [Hash] options + # - `:match => String`: only return keys matching the pattern + # - `:count => Integer`: return count keys at most per iteration + # + # @return [Enumerator] an enumerator for all found keys + def hscan_each(key, **options, &block) + return to_enum(:hscan_each, key, **options) unless block_given? + + cursor = 0 + loop do + cursor, values = hscan(key, cursor, **options) + values.each(&block) + break if cursor == "0" + end + end + end + end +end diff --git a/lib/redis/commands/hyper_log_log.rb b/lib/redis/commands/hyper_log_log.rb new file mode 100644 index 000000000..c7834b710 --- /dev/null +++ b/lib/redis/commands/hyper_log_log.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Redis + module Commands + module HyperLogLog + # Add one or more members to a HyperLogLog structure. + # + # @param [String] key + # @param [String, Array] member one member, or array of members + # @return [Boolean] true if at least 1 HyperLogLog internal register was altered. false otherwise. + def pfadd(key, member) + send_command([:pfadd, key, member], &Boolify) + end + + # Get the approximate cardinality of members added to HyperLogLog structure. + # + # If called with multiple keys, returns the approximate cardinality of the + # union of the HyperLogLogs contained in the keys. + # + # @param [String, Array] keys + # @return [Integer] + def pfcount(*keys) + send_command([:pfcount] + keys.flatten(1)) + end + + # Merge multiple HyperLogLog values into an unique value that will approximate the cardinality of the union of + # the observed Sets of the source HyperLogLog structures. + # + # @param [String] dest_key destination key + # @param [String, Array] source_key source key, or array of keys + # @return [Boolean] + def pfmerge(dest_key, *source_key) + send_command([:pfmerge, dest_key, *source_key], &BoolifySet) + end + end + end +end diff --git a/lib/redis/commands/keys.rb b/lib/redis/commands/keys.rb new file mode 100644 index 000000000..6b1a6e860 --- /dev/null +++ b/lib/redis/commands/keys.rb @@ -0,0 +1,437 @@ +# frozen_string_literal: true + +class Redis + module Commands + module Keys + # Scan the keyspace + # + # @example Retrieve the first batch of keys + # redis.scan(0) + # # => ["4", ["key:21", "key:47", "key:42"]] + # @example Retrieve a batch of keys matching a pattern + # redis.scan(4, :match => "key:1?") + # # => ["92", ["key:13", "key:18"]] + # @example Retrieve a batch of keys of a certain type + # redis.scan(92, :type => "zset") + # # => ["173", ["sortedset:14", "sortedset:78"]] + # + # @param [String, Integer] cursor the cursor of the iteration + # @param [Hash] options + # - `:match => String`: only return keys matching the pattern + # - `:count => Integer`: return count keys at most per iteration + # - `:type => String`: return keys only of the given type + # + # @return [String, Array] the next cursor and all found keys + def scan(cursor, **options) + _scan(:scan, cursor, [], **options) + end + + # Scan the keyspace + # + # @example Retrieve all of the keys (with possible duplicates) + # redis.scan_each.to_a + # # => ["key:21", "key:47", "key:42"] + # @example Execute block for each key matching a pattern + # redis.scan_each(:match => "key:1?") {|key| puts key} + # # => key:13 + # # => key:18 + # @example Execute block for each key of a type + # redis.scan_each(:type => "hash") {|key| puts redis.type(key)} + # # => "hash" + # # => "hash" + # + # @param [Hash] options + # - `:match => String`: only return keys matching the pattern + # - `:count => Integer`: return count keys at most per iteration + # - `:type => String`: return keys only of the given type + # + # @return [Enumerator] an enumerator for all found keys + def scan_each(**options, &block) + return to_enum(:scan_each, **options) unless block_given? + + cursor = 0 + loop do + cursor, keys = scan(cursor, **options) + keys.each(&block) + break if cursor == "0" + end + end + + # Remove the expiration from a key. + # + # @param [String] key + # @return [Boolean] whether the timeout was removed or not + def persist(key) + send_command([:persist, key], &Boolify) + end + + # Set a key's time to live in seconds. + # + # @param [String] key + # @param [Integer] seconds time to live + # @param [Hash] options + # - `:nx => true`: Set expiry only when the key has no expiry. + # - `:xx => true`: Set expiry only when the key has an existing expiry. + # - `:gt => true`: Set expiry only when the new expiry is greater than current one. + # - `:lt => true`: Set expiry only when the new expiry is less than current one. + # @return [Boolean] whether the timeout was set or not + def expire(key, seconds, nx: nil, xx: nil, gt: nil, lt: nil) + args = [:expire, key, Integer(seconds)] + args << "NX" if nx + args << "XX" if xx + args << "GT" if gt + args << "LT" if lt + + send_command(args, &Boolify) + end + + # Set the expiration for a key as a UNIX timestamp. + # + # @param [String] key + # @param [Integer] unix_time expiry time specified as a UNIX timestamp + # @param [Hash] options + # - `:nx => true`: Set expiry only when the key has no expiry. + # - `:xx => true`: Set expiry only when the key has an existing expiry. + # - `:gt => true`: Set expiry only when the new expiry is greater than current one. + # - `:lt => true`: Set expiry only when the new expiry is less than current one. + # @return [Boolean] whether the timeout was set or not + def expireat(key, unix_time, nx: nil, xx: nil, gt: nil, lt: nil) + args = [:expireat, key, Integer(unix_time)] + args << "NX" if nx + args << "XX" if xx + args << "GT" if gt + args << "LT" if lt + + send_command(args, &Boolify) + end + + # Get the time to live (in seconds) for a key. + # + # @param [String] key + # @return [Integer] remaining time to live in seconds. + # + # In Redis 2.6 or older the command returns -1 if the key does not exist or if + # the key exist but has no associated expire. + # + # Starting with Redis 2.8 the return value in case of error changed: + # + # - The command returns -2 if the key does not exist. + # - The command returns -1 if the key exists but has no associated expire. + def ttl(key) + send_command([:ttl, key]) + end + + # Set a key's time to live in milliseconds. + # + # @param [String] key + # @param [Integer] milliseconds time to live + # @param [Hash] options + # - `:nx => true`: Set expiry only when the key has no expiry. + # - `:xx => true`: Set expiry only when the key has an existing expiry. + # - `:gt => true`: Set expiry only when the new expiry is greater than current one. + # - `:lt => true`: Set expiry only when the new expiry is less than current one. + # @return [Boolean] whether the timeout was set or not + def pexpire(key, milliseconds, nx: nil, xx: nil, gt: nil, lt: nil) + args = [:pexpire, key, Integer(milliseconds)] + args << "NX" if nx + args << "XX" if xx + args << "GT" if gt + args << "LT" if lt + + send_command(args, &Boolify) + end + + # Set the expiration for a key as number of milliseconds from UNIX Epoch. + # + # @param [String] key + # @param [Integer] ms_unix_time expiry time specified as number of milliseconds from UNIX Epoch. + # @param [Hash] options + # - `:nx => true`: Set expiry only when the key has no expiry. + # - `:xx => true`: Set expiry only when the key has an existing expiry. + # - `:gt => true`: Set expiry only when the new expiry is greater than current one. + # - `:lt => true`: Set expiry only when the new expiry is less than current one. + # @return [Boolean] whether the timeout was set or not + def pexpireat(key, ms_unix_time, nx: nil, xx: nil, gt: nil, lt: nil) + args = [:pexpireat, key, Integer(ms_unix_time)] + args << "NX" if nx + args << "XX" if xx + args << "GT" if gt + args << "LT" if lt + + send_command(args, &Boolify) + end + + # Get the time to live (in milliseconds) for a key. + # + # @param [String] key + # @return [Integer] remaining time to live in milliseconds + # In Redis 2.6 or older the command returns -1 if the key does not exist or if + # the key exist but has no associated expire. + # + # Starting with Redis 2.8 the return value in case of error changed: + # + # - The command returns -2 if the key does not exist. + # - The command returns -1 if the key exists but has no associated expire. + def pttl(key) + send_command([:pttl, key]) + end + + # Return a serialized version of the value stored at a key. + # + # @param [String] key + # @return [String] serialized_value + def dump(key) + send_command([:dump, key]) + end + + # Create a key using the serialized value, previously obtained using DUMP. + # + # @param [String] key + # @param [String] ttl + # @param [String] serialized_value + # @param [Hash] options + # - `:replace => Boolean`: if false, raises an error if key already exists + # @raise [Redis::CommandError] + # @return [String] `"OK"` + def restore(key, ttl, serialized_value, replace: nil) + args = [:restore, key, ttl, serialized_value] + args << 'REPLACE' if replace + + send_command(args) + end + + # Transfer a key from the connected instance to another instance. + # + # @param [String, Array] key + # @param [Hash] options + # - `:host => String`: host of instance to migrate to + # - `:port => Integer`: port of instance to migrate to + # - `:db => Integer`: database to migrate to (default: same as source) + # - `:timeout => Integer`: timeout (default: same as connection timeout) + # - `:copy => Boolean`: Do not remove the key from the local instance. + # - `:replace => Boolean`: Replace existing key on the remote instance. + # @return [String] `"OK"` + def migrate(key, options) + args = [:migrate] + args << (options[:host] || raise(':host not specified')) + args << (options[:port] || raise(':port not specified')) + args << (key.is_a?(String) ? key : '') + args << (options[:db] || @client.db).to_i + args << (options[:timeout] || @client.timeout).to_i + args << 'COPY' if options[:copy] + args << 'REPLACE' if options[:replace] + args += ['KEYS', *key] if key.is_a?(Array) + + send_command(args) + end + + # Delete one or more keys. + # + # @param [String, Array] keys + # @return [Integer] number of keys that were deleted + def del(*keys) + keys.flatten!(1) + return 0 if keys.empty? + + send_command([:del] + keys) + end + + # Unlink one or more keys. + # + # @param [String, Array] keys + # @return [Integer] number of keys that were unlinked + def unlink(*keys) + send_command([:unlink] + keys) + end + + # Determine how many of the keys exists. + # + # @param [String, Array] keys + # @return [Integer] + def exists(*keys) + send_command([:exists, *keys]) + end + + # Determine if any of the keys exists. + # + # @param [String, Array] keys + # @return [Boolean] + def exists?(*keys) + send_command([:exists, *keys]) do |value| + value > 0 + end + end + + # Find all keys matching the given pattern. + # + # @param [String] pattern + # @return [Array] + def keys(pattern = "*") + send_command([:keys, pattern]) do |reply| + if reply.is_a?(String) + reply.split(" ") + else + reply + end + end + end + + # Move a key to another database. + # + # @example Move a key to another database + # redis.set "foo", "bar" + # # => "OK" + # redis.move "foo", 2 + # # => true + # redis.exists "foo" + # # => false + # redis.select 2 + # # => "OK" + # redis.exists "foo" + # # => true + # redis.get "foo" + # # => "bar" + # + # @param [String] key + # @param [Integer] db + # @return [Boolean] whether the key was moved or not + def move(key, db) + send_command([:move, key, db], &Boolify) + end + + # Copy a value from one key to another. + # + # @example Copy a value to another key + # redis.set "foo", "value" + # # => "OK" + # redis.copy "foo", "bar" + # # => true + # redis.get "bar" + # # => "value" + # + # @example Copy a value to a key in another database + # redis.set "foo", "value" + # # => "OK" + # redis.copy "foo", "bar", db: 2 + # # => true + # redis.select 2 + # # => "OK" + # redis.get "bar" + # # => "value" + # + # @param [String] source + # @param [String] destination + # @param [Integer] db + # @param [Boolean] replace removes the `destination` key before copying value to it + # @return [Boolean] whether the key was copied or not + def copy(source, destination, db: nil, replace: false) + command = [:copy, source, destination] + command << "DB" << db if db + command << "REPLACE" if replace + + send_command(command, &Boolify) + end + + def object(*args) + send_command([:object] + args) + end + + # Return a random key from the keyspace. + # + # @return [String] + def randomkey + send_command([:randomkey]) + end + + # Rename a key. If the new key already exists it is overwritten. + # + # @param [String] old_name + # @param [String] new_name + # @return [String] `OK` + def rename(old_name, new_name) + send_command([:rename, old_name, new_name]) + end + + # Rename a key, only if the new key does not exist. + # + # @param [String] old_name + # @param [String] new_name + # @return [Boolean] whether the key was renamed or not + def renamenx(old_name, new_name) + send_command([:renamenx, old_name, new_name], &Boolify) + end + + # Sort the elements in a list, set or sorted set. + # + # @example Retrieve the first 2 elements from an alphabetically sorted "list" + # redis.sort("list", :order => "alpha", :limit => [0, 2]) + # # => ["a", "b"] + # @example Store an alphabetically descending list in "target" + # redis.sort("list", :order => "desc alpha", :store => "target") + # # => 26 + # + # @param [String] key + # @param [Hash] options + # - `:by => String`: use external key to sort elements by + # - `:limit => [offset, count]`: skip `offset` elements, return a maximum + # of `count` elements + # - `:get => [String, Array]`: single key or array of keys to + # retrieve per element in the result + # - `:order => String`: combination of `ASC`, `DESC` and optionally `ALPHA` + # - `:store => String`: key to store the result at + # + # @return [Array, Array>, Integer] + # - when `:get` is not specified, or holds a single element, an array of elements + # - when `:get` is specified, and holds more than one element, an array of + # elements where every element is an array with the result for every + # element specified in `:get` + # - when `:store` is specified, the number of elements in the stored result + def sort(key, by: nil, limit: nil, get: nil, order: nil, store: nil) + args = [:sort, key] + args << "BY" << by if by + + if limit + args << "LIMIT" + args.concat(limit) + end + + get = Array(get) + get.each do |item| + args << "GET" << item + end + + args.concat(order.split(" ")) if order + args << "STORE" << store if store + + send_command(args) do |reply| + if get.size > 1 && !store + reply.each_slice(get.size).to_a if reply + else + reply + end + end + end + + # Determine the type stored at key. + # + # @param [String] key + # @return [String] `string`, `list`, `set`, `zset`, `hash` or `none` + def type(key) + send_command([:type, key]) + end + + private + + def _scan(command, cursor, args, match: nil, count: nil, type: nil, &block) + # SSCAN/ZSCAN/HSCAN already prepend the key to +args+. + + args << cursor + args << "MATCH" << match if match + args << "COUNT" << Integer(count) if count + args << "TYPE" << type if type + + send_command([command] + args, &block) + end + end + end +end diff --git a/lib/redis/commands/lists.rb b/lib/redis/commands/lists.rb new file mode 100644 index 000000000..08a619a21 --- /dev/null +++ b/lib/redis/commands/lists.rb @@ -0,0 +1,339 @@ +# frozen_string_literal: true + +class Redis + module Commands + module Lists + # Get the length of a list. + # + # @param [String] key + # @return [Integer] + def llen(key) + send_command([:llen, key]) + end + + # Remove the first/last element in a list, append/prepend it to another list and return it. + # + # @param [String] source source key + # @param [String] destination destination key + # @param [String, Symbol] where_source from where to remove the element from the source list + # e.g. 'LEFT' - from head, 'RIGHT' - from tail + # @param [String, Symbol] where_destination where to push the element to the source list + # e.g. 'LEFT' - to head, 'RIGHT' - to tail + # + # @return [nil, String] the element, or nil when the source key does not exist + # + # @note This command comes in place of the now deprecated RPOPLPUSH. + # Doing LMOVE RIGHT LEFT is equivalent. + def lmove(source, destination, where_source, where_destination) + where_source, where_destination = _normalize_move_wheres(where_source, where_destination) + + send_command([:lmove, source, destination, where_source, where_destination]) + end + + # Remove the first/last element in a list and append/prepend it + # to another list and return it, or block until one is available. + # + # @example With timeout + # element = redis.blmove("foo", "bar", "LEFT", "RIGHT", timeout: 5) + # # => nil on timeout + # # => "element" on success + # @example Without timeout + # element = redis.blmove("foo", "bar", "LEFT", "RIGHT") + # # => "element" + # + # @param [String] source source key + # @param [String] destination destination key + # @param [String, Symbol] where_source from where to remove the element from the source list + # e.g. 'LEFT' - from head, 'RIGHT' - from tail + # @param [String, Symbol] where_destination where to push the element to the source list + # e.g. 'LEFT' - to head, 'RIGHT' - to tail + # @param [Hash] options + # - `:timeout => [Float, Integer]`: timeout in seconds, defaults to no timeout + # + # @return [nil, String] the element, or nil when the source key does not exist or the timeout expired + # + def blmove(source, destination, where_source, where_destination, timeout: 0) + where_source, where_destination = _normalize_move_wheres(where_source, where_destination) + + command = [:blmove, source, destination, where_source, where_destination, timeout] + send_blocking_command(command, timeout) + end + + # Prepend one or more values to a list, creating the list if it doesn't exist + # + # @param [String] key + # @param [String, Array] value string value, or array of string values to push + # @return [Integer] the length of the list after the push operation + def lpush(key, value) + send_command([:lpush, key, value]) + end + + # Prepend a value to a list, only if the list exists. + # + # @param [String] key + # @param [String] value + # @return [Integer] the length of the list after the push operation + def lpushx(key, value) + send_command([:lpushx, key, value]) + end + + # Append one or more values to a list, creating the list if it doesn't exist + # + # @param [String] key + # @param [String, Array] value string value, or array of string values to push + # @return [Integer] the length of the list after the push operation + def rpush(key, value) + send_command([:rpush, key, value]) + end + + # Append a value to a list, only if the list exists. + # + # @param [String] key + # @param [String] value + # @return [Integer] the length of the list after the push operation + def rpushx(key, value) + send_command([:rpushx, key, value]) + end + + # Remove and get the first elements in a list. + # + # @param [String] key + # @param [Integer] count number of elements to remove + # @return [nil, String, Array] the values of the first elements + def lpop(key, count = nil) + command = [:lpop, key] + command << Integer(count) if count + send_command(command) + end + + # Remove and get the last elements in a list. + # + # @param [String] key + # @param [Integer] count number of elements to remove + # @return [nil, String, Array] the values of the last elements + def rpop(key, count = nil) + command = [:rpop, key] + command << Integer(count) if count + send_command(command) + end + + # Remove the last element in a list, append it to another list and return it. + # + # @param [String] source source key + # @param [String] destination destination key + # @return [nil, String] the element, or nil when the source key does not exist + def rpoplpush(source, destination) + send_command([:rpoplpush, source, destination]) + end + + # Remove and get the first element in a list, or block until one is available. + # + # @example With timeout + # list, element = redis.blpop("list", :timeout => 5) + # # => nil on timeout + # # => ["list", "element"] on success + # @example Without timeout + # list, element = redis.blpop("list") + # # => ["list", "element"] + # @example Blocking pop on multiple lists + # list, element = redis.blpop(["list", "another_list"]) + # # => ["list", "element"] + # + # @param [String, Array] keys one or more keys to perform the + # blocking pop on + # @param [Hash] options + # - `:timeout => [Float, Integer]`: timeout in seconds, defaults to no timeout + # + # @return [nil, [String, String]] + # - `nil` when the operation timed out + # - tuple of the list that was popped from and element was popped otherwise + def blpop(*args) + _bpop(:blpop, args) + end + + # Remove and get the last element in a list, or block until one is available. + # + # @param [String, Array] keys one or more keys to perform the + # blocking pop on + # @param [Hash] options + # - `:timeout => [Float, Integer]`: timeout in seconds, defaults to no timeout + # + # @return [nil, [String, String]] + # - `nil` when the operation timed out + # - tuple of the list that was popped from and element was popped otherwise + # + # @see #blpop + def brpop(*args) + _bpop(:brpop, args) + end + + # Pop a value from a list, push it to another list and return it; or block + # until one is available. + # + # @param [String] source source key + # @param [String] destination destination key + # @param [Hash] options + # - `:timeout => [Float, Integer]`: timeout in seconds, defaults to no timeout + # + # @return [nil, String] + # - `nil` when the operation timed out + # - the element was popped and pushed otherwise + def brpoplpush(source, destination, timeout: 0) + command = [:brpoplpush, source, destination, timeout] + send_blocking_command(command, timeout) + end + + # Pops one or more elements from the first non-empty list key from the list + # of provided key names. If lists are empty, blocks until timeout has passed. + # + # @example Popping a element + # redis.blmpop(1.0, 'list') + # #=> ['list', ['a']] + # @example With count option + # redis.blmpop(1.0, 'list', count: 2) + # #=> ['list', ['a', 'b']] + # + # @params timeout [Float] a float value specifying the maximum number of seconds to block) elapses. + # A timeout of zero can be used to block indefinitely. + # @params key [String, Array] one or more keys with lists + # @params modifier [String] + # - when `"LEFT"` - the elements popped are those from the left of the list + # - when `"RIGHT"` - the elements popped are those from the right of the list + # @params count [Integer] a number of elements to pop + # + # @return [Array>] list of popped elements or nil + def blmpop(timeout, *keys, modifier: "LEFT", count: nil) + raise ArgumentError, "Pick either LEFT or RIGHT" unless modifier == "LEFT" || modifier == "RIGHT" + + args = [:lmpop, keys.size, *keys, modifier] + args << "COUNT" << Integer(count) if count + + send_blocking_command(args, timeout) + end + + # Pops one or more elements from the first non-empty list key from the list + # of provided key names. + # + # @example Popping a element + # redis.lmpop('list') + # #=> ['list', ['a']] + # @example With count option + # redis.lmpop('list', count: 2) + # #=> ['list', ['a', 'b']] + # + # @params key [String, Array] one or more keys with lists + # @params modifier [String] + # - when `"LEFT"` - the elements popped are those from the left of the list + # - when `"RIGHT"` - the elements popped are those from the right of the list + # @params count [Integer] a number of elements to pop + # + # @return [Array>] list of popped elements or nil + def lmpop(*keys, modifier: "LEFT", count: nil) + raise ArgumentError, "Pick either LEFT or RIGHT" unless modifier == "LEFT" || modifier == "RIGHT" + + args = [:lmpop, keys.size, *keys, modifier] + args << "COUNT" << Integer(count) if count + + send_command(args) + end + + # Get an element from a list by its index. + # + # @param [String] key + # @param [Integer] index + # @return [String] + def lindex(key, index) + send_command([:lindex, key, Integer(index)]) + end + + # Insert an element before or after another element in a list. + # + # @param [String] key + # @param [String, Symbol] where `BEFORE` or `AFTER` + # @param [String] pivot reference element + # @param [String] value + # @return [Integer] length of the list after the insert operation, or `-1` + # when the element `pivot` was not found + def linsert(key, where, pivot, value) + send_command([:linsert, key, where, pivot, value]) + end + + # Get a range of elements from a list. + # + # @param [String] key + # @param [Integer] start start index + # @param [Integer] stop stop index + # @return [Array] + def lrange(key, start, stop) + send_command([:lrange, key, Integer(start), Integer(stop)]) + end + + # Remove elements from a list. + # + # @param [String] key + # @param [Integer] count number of elements to remove. Use a positive + # value to remove the first `count` occurrences of `value`. A negative + # value to remove the last `count` occurrences of `value`. Or zero, to + # remove all occurrences of `value` from the list. + # @param [String] value + # @return [Integer] the number of removed elements + def lrem(key, count, value) + send_command([:lrem, key, Integer(count), value]) + end + + # Set the value of an element in a list by its index. + # + # @param [String] key + # @param [Integer] index + # @param [String] value + # @return [String] `OK` + def lset(key, index, value) + send_command([:lset, key, Integer(index), value]) + end + + # Trim a list to the specified range. + # + # @param [String] key + # @param [Integer] start start index + # @param [Integer] stop stop index + # @return [String] `OK` + def ltrim(key, start, stop) + send_command([:ltrim, key, Integer(start), Integer(stop)]) + end + + private + + def _bpop(cmd, args, &blk) + timeout = if args.last.is_a?(Hash) + options = args.pop + options[:timeout] + end + + timeout ||= 0 + unless timeout.is_a?(Integer) || timeout.is_a?(Float) + raise ArgumentError, "timeout must be an Integer or Float, got: #{timeout.class}" + end + + args.flatten!(1) + command = [cmd].concat(args) + command << timeout + send_blocking_command(command, timeout, &blk) + end + + def _normalize_move_wheres(where_source, where_destination) + where_source = where_source.to_s.upcase + where_destination = where_destination.to_s.upcase + + if where_source != "LEFT" && where_source != "RIGHT" + raise ArgumentError, "where_source must be 'LEFT' or 'RIGHT'" + end + + if where_destination != "LEFT" && where_destination != "RIGHT" + raise ArgumentError, "where_destination must be 'LEFT' or 'RIGHT'" + end + + [where_source, where_destination] + end + end + end +end diff --git a/lib/redis/commands/pubsub.rb b/lib/redis/commands/pubsub.rb new file mode 100644 index 000000000..ccdababff --- /dev/null +++ b/lib/redis/commands/pubsub.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +class Redis + module Commands + module Pubsub + # Post a message to a channel. + def publish(channel, message) + send_command([:publish, channel, message]) + end + + def subscribed? + !@subscription_client.nil? + end + + # Listen for messages published to the given channels. + def subscribe(*channels, &block) + _subscription(:subscribe, 0, channels, block) + end + + # Listen for messages published to the given channels. Throw a timeout error + # if there is no messages for a timeout period. + def subscribe_with_timeout(timeout, *channels, &block) + _subscription(:subscribe_with_timeout, timeout, channels, block) + end + + # Stop listening for messages posted to the given channels. + def unsubscribe(*channels) + _subscription(:unsubscribe, 0, channels, nil) + end + + # Listen for messages published to channels matching the given patterns. + def psubscribe(*channels, &block) + _subscription(:psubscribe, 0, channels, block) + end + + # Listen for messages published to channels matching the given patterns. + # Throw a timeout error if there is no messages for a timeout period. + def psubscribe_with_timeout(timeout, *channels, &block) + _subscription(:psubscribe_with_timeout, timeout, channels, block) + end + + # Stop listening for messages posted to channels matching the given patterns. + def punsubscribe(*channels) + _subscription(:punsubscribe, 0, channels, nil) + end + + # Inspect the state of the Pub/Sub subsystem. + # Possible subcommands: channels, numsub, numpat. + def pubsub(subcommand, *args) + send_command([:pubsub, subcommand] + args) + end + + # Post a message to a channel in a shard. + def spublish(channel, message) + send_command([:spublish, channel, message]) + end + + # Listen for messages published to the given channels in a shard. + def ssubscribe(*channels, &block) + _subscription(:ssubscribe, 0, channels, block) + end + + # Listen for messages published to the given channels in a shard. + # Throw a timeout error if there is no messages for a timeout period. + def ssubscribe_with_timeout(timeout, *channels, &block) + _subscription(:ssubscribe_with_timeout, timeout, channels, block) + end + + # Stop listening for messages posted to the given channels in a shard. + def sunsubscribe(*channels) + _subscription(:sunsubscribe, 0, channels, nil) + end + end + end +end diff --git a/lib/redis/commands/scripting.rb b/lib/redis/commands/scripting.rb new file mode 100644 index 000000000..8cc22890d --- /dev/null +++ b/lib/redis/commands/scripting.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +class Redis + module Commands + module Scripting + # Control remote script registry. + # + # @example Load a script + # sha = redis.script(:load, "return 1") + # # => + # @example Check if a script exists + # redis.script(:exists, sha) + # # => true + # @example Check if multiple scripts exist + # redis.script(:exists, [sha, other_sha]) + # # => [true, false] + # @example Flush the script registry + # redis.script(:flush) + # # => "OK" + # @example Kill a running script + # redis.script(:kill) + # # => "OK" + # + # @param [String] subcommand e.g. `exists`, `flush`, `load`, `kill` + # @param [Array] args depends on subcommand + # @return [String, Boolean, Array, ...] depends on subcommand + # + # @see #eval + # @see #evalsha + def script(subcommand, *args) + subcommand = subcommand.to_s.downcase + + if subcommand == "exists" + arg = args.first + + send_command([:script, :exists, arg]) do |reply| + reply = reply.map { |r| Boolify.call(r) } + + if arg.is_a?(Array) + reply + else + reply.first + end + end + else + send_command([:script, subcommand] + args) + end + end + + # Evaluate Lua script. + # + # @example EVAL without KEYS nor ARGV + # redis.eval("return 1") + # # => 1 + # @example EVAL with KEYS and ARGV as array arguments + # redis.eval("return { KEYS, ARGV }", ["k1", "k2"], ["a1", "a2"]) + # # => [["k1", "k2"], ["a1", "a2"]] + # @example EVAL with KEYS and ARGV in a hash argument + # redis.eval("return { KEYS, ARGV }", :keys => ["k1", "k2"], :argv => ["a1", "a2"]) + # # => [["k1", "k2"], ["a1", "a2"]] + # + # @param [Array] keys optional array with keys to pass to the script + # @param [Array] argv optional array with arguments to pass to the script + # @param [Hash] options + # - `:keys => Array`: optional array with keys to pass to the script + # - `:argv => Array`: optional array with arguments to pass to the script + # @return depends on the script + # + # @see #script + # @see #evalsha + def eval(*args) + _eval(:eval, args) + end + + # Evaluate Lua script by its SHA. + # + # @example EVALSHA without KEYS nor ARGV + # redis.evalsha(sha) + # # => + # @example EVALSHA with KEYS and ARGV as array arguments + # redis.evalsha(sha, ["k1", "k2"], ["a1", "a2"]) + # # => + # @example EVALSHA with KEYS and ARGV in a hash argument + # redis.evalsha(sha, :keys => ["k1", "k2"], :argv => ["a1", "a2"]) + # # => + # + # @param [Array] keys optional array with keys to pass to the script + # @param [Array] argv optional array with arguments to pass to the script + # @param [Hash] options + # - `:keys => Array`: optional array with keys to pass to the script + # - `:argv => Array`: optional array with arguments to pass to the script + # @return depends on the script + # + # @see #script + # @see #eval + def evalsha(*args) + _eval(:evalsha, args) + end + + private + + def _eval(cmd, args) + script = args.shift + options = args.pop if args.last.is_a?(Hash) + options ||= {} + + keys = args.shift || options[:keys] || [] + argv = args.shift || options[:argv] || [] + + send_command([cmd, script, keys.length] + keys + argv) + end + end + end +end diff --git a/lib/redis/commands/server.rb b/lib/redis/commands/server.rb new file mode 100644 index 000000000..eca57782f --- /dev/null +++ b/lib/redis/commands/server.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +class Redis + module Commands + module Server + # Asynchronously rewrite the append-only file. + # + # @return [String] `OK` + def bgrewriteaof + send_command([:bgrewriteaof]) + end + + # Asynchronously save the dataset to disk. + # + # @return [String] `OK` + def bgsave + send_command([:bgsave]) + end + + # Get or set server configuration parameters. + # + # @param [Symbol] action e.g. `:get`, `:set`, `:resetstat` + # @return [String, Hash] string reply, or hash when retrieving more than one + # property with `CONFIG GET` + def config(action, *args) + send_command([:config, action] + args) do |reply| + if reply.is_a?(Array) && action == :get + Hashify.call(reply) + else + reply + end + end + end + + # Manage client connections. + # + # @param [String, Symbol] subcommand e.g. `kill`, `list`, `getname`, `setname` + # @return [String, Hash] depends on subcommand + def client(subcommand, *args) + send_command([:client, subcommand] + args) do |reply| + if subcommand.to_s == "list" + reply.lines.map do |line| + entries = line.chomp.split(/[ =]/) + Hash[entries.each_slice(2).to_a] + end + else + reply + end + end + end + + # Return the number of keys in the selected database. + # + # @return [Integer] + def dbsize + send_command([:dbsize]) + end + + # Remove all keys from all databases. + # + # @param [Hash] options + # - `:async => Boolean`: async flush (default: false) + # @return [String] `OK` + def flushall(options = nil) + if options && options[:async] + send_command(%i[flushall async]) + else + send_command([:flushall]) + end + end + + # Remove all keys from the current database. + # + # @param [Hash] options + # - `:async => Boolean`: async flush (default: false) + # @return [String] `OK` + def flushdb(options = nil) + if options && options[:async] + send_command(%i[flushdb async]) + else + send_command([:flushdb]) + end + end + + # Get information and statistics about the server. + # + # @param [String, Symbol] cmd e.g. "commandstats" + # @return [Hash] + def info(cmd = nil) + send_command([:info, cmd].compact) do |reply| + if reply.is_a?(String) + reply = HashifyInfo.call(reply) + + if cmd && cmd.to_s == "commandstats" + # Extract nested hashes for INFO COMMANDSTATS + reply = Hash[reply.map do |k, v| + v = v.split(",").map { |e| e.split("=") } + [k[/^cmdstat_(.*)$/, 1], Hash[v]] + end] + end + end + + reply + end + end + + # Get the UNIX time stamp of the last successful save to disk. + # + # @return [Integer] + def lastsave + send_command([:lastsave]) + end + + # Listen for all requests received by the server in real time. + # + # There is no way to interrupt this command. + # + # @yield a block to be called for every line of output + # @yieldparam [String] line timestamp and command that was executed + def monitor + synchronize do |client| + client = client.pubsub + client.call_v([:monitor]) + loop do + yield client.next_event + end + end + end + + # Synchronously save the dataset to disk. + # + # @return [String] + def save + send_command([:save]) + end + + # Synchronously save the dataset to disk and then shut down the server. + def shutdown + synchronize do |client| + client.disable_reconnection do + client.call_v([:shutdown]) + rescue ConnectionError + # This means Redis has probably exited. + nil + end + end + end + + # Make the server a slave of another instance, or promote it as master. + def slaveof(host, port) + send_command([:slaveof, host, port]) + end + + # Interact with the slowlog (get, len, reset) + # + # @param [String] subcommand e.g. `get`, `len`, `reset` + # @param [Integer] length maximum number of entries to return + # @return [Array, Integer, String] depends on subcommand + def slowlog(subcommand, length = nil) + args = [:slowlog, subcommand] + args << Integer(length) if length + send_command(args) + end + + # Internal command used for replication. + def sync + send_command([:sync]) + end + + # Return the server time. + # + # @example + # r.time # => [ 1333093196, 606806 ] + # + # @return [Array] tuple of seconds since UNIX epoch and + # microseconds in the current second + def time + send_command([:time]) do |reply| + reply&.map(&:to_i) + end + end + + def debug(*args) + send_command([:debug] + args) + end + end + end +end diff --git a/lib/redis/commands/sets.rb b/lib/redis/commands/sets.rb new file mode 100644 index 000000000..e05751397 --- /dev/null +++ b/lib/redis/commands/sets.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +class Redis + module Commands + module Sets + # Get the number of members in a set. + # + # @param [String] key + # @return [Integer] + def scard(key) + send_command([:scard, key]) + end + + # Add one or more members to a set. + # + # @param [String] key + # @param [String, Array] member one member, or array of members + # @return [Integer] The number of members that were successfully added + def sadd(key, *members) + members.flatten!(1) + send_command([:sadd, key].concat(members)) + end + + # Add one or more members to a set. + # + # @param [String] key + # @param [String, Array] member one member, or array of members + # @return [Boolean] Wether at least one member was successfully added. + def sadd?(key, *members) + members.flatten!(1) + send_command([:sadd, key].concat(members), &Boolify) + end + + # Remove one or more members from a set. + # + # @param [String] key + # @param [String, Array] member one member, or array of members + # @return [Integer] The number of members that were successfully removed + def srem(key, *members) + members.flatten!(1) + send_command([:srem, key].concat(members)) + end + + # Remove one or more members from a set. + # + # @param [String] key + # @param [String, Array] member one member, or array of members + # @return [Boolean] Wether at least one member was successfully removed. + def srem?(key, *members) + members.flatten!(1) + send_command([:srem, key].concat(members), &Boolify) + end + + # Remove and return one or more random member from a set. + # + # @param [String] key + # @return [String] + # @param [Integer] count + def spop(key, count = nil) + if count.nil? + send_command([:spop, key]) + else + send_command([:spop, key, Integer(count)]) + end + end + + # Get one or more random members from a set. + # + # @param [String] key + # @param [Integer] count + # @return [String] + def srandmember(key, count = nil) + if count.nil? + send_command([:srandmember, key]) + else + send_command([:srandmember, key, count]) + end + end + + # Move a member from one set to another. + # + # @param [String] source source key + # @param [String] destination destination key + # @param [String] member member to move from `source` to `destination` + # @return [Boolean] + def smove(source, destination, member) + send_command([:smove, source, destination, member], &Boolify) + end + + # Determine if a given value is a member of a set. + # + # @param [String] key + # @param [String] member + # @return [Boolean] + def sismember(key, member) + send_command([:sismember, key, member], &Boolify) + end + + # Determine if multiple values are members of a set. + # + # @param [String] key + # @param [String, Array] members + # @return [Array] + def smismember(key, *members) + members.flatten!(1) + send_command([:smismember, key].concat(members)) do |reply| + reply.map(&Boolify) + end + end + + # Get all the members in a set. + # + # @param [String] key + # @return [Array] + def smembers(key) + send_command([:smembers, key]) + end + + # Subtract multiple sets. + # + # @param [String, Array] keys keys pointing to sets to subtract + # @return [Array] members in the difference + def sdiff(*keys) + keys.flatten!(1) + send_command([:sdiff].concat(keys)) + end + + # Subtract multiple sets and store the resulting set in a key. + # + # @param [String] destination destination key + # @param [String, Array] keys keys pointing to sets to subtract + # @return [Integer] number of elements in the resulting set + def sdiffstore(destination, *keys) + keys.flatten!(1) + send_command([:sdiffstore, destination].concat(keys)) + end + + # Intersect multiple sets. + # + # @param [String, Array] keys keys pointing to sets to intersect + # @return [Array] members in the intersection + def sinter(*keys) + keys.flatten!(1) + send_command([:sinter].concat(keys)) + end + + # Intersect multiple sets and store the resulting set in a key. + # + # @param [String] destination destination key + # @param [String, Array] keys keys pointing to sets to intersect + # @return [Integer] number of elements in the resulting set + def sinterstore(destination, *keys) + keys.flatten!(1) + send_command([:sinterstore, destination].concat(keys)) + end + + # Add multiple sets. + # + # @param [String, Array] keys keys pointing to sets to unify + # @return [Array] members in the union + def sunion(*keys) + keys.flatten!(1) + send_command([:sunion].concat(keys)) + end + + # Add multiple sets and store the resulting set in a key. + # + # @param [String] destination destination key + # @param [String, Array] keys keys pointing to sets to unify + # @return [Integer] number of elements in the resulting set + def sunionstore(destination, *keys) + keys.flatten!(1) + send_command([:sunionstore, destination].concat(keys)) + end + + # Scan a set + # + # @example Retrieve the first batch of keys in a set + # redis.sscan("set", 0) + # + # @param [String, Integer] cursor the cursor of the iteration + # @param [Hash] options + # - `:match => String`: only return keys matching the pattern + # - `:count => Integer`: return count keys at most per iteration + # + # @return [String, Array] the next cursor and all found members + def sscan(key, cursor, **options) + _scan(:sscan, cursor, [key], **options) + end + + # Scan a set + # + # @example Retrieve all of the keys in a set + # redis.sscan_each("set").to_a + # # => ["key1", "key2", "key3"] + # + # @param [Hash] options + # - `:match => String`: only return keys matching the pattern + # - `:count => Integer`: return count keys at most per iteration + # + # @return [Enumerator] an enumerator for all keys in the set + def sscan_each(key, **options, &block) + return to_enum(:sscan_each, key, **options) unless block_given? + + cursor = 0 + loop do + cursor, keys = sscan(key, cursor, **options) + keys.each(&block) + break if cursor == "0" + end + end + end + end +end diff --git a/lib/redis/commands/sorted_sets.rb b/lib/redis/commands/sorted_sets.rb new file mode 100644 index 000000000..f07f95275 --- /dev/null +++ b/lib/redis/commands/sorted_sets.rb @@ -0,0 +1,884 @@ +# frozen_string_literal: true + +class Redis + module Commands + module SortedSets + # Get the number of members in a sorted set. + # + # @example + # redis.zcard("zset") + # # => 4 + # + # @param [String] key + # @return [Integer] + def zcard(key) + send_command([:zcard, key]) + end + + # Add one or more members to a sorted set, or update the score for members + # that already exist. + # + # @example Add a single `[score, member]` pair to a sorted set + # redis.zadd("zset", 32.0, "member") + # @example Add an array of `[score, member]` pairs to a sorted set + # redis.zadd("zset", [[32.0, "a"], [64.0, "b"]]) + # + # @param [String] key + # @param [[Float, String], Array<[Float, String]>] args + # - a single `[score, member]` pair + # - an array of `[score, member]` pairs + # @param [Hash] options + # - `:xx => true`: Only update elements that already exist (never + # add elements) + # - `:nx => true`: Don't update already existing elements (always + # add new elements) + # - `:lt => true`: Only update existing elements if the new score + # is less than the current score + # - `:gt => true`: Only update existing elements if the new score + # is greater than the current score + # - `:ch => true`: Modify the return value from the number of new + # elements added, to the total number of elements changed (CH is an + # abbreviation of changed); changed elements are new elements added + # and elements already existing for which the score was updated + # - `:incr => true`: When this option is specified ZADD acts like + # ZINCRBY; only one score-element pair can be specified in this mode + # + # @return [Boolean, Integer, Float] + # - `Boolean` when a single pair is specified, holding whether or not it was + # **added** to the sorted set. + # - `Integer` when an array of pairs is specified, holding the number of + # pairs that were **added** to the sorted set. + # - `Float` when option :incr is specified, holding the score of the member + # after incrementing it. + def zadd(key, *args, nx: nil, xx: nil, lt: nil, gt: nil, ch: nil, incr: nil) + command = [:zadd, key] + command << "NX" if nx + command << "XX" if xx + command << "LT" if lt + command << "GT" if gt + command << "CH" if ch + command << "INCR" if incr + + if args.size == 1 && args[0].is_a?(Array) + members_to_add = args[0] + return 0 if members_to_add.empty? + + # Variadic: return float if INCR, integer if !INCR + send_command(command + members_to_add, &(incr ? Floatify : nil)) + elsif args.size == 2 + # Single pair: return float if INCR, boolean if !INCR + send_command(command + args, &(incr ? Floatify : Boolify)) + else + raise ArgumentError, "wrong number of arguments" + end + end + + # Increment the score of a member in a sorted set. + # + # @example + # redis.zincrby("zset", 32.0, "a") + # # => 64.0 + # + # @param [String] key + # @param [Float] increment + # @param [String] member + # @return [Float] score of the member after incrementing it + def zincrby(key, increment, member) + send_command([:zincrby, key, increment, member], &Floatify) + end + + # Remove one or more members from a sorted set. + # + # @example Remove a single member from a sorted set + # redis.zrem("zset", "a") + # @example Remove an array of members from a sorted set + # redis.zrem("zset", ["a", "b"]) + # + # @param [String] key + # @param [String, Array] member + # - a single member + # - an array of members + # + # @return [Boolean, Integer] + # - `Boolean` when a single member is specified, holding whether or not it + # was removed from the sorted set + # - `Integer` when an array of pairs is specified, holding the number of + # members that were removed to the sorted set + def zrem(key, member) + if member.is_a?(Array) + members_to_remove = member + return 0 if members_to_remove.empty? + end + + send_command([:zrem, key, member]) do |reply| + if member.is_a? Array + # Variadic: return integer + reply + else + # Single argument: return boolean + Boolify.call(reply) + end + end + end + + # Removes and returns up to count members with the highest scores in the sorted set stored at key. + # + # @example Popping a member + # redis.zpopmax('zset') + # #=> ['b', 2.0] + # @example With count option + # redis.zpopmax('zset', 2) + # #=> [['b', 2.0], ['a', 1.0]] + # + # @params key [String] a key of the sorted set + # @params count [Integer] a number of members + # + # @return [Array] element and score pair if count is not specified + # @return [Array>] list of popped elements and scores + def zpopmax(key, count = nil) + command = [:zpopmax, key] + command << Integer(count) if count + send_command(command) do |members| + members = FloatifyPairs.call(members) + count.to_i > 1 ? members : members.first + end + end + + # Removes and returns up to count members with the lowest scores in the sorted set stored at key. + # + # @example Popping a member + # redis.zpopmin('zset') + # #=> ['a', 1.0] + # @example With count option + # redis.zpopmin('zset', 2) + # #=> [['a', 1.0], ['b', 2.0]] + # + # @params key [String] a key of the sorted set + # @params count [Integer] a number of members + # + # @return [Array] element and score pair if count is not specified + # @return [Array>] list of popped elements and scores + def zpopmin(key, count = nil) + command = [:zpopmin, key] + command << Integer(count) if count + send_command(command) do |members| + members = FloatifyPairs.call(members) + count.to_i > 1 ? members : members.first + end + end + + # Removes and returns up to count members with scores in the sorted set stored at key. + # + # @example Popping a member + # redis.bzmpop('zset') + # #=> ['zset', ['a', 1.0]] + # @example With count option + # redis.bzmpop('zset', count: 2) + # #=> ['zset', [['a', 1.0], ['b', 2.0]] + # + # @params timeout [Float] a float value specifying the maximum number of seconds to block) elapses. + # A timeout of zero can be used to block indefinitely. + # @params key [String, Array] one or more keys with sorted sets + # @params modifier [String] + # - when `"MIN"` - the elements popped are those with lowest scores + # - when `"MAX"` - the elements popped are those with the highest scores + # @params count [Integer] a number of members to pop + # + # @return [Array>] list of popped elements and scores + def bzmpop(timeout, *keys, modifier: "MIN", count: nil) + raise ArgumentError, "Pick either MIN or MAX" unless modifier == "MIN" || modifier == "MAX" + + args = [:bzmpop, timeout, keys.size, *keys, modifier] + args << "COUNT" << Integer(count) if count + + send_blocking_command(args, timeout) do |response| + response&.map do |entry| + case entry + when String then entry + when Array then entry.map { |pair| FloatifyPairs.call(pair) }.flatten(1) + end + end + end + end + + # Removes and returns up to count members with scores in the sorted set stored at key. + # + # @example Popping a member + # redis.zmpop('zset') + # #=> ['zset', ['a', 1.0]] + # @example With count option + # redis.zmpop('zset', count: 2) + # #=> ['zset', [['a', 1.0], ['b', 2.0]] + # + # @params key [String, Array] one or more keys with sorted sets + # @params modifier [String] + # - when `"MIN"` - the elements popped are those with lowest scores + # - when `"MAX"` - the elements popped are those with the highest scores + # @params count [Integer] a number of members to pop + # + # @return [Array>] list of popped elements and scores + def zmpop(*keys, modifier: "MIN", count: nil) + raise ArgumentError, "Pick either MIN or MAX" unless modifier == "MIN" || modifier == "MAX" + + args = [:zmpop, keys.size, *keys, modifier] + args << "COUNT" << Integer(count) if count + + send_command(args) do |response| + response&.map do |entry| + case entry + when String then entry + when Array then entry.map { |pair| FloatifyPairs.call(pair) }.flatten(1) + end + end + end + end + + # Removes and returns up to count members with the highest scores in the sorted set stored at keys, + # or block until one is available. + # + # @example Popping a member from a sorted set + # redis.bzpopmax('zset', 1) + # #=> ['zset', 'b', 2.0] + # @example Popping a member from multiple sorted sets + # redis.bzpopmax('zset1', 'zset2', 1) + # #=> ['zset1', 'b', 2.0] + # + # @params keys [Array] one or multiple keys of the sorted sets + # @params timeout [Integer] the maximum number of seconds to block + # + # @return [Array] a touple of key, member and score + # @return [nil] when no element could be popped and the timeout expired + def bzpopmax(*args) + _bpop(:bzpopmax, args) do |reply| + reply.is_a?(Array) ? [reply[0], reply[1], Floatify.call(reply[2])] : reply + end + end + + # Removes and returns up to count members with the lowest scores in the sorted set stored at keys, + # or block until one is available. + # + # @example Popping a member from a sorted set + # redis.bzpopmin('zset', 1) + # #=> ['zset', 'a', 1.0] + # @example Popping a member from multiple sorted sets + # redis.bzpopmin('zset1', 'zset2', 1) + # #=> ['zset1', 'a', 1.0] + # + # @params keys [Array] one or multiple keys of the sorted sets + # @params timeout [Integer] the maximum number of seconds to block + # + # @return [Array] a touple of key, member and score + # @return [nil] when no element could be popped and the timeout expired + def bzpopmin(*args) + _bpop(:bzpopmin, args) do |reply| + reply.is_a?(Array) ? [reply[0], reply[1], Floatify.call(reply[2])] : reply + end + end + + # Get the score associated with the given member in a sorted set. + # + # @example Get the score for member "a" + # redis.zscore("zset", "a") + # # => 32.0 + # + # @param [String] key + # @param [String] member + # @return [Float] score of the member + def zscore(key, member) + send_command([:zscore, key, member], &Floatify) + end + + # Get the scores associated with the given members in a sorted set. + # + # @example Get the scores for members "a" and "b" + # redis.zmscore("zset", "a", "b") + # # => [32.0, 48.0] + # + # @param [String] key + # @param [String, Array] members + # @return [Array] scores of the members + def zmscore(key, *members) + send_command([:zmscore, key, *members]) do |reply| + reply.map(&Floatify) + end + end + + # Get one or more random members from a sorted set. + # + # @example Get one random member + # redis.zrandmember("zset") + # # => "a" + # @example Get multiple random members + # redis.zrandmember("zset", 2) + # # => ["a", "b"] + # @example Get multiple random members with scores + # redis.zrandmember("zset", 2, with_scores: true) + # # => [["a", 2.0], ["b", 3.0]] + # + # @param [String] key + # @param [Integer] count + # @param [Hash] options + # - `:with_scores => true`: include scores in output + # + # @return [nil, String, Array, Array<[String, Float]>] + # - when `key` does not exist or set is empty, `nil` + # - when `count` is not specified, a member + # - when `count` is specified and `:with_scores` is not specified, an array of members + # - when `:with_scores` is specified, an array with `[member, score]` pairs + def zrandmember(key, count = nil, withscores: false, with_scores: withscores) + if with_scores && count.nil? + raise ArgumentError, "count argument must be specified" + end + + args = [:zrandmember, key] + args << Integer(count) if count + + if with_scores + args << "WITHSCORES" + block = FloatifyPairs + end + + send_command(args, &block) + end + + # Return a range of members in a sorted set, by index, score or lexicographical ordering. + # + # @example Retrieve all members from a sorted set, by index + # redis.zrange("zset", 0, -1) + # # => ["a", "b"] + # @example Retrieve all members and their scores from a sorted set + # redis.zrange("zset", 0, -1, :with_scores => true) + # # => [["a", 32.0], ["b", 64.0]] + # + # @param [String] key + # @param [Integer] start start index + # @param [Integer] stop stop index + # @param [Hash] options + # - `:by_score => false`: return members by score + # - `:by_lex => false`: return members by lexicographical ordering + # - `:rev => false`: reverse the ordering, from highest to lowest + # - `:limit => [offset, count]`: skip `offset` members, return a maximum of + # `count` members + # - `:with_scores => true`: include scores in output + # + # @return [Array, Array<[String, Float]>] + # - when `:with_scores` is not specified, an array of members + # - when `:with_scores` is specified, an array with `[member, score]` pairs + def zrange(key, start, stop, byscore: false, by_score: byscore, bylex: false, by_lex: bylex, + rev: false, limit: nil, withscores: false, with_scores: withscores) + + if by_score && by_lex + raise ArgumentError, "only one of :by_score or :by_lex can be specified" + end + + args = [:zrange, key, start, stop] + + if by_score + args << "BYSCORE" + elsif by_lex + args << "BYLEX" + end + + args << "REV" if rev + + if limit + args << "LIMIT" + args.concat(limit.map { |l| Integer(l) }) + end + + if with_scores + args << "WITHSCORES" + block = FloatifyPairs + end + + send_command(args, &block) + end + + # Select a range of members in a sorted set, by index, score or lexicographical ordering + # and store the resulting sorted set in a new key. + # + # @example + # redis.zadd("foo", [[1.0, "s1"], [2.0, "s2"], [3.0, "s3"]]) + # redis.zrangestore("bar", "foo", 0, 1) + # # => 2 + # redis.zrange("bar", 0, -1) + # # => ["s1", "s2"] + # + # @return [Integer] the number of elements in the resulting sorted set + # @see #zrange + def zrangestore(dest_key, src_key, start, stop, byscore: false, by_score: byscore, + bylex: false, by_lex: bylex, rev: false, limit: nil) + if by_score && by_lex + raise ArgumentError, "only one of :by_score or :by_lex can be specified" + end + + args = [:zrangestore, dest_key, src_key, start, stop] + + if by_score + args << "BYSCORE" + elsif by_lex + args << "BYLEX" + end + + args << "REV" if rev + + if limit + args << "LIMIT" + args.concat(limit.map { |l| Integer(l) }) + end + + send_command(args) + end + + # Return a range of members in a sorted set, by index, with scores ordered + # from high to low. + # + # @example Retrieve all members from a sorted set + # redis.zrevrange("zset", 0, -1) + # # => ["b", "a"] + # @example Retrieve all members and their scores from a sorted set + # redis.zrevrange("zset", 0, -1, :with_scores => true) + # # => [["b", 64.0], ["a", 32.0]] + # + # @see #zrange + def zrevrange(key, start, stop, withscores: false, with_scores: withscores) + args = [:zrevrange, key, Integer(start), Integer(stop)] + + if with_scores + args << "WITHSCORES" + block = FloatifyPairs + end + + send_command(args, &block) + end + + # Determine the index of a member in a sorted set. + # + # @param [String] key + # @param [String] member + # @return [Integer] + def zrank(key, member) + send_command([:zrank, key, member]) + end + + # Determine the index of a member in a sorted set, with scores ordered from + # high to low. + # + # @param [String] key + # @param [String] member + # @return [Integer] + def zrevrank(key, member) + send_command([:zrevrank, key, member]) + end + + # Remove all members in a sorted set within the given indexes. + # + # @example Remove first 5 members + # redis.zremrangebyrank("zset", 0, 4) + # # => 5 + # @example Remove last 5 members + # redis.zremrangebyrank("zset", -5, -1) + # # => 5 + # + # @param [String] key + # @param [Integer] start start index + # @param [Integer] stop stop index + # @return [Integer] number of members that were removed + def zremrangebyrank(key, start, stop) + send_command([:zremrangebyrank, key, start, stop]) + end + + # Count the members, with the same score in a sorted set, within the given lexicographical range. + # + # @example Count members matching a + # redis.zlexcount("zset", "[a", "[a\xff") + # # => 1 + # @example Count members matching a-z + # redis.zlexcount("zset", "[a", "[z\xff") + # # => 26 + # + # @param [String] key + # @param [String] min + # - inclusive minimum is specified by prefixing `(` + # - exclusive minimum is specified by prefixing `[` + # @param [String] max + # - inclusive maximum is specified by prefixing `(` + # - exclusive maximum is specified by prefixing `[` + # + # @return [Integer] number of members within the specified lexicographical range + def zlexcount(key, min, max) + send_command([:zlexcount, key, min, max]) + end + + # Return a range of members with the same score in a sorted set, by lexicographical ordering + # + # @example Retrieve members matching a + # redis.zrangebylex("zset", "[a", "[a\xff") + # # => ["aaren", "aarika", "abagael", "abby"] + # @example Retrieve the first 2 members matching a + # redis.zrangebylex("zset", "[a", "[a\xff", :limit => [0, 2]) + # # => ["aaren", "aarika"] + # + # @param [String] key + # @param [String] min + # - inclusive minimum is specified by prefixing `(` + # - exclusive minimum is specified by prefixing `[` + # @param [String] max + # - inclusive maximum is specified by prefixing `(` + # - exclusive maximum is specified by prefixing `[` + # @param [Hash] options + # - `:limit => [offset, count]`: skip `offset` members, return a maximum of + # `count` members + # + # @return [Array, Array<[String, Float]>] + def zrangebylex(key, min, max, limit: nil) + args = [:zrangebylex, key, min, max] + + if limit + args << "LIMIT" + args.concat(limit.map { |l| Integer(l) }) + end + + send_command(args) + end + + # Return a range of members with the same score in a sorted set, by reversed lexicographical ordering. + # Apart from the reversed ordering, #zrevrangebylex is similar to #zrangebylex. + # + # @example Retrieve members matching a + # redis.zrevrangebylex("zset", "[a", "[a\xff") + # # => ["abbygail", "abby", "abagael", "aaren"] + # @example Retrieve the last 2 members matching a + # redis.zrevrangebylex("zset", "[a", "[a\xff", :limit => [0, 2]) + # # => ["abbygail", "abby"] + # + # @see #zrangebylex + def zrevrangebylex(key, max, min, limit: nil) + args = [:zrevrangebylex, key, max, min] + + if limit + args << "LIMIT" + args.concat(limit.map { |l| Integer(l) }) + end + + send_command(args) + end + + # Return a range of members in a sorted set, by score. + # + # @example Retrieve members with score `>= 5` and `< 100` + # redis.zrangebyscore("zset", "5", "(100") + # # => ["a", "b"] + # @example Retrieve the first 2 members with score `>= 0` + # redis.zrangebyscore("zset", "0", "+inf", :limit => [0, 2]) + # # => ["a", "b"] + # @example Retrieve members and their scores with scores `> 5` + # redis.zrangebyscore("zset", "(5", "+inf", :with_scores => true) + # # => [["a", 32.0], ["b", 64.0]] + # + # @param [String] key + # @param [String] min + # - inclusive minimum score is specified verbatim + # - exclusive minimum score is specified by prefixing `(` + # @param [String] max + # - inclusive maximum score is specified verbatim + # - exclusive maximum score is specified by prefixing `(` + # @param [Hash] options + # - `:with_scores => true`: include scores in output + # - `:limit => [offset, count]`: skip `offset` members, return a maximum of + # `count` members + # + # @return [Array, Array<[String, Float]>] + # - when `:with_scores` is not specified, an array of members + # - when `:with_scores` is specified, an array with `[member, score]` pairs + def zrangebyscore(key, min, max, withscores: false, with_scores: withscores, limit: nil) + args = [:zrangebyscore, key, min, max] + + if with_scores + args << "WITHSCORES" + block = FloatifyPairs + end + + if limit + args << "LIMIT" + args.concat(limit.map { |l| Integer(l) }) + end + + send_command(args, &block) + end + + # Return a range of members in a sorted set, by score, with scores ordered + # from high to low. + # + # @example Retrieve members with score `< 100` and `>= 5` + # redis.zrevrangebyscore("zset", "(100", "5") + # # => ["b", "a"] + # @example Retrieve the first 2 members with score `<= 0` + # redis.zrevrangebyscore("zset", "0", "-inf", :limit => [0, 2]) + # # => ["b", "a"] + # @example Retrieve members and their scores with scores `> 5` + # redis.zrevrangebyscore("zset", "+inf", "(5", :with_scores => true) + # # => [["b", 64.0], ["a", 32.0]] + # + # @see #zrangebyscore + def zrevrangebyscore(key, max, min, withscores: false, with_scores: withscores, limit: nil) + args = [:zrevrangebyscore, key, max, min] + + if with_scores + args << "WITHSCORES" + block = FloatifyPairs + end + + if limit + args << "LIMIT" + args.concat(limit.map { |l| Integer(l) }) + end + + send_command(args, &block) + end + + # Remove all members in a sorted set within the given scores. + # + # @example Remove members with score `>= 5` and `< 100` + # redis.zremrangebyscore("zset", "5", "(100") + # # => 2 + # @example Remove members with scores `> 5` + # redis.zremrangebyscore("zset", "(5", "+inf") + # # => 2 + # + # @param [String] key + # @param [String] min + # - inclusive minimum score is specified verbatim + # - exclusive minimum score is specified by prefixing `(` + # @param [String] max + # - inclusive maximum score is specified verbatim + # - exclusive maximum score is specified by prefixing `(` + # @return [Integer] number of members that were removed + def zremrangebyscore(key, min, max) + send_command([:zremrangebyscore, key, min, max]) + end + + # Count the members in a sorted set with scores within the given values. + # + # @example Count members with score `>= 5` and `< 100` + # redis.zcount("zset", "5", "(100") + # # => 2 + # @example Count members with scores `> 5` + # redis.zcount("zset", "(5", "+inf") + # # => 2 + # + # @param [String] key + # @param [String] min + # - inclusive minimum score is specified verbatim + # - exclusive minimum score is specified by prefixing `(` + # @param [String] max + # - inclusive maximum score is specified verbatim + # - exclusive maximum score is specified by prefixing `(` + # @return [Integer] number of members in within the specified range + def zcount(key, min, max) + send_command([:zcount, key, min, max]) + end + + # Return the intersection of multiple sorted sets + # + # @example Retrieve the intersection of `2*zsetA` and `1*zsetB` + # redis.zinter("zsetA", "zsetB", :weights => [2.0, 1.0]) + # # => ["v1", "v2"] + # @example Retrieve the intersection of `2*zsetA` and `1*zsetB`, and their scores + # redis.zinter("zsetA", "zsetB", :weights => [2.0, 1.0], :with_scores => true) + # # => [["v1", 3.0], ["v2", 6.0]] + # + # @param [String, Array] keys one or more keys to intersect + # @param [Hash] options + # - `:weights => [Float, Float, ...]`: weights to associate with source + # sorted sets + # - `:aggregate => String`: aggregate function to use (sum, min, max, ...) + # - `:with_scores => true`: include scores in output + # + # @return [Array, Array<[String, Float]>] + # - when `:with_scores` is not specified, an array of members + # - when `:with_scores` is specified, an array with `[member, score]` pairs + def zinter(*args) + _zsets_operation(:zinter, *args) + end + ruby2_keywords(:zinter) if respond_to?(:ruby2_keywords, true) + + # Intersect multiple sorted sets and store the resulting sorted set in a new + # key. + # + # @example Compute the intersection of `2*zsetA` with `1*zsetB`, summing their scores + # redis.zinterstore("zsetC", ["zsetA", "zsetB"], :weights => [2.0, 1.0], :aggregate => "sum") + # # => 4 + # + # @param [String] destination destination key + # @param [Array] keys source keys + # @param [Hash] options + # - `:weights => [Array]`: weights to associate with source + # sorted sets + # - `:aggregate => String`: aggregate function to use (sum, min, max) + # @return [Integer] number of elements in the resulting sorted set + def zinterstore(*args) + _zsets_operation_store(:zinterstore, *args) + end + ruby2_keywords(:zinterstore) if respond_to?(:ruby2_keywords, true) + + # Return the union of multiple sorted sets + # + # @example Retrieve the union of `2*zsetA` and `1*zsetB` + # redis.zunion("zsetA", "zsetB", :weights => [2.0, 1.0]) + # # => ["v1", "v2"] + # @example Retrieve the union of `2*zsetA` and `1*zsetB`, and their scores + # redis.zunion("zsetA", "zsetB", :weights => [2.0, 1.0], :with_scores => true) + # # => [["v1", 3.0], ["v2", 6.0]] + # + # @param [String, Array] keys one or more keys to union + # @param [Hash] options + # - `:weights => [Array]`: weights to associate with source + # sorted sets + # - `:aggregate => String`: aggregate function to use (sum, min, max) + # - `:with_scores => true`: include scores in output + # + # @return [Array, Array<[String, Float]>] + # - when `:with_scores` is not specified, an array of members + # - when `:with_scores` is specified, an array with `[member, score]` pairs + def zunion(*args) + _zsets_operation(:zunion, *args) + end + ruby2_keywords(:zunion) if respond_to?(:ruby2_keywords, true) + + # Add multiple sorted sets and store the resulting sorted set in a new key. + # + # @example Compute the union of `2*zsetA` with `1*zsetB`, summing their scores + # redis.zunionstore("zsetC", ["zsetA", "zsetB"], :weights => [2.0, 1.0], :aggregate => "sum") + # # => 8 + # + # @param [String] destination destination key + # @param [Array] keys source keys + # @param [Hash] options + # - `:weights => [Float, Float, ...]`: weights to associate with source + # sorted sets + # - `:aggregate => String`: aggregate function to use (sum, min, max, ...) + # @return [Integer] number of elements in the resulting sorted set + def zunionstore(*args) + _zsets_operation_store(:zunionstore, *args) + end + ruby2_keywords(:zunionstore) if respond_to?(:ruby2_keywords, true) + + # Return the difference between the first and all successive input sorted sets + # + # @example + # redis.zadd("zsetA", [[1.0, "v1"], [2.0, "v2"]]) + # redis.zadd("zsetB", [[3.0, "v2"], [2.0, "v3"]]) + # redis.zdiff("zsetA", "zsetB") + # => ["v1"] + # @example With scores + # redis.zadd("zsetA", [[1.0, "v1"], [2.0, "v2"]]) + # redis.zadd("zsetB", [[3.0, "v2"], [2.0, "v3"]]) + # redis.zdiff("zsetA", "zsetB", :with_scores => true) + # => [["v1", 1.0]] + # + # @param [String, Array] keys one or more keys to compute the difference + # @param [Hash] options + # - `:with_scores => true`: include scores in output + # + # @return [Array, Array<[String, Float]>] + # - when `:with_scores` is not specified, an array of members + # - when `:with_scores` is specified, an array with `[member, score]` pairs + def zdiff(*keys, with_scores: false) + _zsets_operation(:zdiff, *keys, with_scores: with_scores) + end + + # Compute the difference between the first and all successive input sorted sets + # and store the resulting sorted set in a new key + # + # @example + # redis.zadd("zsetA", [[1.0, "v1"], [2.0, "v2"]]) + # redis.zadd("zsetB", [[3.0, "v2"], [2.0, "v3"]]) + # redis.zdiffstore("zsetA", "zsetB") + # # => 1 + # + # @param [String] destination destination key + # @param [Array] keys source keys + # @return [Integer] number of elements in the resulting sorted set + def zdiffstore(*args) + _zsets_operation_store(:zdiffstore, *args) + end + ruby2_keywords(:zdiffstore) if respond_to?(:ruby2_keywords, true) + + # Scan a sorted set + # + # @example Retrieve the first batch of key/value pairs in a hash + # redis.zscan("zset", 0) + # + # @param [String, Integer] cursor the cursor of the iteration + # @param [Hash] options + # - `:match => String`: only return keys matching the pattern + # - `:count => Integer`: return count keys at most per iteration + # + # @return [String, Array<[String, Float]>] the next cursor and all found + # members and scores + def zscan(key, cursor, **options) + _scan(:zscan, cursor, [key], **options) do |reply| + [reply[0], FloatifyPairs.call(reply[1])] + end + end + + # Scan a sorted set + # + # @example Retrieve all of the members/scores in a sorted set + # redis.zscan_each("zset").to_a + # # => [["key70", "70"], ["key80", "80"]] + # + # @param [Hash] options + # - `:match => String`: only return keys matching the pattern + # - `:count => Integer`: return count keys at most per iteration + # + # @return [Enumerator] an enumerator for all found scores and members + def zscan_each(key, **options, &block) + return to_enum(:zscan_each, key, **options) unless block_given? + + cursor = 0 + loop do + cursor, values = zscan(key, cursor, **options) + values.each(&block) + break if cursor == "0" + end + end + + private + + def _zsets_operation(cmd, *keys, weights: nil, aggregate: nil, with_scores: false) + keys.flatten!(1) + command = [cmd, keys.size].concat(keys) + + if weights + command << "WEIGHTS" + command.concat(weights) + end + + command << "AGGREGATE" << aggregate if aggregate + + if with_scores + command << "WITHSCORES" + block = FloatifyPairs + end + + send_command(command, &block) + end + + def _zsets_operation_store(cmd, destination, keys, weights: nil, aggregate: nil) + keys.flatten!(1) + command = [cmd, destination, keys.size].concat(keys) + + if weights + command << "WEIGHTS" + command.concat(weights) + end + + command << "AGGREGATE" << aggregate if aggregate + + send_command(command) + end + end + end +end diff --git a/lib/redis/commands/streams.rb b/lib/redis/commands/streams.rb new file mode 100644 index 000000000..e3c750688 --- /dev/null +++ b/lib/redis/commands/streams.rb @@ -0,0 +1,402 @@ +# frozen_string_literal: true + +class Redis + module Commands + module Streams + # Returns the stream information each subcommand. + # + # @example stream + # redis.xinfo(:stream, 'mystream') + # @example groups + # redis.xinfo(:groups, 'mystream') + # @example consumers + # redis.xinfo(:consumers, 'mystream', 'mygroup') + # + # @param subcommand [String] e.g. `stream` `groups` `consumers` + # @param key [String] the stream key + # @param group [String] the consumer group name, required if subcommand is `consumers` + # + # @return [Hash] information of the stream if subcommand is `stream` + # @return [Array] information of the consumer groups if subcommand is `groups` + # @return [Array] information of the consumers if subcommand is `consumers` + def xinfo(subcommand, key, group = nil) + args = [:xinfo, subcommand, key, group].compact + block = case subcommand.to_s.downcase + when 'stream' then Hashify + when 'groups', 'consumers' then proc { |r| r.map(&Hashify) } + end + + send_command(args, &block) + end + + # Add new entry to the stream. + # + # @example Without options + # redis.xadd('mystream', f1: 'v1', f2: 'v2') + # @example With options + # redis.xadd('mystream', { f1: 'v1', f2: 'v2' }, id: '0-0', maxlen: 1000, approximate: true, nomkstream: true) + # + # @param key [String] the stream key + # @param entry [Hash] one or multiple field-value pairs + # @param opts [Hash] several options for `XADD` command + # + # @option opts [String] :id the entry id, default value is `*`, it means auto generation + # @option opts [Integer] :maxlen max length of entries + # @option opts [Boolean] :approximate whether to add `~` modifier of maxlen or not + # @option opts [Boolean] :nomkstream whether to add NOMKSTREAM, default is not to add + # + # @return [String] the entry id + def xadd(key, entry, approximate: nil, maxlen: nil, nomkstream: nil, id: '*') + args = [:xadd, key] + args << 'NOMKSTREAM' if nomkstream + if maxlen + args << "MAXLEN" + args << "~" if approximate + args << maxlen + end + args << id + args.concat(entry.flatten) + send_command(args) + end + + # Trims older entries of the stream if needed. + # + # @example Without options + # redis.xtrim('mystream', 1000) + # @example With options + # redis.xtrim('mystream', 1000, approximate: true) + # @example With strategy + # redis.xtrim('mystream', '1-0', strategy: 'MINID') + # + # @overload xtrim(key, maxlen, strategy: 'MAXLEN', approximate: true) + # @param key [String] the stream key + # @param maxlen [Integer] max length of entries + # @param strategy [String] the limit strategy, must be MAXLEN + # @param approximate [Boolean] whether to add `~` modifier of maxlen or not + # @param limit [Integer] maximum count of entries to be evicted + # @overload xtrim(key, minid, strategy: 'MINID', approximate: true) + # @param key [String] the stream key + # @param minid [String] minimum id of entries + # @param strategy [String] the limit strategy, must be MINID + # @param approximate [Boolean] whether to add `~` modifier of minid or not + # @param limit [Integer] maximum count of entries to be evicted + # + # @return [Integer] the number of entries actually deleted + def xtrim(key, len_or_id, strategy: 'MAXLEN', approximate: false, limit: nil) + strategy = strategy.to_s.upcase + + args = [:xtrim, key, strategy] + args << '~' if approximate + args << len_or_id + args.concat(['LIMIT', limit]) if limit + send_command(args) + end + + # Delete entries by entry ids. + # + # @example With splatted entry ids + # redis.xdel('mystream', '0-1', '0-2') + # @example With arrayed entry ids + # redis.xdel('mystream', ['0-1', '0-2']) + # + # @param key [String] the stream key + # @param ids [Array] one or multiple entry ids + # + # @return [Integer] the number of entries actually deleted + def xdel(key, *ids) + args = [:xdel, key].concat(ids.flatten) + send_command(args) + end + + # Fetches entries of the stream in ascending order. + # + # @example Without options + # redis.xrange('mystream') + # @example With a specific start + # redis.xrange('mystream', '0-1') + # @example With a specific start and end + # redis.xrange('mystream', '0-1', '0-3') + # @example With count options + # redis.xrange('mystream', count: 10) + # + # @param key [String] the stream key + # @param start [String] first entry id of range, default value is `-` + # @param end [String] last entry id of range, default value is `+` + # @param count [Integer] the number of entries as limit + # + # @return [Array>] the ids and entries pairs + def xrange(key, start = '-', range_end = '+', count: nil) + args = [:xrange, key, start, range_end] + args.concat(['COUNT', count]) if count + send_command(args, &HashifyStreamEntries) + end + + # Fetches entries of the stream in descending order. + # + # @example Without options + # redis.xrevrange('mystream') + # @example With a specific end + # redis.xrevrange('mystream', '0-3') + # @example With a specific end and start + # redis.xrevrange('mystream', '0-3', '0-1') + # @example With count options + # redis.xrevrange('mystream', count: 10) + # + # @param key [String] the stream key + # @param end [String] first entry id of range, default value is `+` + # @param start [String] last entry id of range, default value is `-` + # @params count [Integer] the number of entries as limit + # + # @return [Array>] the ids and entries pairs + def xrevrange(key, range_end = '+', start = '-', count: nil) + args = [:xrevrange, key, range_end, start] + args.concat(['COUNT', count]) if count + send_command(args, &HashifyStreamEntries) + end + + # Returns the number of entries inside a stream. + # + # @example With key + # redis.xlen('mystream') + # + # @param key [String] the stream key + # + # @return [Integer] the number of entries + def xlen(key) + send_command([:xlen, key]) + end + + # Fetches entries from one or multiple streams. Optionally blocking. + # + # @example With a key + # redis.xread('mystream', '0-0') + # @example With multiple keys + # redis.xread(%w[mystream1 mystream2], %w[0-0 0-0]) + # @example With count option + # redis.xread('mystream', '0-0', count: 2) + # @example With block option + # redis.xread('mystream', '$', block: 1000) + # + # @param keys [Array] one or multiple stream keys + # @param ids [Array] one or multiple entry ids + # @param count [Integer] the number of entries as limit per stream + # @param block [Integer] the number of milliseconds as blocking timeout + # + # @return [Hash{String => Hash{String => Hash}}] the entries + def xread(keys, ids, count: nil, block: nil) + args = [:xread] + args << 'COUNT' << count if count + args << 'BLOCK' << block.to_i if block + _xread(args, keys, ids, block) + end + + # Manages the consumer group of the stream. + # + # @example With `create` subcommand + # redis.xgroup(:create, 'mystream', 'mygroup', '$') + # @example With `setid` subcommand + # redis.xgroup(:setid, 'mystream', 'mygroup', '$') + # @example With `destroy` subcommand + # redis.xgroup(:destroy, 'mystream', 'mygroup') + # @example With `delconsumer` subcommand + # redis.xgroup(:delconsumer, 'mystream', 'mygroup', 'consumer1') + # + # @param subcommand [String] `create` `setid` `destroy` `delconsumer` + # @param key [String] the stream key + # @param group [String] the consumer group name + # @param id_or_consumer [String] + # * the entry id or `$`, required if subcommand is `create` or `setid` + # * the consumer name, required if subcommand is `delconsumer` + # @param mkstream [Boolean] whether to create an empty stream automatically or not + # + # @return [String] `OK` if subcommand is `create` or `setid` + # @return [Integer] effected count if subcommand is `destroy` or `delconsumer` + def xgroup(subcommand, key, group, id_or_consumer = nil, mkstream: false) + args = [:xgroup, subcommand, key, group, id_or_consumer, (mkstream ? 'MKSTREAM' : nil)].compact + send_command(args) + end + + # Fetches a subset of the entries from one or multiple streams related with the consumer group. + # Optionally blocking. + # + # @example With a key + # redis.xreadgroup('mygroup', 'consumer1', 'mystream', '>') + # @example With multiple keys + # redis.xreadgroup('mygroup', 'consumer1', %w[mystream1 mystream2], %w[> >]) + # @example With count option + # redis.xreadgroup('mygroup', 'consumer1', 'mystream', '>', count: 2) + # @example With block option + # redis.xreadgroup('mygroup', 'consumer1', 'mystream', '>', block: 1000) + # @example With noack option + # redis.xreadgroup('mygroup', 'consumer1', 'mystream', '>', noack: true) + # + # @param group [String] the consumer group name + # @param consumer [String] the consumer name + # @param keys [Array] one or multiple stream keys + # @param ids [Array] one or multiple entry ids + # @param opts [Hash] several options for `XREADGROUP` command + # + # @option opts [Integer] :count the number of entries as limit + # @option opts [Integer] :block the number of milliseconds as blocking timeout + # @option opts [Boolean] :noack whether message loss is acceptable or not + # + # @return [Hash{String => Hash{String => Hash}}] the entries + def xreadgroup(group, consumer, keys, ids, count: nil, block: nil, noack: nil) + args = [:xreadgroup, 'GROUP', group, consumer] + args << 'COUNT' << count if count + args << 'BLOCK' << block.to_i if block + args << 'NOACK' if noack + _xread(args, keys, ids, block) + end + + # Removes one or multiple entries from the pending entries list of a stream consumer group. + # + # @example With a entry id + # redis.xack('mystream', 'mygroup', '1526569495631-0') + # @example With splatted entry ids + # redis.xack('mystream', 'mygroup', '0-1', '0-2') + # @example With arrayed entry ids + # redis.xack('mystream', 'mygroup', %w[0-1 0-2]) + # + # @param key [String] the stream key + # @param group [String] the consumer group name + # @param ids [Array] one or multiple entry ids + # + # @return [Integer] the number of entries successfully acknowledged + def xack(key, group, *ids) + args = [:xack, key, group].concat(ids.flatten) + send_command(args) + end + + # Changes the ownership of a pending entry + # + # @example With splatted entry ids + # redis.xclaim('mystream', 'mygroup', 'consumer1', 3600000, '0-1', '0-2') + # @example With arrayed entry ids + # redis.xclaim('mystream', 'mygroup', 'consumer1', 3600000, %w[0-1 0-2]) + # @example With idle option + # redis.xclaim('mystream', 'mygroup', 'consumer1', 3600000, %w[0-1 0-2], idle: 1000) + # @example With time option + # redis.xclaim('mystream', 'mygroup', 'consumer1', 3600000, %w[0-1 0-2], time: 1542866959000) + # @example With retrycount option + # redis.xclaim('mystream', 'mygroup', 'consumer1', 3600000, %w[0-1 0-2], retrycount: 10) + # @example With force option + # redis.xclaim('mystream', 'mygroup', 'consumer1', 3600000, %w[0-1 0-2], force: true) + # @example With justid option + # redis.xclaim('mystream', 'mygroup', 'consumer1', 3600000, %w[0-1 0-2], justid: true) + # + # @param key [String] the stream key + # @param group [String] the consumer group name + # @param consumer [String] the consumer name + # @param min_idle_time [Integer] the number of milliseconds + # @param ids [Array] one or multiple entry ids + # @param opts [Hash] several options for `XCLAIM` command + # + # @option opts [Integer] :idle the number of milliseconds as last time it was delivered of the entry + # @option opts [Integer] :time the number of milliseconds as a specific Unix Epoch time + # @option opts [Integer] :retrycount the number of retry counter + # @option opts [Boolean] :force whether to create the pending entry to the pending entries list or not + # @option opts [Boolean] :justid whether to fetch just an array of entry ids or not + # + # @return [Hash{String => Hash}] the entries successfully claimed + # @return [Array] the entry ids successfully claimed if justid option is `true` + def xclaim(key, group, consumer, min_idle_time, *ids, **opts) + args = [:xclaim, key, group, consumer, min_idle_time].concat(ids.flatten) + args.concat(['IDLE', opts[:idle].to_i]) if opts[:idle] + args.concat(['TIME', opts[:time].to_i]) if opts[:time] + args.concat(['RETRYCOUNT', opts[:retrycount]]) if opts[:retrycount] + args << 'FORCE' if opts[:force] + args << 'JUSTID' if opts[:justid] + blk = opts[:justid] ? Noop : HashifyStreamEntries + send_command(args, &blk) + end + + # Transfers ownership of pending stream entries that match the specified criteria. + # + # @example Claim next pending message stuck > 5 minutes and mark as retry + # redis.xautoclaim('mystream', 'mygroup', 'consumer1', 3600000, '0-0') + # @example Claim 50 next pending messages stuck > 5 minutes and mark as retry + # redis.xclaim('mystream', 'mygroup', 'consumer1', 3600000, '0-0', count: 50) + # @example Claim next pending message stuck > 5 minutes and don't mark as retry + # redis.xclaim('mystream', 'mygroup', 'consumer1', 3600000, '0-0', justid: true) + # @example Claim next pending message after this id stuck > 5 minutes and mark as retry + # redis.xautoclaim('mystream', 'mygroup', 'consumer1', 3600000, '1641321233-0') + # + # @param key [String] the stream key + # @param group [String] the consumer group name + # @param consumer [String] the consumer name + # @param min_idle_time [Integer] the number of milliseconds + # @param start [String] entry id to start scanning from or 0-0 for everything + # @param count [Integer] number of messages to claim (default 1) + # @param justid [Boolean] whether to fetch just an array of entry ids or not. + # Does not increment retry count when true + # + # @return [Hash{String => Hash}] the entries successfully claimed + # @return [Array] the entry ids successfully claimed if justid option is `true` + def xautoclaim(key, group, consumer, min_idle_time, start, count: nil, justid: false) + args = [:xautoclaim, key, group, consumer, min_idle_time, start] + if count + args << 'COUNT' << count.to_s + end + args << 'JUSTID' if justid + blk = justid ? HashifyStreamAutoclaimJustId : HashifyStreamAutoclaim + send_command(args, &blk) + end + + # Fetches not acknowledging pending entries + # + # @example With key and group + # redis.xpending('mystream', 'mygroup') + # @example With range options + # redis.xpending('mystream', 'mygroup', '-', '+', 10) + # @example With range and idle time options + # redis.xpending('mystream', 'mygroup', '-', '+', 10, idle: 9000) + # @example With range and consumer options + # redis.xpending('mystream', 'mygroup', '-', '+', 10, 'consumer1') + # + # @param key [String] the stream key + # @param group [String] the consumer group name + # @param start [String] start first entry id of range + # @param end [String] end last entry id of range + # @param count [Integer] count the number of entries as limit + # @param consumer [String] the consumer name + # + # @option opts [Integer] :idle pending message minimum idle time in milliseconds + # + # @return [Hash] the summary of pending entries + # @return [Array] the pending entries details if options were specified + def xpending(key, group, *args, idle: nil) + command_args = [:xpending, key, group] + command_args << 'IDLE' << Integer(idle) if idle + case args.size + when 0, 3, 4 + command_args.concat(args) + else + raise ArgumentError, "wrong number of arguments (given #{args.size + 2}, expected 2, 5 or 6)" + end + + summary_needed = args.empty? + blk = summary_needed ? HashifyStreamPendings : HashifyStreamPendingDetails + send_command(command_args, &blk) + end + + private + + def _xread(args, keys, ids, blocking_timeout_msec) + keys = keys.is_a?(Array) ? keys : [keys] + ids = ids.is_a?(Array) ? ids : [ids] + args << 'STREAMS' + args.concat(keys) + args.concat(ids) + + if blocking_timeout_msec.nil? + send_command(args, &HashifyStreams) + elsif blocking_timeout_msec.to_f.zero? + send_blocking_command(args, 0, &HashifyStreams) + else + send_blocking_command(args, blocking_timeout_msec.to_f / 1_000, &HashifyStreams) + end + end + end + end +end diff --git a/lib/redis/commands/strings.rb b/lib/redis/commands/strings.rb new file mode 100644 index 000000000..6080a5d6d --- /dev/null +++ b/lib/redis/commands/strings.rb @@ -0,0 +1,314 @@ +# frozen_string_literal: true + +class Redis + module Commands + module Strings + # Decrement the integer value of a key by one. + # + # @example + # redis.decr("value") + # # => 4 + # + # @param [String] key + # @return [Integer] value after decrementing it + def decr(key) + send_command([:decr, key]) + end + + # Decrement the integer value of a key by the given number. + # + # @example + # redis.decrby("value", 5) + # # => 0 + # + # @param [String] key + # @param [Integer] decrement + # @return [Integer] value after decrementing it + def decrby(key, decrement) + send_command([:decrby, key, Integer(decrement)]) + end + + # Increment the integer value of a key by one. + # + # @example + # redis.incr("value") + # # => 6 + # + # @param [String] key + # @return [Integer] value after incrementing it + def incr(key) + send_command([:incr, key]) + end + + # Increment the integer value of a key by the given integer number. + # + # @example + # redis.incrby("value", 5) + # # => 10 + # + # @param [String] key + # @param [Integer] increment + # @return [Integer] value after incrementing it + def incrby(key, increment) + send_command([:incrby, key, Integer(increment)]) + end + + # Increment the numeric value of a key by the given float number. + # + # @example + # redis.incrbyfloat("value", 1.23) + # # => 1.23 + # + # @param [String] key + # @param [Float] increment + # @return [Float] value after incrementing it + def incrbyfloat(key, increment) + send_command([:incrbyfloat, key, Float(increment)], &Floatify) + end + + # Set the string value of a key. + # + # @param [String] key + # @param [String] value + # @param [Hash] options + # - `:ex => Integer`: Set the specified expire time, in seconds. + # - `:px => Integer`: Set the specified expire time, in milliseconds. + # - `:exat => Integer` : Set the specified Unix time at which the key will expire, in seconds. + # - `:pxat => Integer` : Set the specified Unix time at which the key will expire, in milliseconds. + # - `:nx => true`: Only set the key if it does not already exist. + # - `:xx => true`: Only set the key if it already exist. + # - `:keepttl => true`: Retain the time to live associated with the key. + # - `:get => true`: Return the old string stored at key, or nil if key did not exist. + # @return [String, Boolean] `"OK"` or true, false if `:nx => true` or `:xx => true` + def set(key, value, ex: nil, px: nil, exat: nil, pxat: nil, nx: nil, xx: nil, keepttl: nil, get: nil) + args = [:set, key, value.to_s] + args << "EX" << Integer(ex) if ex + args << "PX" << Integer(px) if px + args << "EXAT" << Integer(exat) if exat + args << "PXAT" << Integer(pxat) if pxat + args << "NX" if nx + args << "XX" if xx + args << "KEEPTTL" if keepttl + args << "GET" if get + + if nx || xx + send_command(args, &BoolifySet) + else + send_command(args) + end + end + + # Set the time to live in seconds of a key. + # + # @param [String] key + # @param [Integer] ttl + # @param [String] value + # @return [String] `"OK"` + def setex(key, ttl, value) + send_command([:setex, key, Integer(ttl), value.to_s]) + end + + # Set the time to live in milliseconds of a key. + # + # @param [String] key + # @param [Integer] ttl + # @param [String] value + # @return [String] `"OK"` + def psetex(key, ttl, value) + send_command([:psetex, key, Integer(ttl), value.to_s]) + end + + # Set the value of a key, only if the key does not exist. + # + # @param [String] key + # @param [String] value + # @return [Boolean] whether the key was set or not + def setnx(key, value) + send_command([:setnx, key, value.to_s], &Boolify) + end + + # Set one or more values. + # + # @example + # redis.mset("key1", "v1", "key2", "v2") + # # => "OK" + # + # @param [Array] args array of keys and values + # @return [String] `"OK"` + # + # @see #mapped_mset + def mset(*args) + send_command([:mset] + args) + end + + # Set one or more values. + # + # @example + # redis.mapped_mset({ "f1" => "v1", "f2" => "v2" }) + # # => "OK" + # + # @param [Hash] hash keys mapping to values + # @return [String] `"OK"` + # + # @see #mset + def mapped_mset(hash) + mset(hash.flatten) + end + + # Set one or more values, only if none of the keys exist. + # + # @example + # redis.msetnx("key1", "v1", "key2", "v2") + # # => true + # + # @param [Array] args array of keys and values + # @return [Boolean] whether or not all values were set + # + # @see #mapped_msetnx + def msetnx(*args) + send_command([:msetnx, *args], &Boolify) + end + + # Set one or more values, only if none of the keys exist. + # + # @example + # redis.mapped_msetnx({ "key1" => "v1", "key2" => "v2" }) + # # => true + # + # @param [Hash] hash keys mapping to values + # @return [Boolean] whether or not all values were set + # + # @see #msetnx + def mapped_msetnx(hash) + msetnx(hash.flatten) + end + + # Get the value of a key. + # + # @param [String] key + # @return [String] + def get(key) + send_command([:get, key]) + end + + # Get the values of all the given keys. + # + # @example + # redis.mget("key1", "key2") + # # => ["v1", "v2"] + # + # @param [Array] keys + # @return [Array] an array of values for the specified keys + # + # @see #mapped_mget + def mget(*keys, &blk) + keys.flatten!(1) + send_command([:mget, *keys], &blk) + end + + # Get the values of all the given keys. + # + # @example + # redis.mapped_mget("key1", "key2") + # # => { "key1" => "v1", "key2" => "v2" } + # + # @param [Array] keys array of keys + # @return [Hash] a hash mapping the specified keys to their values + # + # @see #mget + def mapped_mget(*keys) + mget(*keys) do |reply| + if reply.is_a?(Array) + Hash[keys.zip(reply)] + else + reply + end + end + end + + # Overwrite part of a string at key starting at the specified offset. + # + # @param [String] key + # @param [Integer] offset byte offset + # @param [String] value + # @return [Integer] length of the string after it was modified + def setrange(key, offset, value) + send_command([:setrange, key, Integer(offset), value.to_s]) + end + + # Get a substring of the string stored at a key. + # + # @param [String] key + # @param [Integer] start zero-based start offset + # @param [Integer] stop zero-based end offset. Use -1 for representing + # the end of the string + # @return [Integer] `0` or `1` + def getrange(key, start, stop) + send_command([:getrange, key, Integer(start), Integer(stop)]) + end + + # Append a value to a key. + # + # @param [String] key + # @param [String] value value to append + # @return [Integer] length of the string after appending + def append(key, value) + send_command([:append, key, value]) + end + + # Set the string value of a key and return its old value. + # + # @param [String] key + # @param [String] value value to replace the current value with + # @return [String] the old value stored in the key, or `nil` if the key + # did not exist + def getset(key, value) + send_command([:getset, key, value.to_s]) + end + + # Get the value of key and delete the key. This command is similar to GET, + # except for the fact that it also deletes the key on success. + # + # @param [String] key + # @return [String] the old value stored in the key, or `nil` if the key + # did not exist + def getdel(key) + send_command([:getdel, key]) + end + + # Get the value of key and optionally set its expiration. GETEX is similar to + # GET, but is a write command with additional options. When no options are + # provided, GETEX behaves like GET. + # + # @param [String] key + # @param [Hash] options + # - `:ex => Integer`: Set the specified expire time, in seconds. + # - `:px => Integer`: Set the specified expire time, in milliseconds. + # - `:exat => true`: Set the specified Unix time at which the key will + # expire, in seconds. + # - `:pxat => true`: Set the specified Unix time at which the key will + # expire, in milliseconds. + # - `:persist => true`: Remove the time to live associated with the key. + # @return [String] The value of key, or nil when key does not exist. + def getex(key, ex: nil, px: nil, exat: nil, pxat: nil, persist: false) + args = [:getex, key] + args << "EX" << Integer(ex) if ex + args << "PX" << Integer(px) if px + args << "EXAT" << Integer(exat) if exat + args << "PXAT" << Integer(pxat) if pxat + args << "PERSIST" if persist + + send_command(args) + end + + # Get the length of the value stored in a key. + # + # @param [String] key + # @return [Integer] the length of the value stored in the key, or 0 + # if the key does not exist + def strlen(key) + send_command([:strlen, key]) + end + end + end +end diff --git a/lib/redis/commands/transactions.rb b/lib/redis/commands/transactions.rb new file mode 100644 index 000000000..c0dcf984d --- /dev/null +++ b/lib/redis/commands/transactions.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +class Redis + module Commands + module Transactions + # Mark the start of a transaction block. + # + # @example With a block + # redis.multi do |multi| + # multi.set("key", "value") + # multi.incr("counter") + # end # => ["OK", 6] + # + # @yield [multi] the commands that are called inside this block are cached + # and written to the server upon returning from it + # @yieldparam [Redis] multi `self` + # + # @return [Array<...>] + # - an array with replies + # + # @see #watch + # @see #unwatch + def multi + synchronize do |client| + client.multi do |raw_transaction| + yield MultiConnection.new(raw_transaction) + end + end + end + + # Watch the given keys to determine execution of the MULTI/EXEC block. + # + # Using a block is optional, but is necessary for thread-safety. + # + # An `#unwatch` is automatically issued if an exception is raised within the + # block that is a subclass of StandardError and is not a ConnectionError. + # + # @example With a block + # redis.watch("key") do + # if redis.get("key") == "some value" + # redis.multi do |multi| + # multi.set("key", "other value") + # multi.incr("counter") + # end + # else + # redis.unwatch + # end + # end + # # => ["OK", 6] + # + # @example Without a block + # redis.watch("key") + # # => "OK" + # + # @param [String, Array] keys one or more keys to watch + # @return [Object] if using a block, returns the return value of the block + # @return [String] if not using a block, returns `OK` + # + # @see #unwatch + # @see #multi + def watch(*keys) + synchronize do |client| + res = client.call_v([:watch] + keys) + + if block_given? + begin + yield(self) + rescue ConnectionError + raise + rescue StandardError + unwatch + raise + end + else + res + end + end + end + + # Forget about all watched keys. + # + # @return [String] `OK` + # + # @see #watch + # @see #multi + def unwatch + send_command([:unwatch]) + end + + # Execute all commands issued after MULTI. + # + # Only call this method when `#multi` was called **without** a block. + # + # @return [nil, Array<...>] + # - when commands were not executed, `nil` + # - when commands were executed, an array with their replies + # + # @see #multi + # @see #discard + def exec + send_command([:exec]) + end + + # Discard all commands issued after MULTI. + # + # @return [String] `"OK"` + # + # @see #multi + # @see #exec + def discard + send_command([:discard]) + end + end + end +end diff --git a/lib/redis/connection.rb b/lib/redis/connection.rb deleted file mode 100644 index badff22b9..000000000 --- a/lib/redis/connection.rb +++ /dev/null @@ -1,9 +0,0 @@ -require "redis/connection/registry" - -# If a connection driver was required before this file, the array -# Redis::Connection.drivers will contain one or more classes. The last driver -# in this array will be used as default driver. If this array is empty, we load -# the plain Ruby driver as our default. Another driver can be required at a -# later point in time, causing it to be the last element of the #drivers array -# and therefore be chosen by default. -require "redis/connection/ruby" if Redis::Connection.drivers.empty? \ No newline at end of file diff --git a/lib/redis/connection/command_helper.rb b/lib/redis/connection/command_helper.rb deleted file mode 100644 index 74e89dc10..000000000 --- a/lib/redis/connection/command_helper.rb +++ /dev/null @@ -1,44 +0,0 @@ -class Redis - module Connection - module CommandHelper - - COMMAND_DELIMITER = "\r\n" - - def build_command(args) - command = [nil] - - args.each do |i| - if i.is_a? Array - i.each do |j| - j = j.to_s - command << "$#{j.bytesize}" - command << j - end - else - i = i.to_s - command << "$#{i.bytesize}" - command << i - end - end - - command[0] = "*#{(command.length - 1) / 2}" - - # Trailing delimiter - command << "" - command.join(COMMAND_DELIMITER) - end - - protected - - if defined?(Encoding::default_external) - def encode(string) - string.force_encoding(Encoding::default_external) - end - else - def encode(string) - string - end - end - end - end -end diff --git a/lib/redis/connection/hiredis.rb b/lib/redis/connection/hiredis.rb deleted file mode 100644 index f4056d386..000000000 --- a/lib/redis/connection/hiredis.rb +++ /dev/null @@ -1,66 +0,0 @@ -require "redis/connection/registry" -require "redis/errors" -require "hiredis/connection" -require "timeout" - -class Redis - module Connection - class Hiredis - - def self.connect(config) - connection = ::Hiredis::Connection.new - connect_timeout = (config.fetch(:connect_timeout, 0) * 1_000_000).to_i - - if config[:scheme] == "unix" - connection.connect_unix(config[:path], connect_timeout) - elsif config[:scheme] == "rediss" || config[:ssl] - raise NotImplementedError, "SSL not supported by hiredis driver" - else - connection.connect(config[:host], config[:port], connect_timeout) - end - - instance = new(connection) - instance.timeout = config[:read_timeout] - instance - rescue Errno::ETIMEDOUT - raise TimeoutError - end - - def initialize(connection) - @connection = connection - end - - def connected? - @connection && @connection.connected? - end - - def timeout=(timeout) - # Hiredis works with microsecond timeouts - @connection.timeout = Integer(timeout * 1_000_000) - end - - def disconnect - @connection.disconnect - @connection = nil - end - - def write(command) - @connection.write(command.flatten(1)) - rescue Errno::EAGAIN - raise TimeoutError - end - - def read - reply = @connection.read - reply = CommandError.new(reply.message) if reply.is_a?(RuntimeError) - reply - rescue Errno::EAGAIN - raise TimeoutError - rescue RuntimeError => err - raise ProtocolError.new(err.message) - end - end - end -end - -Redis::Connection.drivers << Redis::Connection::Hiredis diff --git a/lib/redis/connection/registry.rb b/lib/redis/connection/registry.rb deleted file mode 100644 index 69ca63d3e..000000000 --- a/lib/redis/connection/registry.rb +++ /dev/null @@ -1,12 +0,0 @@ -class Redis - module Connection - - # Store a list of loaded connection drivers in the Connection module. - # Redis::Client uses the last required driver by default, and will be aware - # of the loaded connection drivers if the user chooses to override the - # default connection driver. - def self.drivers - @drivers ||= [] - end - end -end diff --git a/lib/redis/connection/ruby.rb b/lib/redis/connection/ruby.rb deleted file mode 100644 index 96f1d6a59..000000000 --- a/lib/redis/connection/ruby.rb +++ /dev/null @@ -1,429 +0,0 @@ -require "redis/connection/registry" -require "redis/connection/command_helper" -require "redis/errors" -require "socket" -require "timeout" - -begin - require "openssl" -rescue LoadError - # Not all systems have OpenSSL support -end - -if RUBY_VERSION < "1.9.3" - class String - # Ruby 1.8.7 does not have byteslice, but it handles encodings differently anyway. - # We can simply slice the string, which is a byte array there. - def byteslice(*args) - slice(*args) - end - end -end - -class Redis - module Connection - module SocketMixin - - CRLF = "\r\n".freeze - - # Exceptions raised during non-blocking I/O ops that require retrying the op - if RUBY_VERSION >= "1.9.3" - NBIO_READ_EXCEPTIONS = [IO::WaitReadable] - NBIO_WRITE_EXCEPTIONS = [IO::WaitWritable] - else - NBIO_READ_EXCEPTIONS = [Errno::EWOULDBLOCK, Errno::EAGAIN] - NBIO_WRITE_EXCEPTIONS = [Errno::EWOULDBLOCK, Errno::EAGAIN] - end - - def initialize(*args) - super(*args) - - @timeout = @write_timeout = nil - @buffer = "" - end - - def timeout=(timeout) - if timeout && timeout > 0 - @timeout = timeout - else - @timeout = nil - end - end - - def write_timeout=(timeout) - if timeout && timeout > 0 - @write_timeout = timeout - else - @write_timeout = nil - end - end - - def read(nbytes) - result = @buffer.slice!(0, nbytes) - - while result.bytesize < nbytes - result << _read_from_socket(nbytes - result.bytesize) - end - - result - end - - def gets - crlf = nil - - while (crlf = @buffer.index(CRLF)) == nil - @buffer << _read_from_socket(1024) - end - - @buffer.slice!(0, crlf + CRLF.bytesize) - end - - def _read_from_socket(nbytes) - - begin - read_nonblock(nbytes) - - rescue *NBIO_READ_EXCEPTIONS - if IO.select([self], nil, nil, @timeout) - retry - else - raise Redis::TimeoutError - end - rescue *NBIO_WRITE_EXCEPTIONS - if IO.select(nil, [self], nil, @timeout) - retry - else - raise Redis::TimeoutError - end - end - - rescue EOFError - raise Errno::ECONNRESET - end - - def _write_to_socket(data) - begin - write_nonblock(data) - - rescue *NBIO_WRITE_EXCEPTIONS - if IO.select(nil, [self], nil, @write_timeout) - retry - else - raise Redis::TimeoutError - end - rescue *NBIO_READ_EXCEPTIONS - if IO.select([self], nil, nil, @write_timeout) - retry - else - raise Redis::TimeoutError - end - end - - rescue EOFError - raise Errno::ECONNRESET - end - - def write(data) - return super(data) unless @write_timeout - - length = data.bytesize - total_count = 0 - loop do - count = _write_to_socket(data) - - total_count += count - return total_count if total_count >= length - data = data.byteslice(count..-1) - end - end - end - - if defined?(RUBY_ENGINE) && RUBY_ENGINE == "jruby" - - require "timeout" - - class TCPSocket < ::TCPSocket - - include SocketMixin - - def self.connect(host, port, timeout) - Timeout.timeout(timeout) do - sock = new(host, port) - sock - end - rescue Timeout::Error - raise TimeoutError - end - end - - if defined?(::UNIXSocket) - - class UNIXSocket < ::UNIXSocket - - include SocketMixin - - def self.connect(path, timeout) - Timeout.timeout(timeout) do - sock = new(path) - sock - end - rescue Timeout::Error - raise TimeoutError - end - - # JRuby raises Errno::EAGAIN on #read_nonblock even when IO.select - # says it is readable (1.6.6, in both 1.8 and 1.9 mode). - # Use the blocking #readpartial method instead. - - def _read_from_socket(nbytes) - readpartial(nbytes) - - rescue EOFError - raise Errno::ECONNRESET - end - end - - end - - else - - class TCPSocket < ::Socket - - include SocketMixin - - def self.connect_addrinfo(ai, port, timeout) - sock = new(::Socket.const_get(ai[0]), Socket::SOCK_STREAM, 0) - sockaddr = ::Socket.pack_sockaddr_in(port, ai[3]) - - begin - sock.connect_nonblock(sockaddr) - rescue Errno::EINPROGRESS - if IO.select(nil, [sock], nil, timeout) == nil - raise TimeoutError - end - - begin - sock.connect_nonblock(sockaddr) - rescue Errno::EISCONN - end - end - - sock - end - - def self.connect(host, port, timeout) - # Don't pass AI_ADDRCONFIG as flag to getaddrinfo(3) - # - # From the man page for getaddrinfo(3): - # - # If hints.ai_flags includes the AI_ADDRCONFIG flag, then IPv4 - # addresses are returned in the list pointed to by res only if the - # local system has at least one IPv4 address configured, and IPv6 - # addresses are returned only if the local system has at least one - # IPv6 address configured. The loopback address is not considered - # for this case as valid as a configured address. - # - # We do want the IPv6 loopback address to be returned if applicable, - # even if it is the only configured IPv6 address on the machine. - # Also see: https://github.com/redis/redis-rb/pull/394. - addrinfo = ::Socket.getaddrinfo(host, nil, Socket::AF_UNSPEC, Socket::SOCK_STREAM) - - # From the man page for getaddrinfo(3): - # - # Normally, the application should try using the addresses in the - # order in which they are returned. The sorting function used - # within getaddrinfo() is defined in RFC 3484 [...]. - # - addrinfo.each_with_index do |ai, i| - begin - return connect_addrinfo(ai, port, timeout) - rescue SystemCallError - # Raise if this was our last attempt. - raise if addrinfo.length == i+1 - end - end - end - end - - class UNIXSocket < ::Socket - - include SocketMixin - - def self.connect(path, timeout) - sock = new(::Socket::AF_UNIX, Socket::SOCK_STREAM, 0) - sockaddr = ::Socket.pack_sockaddr_un(path) - - begin - sock.connect_nonblock(sockaddr) - rescue Errno::EINPROGRESS - if IO.select(nil, [sock], nil, timeout) == nil - raise TimeoutError - end - - begin - sock.connect_nonblock(sockaddr) - rescue Errno::EISCONN - end - end - - sock - end - end - - end - - if defined?(OpenSSL) - class SSLSocket < ::OpenSSL::SSL::SSLSocket - include SocketMixin - - def self.connect(host, port, timeout, ssl_params) - # Note: this is using Redis::Connection::TCPSocket - tcp_sock = TCPSocket.connect(host, port, timeout) - - ctx = OpenSSL::SSL::SSLContext.new - ctx.set_params(ssl_params) if ssl_params && !ssl_params.empty? - - ssl_sock = new(tcp_sock, ctx) - ssl_sock.hostname = host - ssl_sock.connect - ssl_sock.post_connection_check(host) - - ssl_sock - end - end - end - - class Ruby - include Redis::Connection::CommandHelper - - MINUS = "-".freeze - PLUS = "+".freeze - COLON = ":".freeze - DOLLAR = "$".freeze - ASTERISK = "*".freeze - - def self.connect(config) - if config[:scheme] == "unix" - raise ArgumentError, "SSL incompatible with unix sockets" if config[:ssl] - sock = UNIXSocket.connect(config[:path], config[:connect_timeout]) - elsif config[:scheme] == "rediss" || config[:ssl] - raise ArgumentError, "This library does not support SSL on Ruby < 1.9" if RUBY_VERSION < "1.9.3" - sock = SSLSocket.connect(config[:host], config[:port], config[:connect_timeout], config[:ssl_params]) - else - sock = TCPSocket.connect(config[:host], config[:port], config[:connect_timeout]) - end - - instance = new(sock) - instance.timeout = config[:timeout] - instance.write_timeout = config[:write_timeout] - instance.set_tcp_keepalive config[:tcp_keepalive] - instance - end - - if [:SOL_SOCKET, :SO_KEEPALIVE, :SOL_TCP, :TCP_KEEPIDLE, :TCP_KEEPINTVL, :TCP_KEEPCNT].all?{|c| Socket.const_defined? c} - def set_tcp_keepalive(keepalive) - return unless keepalive.is_a?(Hash) - - @sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true) - @sock.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, keepalive[:time]) - @sock.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, keepalive[:intvl]) - @sock.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, keepalive[:probes]) - end - - def get_tcp_keepalive - { - :time => @sock.getsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE).int, - :intvl => @sock.getsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL).int, - :probes => @sock.getsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT).int, - } - end - else - def set_tcp_keepalive(keepalive) - end - - def get_tcp_keepalive - { - } - end - end - - def initialize(sock) - @sock = sock - end - - def connected? - !! @sock - end - - def disconnect - @sock.close - rescue - ensure - @sock = nil - end - - def timeout=(timeout) - if @sock.respond_to?(:timeout=) - @sock.timeout = timeout - end - end - - def write_timeout=(timeout) - @sock.write_timeout = timeout - end - - def write(command) - @sock.write(build_command(command)) - end - - def read - line = @sock.gets - reply_type = line.slice!(0, 1) - format_reply(reply_type, line) - - rescue Errno::EAGAIN - raise TimeoutError - end - - def format_reply(reply_type, line) - case reply_type - when MINUS then format_error_reply(line) - when PLUS then format_status_reply(line) - when COLON then format_integer_reply(line) - when DOLLAR then format_bulk_reply(line) - when ASTERISK then format_multi_bulk_reply(line) - else raise ProtocolError.new(reply_type) - end - end - - def format_error_reply(line) - CommandError.new(line.strip) - end - - def format_status_reply(line) - line.strip - end - - def format_integer_reply(line) - line.to_i - end - - def format_bulk_reply(line) - bulklen = line.to_i - return if bulklen == -1 - reply = encode(@sock.read(bulklen)) - @sock.read(2) # Discard CRLF. - reply - end - - def format_multi_bulk_reply(line) - n = line.to_i - return if n == -1 - - Array.new(n) { read } - end - end - end -end - -Redis::Connection.drivers << Redis::Connection::Ruby diff --git a/lib/redis/connection/synchrony.rb b/lib/redis/connection/synchrony.rb deleted file mode 100644 index 9f0b67cdc..000000000 --- a/lib/redis/connection/synchrony.rb +++ /dev/null @@ -1,133 +0,0 @@ -require "redis/connection/command_helper" -require "redis/connection/registry" -require "redis/errors" -require "em-synchrony" -require "hiredis/reader" - -class Redis - module Connection - class RedisClient < EventMachine::Connection - include EventMachine::Deferrable - - attr_accessor :timeout - - def post_init - @req = nil - @connected = false - @reader = ::Hiredis::Reader.new - end - - def connection_completed - @connected = true - succeed - end - - def connected? - @connected - end - - def receive_data(data) - @reader.feed(data) - - loop do - begin - reply = @reader.gets - rescue RuntimeError => err - @req.fail [:error, ProtocolError.new(err.message)] - break - end - - break if reply == false - - reply = CommandError.new(reply.message) if reply.is_a?(RuntimeError) - @req.succeed [:reply, reply] - end - end - - def read - @req = EventMachine::DefaultDeferrable.new - if @timeout > 0 - @req.timeout(@timeout, :timeout) - end - EventMachine::Synchrony.sync @req - end - - def send(data) - callback { send_data data } - end - - def unbind - @connected = false - if @req - @req.fail [:error, Errno::ECONNRESET] - @req = nil - else - fail - end - end - end - - class Synchrony - include Redis::Connection::CommandHelper - - def self.connect(config) - if config[:scheme] == "unix" - conn = EventMachine.connect_unix_domain(config[:path], RedisClient) - elsif config[:scheme] == "rediss" || config[:ssl] - raise NotImplementedError, "SSL not supported by synchrony driver" - else - conn = EventMachine.connect(config[:host], config[:port], RedisClient) do |c| - c.pending_connect_timeout = [config[:connect_timeout], 0.1].max - end - end - - fiber = Fiber.current - conn.callback { fiber.resume } - conn.errback { fiber.resume :refused } - - raise Errno::ECONNREFUSED if Fiber.yield == :refused - - instance = new(conn) - instance.timeout = config[:read_timeout] - instance - end - - def initialize(connection) - @connection = connection - end - - def connected? - @connection && @connection.connected? - end - - def timeout=(timeout) - @connection.timeout = timeout - end - - def disconnect - @connection.close_connection - @connection = nil - end - - def write(command) - @connection.send(build_command(command)) - end - - def read - type, payload = @connection.read - - if type == :reply - payload - elsif type == :error - raise payload - elsif type == :timeout - raise TimeoutError - else - raise "Unknown type #{type.inspect}" - end - end - end - end -end - -Redis::Connection.drivers << Redis::Connection::Synchrony diff --git a/lib/redis/distributed.rb b/lib/redis/distributed.rb index df4914831..acd1a2c9a 100644 --- a/lib/redis/distributed.rb +++ b/lib/redis/distributed.rb @@ -1,15 +1,17 @@ +# frozen_string_literal: true + require "redis/hash_ring" class Redis class Distributed - class CannotDistribute < RuntimeError def initialize(command) @command = command end def message - "#{@command.to_s.upcase} cannot be used in Redis::Distributed because the keys involved need to be on the same server or because we cannot guarantee that the operation will be atomic." + "#{@command.to_s.upcase} cannot be used in Redis::Distributed because the keys involved need " \ + "to be on the same server or because we cannot guarantee that the operation will be atomic." end end @@ -18,14 +20,18 @@ def message def initialize(node_configs, options = {}) @tag = options[:tag] || /^\{(.+?)\}/ @ring = options[:ring] || HashRing.new - @node_configs = node_configs.dup + @node_configs = node_configs.map(&:dup) @default_options = options.dup node_configs.each { |node_config| add_node(node_config) } @subscribed_node = nil + @watch_key = nil end def node_for(key) - @ring.get_node(key_tag(key.to_s) || key.to_s) + key = key_tag(key.to_s) || key.to_s + raise CannotDistribute, :watch if @watch_key && @watch_key != key + + @ring.get_node(key) end def nodes @@ -33,9 +39,11 @@ def nodes end def add_node(options) - options = { :url => options } if options.is_a?(String) + options = { url: options } if options.is_a?(String) options = @default_options.merge(options) - @ring.add_node Redis.new( options ) + options.delete(:tag) + options.delete(:ring) + @ring.add_node Redis.new(options) end # Change the selected database for the current connection. @@ -58,6 +66,10 @@ def quit on_each_node :quit end + def close + on_each_node :close + end + # Asynchronously save the dataset to disk. def bgsave on_each_node :bgsave @@ -109,13 +121,13 @@ def persist(key) end # Set a key's time to live in seconds. - def expire(key, seconds) - node_for(key).expire(key, seconds) + def expire(key, seconds, **kwargs) + node_for(key).expire(key, seconds, **kwargs) end # Set the expiration for a key as a UNIX timestamp. - def expireat(key, unix_time) - node_for(key).expireat(key, unix_time) + def expireat(key, unix_time, **kwargs) + node_for(key).expireat(key, unix_time, **kwargs) end # Get the time to live (in seconds) for a key. @@ -124,13 +136,13 @@ def ttl(key) end # Set a key's time to live in milliseconds. - def pexpire(key, milliseconds) - node_for(key).pexpire(key, milliseconds) + def pexpire(key, milliseconds, **kwarg) + node_for(key).pexpire(key, milliseconds, **kwarg) end # Set the expiration for a key as number of milliseconds from UNIX Epoch. - def pexpireat(key, ms_unix_time) - node_for(key).pexpireat(key, ms_unix_time) + def pexpireat(key, ms_unix_time, **kwarg) + node_for(key).pexpireat(key, ms_unix_time, **kwarg) end # Get the time to live (in milliseconds) for a key. @@ -144,26 +156,50 @@ def dump(key) end # Create a key using the serialized value, previously obtained using DUMP. - def restore(key, ttl, serialized_value) - node_for(key).restore(key, ttl, serialized_value) + def restore(key, ttl, serialized_value, **options) + node_for(key).restore(key, ttl, serialized_value, **options) end # Transfer a key from the connected instance to another instance. - def migrate(key, options) + def migrate(_key, _options) raise CannotDistribute, :migrate end # Delete a key. def del(*args) + args.flatten!(1) keys_per_node = args.group_by { |key| node_for(key) } keys_per_node.inject(0) do |sum, (node, keys)| sum + node.del(*keys) end end + # Unlink keys. + def unlink(*args) + args.flatten!(1) + keys_per_node = args.group_by { |key| node_for(key) } + keys_per_node.inject(0) do |sum, (node, keys)| + sum + node.unlink(*keys) + end + end + # Determine if a key exists. - def exists(key) - node_for(key).exists(key) + def exists(*args) + args.flatten!(1) + keys_per_node = args.group_by { |key| node_for(key) } + keys_per_node.inject(0) do |sum, (node, keys)| + sum + node.exists(*keys) + end + end + + # Determine if any of the keys exists. + def exists?(*args) + args.flatten!(1) + keys_per_node = args.group_by { |key| node_for(key) } + keys_per_node.each do |node, keys| + return true if node.exists?(*keys) + end + false end # Find all keys matching the given pattern. @@ -176,6 +212,13 @@ def move(key, db) node_for(key).move(key, db) end + # Copy a value from one key to another. + def copy(source, destination, **options) + ensure_same_node(:copy, [source, destination]) do |node| + node.copy(source, destination, **options) + end + end + # Return a random key from the keyspace. def randomkey raise CannotDistribute, :randomkey @@ -196,11 +239,11 @@ def renamenx(old_name, new_name) end # Sort the elements in a list, set or sorted set. - def sort(key, options = {}) + def sort(key, **options) keys = [key, options[:by], options[:store], *Array(options[:get])].compact ensure_same_node(:sort, keys) do |node| - node.sort(key, options) + node.sort(key, **options) end end @@ -235,8 +278,8 @@ def incrbyfloat(key, increment) end # Set the string value of a key. - def set(key, value, options = {}) - node_for(key).set(key, value, options) + def set(key, value, **options) + node_for(key).set(key, value, **options) end # Set the time to live in seconds of a key. @@ -255,20 +298,20 @@ def setnx(key, value) end # Set multiple keys to multiple values. - def mset(*args) + def mset(*) raise CannotDistribute, :mset end - def mapped_mset(hash) + def mapped_mset(_hash) raise CannotDistribute, :mapped_mset end # Set multiple keys to multiple values, only if none of the keys exist. - def msetnx(*args) + def msetnx(*) raise CannotDistribute, :msetnx end - def mapped_msetnx(hash) + def mapped_msetnx(_hash) raise CannotDistribute, :mapped_msetnx end @@ -277,13 +320,28 @@ def get(key) node_for(key).get(key) end - # Get the values of all the given keys. + # Get the value of a key and delete it. + def getdel(key) + node_for(key).getdel(key) + end + + # Get the value of a key and sets its time to live based on options. + def getex(key, **options) + node_for(key).getex(key, **options) + end + + # Get the values of all the given keys as an Array. def mget(*keys) - raise CannotDistribute, :mget + keys.flatten!(1) + mapped_mget(*keys).values_at(*keys) end + # Get the values of all the given keys as a Hash. def mapped_mget(*keys) - raise CannotDistribute, :mapped_mget + keys.flatten!(1) + keys.group_by { |k| node_for k }.inject({}) do |results, (node, subkeys)| + results.merge! node.mapped_mget(*subkeys) + end end # Overwrite part of a string at key starting at the specified offset. @@ -312,20 +370,21 @@ def append(key, value) end # Count the number of set bits in a range of the string value stored at key. - def bitcount(key, start = 0, stop = -1) - node_for(key).bitcount(key, start, stop) + def bitcount(key, start = 0, stop = -1, scale: nil) + node_for(key).bitcount(key, start, stop, scale: scale) end # Perform a bitwise operation between strings and store the resulting string in a key. def bitop(operation, destkey, *keys) + keys.flatten!(1) ensure_same_node(:bitop, [destkey] + keys) do |node| - node.bitop(operation, destkey, *keys) + node.bitop(operation, destkey, keys) end end # Return the position of the first bit set to 1 or 0 in a string. - def bitpos(key, bit, start=nil, stop=nil) - node_for(key).bitpos(key, bit, start, stop) + def bitpos(key, bit, start = nil, stop = nil, scale: nil) + node_for(key).bitpos(key, bit, start, stop, scale: scale) end # Set the string value of a key and return its old value. @@ -342,7 +401,7 @@ def [](key) get(key) end - def []=(key,value) + def []=(key, value) set(key, value) end @@ -351,6 +410,21 @@ def llen(key) node_for(key).llen(key) end + # Remove the first/last element in a list, append/prepend it to another list and return it. + def lmove(source, destination, where_source, where_destination) + ensure_same_node(:lmove, [source, destination]) do |node| + node.lmove(source, destination, where_source, where_destination) + end + end + + # Remove the first/last element in a list and append/prepend it + # to another list and return it, or block until one is available. + def blmove(source, destination, where_source, where_destination, timeout: 0) + ensure_same_node(:lmove, [source, destination]) do |node| + node.blmove(source, destination, where_source, where_destination, timeout: timeout) + end + end + # Prepend one or more values to a list. def lpush(key, value) node_for(key).lpush(key, value) @@ -371,14 +445,14 @@ def rpushx(key, value) node_for(key).rpushx(key, value) end - # Remove and get the first element in a list. - def lpop(key) - node_for(key).lpop(key) + # Remove and get the first elements in a list. + def lpop(key, count = nil) + node_for(key).lpop(key, count) end - # Remove and get the last element in a list. - def rpop(key) - node_for(key).rpop(key) + # Remove and get the last elements in a list. + def rpop(key, count = nil) + node_for(key).rpop(key, count) end # Remove the last element in a list, append it to another list and return @@ -390,24 +464,19 @@ def rpoplpush(source, destination) end def _bpop(cmd, args) - options = {} - - case args.last - when Hash + timeout = if args.last.is_a?(Hash) options = args.pop - when Integer - # Issue deprecation notice in obnoxious mode... - options[:timeout] = args.pop - end - - if args.size > 1 - # Issue deprecation notice in obnoxious mode... + options[:timeout] end - keys = args.flatten + args.flatten!(1) - ensure_same_node(cmd, keys) do |node| - node.__send__(cmd, keys, options) + ensure_same_node(cmd, args) do |node| + if timeout + node.__send__(cmd, args, timeout: timeout) + else + node.__send__(cmd, args) + end end end @@ -417,6 +486,18 @@ def blpop(*args) _bpop(:blpop, args) end + def bzpopmax(*args) + _bpop(:bzpopmax, args) do |reply| + reply.is_a?(Array) ? [reply[0], reply[1], Floatify.call(reply[2])] : reply + end + end + + def bzpopmin(*args) + _bpop(:bzpopmin, args) do |reply| + reply.is_a?(Array) ? [reply[0], reply[1], Floatify.call(reply[2])] : reply + end + end + # Remove and get the last element in a list, or block until one is # available. def brpop(*args) @@ -425,15 +506,9 @@ def brpop(*args) # Pop a value from a list, push it to another list and return it; or block # until one is available. - def brpoplpush(source, destination, options = {}) - case options - when Integer - # Issue deprecation notice in obnoxious mode... - options = { :timeout => options } - end - + def brpoplpush(source, destination, **options) ensure_same_node(:brpoplpush, [source, destination]) do |node| - node.brpoplpush(source, destination, options) + node.brpoplpush(source, destination, **options) end end @@ -467,19 +542,43 @@ def ltrim(key, start, stop) node_for(key).ltrim(key, start, stop) end + # Iterate over keys, blocking and removing elements from the first non empty liist found. + def blmpop(timeout, *keys, modifier: "LEFT", count: nil) + ensure_same_node(:blmpop, keys) do |node| + node.blmpop(timeout, *keys, modifier: modifier, count: count) + end + end + + # Iterate over keys, removing elements from the first non list found. + def lmpop(*keys, modifier: "LEFT", count: nil) + ensure_same_node(:lmpop, keys) do |node| + node.lmpop(*keys, modifier: modifier, count: count) + end + end + # Get the number of members in a set. def scard(key) node_for(key).scard(key) end # Add one or more members to a set. - def sadd(key, member) - node_for(key).sadd(key, member) + def sadd(key, *members) + node_for(key).sadd(key, *members) + end + + # Add one or more members to a set. + def sadd?(key, *members) + node_for(key).sadd?(key, *members) + end + + # Remove one or more members from a set. + def srem(key, *members) + node_for(key).srem(key, *members) end # Remove one or more members from a set. - def srem(key, member) - node_for(key).srem(key, member) + def srem?(key, *members) + node_for(key).srem?(key, *members) end # Remove and return a random member from a set. @@ -504,50 +603,71 @@ def sismember(key, member) node_for(key).sismember(key, member) end + # Determine if multiple values are members of a set. + def smismember(key, *members) + node_for(key).smismember(key, *members) + end + # Get all the members in a set. def smembers(key) node_for(key).smembers(key) end + # Scan a set + def sscan(key, cursor, **options) + node_for(key).sscan(key, cursor, **options) + end + + # Scan a set and return an enumerator + def sscan_each(key, **options, &block) + node_for(key).sscan_each(key, **options, &block) + end + # Subtract multiple sets. def sdiff(*keys) + keys.flatten!(1) ensure_same_node(:sdiff, keys) do |node| - node.sdiff(*keys) + node.sdiff(keys) end end # Subtract multiple sets and store the resulting set in a key. def sdiffstore(destination, *keys) - ensure_same_node(:sdiffstore, [destination] + keys) do |node| - node.sdiffstore(destination, *keys) + keys.flatten!(1) + ensure_same_node(:sdiffstore, [destination].concat(keys)) do |node| + node.sdiffstore(destination, keys) end end # Intersect multiple sets. def sinter(*keys) + keys.flatten!(1) ensure_same_node(:sinter, keys) do |node| - node.sinter(*keys) + node.sinter(keys) end end # Intersect multiple sets and store the resulting set in a key. def sinterstore(destination, *keys) - ensure_same_node(:sinterstore, [destination] + keys) do |node| - node.sinterstore(destination, *keys) + keys.flatten!(1) + ensure_same_node(:sinterstore, [destination].concat(keys)) do |node| + node.sinterstore(destination, keys) end end # Add multiple sets. def sunion(*keys) + keys.flatten!(1) ensure_same_node(:sunion, keys) do |node| - node.sunion(*keys) + node.sunion(keys) end end # Add multiple sets and store the resulting set in a key. def sunionstore(destination, *keys) - ensure_same_node(:sunionstore, [destination] + keys) do |node| - node.sunionstore(destination, *keys) + keys.flatten!(1) + ensure_same_node(:sunionstore, [destination].concat(keys)) do |node| + node.sunionstore(destination, keys) end end @@ -561,6 +681,7 @@ def zcard(key) def zadd(key, *args) node_for(key).zadd(key, *args) end + ruby2_keywords(:zadd) if respond_to?(:ruby2_keywords, true) # Increment the score of a member in a sorted set. def zincrby(key, increment, member) @@ -577,15 +698,47 @@ def zscore(key, member) node_for(key).zscore(key, member) end - # Return a range of members in a sorted set, by index. - def zrange(key, start, stop, options = {}) - node_for(key).zrange(key, start, stop, options) + # Get one or more random members from a sorted set. + def zrandmember(key, count = nil, **options) + node_for(key).zrandmember(key, count, **options) + end + + # Get the scores associated with the given members in a sorted set. + def zmscore(key, *members) + node_for(key).zmscore(key, *members) + end + + # Iterate over keys, blocking and removing members from the first non empty sorted set found. + def bzmpop(timeout, *keys, modifier: "MIN", count: nil) + ensure_same_node(:bzmpop, keys) do |node| + node.bzmpop(timeout, *keys, modifier: modifier, count: count) + end + end + + # Iterate over keys, removing members from the first non empty sorted set found. + def zmpop(*keys, modifier: "MIN", count: nil) + ensure_same_node(:zmpop, keys) do |node| + node.zmpop(*keys, modifier: modifier, count: count) + end + end + + # Return a range of members in a sorted set, by index, score or lexicographical ordering. + def zrange(key, start, stop, **options) + node_for(key).zrange(key, start, stop, **options) + end + + # Select a range of members in a sorted set, by index, score or lexicographical ordering + # and store the resulting sorted set in a new key. + def zrangestore(dest_key, src_key, start, stop, **options) + ensure_same_node(:zrangestore, [dest_key, src_key]) do |node| + node.zrangestore(dest_key, src_key, start, stop, **options) + end end # Return a range of members in a sorted set, by index, with scores ordered # from high to low. - def zrevrange(key, start, stop, options = {}) - node_for(key).zrevrange(key, start, stop, options) + def zrevrange(key, start, stop, **options) + node_for(key).zrevrange(key, start, stop, **options) end # Determine the index of a member in a sorted set. @@ -605,14 +758,14 @@ def zremrangebyrank(key, start, stop) end # Return a range of members in a sorted set, by score. - def zrangebyscore(key, min, max, options = {}) - node_for(key).zrangebyscore(key, min, max, options) + def zrangebyscore(key, min, max, **options) + node_for(key).zrangebyscore(key, min, max, **options) end # Return a range of members in a sorted set, by score, with scores ordered # from high to low. - def zrevrangebyscore(key, max, min, options = {}) - node_for(key).zrevrangebyscore(key, max, min, options) + def zrevrangebyscore(key, max, min, **options) + node_for(key).zrevrangebyscore(key, max, min, **options) end # Remove all members in a sorted set within the given scores. @@ -625,18 +778,53 @@ def zcount(key, min, max) node_for(key).zcount(key, min, max) end + # Get the intersection of multiple sorted sets + def zinter(*keys, **options) + keys.flatten!(1) + ensure_same_node(:zinter, keys) do |node| + node.zinter(keys, **options) + end + end + # Intersect multiple sorted sets and store the resulting sorted set in a new # key. - def zinterstore(destination, keys, options = {}) - ensure_same_node(:zinterstore, [destination] + keys) do |node| - node.zinterstore(destination, keys, options) + def zinterstore(destination, *keys, **options) + keys.flatten!(1) + ensure_same_node(:zinterstore, [destination].concat(keys)) do |node| + node.zinterstore(destination, keys, **options) + end + end + + # Return the union of multiple sorted sets. + def zunion(*keys, **options) + keys.flatten!(1) + ensure_same_node(:zunion, keys) do |node| + node.zunion(keys, **options) end end # Add multiple sorted sets and store the resulting sorted set in a new key. - def zunionstore(destination, keys, options = {}) - ensure_same_node(:zunionstore, [destination] + keys) do |node| - node.zunionstore(destination, keys, options) + def zunionstore(destination, *keys, **options) + keys.flatten!(1) + ensure_same_node(:zunionstore, [destination].concat(keys)) do |node| + node.zunionstore(destination, keys, **options) + end + end + + # Return the difference between the first and all successive input sorted sets. + def zdiff(*keys, **options) + keys.flatten!(1) + ensure_same_node(:zdiff, keys) do |node| + node.zdiff(keys, **options) + end + end + + # Compute the difference between the first and all successive input sorted sets + # and store the resulting sorted set in a new key. + def zdiffstore(destination, *keys, **options) + keys.flatten!(1) + ensure_same_node(:zdiffstore, [destination] + keys) do |node| + node.zdiffstore(destination, keys, **options) end end @@ -645,9 +833,9 @@ def hlen(key) node_for(key).hlen(key) end - # Set the string value of a hash field. - def hset(key, field, value) - node_for(key).hset(key, field, value) + # Set multiple hash fields to multiple values. + def hset(key, *attrs) + node_for(key).hset(key, *attrs) end # Set the value of a hash field, only if the field does not exist. @@ -661,7 +849,7 @@ def hmset(key, *attrs) end def mapped_hmset(key, hash) - node_for(key).hmset(key, *hash.to_a.flatten) + node_for(key).hmset(key, hash) end # Get the value of a hash field. @@ -671,16 +859,23 @@ def hget(key, field) # Get the values of all the given hash fields. def hmget(key, *fields) - node_for(key).hmget(key, *fields) + fields.flatten!(1) + node_for(key).hmget(key, fields) end def mapped_hmget(key, *fields) - Hash[*fields.zip(hmget(key, *fields)).flatten] + fields.flatten!(1) + node_for(key).mapped_hmget(key, fields) + end + + def hrandfield(key, count = nil, **options) + node_for(key).hrandfield(key, count, **options) end # Delete one or more hash fields. - def hdel(key, field) - node_for(key).hdel(key, field) + def hdel(key, *fields) + fields.flatten!(1) + node_for(key).hdel(key, fields) end # Determine if a hash field exists. @@ -719,7 +914,7 @@ def publish(channel, message) end def subscribed? - !! @subscribed_node + !!@subscribed_node end # Listen for messages published to the given channels. @@ -737,7 +932,8 @@ def subscribe(channel, *channels, &block) # Stop listening for messages posted to the given channels. def unsubscribe(*channels) - raise RuntimeError, "Can't unsubscribe if not subscribed." unless subscribed? + raise SubscriptionError, "Can't unsubscribe if not subscribed." unless subscribed? + @subscribed_node.unsubscribe(*channels) end @@ -753,13 +949,26 @@ def punsubscribe(*channels) end # Watch the given keys to determine execution of the MULTI/EXEC block. - def watch(*keys) - raise CannotDistribute, :watch + def watch(*keys, &block) + ensure_same_node(:watch, keys) do |node| + @watch_key = key_tag(keys.first) || keys.first.to_s + + begin + node.watch(*keys, &block) + rescue StandardError + @watch_key = nil + raise + end + end end # Forget about all watched keys. def unwatch - raise CannotDistribute, :unwatch + raise CannotDistribute, :unwatch unless @watch_key + + result = node_for(@watch_key).unwatch + @watch_key = nil + result end def pipelined @@ -767,18 +976,28 @@ def pipelined end # Mark the start of a transaction block. - def multi - raise CannotDistribute, :multi + def multi(&block) + raise CannotDistribute, :multi unless @watch_key + + node_for(@watch_key).multi(&block) end # Execute all commands issued after MULTI. def exec - raise CannotDistribute, :exec + raise CannotDistribute, :exec unless @watch_key + + result = node_for(@watch_key).exec + @watch_key = nil + result end # Discard all commands issued after MULTI. def discard - raise CannotDistribute, :discard + raise CannotDistribute, :discard unless @watch_key + + result = node_for(@watch_key).discard + @watch_key = nil + result end # Control remote script registry. @@ -837,7 +1056,7 @@ def dup self.class.new(@node_configs, @default_options) end - protected + protected def on_each_node(command, *args) nodes.map do |node| @@ -850,7 +1069,8 @@ def node_index_for(key) end def key_tag(key) - key.to_s[@tag, 1] if @tag + key = key.to_s + key[@tag, 1] if key.match?(@tag) end def ensure_same_node(command, keys) diff --git a/lib/redis/errors.rb b/lib/redis/errors.rb index 85b222ec6..bef5a8d45 100644 --- a/lib/redis/errors.rb +++ b/lib/redis/errors.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + class Redis # Base error for all redis-rb errors. - class BaseError < RuntimeError + class BaseError < StandardError end # Raised by the connection when a protocol error occurs. @@ -18,6 +20,15 @@ def initialize(reply_type) class CommandError < BaseError end + class PermissionError < CommandError + end + + class WrongTypeError < CommandError + end + + class OutOfMemoryError < CommandError + end + # Base error for connection related errors. class BaseConnectionError < BaseError end @@ -37,4 +48,15 @@ class TimeoutError < BaseConnectionError # Raised when the connection was inherited by a child process. class InheritedError < BaseConnectionError end + + # Generally raised during Redis failover scenarios + class ReadOnlyError < BaseConnectionError + end + + # Raised when client options are invalid. + class InvalidClientOptionError < BaseError + end + + class SubscriptionError < BaseError + end end diff --git a/lib/redis/hash_ring.rb b/lib/redis/hash_ring.rb index 2a199bd53..15f38d556 100644 --- a/lib/redis/hash_ring.rb +++ b/lib/redis/hash_ring.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + require 'zlib' +require 'digest/md5' class Redis class HashRing - POINTS_PER_SERVER = 160 # this is the default in libmemcached attr_reader :ring, :sorted_keys, :replicas, :nodes @@ -10,7 +12,7 @@ class HashRing # nodes is a list of objects that have a proper to_s representation. # replicas indicates how many virtual points should be used pr. node, # replicas are required to improve the distribution. - def initialize(nodes=[], replicas=POINTS_PER_SERVER) + def initialize(nodes = [], replicas = POINTS_PER_SERVER) @replicas = replicas @ring = {} @nodes = [] @@ -24,8 +26,7 @@ def initialize(nodes=[], replicas=POINTS_PER_SERVER) def add_node(node) @nodes << node @replicas.times do |i| - key = Zlib.crc32("#{node.id}:#{i}") - raise "Node ID collision" if @ring.has_key?(key) + key = server_hash_for("#{node.id}:#{i}") @ring[key] = node @sorted_keys << key end @@ -33,100 +34,56 @@ def add_node(node) end def remove_node(node) - @nodes.reject!{|n| n.id == node.id} + @nodes.reject! { |n| n.id == node.id } @replicas.times do |i| - key = Zlib.crc32("#{node.id}:#{i}") + key = server_hash_for("#{node.id}:#{i}") @ring.delete(key) - @sorted_keys.reject! {|k| k == key} + @sorted_keys.reject! { |k| k == key } end end # get the node in the hash ring for this key def get_node(key) - get_node_pos(key)[0] - end - - def get_node_pos(key) - return [nil,nil] if @ring.size == 0 - crc = Zlib.crc32(key) - idx = HashRing.binary_search(@sorted_keys, crc) - return [@ring[@sorted_keys[idx]], idx] + hash = hash_for(key) + idx = binary_search(@sorted_keys, hash) + @ring[@sorted_keys[idx]] end def iter_nodes(key) - return [nil,nil] if @ring.size == 0 - _, pos = get_node_pos(key) + return [nil, nil] if @ring.empty? + + crc = hash_for(key) + pos = binary_search(@sorted_keys, crc) @ring.size.times do |n| - yield @ring[@sorted_keys[(pos+n) % @ring.size]] + yield @ring[@sorted_keys[(pos + n) % @ring.size]] end end - class << self - - # gem install RubyInline to use this code - # Native extension to perform the binary search within the hashring. - # There's a pure ruby version below so this is purely optional - # for performance. In testing 20k gets and sets, the native - # binary search shaved about 12% off the runtime (9sec -> 8sec). - begin - require 'inline' - inline do |builder| - builder.c <<-EOM - int binary_search(VALUE ary, unsigned int r) { - int upper = RARRAY_LEN(ary) - 1; - int lower = 0; - int idx = 0; - - while (lower <= upper) { - idx = (lower + upper) / 2; + private - VALUE continuumValue = RARRAY_PTR(ary)[idx]; - unsigned int l = NUM2UINT(continuumValue); - if (l == r) { - return idx; - } - else if (l > r) { - upper = idx - 1; - } - else { - lower = idx + 1; - } - } - if (upper < 0) { - upper = RARRAY_LEN(ary) - 1; - } - return upper; - } - EOM - end - rescue Exception - # Find the closest index in HashRing with value <= the given value - def binary_search(ary, value, &block) - upper = ary.size - 1 - lower = 0 - idx = 0 - - while(lower <= upper) do - idx = (lower + upper) / 2 - comp = ary[idx] <=> value + def hash_for(key) + Zlib.crc32(key) + end - if comp == 0 - return idx - elsif comp > 0 - upper = idx - 1 - else - lower = idx + 1 - end - end + def server_hash_for(key) + Digest::MD5.digest(key).unpack1("L>") + end - if upper < 0 - upper = ary.size - 1 - end - return upper + # Find the closest index in HashRing with value <= the given value + def binary_search(ary, value) + upper = ary.size + lower = 0 + + while lower < upper + mid = (lower + upper) / 2 + if ary[mid] > value + upper = mid + else + lower = mid + 1 end - end - end + upper - 1 + end end end diff --git a/lib/redis/pipeline.rb b/lib/redis/pipeline.rb index a77f86d9b..65e2cda2c 100644 --- a/lib/redis/pipeline.rb +++ b/lib/redis/pipeline.rb @@ -1,99 +1,72 @@ -class Redis - unless defined?(::BasicObject) - class BasicObject - instance_methods.each { |meth| undef_method(meth) unless meth =~ /\A(__|instance_eval)/ } - end - end +# frozen_string_literal: true - class Pipeline - attr_accessor :db +require "delegate" - attr :futures +class Redis + class PipelinedConnection + attr_accessor :db - def initialize - @with_reconnect = true - @shutdown = false - @futures = [] + def initialize(pipeline, futures = []) + @pipeline = pipeline + @futures = futures end - def with_reconnect? - @with_reconnect + include Commands + + def pipelined + yield self end - def without_reconnect? - !@with_reconnect + def multi + transaction = MultiConnection.new(@pipeline, @futures) + send_command([:multi]) + size = @futures.size + yield transaction + multi_future = MultiFuture.new(@futures[size..-1]) + @pipeline.call_v([:exec]) do |result| + multi_future._set(result) + end + @futures << multi_future + multi_future end - def shutdown? - @shutdown + private + + def synchronize + yield self end - def call(command, &block) - # A pipeline that contains a shutdown should not raise ECONNRESET when - # the connection is gone. - @shutdown = true if command.first == :shutdown + def send_command(command, &block) future = Future.new(command, block) + @pipeline.call_v(command) do |result| + future._set(result) + end @futures << future future end - def call_pipeline(pipeline) - @shutdown = true if pipeline.shutdown? - @futures.concat(pipeline.futures) - @db = pipeline.db - nil - end - - def commands - @futures.map { |f| f._command } - end - - def with_reconnect(val=true) - @with_reconnect = false unless val - yield - end - - def without_reconnect(&blk) - with_reconnect(false, &blk) - end - - def finish(replies, &blk) - if blk - futures.each_with_index.map do |future, i| - future._set(blk.call(replies[i])) - end - else - futures.each_with_index.map do |future, i| - future._set(replies[i]) - end + def send_blocking_command(command, timeout, &block) + future = Future.new(command, block) + @pipeline.blocking_call_v(timeout, command) do |result| + future._set(result) end + @futures << future + future end + end - class Multi < self - def finish(replies) - exec = replies.last - - return if exec.nil? # The transaction failed because of WATCH. - - # EXEC command failed. - raise exec if exec.is_a?(CommandError) - - if exec.size < futures.size - # Some command wasn't recognized by Redis. - raise replies.detect { |r| r.is_a?(CommandError) } - end + class MultiConnection < PipelinedConnection + def multi + raise Redis::Error, "Can't nest multi transaction" + end - super(exec) do |reply| - # Because an EXEC returns nested replies, hiredis won't be able to - # convert an error reply to a CommandError instance itself. This is - # specific to MULTI/EXEC, so we solve this here. - reply.is_a?(::RuntimeError) ? CommandError.new(reply.message) : reply - end - end + private - def commands - [[:multi]] + super + [[:exec]] - end + # Blocking commands inside transaction behave like non-blocking. + # It shouldn't be done though. + # https://redis.io/commands/blpop/#blpop-inside-a-multi--exec-transaction + def send_blocking_command(command, _timeout, &block) + send_command(command, &block) end end @@ -106,10 +79,10 @@ def initialize class Future < BasicObject FutureNotReady = ::Redis::FutureNotReady.new - def initialize(command, transformation) + def initialize(command, coerce) @command = command - @transformation = transformation @object = FutureNotReady + @coerce = coerce end def inspect @@ -117,16 +90,12 @@ def inspect end def _set(object) - @object = @transformation ? @transformation.call(object) : object + @object = @coerce ? @coerce.call(object) : object value end - def _command - @command - end - def value - ::Kernel.raise(@object) if @object.kind_of?(::RuntimeError) + ::Kernel.raise(@object) if @object.is_a?(::StandardError) @object end @@ -138,4 +107,21 @@ def class Future end end + + class MultiFuture < Future + def initialize(futures) + @futures = futures + @command = [:exec] + @object = FutureNotReady + end + + def _set(replies) + if replies + @futures.each_with_index do |future, index| + future._set(replies[index]) + end + end + @object = replies + end + end end diff --git a/lib/redis/subscribe.rb b/lib/redis/subscribe.rb index 3029d048c..f95e29dfb 100644 --- a/lib/redis/subscribe.rb +++ b/lib/redis/subscribe.rb @@ -1,11 +1,16 @@ +# frozen_string_literal: true + class Redis class SubscribedClient def initialize(client) @client = client + @write_monitor = Monitor.new end - def call(command) - @client.process([command]) + def call_v(command) + @write_monitor.synchronize do + @client.call_v(command) + end end def subscribe(*channels, &block) @@ -24,32 +29,53 @@ def psubscribe_with_timeout(timeout, *channels, &block) subscription("psubscribe", "punsubscribe", channels, block, timeout) end + def ssubscribe(*channels, &block) + subscription("ssubscribe", "sunsubscribe", channels, block) + end + + def ssubscribe_with_timeout(timeout, *channels, &block) + subscription("ssubscribe", "sunsubscribe", channels, block, timeout) + end + def unsubscribe(*channels) - call([:unsubscribe, *channels]) + call_v([:unsubscribe, *channels]) end def punsubscribe(*channels) - call([:punsubscribe, *channels]) + call_v([:punsubscribe, *channels]) end - protected + def sunsubscribe(*channels) + call_v([:sunsubscribe, *channels]) + end + + def close + @client.close + end + + protected def subscription(start, stop, channels, block, timeout = 0) sub = Subscription.new(&block) - unsubscribed = false + case start + when "ssubscribe" then channels.each { |c| call_v([start, c]) } # avoid cross-slot keys + else call_v([start, *channels]) + end - begin - @client.call_loop([start, *channels], timeout) do |line| - type, *rest = line - sub.callbacks[type].call(*rest) - unsubscribed = type == stop && rest.last == 0 - break if unsubscribed + while event = @client.next_event(timeout) + if event.is_a?(::RedisClient::CommandError) + raise Client::ERROR_MAPPING.fetch(event.class), event.message end - ensure - # No need to unsubscribe here. The real client closes the connection - # whenever an exception is raised (see #ensure_connected). + + type, *rest = event + if callback = sub.callbacks[type] + callback.call(*rest) + end + break if type == stop && rest.last == 0 end + # No need to unsubscribe here. The real client closes the connection + # whenever an exception is raised (see #ensure_connected). end end @@ -57,10 +83,7 @@ class Subscription attr :callbacks def initialize - @callbacks = Hash.new do |hash, key| - hash[key] = lambda { |*_| } - end - + @callbacks = {} yield(self) end @@ -87,5 +110,17 @@ def punsubscribe(&block) def pmessage(&block) @callbacks["pmessage"] = block end + + def ssubscribe(&block) + @callbacks["ssubscribe"] = block + end + + def sunsubscribe(&block) + @callbacks["sunsubscribe"] = block + end + + def smessage(&block) + @callbacks["smessage"] = block + end end end diff --git a/lib/redis/version.rb b/lib/redis/version.rb index b5d80d24d..0c6a3bfc3 100644 --- a/lib/redis/version.rb +++ b/lib/redis/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Redis - VERSION = "3.3.3" + VERSION = '5.0.8' end diff --git a/makefile b/makefile new file mode 100644 index 000000000..9d9079f75 --- /dev/null +++ b/makefile @@ -0,0 +1,128 @@ +REDIS_BRANCH ?= 7.2 +ROOT_DIR :=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) +TMP := tmp +CONF := ${ROOT_DIR}/test/support/conf/redis-${REDIS_BRANCH}.conf +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 := ${TMP}/redis.sock +PORT := 6381 +SLAVE_PORT := 6382 +SLAVE_PID_PATH := ${BUILD_DIR}/redis_slave.pid +SLAVE_SOCKET_PATH := ${BUILD_DIR}/redis_slave.sock +HA_GROUP_NAME := master1 +SENTINEL_PORTS := 6400 6401 6402 +SENTINEL_PID_PATHS := $(addprefix ${TMP}/redis,$(addsuffix .pid,${SENTINEL_PORTS})) +CLUSTER_PORTS := 16380 16381 16382 16383 16384 16385 +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}) + +define kill-redis + (ls $1 > /dev/null 2>&1 && kill $$(cat $1) && rm -f $1) || true +endef + +all: start_all test stop_all + +start_all: start start_slave start_sentinel wait_for_sentinel start_cluster create_cluster + +stop_all: stop_sentinel stop_slave stop stop_cluster + +${TMP}: + @mkdir -p $@ + +${BINARY}: ${TMP} + @bin/build ${REDIS_BRANCH} $< + +test: + @env REDIS_SOCKET_PATH=${SOCKET_PATH} bundle exec rake test + +stop: + @$(call kill-redis,${PID_PATH});\ + +start: ${BINARY} + @cp ${CONF} ${TMP}/redis.conf; \ + ${BINARY} ${TMP}/redis.conf \ + --daemonize yes\ + --pidfile ${PID_PATH}\ + --port ${PORT}\ + --unixsocket ${SOCKET_PATH} + +stop_slave: + @$(call kill-redis,${SLAVE_PID_PATH}) + +start_slave: start + @${BINARY}\ + --daemonize yes\ + --pidfile ${SLAVE_PID_PATH}\ + --port ${SLAVE_PORT}\ + --unixsocket ${SLAVE_SOCKET_PATH}\ + --slaveof 127.0.0.1 ${PORT} + +stop_sentinel: stop_slave stop + @$(call kill-redis,${SENTINEL_PID_PATHS}) + @rm -f ${TMP}/sentinel*.conf || true + +start_sentinel: start start_slave + @for port in ${SENTINEL_PORTS}; do\ + conf=${TMP}/sentinel$$port.conf;\ + touch $$conf;\ + echo '' > $$conf;\ + echo 'sentinel monitor ${HA_GROUP_NAME} 127.0.0.1 ${PORT} 2' >> $$conf;\ + echo 'sentinel down-after-milliseconds ${HA_GROUP_NAME} 5000' >> $$conf;\ + echo 'sentinel failover-timeout ${HA_GROUP_NAME} 30000' >> $$conf;\ + echo 'sentinel parallel-syncs ${HA_GROUP_NAME} 1' >> $$conf;\ + ${BINARY} $$conf\ + --daemonize yes\ + --pidfile ${TMP}/redis$$port.pid\ + --port $$port\ + --sentinel;\ + done + +wait_for_sentinel: MAX_ATTEMPTS_FOR_WAIT ?= 60 +wait_for_sentinel: + @for port in ${SENTINEL_PORTS}; do\ + i=0;\ + while : ; do\ + if [ $${i} -ge ${MAX_ATTEMPTS_FOR_WAIT} ]; then\ + echo "Max attempts exceeded: $${i} times";\ + exit 1;\ + fi;\ + if [ $$(${REDIS_CLIENT} -p $${port} SENTINEL SLAVES ${HA_GROUP_NAME} | wc -l) -gt 1 ]; then\ + break;\ + fi;\ + echo 'Waiting for Redis sentinel to be ready...';\ + sleep 1;\ + i=$$(( $${i}+1 ));\ + done;\ + done + +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 no\ + --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: + @bin/cluster_creator ${CLUSTER_ADDRS} + +clean: + @(test -d ${BUILD_DIR} && cd ${BUILD_DIR}/src && make clean distclean) || true + +.PHONY: all test stop start stop_slave start_slave stop_sentinel start_sentinel\ + stop_cluster start_cluster create_cluster stop_all start_all clean diff --git a/redis.gemspec b/redis.gemspec index 3099b6d9b..62aaf4b4c 100644 --- a/redis.gemspec +++ b/redis.gemspec @@ -1,8 +1,6 @@ -# -*- encoding: utf-8 -*- +# frozen_string_literal: true -$:.unshift File.expand_path("../lib", __FILE__) - -require "redis/version" +require "./lib/redis/version" Gem::Specification.new do |s| s.name = "redis" @@ -15,8 +13,7 @@ Gem::Specification.new do |s| s.description = <<-EOS A Ruby client that tries to match Redis' API one-to-one, while still - providing an idiomatic interface. It features thread-safety, - client-side sharding, pipelining, and an obsession for performance. + providing an idiomatic interface. EOS s.license = "MIT" @@ -35,10 +32,18 @@ Gem::Specification.new do |s| s.email = ["redis-db@googlegroups.com"] - s.files = `git ls-files`.split("\n") - s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") - s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } + s.metadata = { + "bug_tracker_uri" => "#{s.homepage}/issues", + "changelog_uri" => "#{s.homepage}/blob/master/CHANGELOG.md", + "documentation_uri" => "https://www.rubydoc.info/gems/redis/#{s.version}", + "homepage_uri" => s.homepage, + "source_code_uri" => "#{s.homepage}/tree/v#{s.version}" + } + + s.files = Dir["CHANGELOG.md", "LICENSE", "README.md", "lib/**/*"] + s.executables = `git ls-files -- exe/*`.split("\n").map { |f| File.basename(f) } + + s.required_ruby_version = '>= 2.5.0' - s.add_development_dependency("rake", "<11.0.0") - s.add_development_dependency("test-unit", "3.1.5") + s.add_runtime_dependency('redis-client', '>= 0.17.0') end diff --git a/test/bitpos_test.rb b/test/bitpos_test.rb deleted file mode 100644 index 118294da7..000000000 --- a/test/bitpos_test.rb +++ /dev/null @@ -1,69 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -unless defined?(Enumerator) - Enumerator = Enumerable::Enumerator -end - -class TestBitpos < Test::Unit::TestCase - - include Helper::Client - - def test_bitpos_empty_zero - target_version "2.9.11" do - r.del "foo" - assert_equal 0, r.bitpos("foo", 0) - end - end - - def test_bitpos_empty_one - target_version "2.9.11" do - r.del "foo" - assert_equal -1, r.bitpos("foo", 1) - end - end - - def test_bitpos_zero - target_version "2.9.11" do - r.set "foo", "\xff\xf0\x00" - assert_equal 12, r.bitpos("foo", 0) - end - end - - def test_bitpos_one - target_version "2.9.11" do - r.set "foo", "\x00\x0f\x00" - assert_equal 12, r.bitpos("foo", 1) - end - end - - def test_bitpos_zero_end_is_given - target_version "2.9.11" do - r.set "foo", "\xff\xff\xff" - assert_equal 24, r.bitpos("foo", 0) - assert_equal 24, r.bitpos("foo", 0, 0) - assert_equal -1, r.bitpos("foo", 0, 0, -1) - end - end - - def test_bitpos_one_intervals - target_version "2.9.11" do - r.set "foo", "\x00\xff\x00" - assert_equal 8, r.bitpos("foo", 1, 0, -1) - assert_equal 8, r.bitpos("foo", 1, 1, -1) - assert_equal -1, r.bitpos("foo", 1, 2, -1) - assert_equal -1, r.bitpos("foo", 1, 2, 200) - assert_equal 8, r.bitpos("foo", 1, 1, 1) - end - end - - def test_bitpos_raise_exception_if_stop_not_start - target_version "2.9.11" do - assert_raises(ArgumentError) do - r.bitpos("foo", 0, nil, 2) - end - end - end - -end diff --git a/test/blocking_commands_test.rb b/test/blocking_commands_test.rb deleted file mode 100644 index 4a2a9652d..000000000 --- a/test/blocking_commands_test.rb +++ /dev/null @@ -1,42 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) -require "lint/blocking_commands" - -class TestBlockingCommands < Test::Unit::TestCase - - include Helper::Client - include Lint::BlockingCommands - - def assert_takes_longer_than_client_timeout - timeout = OPTIONS[:timeout] - delay = timeout * 2 - - mock(:delay => delay) do |r| - t1 = Time.now - yield(r) - t2 = Time.now - - assert timeout == r.client.timeout - assert delay <= (t2 - t1) - end - end - - def test_blpop_disable_client_timeout - assert_takes_longer_than_client_timeout do |r| - assert_equal ["foo", "0"], r.blpop("foo") - end - end - - def test_brpop_disable_client_timeout - assert_takes_longer_than_client_timeout do |r| - assert_equal ["foo", "0"], r.brpop("foo") - end - end - - def test_brpoplpush_disable_client_timeout - assert_takes_longer_than_client_timeout do |r| - assert_equal "0", r.brpoplpush("foo", "bar") - end - end -end diff --git a/test/client_test.rb b/test/client_test.rb deleted file mode 100644 index 1d0b8d3a6..000000000 --- a/test/client_test.rb +++ /dev/null @@ -1,59 +0,0 @@ -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestClient < Test::Unit::TestCase - - include Helper::Client - - def test_call - result = r.call("PING") - assert_equal result, "PONG" - end - - def test_call_with_arguments - result = r.call("SET", "foo", "bar") - assert_equal result, "OK" - end - - def test_call_integers - result = r.call("INCR", "foo") - assert_equal result, 1 - end - - def test_call_raise - assert_raises(Redis::CommandError) do - r.call("INCR") - end - end - - def test_queue_commit - r.queue("SET", "foo", "bar") - r.queue("GET", "foo") - result = r.commit - - assert_equal result, ["OK", "bar"] - end - - def test_commit_raise - r.queue("SET", "foo", "bar") - r.queue("INCR") - - assert_raise(Redis::CommandError) do - r.commit - end - end - - def test_queue_after_error - r.queue("SET", "foo", "bar") - r.queue("INCR") - - assert_raise(Redis::CommandError) do - r.commit - end - - r.queue("SET", "foo", "bar") - r.queue("INCR", "baz") - result = r.commit - - assert_equal result, ["OK", 1] - end -end diff --git a/test/command_map_test.rb b/test/command_map_test.rb deleted file mode 100644 index cb401db36..000000000 --- a/test/command_map_test.rb +++ /dev/null @@ -1,30 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestCommandMap < Test::Unit::TestCase - - include Helper::Client - - def test_override_existing_commands - r.set("counter", 1) - - assert_equal 2, r.incr("counter") - - r.client.command_map[:incr] = :decr - - assert_equal 1, r.incr("counter") - end - - def test_override_non_existing_commands - r.set("key", "value") - - assert_raise Redis::CommandError do - r.idontexist("key") - end - - r.client.command_map[:idontexist] = :get - - assert_equal "value", r.idontexist("key") - end -end diff --git a/test/commands_on_hashes_test.rb b/test/commands_on_hashes_test.rb deleted file mode 100644 index f3bbfa5b3..000000000 --- a/test/commands_on_hashes_test.rb +++ /dev/null @@ -1,21 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) -require "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 deleted file mode 100644 index a2fc95be1..000000000 --- a/test/commands_on_hyper_log_log_test.rb +++ /dev/null @@ -1,21 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) -require "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 \ No newline at end of file diff --git a/test/commands_on_lists_test.rb b/test/commands_on_lists_test.rb deleted file mode 100644 index 2916c2854..000000000 --- a/test/commands_on_lists_test.rb +++ /dev/null @@ -1,20 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) -require "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 deleted file mode 100644 index 7ac2f4e03..000000000 --- a/test/commands_on_sets_test.rb +++ /dev/null @@ -1,77 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) -require "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 deleted file mode 100644 index 0a424be50..000000000 --- a/test/commands_on_sorted_sets_test.rb +++ /dev/null @@ -1,137 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) -require "lint/sorted_sets" - -class TestCommandsOnSortedSets < Test::Unit::TestCase - - include Helper::Client - include Lint::SortedSets - - 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 deleted file mode 100644 index 9172aaccd..000000000 --- a/test/commands_on_strings_test.rb +++ /dev/null @@ -1,101 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) -require "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 - try_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/commands_on_value_types_test.rb b/test/commands_on_value_types_test.rb deleted file mode 100644 index 6b2f211ca..000000000 --- a/test/commands_on_value_types_test.rb +++ /dev/null @@ -1,133 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) -require "lint/value_types" - -class TestCommandsOnValueTypes < Test::Unit::TestCase - - include Helper::Client - include Lint::ValueTypes - - def test_del - r.set "foo", "s1" - r.set "bar", "s2" - r.set "baz", "s3" - - assert_equal ["bar", "baz", "foo"], r.keys("*").sort - - assert_equal 1, r.del("foo") - - assert_equal ["bar", "baz"], r.keys("*").sort - - assert_equal 2, r.del("bar", "baz") - - assert_equal [], r.keys("*").sort - end - - def test_del_with_array_argument - r.set "foo", "s1" - r.set "bar", "s2" - r.set "baz", "s3" - - assert_equal ["bar", "baz", "foo"], r.keys("*").sort - - assert_equal 1, r.del(["foo"]) - - assert_equal ["bar", "baz"], r.keys("*").sort - - assert_equal 2, r.del(["bar", "baz"]) - - assert_equal [], r.keys("*").sort - end - - def test_randomkey - assert r.randomkey.to_s.empty? - - r.set("foo", "s1") - - assert_equal "foo", r.randomkey - - r.set("bar", "s2") - - 4.times do - assert ["foo", "bar"].include?(r.randomkey) - end - end - - def test_rename - r.set("foo", "s1") - r.rename "foo", "bar" - - assert_equal "s1", r.get("bar") - assert_equal nil, r.get("foo") - end - - def test_renamenx - r.set("foo", "s1") - r.set("bar", "s2") - - assert_equal false, r.renamenx("foo", "bar") - - assert_equal "s1", r.get("foo") - assert_equal "s2", r.get("bar") - end - - def test_dbsize - assert_equal 0, r.dbsize - - r.set("foo", "s1") - - assert_equal 1, r.dbsize - end - - def test_flushdb - r.set("foo", "s1") - r.set("bar", "s2") - - assert_equal 2, r.dbsize - - r.flushdb - - assert_equal 0, r.dbsize - end - - def test_flushall - redis_mock(:flushall => lambda { "+FLUSHALL" }) do |redis| - assert_equal "FLUSHALL", redis.flushall - end - end - - def test_migrate - redis_mock(:migrate => lambda { |*args| args }) do |redis| - options = { :host => "127.0.0.1", :port => 1234 } - - ex = assert_raise(RuntimeError) do - redis.migrate("foo", options.reject { |key, _| key == :host }) - end - assert ex.message =~ /host not specified/ - - ex = assert_raise(RuntimeError) do - redis.migrate("foo", options.reject { |key, _| key == :port }) - end - assert ex.message =~ /port not specified/ - - default_db = redis.client.db.to_i - default_timeout = redis.client.timeout.to_i - - # Test defaults - actual = redis.migrate("foo", options) - expected = ["127.0.0.1", "1234", "foo", default_db.to_s, default_timeout.to_s] - assert_equal expected, actual - - # Test db override - actual = redis.migrate("foo", options.merge(:db => default_db + 1)) - expected = ["127.0.0.1", "1234", "foo", (default_db + 1).to_s, default_timeout.to_s] - assert_equal expected, actual - - # Test timeout override - actual = redis.migrate("foo", options.merge(:timeout => default_timeout + 1)) - expected = ["127.0.0.1", "1234", "foo", default_db.to_s, (default_timeout + 1).to_s] - assert_equal expected, actual - end - end -end diff --git a/test/connection_handling_test.rb b/test/connection_handling_test.rb deleted file mode 100644 index f11553bae..000000000 --- a/test/connection_handling_test.rb +++ /dev/null @@ -1,277 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestConnectionHandling < Test::Unit::TestCase - - include Helper::Client - - def test_auth - commands = { - :auth => lambda { |password| $auth = password; "+OK" }, - :get => lambda { |key| $auth == "secret" ? "$3\r\nbar" : "$-1" }, - } - - redis_mock(commands, :password => "secret") do |redis| - assert_equal "bar", redis.get("foo") - end - end - - def test_id - commands = { - :client => lambda { |cmd, name| $name = [cmd, name]; "+OK" }, - :ping => lambda { "+PONG" }, - } - - redis_mock(commands, :id => "client-name") do |redis| - assert_equal "PONG", redis.ping - end - - assert_equal ["setname","client-name"], $name - end - - def test_ping - assert_equal "PONG", r.ping - end - - def test_select - r.set "foo", "bar" - - r.select 14 - assert_equal nil, r.get("foo") - - r.client.disconnect - - assert_equal nil, r.get("foo") - end - - def test_quit - r.quit - - assert !r.client.connected? - end - - def test_close - quit = 0 - - commands = { - :quit => lambda do - quit += 1 - "+OK" - end - } - - redis_mock(commands) do |redis| - assert_equal 0, quit - - redis.quit - - assert_equal 1, quit - - redis.ping - - redis.close - - assert_equal 1, quit - - assert !redis.connected? - end - end - - def test_disconnect - quit = 0 - - commands = { - :quit => lambda do - quit += 1 - "+OK" - end - } - - redis_mock(commands) do |redis| - assert_equal 0, quit - - redis.quit - - assert_equal 1, quit - - redis.ping - - redis.disconnect! - - assert_equal 1, quit - - assert !redis.connected? - end - end - - def test_shutdown - commands = { - :shutdown => lambda { :exit } - } - - redis_mock(commands) do |redis| - # SHUTDOWN does not reply: test that it does not raise here. - assert_equal nil, redis.shutdown - end - end - - def test_shutdown_with_error - connections = 0 - commands = { - :select => lambda { |*_| connections += 1; "+OK\r\n" }, - :connections => lambda { ":#{connections}\r\n" }, - :shutdown => lambda { "-ERR could not shutdown\r\n" } - } - - redis_mock(commands) do |redis| - connections = redis.connections - - # SHUTDOWN replies with an error: test that it gets raised - assert_raise Redis::CommandError do - redis.shutdown - end - - # The connection should remain in tact - assert_equal connections, redis.connections - end - end - - def test_shutdown_from_pipeline - commands = { - :shutdown => lambda { :exit } - } - - redis_mock(commands) do |redis| - result = redis.pipelined do - redis.shutdown - end - - assert_equal nil, result - assert !redis.client.connected? - end - end - - def test_shutdown_with_error_from_pipeline - connections = 0 - commands = { - :select => lambda { |*_| connections += 1; "+OK\r\n" }, - :connections => lambda { ":#{connections}\r\n" }, - :shutdown => lambda { "-ERR could not shutdown\r\n" } - } - - redis_mock(commands) do |redis| - connections = redis.connections - - # SHUTDOWN replies with an error: test that it gets raised - assert_raise Redis::CommandError do - redis.pipelined do - redis.shutdown - end - end - - # The connection should remain in tact - assert_equal connections, redis.connections - end - end - - def test_shutdown_from_multi_exec - commands = { - :multi => lambda { "+OK\r\n" }, - :shutdown => lambda { "+QUEUED\r\n" }, - :exec => lambda { :exit } - } - - redis_mock(commands) do |redis| - result = redis.multi do - redis.shutdown - end - - assert_equal nil, result - assert !redis.client.connected? - end - end - - def test_shutdown_with_error_from_multi_exec - connections = 0 - commands = { - :select => lambda { |*_| connections += 1; "+OK\r\n" }, - :connections => lambda { ":#{connections}\r\n" }, - :multi => lambda { "+OK\r\n" }, - :shutdown => lambda { "+QUEUED\r\n" }, - :exec => lambda { "*1\r\n-ERR could not shutdown\r\n" } - } - - redis_mock(commands) do |redis| - connections = redis.connections - - # SHUTDOWN replies with an error: test that it gets returned - # We should test for Redis::CommandError here, but hiredis doesn't yet do - # custom error classes. - err = nil - - begin - redis.multi { redis.shutdown } - rescue => err - end - - assert err.kind_of?(StandardError) - - # The connection should remain intact - assert_equal connections, redis.connections - end - end - - def test_slaveof - redis_mock(:slaveof => lambda { |host, port| "+SLAVEOF #{host} #{port}" }) do |redis| - assert_equal "SLAVEOF somehost 6381", redis.slaveof("somehost", 6381) - end - end - - def test_bgrewriteaof - redis_mock(:bgrewriteaof => lambda { "+BGREWRITEAOF" }) do |redis| - assert_equal "BGREWRITEAOF", redis.bgrewriteaof - end - end - - def test_config_get - assert r.config(:get, "*")["timeout"] != nil - - config = r.config(:get, "timeout") - assert_equal ["timeout"], config.keys - assert config.values.compact.size > 0 - end - - def test_config_set - begin - assert_equal "OK", r.config(:set, "timeout", 200) - assert_equal "200", r.config(:get, "*")["timeout"] - - assert_equal "OK", r.config(:set, "timeout", 100) - assert_equal "100", r.config(:get, "*")["timeout"] - ensure - r.config :set, "timeout", 300 - end - end - - driver(:ruby, :hiredis) do - def test_consistency_on_multithreaded_env - t = nil - - commands = { - :set => lambda { |key, value| t.kill; "+OK\r\n" }, - :incr => lambda { |key| ":1\r\n" }, - } - - redis_mock(commands) do |redis| - t = Thread.new do - redis.set("foo", "bar") - end - - t.join - - assert_equal 1, redis.incr("baz") - end - end - end -end diff --git a/test/distributed/blocking_commands_test.rb b/test/distributed/blocking_commands_test.rb new file mode 100644 index 000000000..2d1c538e7 --- /dev/null +++ b/test/distributed/blocking_commands_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "helper" + +class TestDistributedBlockingCommands < Minitest::Test + include Helper::Distributed + include Lint::BlockingCommands + + def test_blmove_raises + target_version "6.2" do + assert_raises(Redis::Distributed::CannotDistribute) do + r.blmove('foo', 'bar', 'LEFT', 'RIGHT') + end + end + end + + def test_blpop_raises + assert_raises(Redis::Distributed::CannotDistribute) do + r.blpop(%w[foo bar]) + end + end + + def test_brpop_raises + assert_raises(Redis::Distributed::CannotDistribute) do + r.brpop(%w[foo bar]) + end + end + + def test_brpoplpush_raises + assert_raises(Redis::Distributed::CannotDistribute) do + r.brpoplpush('foo', 'bar') + end + end +end diff --git a/test/distributed/commands_on_hashes_test.rb b/test/distributed/commands_on_hashes_test.rb new file mode 100644 index 000000000..31cd174f2 --- /dev/null +++ b/test/distributed/commands_on_hashes_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "helper" + +class TestDistributedCommandsOnHashes < Minitest::Test + 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_raises(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 new file mode 100644 index 000000000..4d91427f5 --- /dev/null +++ b/test/distributed/commands_on_hyper_log_log_test.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "helper" + +class TestDistributedCommandsOnHyperLogLog < Minitest::Test + include Helper::Distributed + include Lint::HyperLogLog + + def test_pfmerge + assert_raises Redis::Distributed::CannotDistribute do + super + end + end + + def test_pfcount_multiple_keys_diff_nodes + assert_raises Redis::Distributed::CannotDistribute do + r.pfadd 'foo', 's1' + r.pfadd 'bar', 's2' + + assert r.pfcount('res', 'foo', 'bar') + end + end +end diff --git a/test/distributed/commands_on_lists_test.rb b/test/distributed/commands_on_lists_test.rb new file mode 100644 index 000000000..38498ecd4 --- /dev/null +++ b/test/distributed/commands_on_lists_test.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "helper" + +class TestDistributedCommandsOnLists < Minitest::Test + include Helper::Distributed + include Lint::Lists + + def test_lmove + target_version "6.2" do + assert_raises Redis::Distributed::CannotDistribute do + r.lmove('foo', 'bar', 'LEFT', 'RIGHT') + end + end + end + + def test_rpoplpush + assert_raises Redis::Distributed::CannotDistribute do + r.rpoplpush('foo', 'bar') + end + end + + def test_brpoplpush + assert_raises Redis::Distributed::CannotDistribute do + 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 new file mode 100644 index 000000000..dd616cd9f --- /dev/null +++ b/test/distributed/commands_on_sets_test.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "helper" + +class TestDistributedCommandsOnSets < Minitest::Test + include Helper::Distributed + include Lint::Sets + + def test_smove + assert_raises Redis::Distributed::CannotDistribute do + r.sadd 'foo', 's1' + r.sadd 'bar', 's2' + + r.smove('foo', 'bar', 's1') + end + end + + def test_sinter + assert_raises Redis::Distributed::CannotDistribute do + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + + r.sinter('foo', 'bar') + end + end + + def test_sinterstore + assert_raises Redis::Distributed::CannotDistribute do + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + + r.sinterstore('baz', 'foo', 'bar') + end + end + + def test_sunion + assert_raises Redis::Distributed::CannotDistribute do + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + r.sadd 'bar', 's3' + + r.sunion('foo', 'bar') + end + end + + def test_sunionstore + assert_raises Redis::Distributed::CannotDistribute do + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + r.sadd 'bar', 's3' + + r.sunionstore('baz', 'foo', 'bar') + end + end + + def test_sdiff + assert_raises Redis::Distributed::CannotDistribute do + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + r.sadd 'bar', 's3' + + r.sdiff('foo', 'bar') + end + end + + def test_sdiffstore + assert_raises Redis::Distributed::CannotDistribute do + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + r.sadd 'bar', 's3' + + r.sdiffstore('baz', 'foo', 'bar') + end + end + + def test_sscan + r.sadd 'foo', 's1' + r.sadd 'foo', 's2' + r.sadd 'bar', 's2' + r.sadd 'bar', 's3' + + cursor, vals = r.sscan 'foo', 0 + assert_equal '0', cursor + assert_equal %w[s1 s2], vals.sort + end + + def test_sscan_each + 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 + end +end diff --git a/test/distributed/commands_on_sorted_sets_test.rb b/test/distributed/commands_on_sorted_sets_test.rb new file mode 100644 index 000000000..d80651dc1 --- /dev/null +++ b/test/distributed/commands_on_sorted_sets_test.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "helper" + +class TestDistributedCommandsOnSortedSets < Minitest::Test + include Helper::Distributed + include Lint::SortedSets + + def test_zrangestore + assert_raises(Redis::Distributed::CannotDistribute) { super } + end + + def test_zinter + assert_raises(Redis::Distributed::CannotDistribute) { super } + end + + def test_zinter_with_aggregate + assert_raises(Redis::Distributed::CannotDistribute) { super } + end + + def test_zinter_with_weights + assert_raises(Redis::Distributed::CannotDistribute) { super } + end + + def test_zinterstore + assert_raises(Redis::Distributed::CannotDistribute) { super } + end + + def test_zinterstore_with_aggregate + assert_raises(Redis::Distributed::CannotDistribute) { super } + end + + def test_zinterstore_with_weights + assert_raises(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_zunion + assert_raises(Redis::Distributed::CannotDistribute) { super } + end + + def test_zunion_with_aggregate + assert_raises(Redis::Distributed::CannotDistribute) { super } + end + + def test_zunion_with_weights + assert_raises(Redis::Distributed::CannotDistribute) { super } + end + + def test_zunionstore + assert_raises(Redis::Distributed::CannotDistribute) { super } + end + + def test_zunionstore_with_aggregate + assert_raises(Redis::Distributed::CannotDistribute) { super } + end + + def test_zunionstore_with_weights + assert_raises(Redis::Distributed::CannotDistribute) { super } + end + + def test_zdiff + assert_raises(Redis::Distributed::CannotDistribute) { super } + end + + def test_zdiffstore + assert_raises(Redis::Distributed::CannotDistribute) { super } + end +end diff --git a/test/distributed/commands_on_strings_test.rb b/test/distributed/commands_on_strings_test.rb new file mode 100644 index 000000000..85e439a0c --- /dev/null +++ b/test/distributed/commands_on_strings_test.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "helper" + +class TestDistributedCommandsOnStrings < Minitest::Test + include Helper::Distributed + 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") + 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_nil response["baz"] + end + + def test_mset + assert_raises Redis::Distributed::CannotDistribute do + r.mset(:foo, "s1", :bar, "s2") + end + end + + def test_mset_mapped + assert_raises Redis::Distributed::CannotDistribute do + r.mapped_mset(foo: "s1", bar: "s2") + end + end + + def test_msetnx + assert_raises Redis::Distributed::CannotDistribute do + r.set("foo", "s1") + r.msetnx(:foo, "s2", :bar, "s3") + end + end + + def test_msetnx_mapped + assert_raises Redis::Distributed::CannotDistribute do + r.set("foo", "s1") + r.mapped_msetnx(foo: "s2", bar: "s3") + end + end + + def test_bitop + assert_raises Redis::Distributed::CannotDistribute do + r.set("foo", "a") + r.set("bar", "b") + + r.bitop(:and, "foo&bar", "foo", "bar") + end + end + + def test_mapped_mget_in_a_pipeline_returns_hash + assert_raises Redis::Distributed::CannotDistribute do + super + end + end + + def test_bitfield + # Not implemented yet + end +end diff --git a/test/distributed_commands_on_value_types_test.rb b/test/distributed/commands_on_value_types_test.rb similarity index 53% rename from test/distributed_commands_on_value_types_test.rb rename to test/distributed/commands_on_value_types_test.rb index 0be9ce298..876641b74 100644 --- a/test/distributed_commands_on_value_types_test.rb +++ b/test/distributed/commands_on_value_types_test.rb @@ -1,10 +1,8 @@ -# encoding: UTF-8 +# frozen_string_literal: true -require File.expand_path("helper", File.dirname(__FILE__)) -require "lint/value_types" - -class TestDistributedCommandsOnValueTypes < Test::Unit::TestCase +require "helper" +class TestDistributedCommandsOnValueTypes < Minitest::Test include Helper::Distributed include Lint::ValueTypes @@ -40,30 +38,62 @@ def test_del_with_array_argument assert_equal [], r.keys("*").sort end + def test_unlink + r.set "foo", "s1" + r.set "bar", "s2" + r.set "baz", "s3" + + assert_equal ["bar", "baz", "foo"], r.keys("*").sort + + assert_equal 1, r.unlink("foo") + + assert_equal ["bar", "baz"], r.keys("*").sort + + assert_equal 2, r.unlink("bar", "baz") + + assert_equal [], r.keys("*").sort + end + + def test_unlink_with_array_argument + r.set "foo", "s1" + r.set "bar", "s2" + r.set "baz", "s3" + + assert_equal ["bar", "baz", "foo"], r.keys("*").sort + + assert_equal 1, r.unlink(["foo"]) + + assert_equal ["bar", "baz"], r.keys("*").sort + + assert_equal 2, r.unlink(["bar", "baz"]) + + assert_equal [], r.keys("*").sort + end + def test_randomkey - assert_raise Redis::Distributed::CannotDistribute do + assert_raises Redis::Distributed::CannotDistribute do r.randomkey end end def test_rename - assert_raise Redis::Distributed::CannotDistribute do + assert_raises Redis::Distributed::CannotDistribute do r.set("foo", "s1") r.rename "foo", "bar" end assert_equal "s1", r.get("foo") - assert_equal nil, r.get("bar") + assert_nil r.get("bar") end def test_renamenx - assert_raise Redis::Distributed::CannotDistribute do + assert_raises Redis::Distributed::CannotDistribute do r.set("foo", "s1") r.rename "foo", "bar" end assert_equal "s1", r.get("foo") - assert_equal nil , r.get("bar") + assert_nil r.get("bar") end def test_dbsize @@ -88,8 +118,16 @@ def test_flushdb def test_migrate r.set("foo", "s1") - assert_raise Redis::Distributed::CannotDistribute do + assert_raises Redis::Distributed::CannotDistribute do r.migrate("foo", {}) end end + + def test_copy + r.set("foo", "s1") + + assert_raises Redis::Distributed::CannotDistribute do + r.copy("foo", "bar") + end + end end diff --git a/test/distributed_commands_requiring_clustering_test.rb b/test/distributed/commands_requiring_clustering_test.rb similarity index 66% rename from test/distributed_commands_requiring_clustering_test.rb rename to test/distributed/commands_requiring_clustering_test.rb index da8063c56..db9002523 100644 --- a/test/distributed_commands_requiring_clustering_test.rb +++ b/test/distributed/commands_requiring_clustering_test.rb @@ -1,9 +1,8 @@ -# encoding: UTF-8 +# frozen_string_literal: true -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestDistributedCommandsRequiringClustering < Test::Unit::TestCase +require "helper" +class TestDistributedCommandsRequiringClustering < Minitest::Test include Helper::Distributed def test_rename @@ -11,7 +10,7 @@ def test_rename r.rename "{qux}foo", "{qux}bar" assert_equal "s1", r.get("{qux}bar") - assert_equal nil, r.get("{qux}foo") + assert_nil r.get("{qux}foo") end def test_renamenx @@ -24,11 +23,24 @@ def test_renamenx assert_equal "s2", r.get("{qux}bar") end + def test_lmove + target_version "6.2" do + r.rpush("{qux}foo", "s1") + r.rpush("{qux}foo", "s2") + r.rpush("{qux}bar", "s3") + r.rpush("{qux}bar", "s4") + + assert_equal "s1", r.lmove("{qux}foo", "{qux}bar", "LEFT", "RIGHT") + assert_equal ["s2"], r.lrange("{qux}foo", 0, -1) + assert_equal ["s3", "s4", "s1"], r.lrange("{qux}bar", 0, -1) + end + end + def test_brpoplpush r.rpush "{qux}foo", "s1" r.rpush "{qux}foo", "s2" - assert_equal "s2", r.brpoplpush("{qux}foo", "{qux}bar", :timeout => 1) + assert_equal "s2", r.brpoplpush("{qux}foo", "{qux}bar", timeout: 1) assert_equal ["s2"], r.lrange("{qux}bar", 0, -1) end @@ -116,8 +128,8 @@ def test_sort r.rpush("{qux}bar", "1") r.rpush("{qux}bar", "2") - assert_equal ["s1"], r.sort("{qux}bar", :get => "{qux}foo:*", :limit => [0, 1]) - assert_equal ["s2"], r.sort("{qux}bar", :get => "{qux}foo:*", :limit => [0, 1], :order => "desc alpha") + assert_equal ["s1"], r.sort("{qux}bar", get: "{qux}foo:*", limit: [0, 1]) + assert_equal ["s2"], r.sort("{qux}bar", get: "{qux}foo:*", limit: [0, 1], order: "desc alpha") end def test_sort_with_an_array_of_gets @@ -130,9 +142,9 @@ def test_sort_with_an_array_of_gets r.rpush("{qux}bar", "1") r.rpush("{qux}bar", "2") - assert_equal [["s1a", "s1b"]], r.sort("{qux}bar", :get => ["{qux}foo:*:a", "{qux}foo:*:b"], :limit => [0, 1]) - assert_equal [["s2a", "s2b"]], r.sort("{qux}bar", :get => ["{qux}foo:*:a", "{qux}foo:*:b"], :limit => [0, 1], :order => "desc alpha") - assert_equal [["s1a", "s1b"], ["s2a", "s2b"]], r.sort("{qux}bar", :get => ["{qux}foo:*:a", "{qux}foo:*:b"]) + assert_equal [["s1a", "s1b"]], r.sort("{qux}bar", get: ["{qux}foo:*:a", "{qux}foo:*:b"], limit: [0, 1]) + assert_equal [["s2a", "s2b"]], r.sort("{qux}bar", get: ["{qux}foo:*:a", "{qux}foo:*:b"], limit: [0, 1], order: "desc alpha") + assert_equal [["s1a", "s1b"], ["s2a", "s2b"]], r.sort("{qux}bar", get: ["{qux}foo:*:a", "{qux}foo:*:b"]) end def test_sort_with_store @@ -142,23 +154,21 @@ def test_sort_with_store r.rpush("{qux}bar", "1") r.rpush("{qux}bar", "2") - r.sort("{qux}bar", :get => "{qux}foo:*", :store => "{qux}baz") + r.sort("{qux}bar", get: "{qux}foo:*", store: "{qux}baz") assert_equal ["s1", "s2"], r.lrange("{qux}baz", 0, -1) end def test_bitop - target_version "2.5.10" do - r.set("{qux}foo", "a") - r.set("{qux}bar", "b") - - r.bitop(:and, "{qux}foo&bar", "{qux}foo", "{qux}bar") - assert_equal "\x60", r.get("{qux}foo&bar") - r.bitop(:or, "{qux}foo|bar", "{qux}foo", "{qux}bar") - assert_equal "\x63", r.get("{qux}foo|bar") - r.bitop(:xor, "{qux}foo^bar", "{qux}foo", "{qux}bar") - assert_equal "\x03", r.get("{qux}foo^bar") - r.bitop(:not, "{qux}~foo", "{qux}foo") - assert_equal "\x9E", r.get("{qux}~foo") - end + r.set("{qux}foo", "a") + r.set("{qux}bar", "b") + + r.bitop(:and, "{qux}foo&bar", "{qux}foo", "{qux}bar") + assert_equal "\x60", r.get("{qux}foo&bar") + r.bitop(:or, "{qux}foo|bar", "{qux}foo", "{qux}bar") + assert_equal "\x63", r.get("{qux}foo|bar") + r.bitop(:xor, "{qux}foo^bar", "{qux}foo", "{qux}bar") + assert_equal "\x03", r.get("{qux}foo^bar") + r.bitop(:not, "{qux}~foo", "{qux}foo") + assert_equal "\x9E".b, r.get("{qux}~foo") end end diff --git a/test/distributed_connection_handling_test.rb b/test/distributed/connection_handling_test.rb similarity index 54% rename from test/distributed_connection_handling_test.rb rename to test/distributed/connection_handling_test.rb index 330886090..0e5a518f2 100644 --- a/test/distributed_connection_handling_test.rb +++ b/test/distributed/connection_handling_test.rb @@ -1,9 +1,8 @@ -# encoding: UTF-8 +# frozen_string_literal: true -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestDistributedConnectionHandling < Test::Unit::TestCase +require "helper" +class TestDistributedConnectionHandling < Minitest::Test include Helper::Distributed def test_ping @@ -14,7 +13,7 @@ def test_select r.set "foo", "bar" r.select 14 - assert_equal nil, r.get("foo") + assert_nil r.get("foo") r.select 15 diff --git a/test/distributed/distributed_test.rb b/test/distributed/distributed_test.rb new file mode 100644 index 000000000..8a5878498 --- /dev/null +++ b/test/distributed/distributed_test.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "helper" + +class TestDistributed < Minitest::Test + include Helper::Distributed + + def test_handle_multiple_servers + @r = Redis::Distributed.new ["redis://127.0.0.1:#{PORT}/15", *NODES] + + 100.times do |idx| + @r.set(idx.to_s, "foo#{idx}") + end + + 100.times do |idx| + assert_equal "foo#{idx}", @r.get(idx.to_s) + end + + assert_equal "0", @r.keys("*").min + assert_equal "string", @r.type("1") + end + + def test_add_nodes + @r = Redis::Distributed.new NODES, timeout: 10 + + assert_equal "127.0.0.1", @r.nodes[0]._client.host + assert_equal PORT, @r.nodes[0]._client.port + assert_equal 15, @r.nodes[0]._client.db + assert_equal 10, @r.nodes[0]._client.timeout + + @r.add_node("redis://127.0.0.1:6380/14") + + assert_equal "127.0.0.1", @r.nodes[1]._client.host + assert_equal 6380, @r.nodes[1]._client.port + assert_equal 14, @r.nodes[1]._client.db + assert_equal 10, @r.nodes[1]._client.timeout + end + + def test_pipelining_commands_cannot_be_distributed + assert_raises Redis::Distributed::CannotDistribute do + r.pipelined do + r.lpush "foo", "s1" + r.lpush "foo", "s2" + end + end + end + + def test_unknown_commands_does_not_work_by_default + assert_raises NoMethodError do + r.not_yet_implemented_command + end + end +end diff --git a/test/distributed/internals_test.rb b/test/distributed/internals_test.rb new file mode 100644 index 000000000..454828021 --- /dev/null +++ b/test/distributed/internals_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "helper" + +class TestDistributedInternals < Minitest::Test + include Helper::Distributed + + def test_provides_a_meaningful_inspect + nodes = ["redis://127.0.0.1:#{PORT}/15", *NODES] + redis = Redis::Distributed.new nodes + + assert_equal "#", redis.inspect + end + + def test_default_as_urls + nodes = ["redis://127.0.0.1:#{PORT}/15", *NODES] + redis = Redis::Distributed.new nodes + assert_equal(["redis://127.0.0.1:#{PORT}/15", *NODES], redis.nodes.map { |node| node._client.server_url }) + end + + def test_default_as_config_hashes + nodes = [OPTIONS.merge(host: '127.0.0.1'), OPTIONS.merge(host: 'somehost', port: PORT.next)] + redis = Redis::Distributed.new nodes + assert_equal(["redis://127.0.0.1:#{PORT}/15", "redis://somehost:#{PORT.next}/15"], redis.nodes.map { |node| node._client.server_url }) + end + + def test_as_mix_and_match + nodes = ["redis://127.0.0.1:7389/15", OPTIONS.merge(host: 'somehost'), OPTIONS.merge(host: 'somehost', port: PORT.next)] + redis = Redis::Distributed.new nodes + assert_equal(["redis://127.0.0.1:7389/15", "redis://somehost:#{PORT}/15", "redis://somehost:#{PORT.next}/15"], redis.nodes.map { |node| node._client.server_url }) + end + + def test_override_id + nodes = [OPTIONS.merge(host: '127.0.0.1', id: "test"), OPTIONS.merge(host: 'somehost', port: PORT.next, id: "test1")] + redis = Redis::Distributed.new nodes + assert_equal redis.nodes.first._client.id, "test" + assert_equal redis.nodes.last._client.id, "test1" + assert_equal "#", redis.inspect + end + + def test_can_be_duped_to_create_a_new_connection + redis = Redis::Distributed.new(NODES) + + clients = redis.info[0]["connected_clients"].to_i + + r2 = redis.dup + r2.ping + + assert_equal clients + 1, redis.info[0]["connected_clients"].to_i + end + + def test_keeps_options_after_dup + r1 = Redis::Distributed.new(NODES, tag: /^(\w+):/) + + assert_raises(Redis::Distributed::CannotDistribute) do + r1.sinter("foo", "bar") + end + + assert_equal [], r1.sinter("baz:foo", "baz:bar") + + r2 = r1.dup + + assert_raises(Redis::Distributed::CannotDistribute) do + r2.sinter("foo", "bar") + end + + assert_equal [], r2.sinter("baz:foo", "baz:bar") + end +end diff --git a/test/distributed_key_tags_test.rb b/test/distributed/key_tags_test.rb similarity index 54% rename from test/distributed_key_tags_test.rb rename to test/distributed/key_tags_test.rb index 12b6d688d..415779ad6 100644 --- a/test/distributed_key_tags_test.rb +++ b/test/distributed/key_tags_test.rb @@ -1,16 +1,15 @@ -# encoding: UTF-8 +# frozen_string_literal: true -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestDistributedKeyTags < Test::Unit::TestCase +require "helper" +class TestDistributedKeyTags < Minitest::Test include Helper include Helper::Distributed def test_hashes_consistently - r1 = Redis::Distributed.new ["redis://localhost:#{PORT}/15", *NODES] - r2 = Redis::Distributed.new ["redis://localhost:#{PORT}/15", *NODES] - r3 = Redis::Distributed.new ["redis://localhost:#{PORT}/15", *NODES] + r1 = Redis::Distributed.new ["redis://127.0.0.1:#{PORT}/15", *NODES] + r2 = Redis::Distributed.new ["redis://127.0.0.1:#{PORT}/15", *NODES] + r3 = Redis::Distributed.new ["redis://127.0.0.1:#{PORT}/15", *NODES] assert_equal r1.node_for("foo").id, r2.node_for("foo").id assert_equal r1.node_for("foo").id, r3.node_for("foo").id @@ -25,21 +24,21 @@ def test_allows_clustering_of_keys r.set "{foo}users:#{i}", i end - assert_equal [0, 100], r.nodes.map { |node| node.keys.size } + assert_equal([0, 100], r.nodes.map { |node| node.keys.size }) end def test_distributes_keys_if_no_clustering_is_used - r.add_node("redis://127.0.0.1:#{PORT}/14") + r.add_node("redis://127.0.0.1:#{PORT}/13") r.flushdb r.set "users:1", 1 r.set "users:4", 4 - assert_equal [1, 1], r.nodes.map { |node| node.keys.size } + assert_equal([1, 1], r.nodes.map { |node| node.keys.size }) end def test_allows_passing_a_custom_tag_extractor - r = Redis::Distributed.new(NODES, :tag => /^(.+?):/) + r = Redis::Distributed.new(NODES, tag: /^(.+?):/) r.add_node("redis://127.0.0.1:#{PORT}/14") r.flushdb @@ -47,6 +46,6 @@ def test_allows_passing_a_custom_tag_extractor r.set "foo:users:#{i}", i end - assert_equal [0, 100], r.nodes.map { |node| node.keys.size } + assert_equal([0, 100], r.nodes.map { |node| node.keys.size }) end end diff --git a/test/distributed/persistence_control_commands_test.rb b/test/distributed/persistence_control_commands_test.rb new file mode 100644 index 000000000..67eff9b72 --- /dev/null +++ b/test/distributed/persistence_control_commands_test.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "helper" + +class TestDistributedPersistenceControlCommands < Minitest::Test + include Helper::Distributed + + def test_save + redis_mock(save: -> { "+SAVE" }) do |redis| + assert_equal ["SAVE"], redis.save + end + end + + def test_bgsave + redis_mock(bgsave: -> { "+BGSAVE" }) do |redis| + assert_equal ["BGSAVE"], redis.bgsave + end + end + + def test_lastsave + redis_mock(lastsave: -> { "+LASTSAVE" }) do |redis| + assert_equal ["LASTSAVE"], redis.lastsave + end + end +end diff --git a/test/distributed_publish_subscribe_test.rb b/test/distributed/publish_subscribe_test.rb similarity index 59% rename from test/distributed_publish_subscribe_test.rb rename to test/distributed/publish_subscribe_test.rb index df36506d9..34f8d69b6 100644 --- a/test/distributed_publish_subscribe_test.rb +++ b/test/distributed/publish_subscribe_test.rb @@ -1,18 +1,17 @@ -# encoding: UTF-8 +# frozen_string_literal: true -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestDistributedPublishSubscribe < Test::Unit::TestCase +require "helper" +class TestDistributedPublishSubscribe < Minitest::Test include Helper::Distributed def test_subscribe_and_unsubscribe - assert_raise Redis::Distributed::CannotDistribute do - r.subscribe("foo", "bar") { } + assert_raises Redis::Distributed::CannotDistribute do + r.subscribe("foo", "bar") {} end - assert_raise Redis::Distributed::CannotDistribute do - r.subscribe("{qux}foo", "bar") { } + assert_raises Redis::Distributed::CannotDistribute do + r.subscribe("{qux}foo", "bar") {} end end @@ -20,21 +19,21 @@ def test_subscribe_and_unsubscribe_with_tags @subscribed = false @unsubscribed = false - wire = Wire.new do + thread = Thread.new do r.subscribe("foo") do |on| - on.subscribe do |channel, total| + on.subscribe do |_channel, total| @subscribed = true @t1 = total end - on.message do |channel, message| + on.message do |_channel, message| if message == "s1" r.unsubscribe @message = message end end - on.unsubscribe do |channel, total| + on.unsubscribe do |_channel, total| @unsubscribed = true @t2 = total end @@ -42,11 +41,11 @@ def test_subscribe_and_unsubscribe_with_tags end # Wait until the subscription is active before publishing - Wire.pass while !@subscribed + Thread.pass until @subscribed Redis::Distributed.new(NODES).publish("foo", "s1") - wire.join + thread.join assert @subscribed assert_equal 1, @t1 @@ -58,9 +57,9 @@ def test_subscribe_and_unsubscribe_with_tags def test_subscribe_within_subscribe @channels = [] - wire = Wire.new do + thread = Thread.new do r.subscribe("foo") do |on| - on.subscribe do |channel, total| + on.subscribe do |channel, _total| @channels << channel r.subscribe("bar") if channel == "foo" @@ -69,23 +68,22 @@ def test_subscribe_within_subscribe end end - wire.join + thread.join assert_equal ["foo", "bar"], @channels end def test_other_commands_within_a_subscribe - assert_raise Redis::CommandError do - r.subscribe("foo") do |on| - on.subscribe do |channel, total| - r.set("bar", "s2") - end + r.subscribe("foo") do |on| + on.subscribe do |_channel, _total| + r.set("bar", "s2") + r.unsubscribe("foo") end end end def test_subscribe_without_a_block - assert_raise LocalJumpError do + assert_raises Redis::SubscriptionError do r.subscribe("foo") end end diff --git a/test/distributed/remote_server_control_commands_test.rb b/test/distributed/remote_server_control_commands_test.rb new file mode 100644 index 000000000..2d2516d01 --- /dev/null +++ b/test/distributed/remote_server_control_commands_test.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "helper" + +class TestDistributedRemoteServerControlCommands < Minitest::Test + include Helper::Distributed + + def test_info + keys = [ + "redis_version", + "uptime_in_seconds", + "uptime_in_days", + "connected_clients", + "used_memory", + "total_connections_received", + "total_commands_processed" + ] + + infos = r.info + + infos.each do |info| + keys.each do |k| + msg = "expected #info to include #{k}" + assert info.keys.include?(k), msg + end + end + end + + def test_info_commandstats + r.nodes.each do |n| + n.config(:resetstat) + n.get("foo") + n.get("bar") + end + + r.info(:commandstats).each do |info| + assert_equal '2', info['get']['calls'] + end + end + + def test_monitor + r.monitor + rescue Exception => ex + ensure + assert ex.is_a?(NotImplementedError) + end + + def test_echo + assert_equal ["foo bar baz\n"], r.echo("foo bar baz\n") + end + + def test_time + # Test that the difference between the time that Ruby reports and the time + # that Redis reports is minimal (prevents the test from being racy). + r.time.each do |rv| + redis_usec = rv[0] * 1_000_000 + rv[1] + ruby_usec = Integer(Time.now.to_f * 1_000_000) + + assert((ruby_usec - redis_usec).abs < 500_000) + end + end +end diff --git a/test/distributed/scripting_test.rb b/test/distributed/scripting_test.rb new file mode 100644 index 000000000..eda11d78a --- /dev/null +++ b/test/distributed/scripting_test.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "helper" + +class TestDistributedScripting < Minitest::Test + include Helper::Distributed + + def to_sha(script) + r.script(:load, script).first + end + + def test_script_exists + a = to_sha("return 1") + b = a.succ + + assert_equal [true], r.script(:exists, a) + assert_equal [false], r.script(:exists, b) + assert_equal [[true]], r.script(:exists, [a]) + assert_equal [[false]], r.script(:exists, [b]) + assert_equal [[true, false]], r.script(:exists, [a, b]) + end + + def test_script_flush + sha = to_sha("return 1") + assert r.script(:exists, sha).first + assert_equal ["OK"], r.script(:flush) + assert !r.script(:exists, sha).first + end + + def test_script_kill + redis_mock(script: ->(arg) { "+#{arg.upcase}" }) do |redis| + assert_equal ["KILL"], redis.script(:kill) + end + end + + def test_eval + assert_raises(Redis::Distributed::CannotDistribute) do + r.eval("return #KEYS") + end + + assert_raises(Redis::Distributed::CannotDistribute) do + r.eval("return KEYS", ["k1", "k2"]) + end + + assert_equal ["k1"], r.eval("return KEYS", ["k1"]) + assert_equal ["a1", "a2"], r.eval("return ARGV", ["k1"], ["a1", "a2"]) + end + + def test_eval_with_options_hash + assert_raises(Redis::Distributed::CannotDistribute) do + r.eval("return #KEYS", {}) + end + + assert_raises(Redis::Distributed::CannotDistribute) do + r.eval("return KEYS", { keys: ["k1", "k2"] }) + end + + assert_equal ["k1"], r.eval("return KEYS", { keys: ["k1"] }) + assert_equal ["a1", "a2"], r.eval("return ARGV", { keys: ["k1"], argv: ["a1", "a2"] }) + end + + def test_evalsha + assert_raises(Redis::Distributed::CannotDistribute) do + r.evalsha(to_sha("return #KEYS")) + end + + assert_raises(Redis::Distributed::CannotDistribute) do + r.evalsha(to_sha("return KEYS"), ["k1", "k2"]) + end + + assert_equal ["k1"], r.evalsha(to_sha("return KEYS"), ["k1"]) + assert_equal ["a1", "a2"], r.evalsha(to_sha("return ARGV"), ["k1"], ["a1", "a2"]) + end + + def test_evalsha_with_options_hash + assert_raises(Redis::Distributed::CannotDistribute) do + r.evalsha(to_sha("return #KEYS"), {}) + end + + assert_raises(Redis::Distributed::CannotDistribute) do + r.evalsha(to_sha("return KEYS"), { keys: ["k1", "k2"] }) + end + + assert_equal ["k1"], r.evalsha(to_sha("return KEYS"), { keys: ["k1"] }) + assert_equal ["a1", "a2"], r.evalsha(to_sha("return ARGV"), { keys: ["k1"], argv: ["a1", "a2"] }) + end +end diff --git a/test/distributed/sorting_test.rb b/test/distributed/sorting_test.rb new file mode 100644 index 000000000..e3507cd71 --- /dev/null +++ b/test/distributed/sorting_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "helper" + +class TestDistributedSorting < Minitest::Test + include Helper::Distributed + + def test_sort + assert_raises(Redis::Distributed::CannotDistribute) do + r.set("foo:1", "s1") + r.set("foo:2", "s2") + + r.rpush("bar", "1") + r.rpush("bar", "2") + + r.sort("bar", get: "foo:*", limit: [0, 1]) + end + end +end diff --git a/test/distributed/transactions_test.rb b/test/distributed/transactions_test.rb new file mode 100644 index 000000000..6be310134 --- /dev/null +++ b/test/distributed/transactions_test.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "helper" + +class TestDistributedTransactions < Minitest::Test + include Helper::Distributed + + def test_multi_discard_without_watch + @foo = nil + + assert_raises Redis::Distributed::CannotDistribute do + r.multi { @foo = 1 } + end + + assert_nil @foo + + assert_raises Redis::Distributed::CannotDistribute do + r.discard + end + end + + def test_watch_unwatch_without_clustering + assert_raises Redis::Distributed::CannotDistribute do + r.watch("foo", "bar") + end + + r.watch("{qux}foo", "{qux}bar") do + assert_raises Redis::Distributed::CannotDistribute do + r.get("{baz}foo") + end + + r.unwatch + end + + assert_raises Redis::Distributed::CannotDistribute do + r.unwatch + end + end + + def test_watch_with_exception + assert_raises StandardError do + r.watch("{qux}foo", "{qux}bar") do + raise StandardError, "woops" + end + end + + assert_equal "OK", r.set("{other}baz", 1) + end + + def test_watch_unwatch + assert_equal "OK", r.watch("{qux}foo", "{qux}bar") + assert_equal "OK", r.unwatch + end + + def test_watch_multi_with_block + r.set("{qux}baz", 1) + + r.watch("{qux}foo", "{qux}bar", "{qux}baz") do + assert_equal '1', r.get("{qux}baz") + + result = r.multi do |transaction| + transaction.incrby("{qux}foo", 3) + transaction.incrby("{qux}bar", 6) + transaction.incrby("{qux}baz", 9) + end + + assert_equal [3, 6, 10], result + end + end +end diff --git a/test/distributed_blocking_commands_test.rb b/test/distributed_blocking_commands_test.rb deleted file mode 100644 index b28cf2743..000000000 --- a/test/distributed_blocking_commands_test.rb +++ /dev/null @@ -1,46 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) -require "lint/blocking_commands" - -class TestDistributedBlockingCommands < Test::Unit::TestCase - - include Helper::Distributed - include Lint::BlockingCommands - - def test_blpop_raises - assert_raises(Redis::Distributed::CannotDistribute) do - r.blpop(["foo", "bar"]) - end - end - - def test_blpop_raises_with_old_prototype - assert_raises(Redis::Distributed::CannotDistribute) do - r.blpop("foo", "bar", 0) - end - end - - def test_brpop_raises - assert_raises(Redis::Distributed::CannotDistribute) do - r.brpop(["foo", "bar"]) - end - end - - def test_brpop_raises_with_old_prototype - assert_raises(Redis::Distributed::CannotDistribute) do - r.brpop("foo", "bar", 0) - end - end - - def test_brpoplpush_raises - assert_raises(Redis::Distributed::CannotDistribute) do - r.brpoplpush("foo", "bar") - end - end - - def test_brpoplpush_raises_with_old_prototype - assert_raises(Redis::Distributed::CannotDistribute) do - r.brpoplpush("foo", "bar", 0) - end - end -end diff --git a/test/distributed_commands_on_hashes_test.rb b/test/distributed_commands_on_hashes_test.rb deleted file mode 100644 index ffd14f519..000000000 --- a/test/distributed_commands_on_hashes_test.rb +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) -require "lint/hashes" - -class TestDistributedCommandsOnHashes < Test::Unit::TestCase - - include Helper::Distributed - include Lint::Hashes -end diff --git a/test/distributed_commands_on_hyper_log_log_test.rb b/test/distributed_commands_on_hyper_log_log_test.rb deleted file mode 100644 index c118b9574..000000000 --- a/test/distributed_commands_on_hyper_log_log_test.rb +++ /dev/null @@ -1,33 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) -require "lint/hyper_log_log" - -class TestDistributedCommandsOnHyperLogLog < Test::Unit::TestCase - - include Helper::Distributed - include Lint::HyperLogLog - - def test_pfmerge - 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") - end - end - end - - def test_pfcount_multiple_keys_diff_nodes - target_version "2.8.9" do - assert_raise Redis::Distributed::CannotDistribute do - r.pfadd "foo", "s1" - r.pfadd "bar", "s2" - - 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 deleted file mode 100644 index d22f3be80..000000000 --- a/test/distributed_commands_on_lists_test.rb +++ /dev/null @@ -1,22 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) -require "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") - end - end - - def test_brpoplpush - assert_raise Redis::Distributed::CannotDistribute do - 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 deleted file mode 100644 index 43a070c8c..000000000 --- a/test/distributed_commands_on_sets_test.rb +++ /dev/null @@ -1,83 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) -require "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.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.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.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.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.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.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.sdiffstore("baz", "foo", "bar") - end - end -end diff --git a/test/distributed_commands_on_sorted_sets_test.rb b/test/distributed_commands_on_sorted_sets_test.rb deleted file mode 100644 index a4150b834..000000000 --- a/test/distributed_commands_on_sorted_sets_test.rb +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) -require "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" - - assert_equal 2, r.zcount("foo", 2, 3) - end -end diff --git a/test/distributed_commands_on_strings_test.rb b/test/distributed_commands_on_strings_test.rb deleted file mode 100644 index ad83c12e5..000000000 --- a/test/distributed_commands_on_strings_test.rb +++ /dev/null @@ -1,59 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) -require "lint/strings" - -class TestDistributedCommandsOnStrings < Test::Unit::TestCase - - include Helper::Distributed - include Lint::Strings - - def test_mget - assert_raise Redis::Distributed::CannotDistribute do - r.mget("foo", "bar") - end - end - - def test_mget_mapped - assert_raise Redis::Distributed::CannotDistribute do - r.mapped_mget("foo", "bar") - end - end - - def test_mset - assert_raise Redis::Distributed::CannotDistribute do - r.mset(:foo, "s1", :bar, "s2") - end - end - - def test_mset_mapped - assert_raise Redis::Distributed::CannotDistribute do - r.mapped_mset(:foo => "s1", :bar => "s2") - end - end - - def test_msetnx - assert_raise Redis::Distributed::CannotDistribute do - r.set("foo", "s1") - r.msetnx(:foo, "s2", :bar, "s3") - end - end - - def test_msetnx_mapped - assert_raise Redis::Distributed::CannotDistribute do - r.set("foo", "s1") - r.mapped_msetnx(:foo => "s2", :bar => "s3") - end - end - - def test_bitop - target_version "2.5.10" do - assert_raise Redis::Distributed::CannotDistribute do - r.set("foo", "a") - r.set("bar", "b") - - r.bitop(:and, "foo&bar", "foo", "bar") - end - end - end -end diff --git a/test/distributed_internals_test.rb b/test/distributed_internals_test.rb deleted file mode 100644 index 887881f1d..000000000 --- a/test/distributed_internals_test.rb +++ /dev/null @@ -1,79 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestDistributedInternals < Test::Unit::TestCase - - include Helper::Distributed - - def test_provides_a_meaningful_inspect - nodes = ["redis://localhost:#{PORT}/15", *NODES] - redis = Redis::Distributed.new nodes - - assert_equal "#", redis.inspect - end - - def test_default_as_urls - nodes = ["redis://localhost:#{PORT}/15", *NODES] - redis = Redis::Distributed.new nodes - assert_equal ["redis://localhost:#{PORT}/15", *NODES], redis.nodes.map { |node| node.client.id} - end - - def test_default_as_config_hashes - nodes = [OPTIONS.merge(:host => '127.0.0.1'), OPTIONS.merge(:host => 'somehost', :port => PORT.next)] - redis = Redis::Distributed.new nodes - assert_equal ["redis://127.0.0.1:#{PORT}/15","redis://somehost:#{PORT.next}/15"], redis.nodes.map { |node| node.client.id } - end - - def test_as_mix_and_match - nodes = ["redis://127.0.0.1:7389/15", OPTIONS.merge(:host => 'somehost'), OPTIONS.merge(:host => 'somehost', :port => PORT.next)] - redis = Redis::Distributed.new nodes - assert_equal ["redis://127.0.0.1:7389/15", "redis://somehost:#{PORT}/15", "redis://somehost:#{PORT.next}/15"], redis.nodes.map { |node| node.client.id } - end - - def test_override_id - nodes = [OPTIONS.merge(:host => '127.0.0.1', :id => "test"), OPTIONS.merge( :host => 'somehost', :port => PORT.next, :id => "test1")] - redis = Redis::Distributed.new nodes - assert_equal redis.nodes.first.client.id, "test" - assert_equal redis.nodes.last.client.id, "test1" - assert_equal "#", redis.inspect - end - - def test_can_be_duped_to_create_a_new_connection - redis = Redis::Distributed.new(NODES) - - clients = redis.info[0]["connected_clients"].to_i - - r2 = redis.dup - r2.ping - - assert_equal clients + 1, redis.info[0]["connected_clients"].to_i - end - - def test_keeps_options_after_dup - r1 = Redis::Distributed.new(NODES, :tag => /^(\w+):/) - - assert_raise(Redis::Distributed::CannotDistribute) do - r1.sinter("foo", "bar") - end - - assert_equal [], r1.sinter("baz:foo", "baz:bar") - - r2 = r1.dup - - assert_raise(Redis::Distributed::CannotDistribute) do - r2.sinter("foo", "bar") - end - - assert_equal [], r2.sinter("baz:foo", "baz:bar") - end - - def test_colliding_node_ids - nodes = ["redis://localhost:#{PORT}/15", "redis://localhost:#{PORT}/15", *NODES] - - assert_raise(RuntimeError) do - Redis::Distributed.new nodes - end - end - -end diff --git a/test/distributed_persistence_control_commands_test.rb b/test/distributed_persistence_control_commands_test.rb deleted file mode 100644 index c24360192..000000000 --- a/test/distributed_persistence_control_commands_test.rb +++ /dev/null @@ -1,26 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestDistributedPersistenceControlCommands < Test::Unit::TestCase - - include Helper::Distributed - - def test_save - redis_mock(:save => lambda { "+SAVE" }) do |redis| - assert_equal ["SAVE"], redis.save - end - end - - def test_bgsave - redis_mock(:bgsave => lambda { "+BGSAVE" }) do |redis| - assert_equal ["BGSAVE"], redis.bgsave - end - end - - def test_lastsave - redis_mock(:lastsave => lambda { "+LASTSAVE" }) do |redis| - assert_equal ["LASTSAVE"], redis.lastsave - end - end -end diff --git a/test/distributed_remote_server_control_commands_test.rb b/test/distributed_remote_server_control_commands_test.rb deleted file mode 100644 index 7799d4fce..000000000 --- a/test/distributed_remote_server_control_commands_test.rb +++ /dev/null @@ -1,66 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestDistributedRemoteServerControlCommands < Test::Unit::TestCase - - include Helper::Distributed - - def test_info - keys = [ - "redis_version", - "uptime_in_seconds", - "uptime_in_days", - "connected_clients", - "used_memory", - "total_connections_received", - "total_commands_processed", - ] - - infos = r.info - - infos.each do |info| - keys.each do |k| - msg = "expected #info to include #{k}" - assert info.keys.include?(k), msg - end - end - end - - def test_info_commandstats - target_version "2.5.7" do - r.nodes.each { |n| n.config(:resetstat) } - r.ping # Executed on every node - - r.info(:commandstats).each do |info| - assert_equal "1", info["ping"]["calls"] - end - end - end - - def test_monitor - begin - r.monitor - rescue Exception => ex - ensure - assert ex.kind_of?(NotImplementedError) - end - end - - def test_echo - assert_equal ["foo bar baz\n"], r.echo("foo bar baz\n") - end - - def test_time - target_version "2.5.4" do - # Test that the difference between the time that Ruby reports and the time - # that Redis reports is minimal (prevents the test from being racy). - r.time.each do |rv| - redis_usec = rv[0] * 1_000_000 + rv[1] - ruby_usec = Integer(Time.now.to_f * 1_000_000) - - assert 500_000 > (ruby_usec - redis_usec).abs - end - end - end -end diff --git a/test/distributed_scripting_test.rb b/test/distributed_scripting_test.rb deleted file mode 100644 index 00bdaa622..000000000 --- a/test/distributed_scripting_test.rb +++ /dev/null @@ -1,102 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestDistributedScripting < Test::Unit::TestCase - - include Helper::Distributed - - def to_sha(script) - r.script(:load, script).first - end - - def test_script_exists - target_version "2.5.9" do # 2.6-rc1 - a = to_sha("return 1") - b = a.succ - - assert_equal [true], r.script(:exists, a) - assert_equal [false], r.script(:exists, b) - assert_equal [[true]], r.script(:exists, [a]) - assert_equal [[false]], r.script(:exists, [b]) - assert_equal [[true, false]], r.script(:exists, [a, b]) - end - end - - def test_script_flush - target_version "2.5.9" do # 2.6-rc1 - sha = to_sha("return 1") - assert r.script(:exists, sha).first - assert_equal ["OK"], r.script(:flush) - assert !r.script(:exists, sha).first - end - end - - def test_script_kill - target_version "2.5.9" do # 2.6-rc1 - redis_mock(:script => lambda { |arg| "+#{arg.upcase}" }) do |redis| - assert_equal ["KILL"], redis.script(:kill) - end - end - end - - def test_eval - target_version "2.5.9" do # 2.6-rc1 - assert_raises(Redis::Distributed::CannotDistribute) do - r.eval("return #KEYS") - end - - assert_raises(Redis::Distributed::CannotDistribute) do - r.eval("return KEYS", ["k1", "k2"]) - end - - assert_equal ["k1"], r.eval("return KEYS", ["k1"]) - assert_equal ["a1", "a2"], r.eval("return ARGV", ["k1"], ["a1", "a2"]) - end - end - - def test_eval_with_options_hash - target_version "2.5.9" do # 2.6-rc1 - assert_raises(Redis::Distributed::CannotDistribute) do - r.eval("return #KEYS", {}) - end - - assert_raises(Redis::Distributed::CannotDistribute) do - r.eval("return KEYS", { :keys => ["k1", "k2"] }) - end - - assert_equal ["k1"], r.eval("return KEYS", { :keys => ["k1"] }) - assert_equal ["a1", "a2"], r.eval("return ARGV", { :keys => ["k1"], :argv => ["a1", "a2"] }) - end - end - - def test_evalsha - target_version "2.5.9" do # 2.6-rc1 - assert_raises(Redis::Distributed::CannotDistribute) do - r.evalsha(to_sha("return #KEYS")) - end - - assert_raises(Redis::Distributed::CannotDistribute) do - r.evalsha(to_sha("return KEYS"), ["k1", "k2"]) - end - - assert_equal ["k1"], r.evalsha(to_sha("return KEYS"), ["k1"]) - assert_equal ["a1", "a2"], r.evalsha(to_sha("return ARGV"), ["k1"], ["a1", "a2"]) - end - end - - def test_evalsha_with_options_hash - target_version "2.5.9" do # 2.6-rc1 - assert_raises(Redis::Distributed::CannotDistribute) do - r.evalsha(to_sha("return #KEYS"), {}) - end - - assert_raises(Redis::Distributed::CannotDistribute) do - r.evalsha(to_sha("return KEYS"), { :keys => ["k1", "k2"] }) - end - - assert_equal ["k1"], r.evalsha(to_sha("return KEYS"), { :keys => ["k1"] }) - assert_equal ["a1", "a2"], r.evalsha(to_sha("return ARGV"), { :keys => ["k1"], :argv => ["a1", "a2"] }) - end - end -end diff --git a/test/distributed_sorting_test.rb b/test/distributed_sorting_test.rb deleted file mode 100644 index 4ae3cf575..000000000 --- a/test/distributed_sorting_test.rb +++ /dev/null @@ -1,20 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestDistributedSorting < Test::Unit::TestCase - - include Helper::Distributed - - def test_sort - assert_raise(Redis::Distributed::CannotDistribute) do - r.set("foo:1", "s1") - r.set("foo:2", "s2") - - r.rpush("bar", "1") - r.rpush("bar", "2") - - r.sort("bar", :get => "foo:*", :limit => [0, 1]) - end - end -end diff --git a/test/distributed_test.rb b/test/distributed_test.rb deleted file mode 100644 index b55287bde..000000000 --- a/test/distributed_test.rb +++ /dev/null @@ -1,58 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestDistributed < Test::Unit::TestCase - - include Helper::Distributed - - def test_handle_multiple_servers - @r = Redis::Distributed.new ["redis://localhost:#{PORT}/15", *NODES] - - 100.times do |idx| - @r.set(idx.to_s, "foo#{idx}") - end - - 100.times do |idx| - assert_equal "foo#{idx}", @r.get(idx.to_s) - end - - assert_equal "0", @r.keys("*").sort.first - assert_equal "string", @r.type("1") - end - - def test_add_nodes - logger = Logger.new("/dev/null") - - @r = Redis::Distributed.new NODES, :logger => logger, :timeout => 10 - - assert_equal "127.0.0.1", @r.nodes[0].client.host - assert_equal PORT, @r.nodes[0].client.port - assert_equal 15, @r.nodes[0].client.db - assert_equal 10, @r.nodes[0].client.timeout - assert_equal logger, @r.nodes[0].client.logger - - @r.add_node("redis://127.0.0.1:6380/14") - - assert_equal "127.0.0.1", @r.nodes[1].client.host - assert_equal 6380, @r.nodes[1].client.port - assert_equal 14, @r.nodes[1].client.db - assert_equal 10, @r.nodes[1].client.timeout - assert_equal logger, @r.nodes[1].client.logger - end - - def test_pipelining_commands_cannot_be_distributed - assert_raise Redis::Distributed::CannotDistribute do - r.pipelined do - r.lpush "foo", "s1" - r.lpush "foo", "s2" - end - end - end - - def test_unknown_commands_does_not_work_by_default - assert_raise NoMethodError do - r.not_yet_implemented_command - end - end -end diff --git a/test/distributed_transactions_test.rb b/test/distributed_transactions_test.rb deleted file mode 100644 index abfb8aa3c..000000000 --- a/test/distributed_transactions_test.rb +++ /dev/null @@ -1,32 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestDistributedTransactions < Test::Unit::TestCase - - include Helper::Distributed - - def test_multi_discard - @foo = nil - - assert_raise Redis::Distributed::CannotDistribute do - r.multi { @foo = 1 } - end - - assert_equal nil, @foo - - assert_raise Redis::Distributed::CannotDistribute do - r.discard - end - end - - def test_watch_unwatch - assert_raise Redis::Distributed::CannotDistribute do - r.watch("foo") - end - - assert_raise Redis::Distributed::CannotDistribute do - r.unwatch - end - end -end diff --git a/test/encoding_test.rb b/test/encoding_test.rb deleted file mode 100644 index cb54bcb2a..000000000 --- a/test/encoding_test.rb +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestEncoding < Test::Unit::TestCase - - include Helper::Client - - def test_returns_properly_encoded_strings - if defined?(Encoding) - with_external_encoding("UTF-8") do - r.set "foo", "שלום" - - assert_equal "Shalom שלום", "Shalom " + r.get("foo") - end - end - end -end diff --git a/test/error_replies_test.rb b/test/error_replies_test.rb deleted file mode 100644 index 08ec81e15..000000000 --- a/test/error_replies_test.rb +++ /dev/null @@ -1,59 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestErrorReplies < Test::Unit::TestCase - - include Helper::Client - - # Every test shouldn't disconnect from the server. Also, when error replies are - # in play, the protocol should never get into an invalid state where there are - # pending replies in the connection. Calling INFO after every test ensures that - # the protocol is still in a valid state. - def with_reconnection_check - before = r.info["total_connections_received"] - yield(r) - after = r.info["total_connections_received"] - ensure - assert_equal before, after - end - - def test_error_reply_for_single_command - with_reconnection_check do - begin - r.unknown_command - rescue => ex - ensure - assert ex.message =~ /unknown command/i - end - end - end - - def test_raise_first_error_reply_in_pipeline - with_reconnection_check do - begin - r.pipelined do - r.set("foo", "s1") - r.incr("foo") # not an integer - r.lpush("foo", "value") # wrong kind of value - end - rescue => ex - ensure - assert ex.message =~ /not an integer/i - end - end - end - - def test_recover_from_raise_in__call_loop - with_reconnection_check do - begin - r.client.call_loop([:invalid_monitor]) do - assert false # Should never be executed - end - rescue => ex - ensure - assert ex.message =~ /unknown command/i - end - end - end -end diff --git a/test/fork_safety_test.rb b/test/fork_safety_test.rb deleted file mode 100644 index a49d5b46d..000000000 --- a/test/fork_safety_test.rb +++ /dev/null @@ -1,65 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestForkSafety < Test::Unit::TestCase - - include Helper::Client - include Helper::Skipable - - driver(:ruby, :hiredis) do - def test_fork_safety - redis = Redis.new(OPTIONS) - redis.set "foo", 1 - - child_pid = fork do - begin - # InheritedError triggers a reconnect, - # so we need to disable reconnects to force - # the exception bubble up - redis.without_reconnect do - redis.set "foo", 2 - end - rescue Redis::InheritedError - exit 127 - end - end - - _, status = Process.wait2(child_pid) - - assert_equal 127, status.exitstatus - assert_equal "1", redis.get("foo") - - rescue NotImplementedError => error - raise unless error.message =~ /fork is not available/ - return skip(error.message) - end - - def test_fork_safety_with_enabled_inherited_socket - redis = Redis.new(OPTIONS.merge(:inherit_socket => true)) - redis.set "foo", 1 - - child_pid = fork do - begin - # InheritedError triggers a reconnect, - # so we need to disable reconnects to force - # the exception bubble up - redis.without_reconnect do - redis.set "foo", 2 - end - rescue Redis::InheritedError - exit 127 - end - end - - _, status = Process.wait2(child_pid) - - assert_equal 0, status.exitstatus - assert_equal "2", redis.get("foo") - - rescue NotImplementedError => error - raise unless error.message =~ /fork is not available/ - return skip(error.message) - end - end -end diff --git a/test/helper.rb b/test/helper.rb index 169440728..dc4ebf33e 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -1,71 +1,49 @@ -$:.unshift File.expand_path("../lib", File.dirname(__FILE__)) -$:.unshift File.expand_path(File.dirname(__FILE__)) +# frozen_string_literal: true -require "test/unit" -require "logger" -require "stringio" +$LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) -(class Random; def self.rand(*args) super end; end) unless defined?(Random) - -begin - require "ruby-debug" -rescue LoadError -end +require "minitest/autorun" +require "mocha/minitest" $VERBOSE = true -ENV["conn"] ||= "ruby" +ENV["DRIVER"] ||= "ruby" require "redis" -require "redis/distributed" -require "redis/connection/#{ENV["conn"]}" - -require "support/redis_mock" -require "support/connection/#{ENV["conn"]}" - -PORT = 6381 -OPTIONS = {:port => PORT, :db => 15, :timeout => Float(ENV["TIMEOUT"] || 0.1)} -NODES = ["redis://127.0.0.1:#{PORT}/15"] +Redis.silence_deprecations = true -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. +require "redis/distributed" - Try this once: +require_relative "support/redis_mock" - $ rake clean +if ENV["DRIVER"] == "hiredis" + require "hiredis-client" +end - Then run the build again: +PORT = 6381 +DB = 15 +TIMEOUT = Float(ENV['TIMEOUT'] || 1.0) +LOW_TIMEOUT = Float(ENV['LOW_TIMEOUT'] || 0.01) # for blocking-command tests +OPTIONS = { port: PORT, db: DB, timeout: TIMEOUT }.freeze - $ rake +if ENV['REDIS_SOCKET_PATH'].nil? + sock_file = File.expand_path('../tmp/redis.sock', __dir__) - EOS - exit 1 + unless File.exist?(sock_file) + abort "Couldn't locate the redis unix socket, did you run `make start`?" end + + ENV['REDIS_SOCKET_PATH'] = sock_file end -def driver(*drivers, &blk) - if drivers.map(&:to_s).include?(ENV["conn"]) - class_eval(&blk) - end +Dir[File.expand_path('lint/**/*.rb', __dir__)].sort.each do |f| + require f end module Helper - - def run(runner) + def run if respond_to?(:around) - around { super(runner) } + around { super } else super end @@ -81,45 +59,25 @@ def silent end end - def with_external_encoding(encoding) - original_encoding = Encoding.default_external - - begin - silent { Encoding.default_external = Encoding.find(encoding) } - yield - ensure - silent { Encoding.default_external = original_encoding } - end - end - - def try_encoding(encoding, &block) - if defined?(Encoding) - with_external_encoding(encoding, &block) - else - yield - end - end - class Version - include Comparable attr :parts - def initialize(v) - case v + def initialize(version) + @parts = case version when Version - @parts = v.parts + version.parts else - @parts = v.to_s.split(".") + version.to_s.split(".") end end def <=>(other) other = Version.new(other) - length = [self.parts.length, other.parts.length].max + length = [parts.length, other.parts.length].max length.times do |i| - a, b = self.parts[i], other.parts[i] + a, b = parts[i], other.parts[i] return -1 if a.nil? return +1 if b.nil? @@ -131,35 +89,60 @@ def <=>(other) end module Generic - include Helper - attr_reader :log - attr_reader :redis + attr_reader :log, :redis - alias :r :redis + alias r redis def setup - @log = StringIO.new @redis = init _new_client # Run GC to make sure orphaned connections are closed. GC.start + super end def teardown - @redis.quit if @redis + redis&.close + super end - def redis_mock(commands, options = {}, &blk) + 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 = {}) RedisMock.start(commands, options) do |port| - yield _new_client(options.merge(:port => port)) + yield _new_client(options.merge(port: port)) end end - def redis_mock_with_handler(handler, options = {}, &blk) + def redis_mock_with_handler(handler, options = {}) RedisMock.start_with_handler(handler, options) do |port| - yield _new_client(options.merge(:port => port)) + yield _new_client(options.merge(port: port)) end end @@ -174,59 +157,122 @@ def target_version(target) yield end end - end - module Client + def with_db(index) + r.select(index) + yield + end - include Generic + def omit_version(min_ver) + skip("Requires Redis > #{min_ver}") if version < min_ver + end def version - Version.new(redis.info["redis_version"]) + Version.new(redis.info['redis_version']) + end + + def with_acl + admin = _new_client + admin.acl('SETUSER', 'johndoe', 'on', + '+ping', '+select', '+command', '+cluster|slots', '+cluster|nodes', + '>mysecret') + yield('johndoe', 'mysecret') + ensure + admin.acl('DELUSER', 'johndoe') + admin.close + end + + def with_default_user_password + admin = _new_client + admin.acl('SETUSER', 'default', '>mysecret') + yield('default', 'mysecret') + ensure + admin.acl('SETUSER', 'default', 'nopass') + admin.close end + end + + module Client + include Generic private def _format_options(options) - OPTIONS.merge(:logger => ::Logger.new(@log)).merge(options) + OPTIONS.merge(options) end def _new_client(options = {}) - Redis.new(_format_options(options).merge(:driver => ENV["conn"])) + Redis.new(_format_options(options).merge(driver: ENV["DRIVER"])) end end - module Distributed - + module Sentinel include Generic - def version - Version.new(redis.info.first["redis_version"]) + MASTER_PORT = PORT.to_s + SLAVE_PORT = '6382' + SENTINEL_PORT = '6400' + SENTINEL_PORTS = %w[6400 6401 6402].freeze + MASTER_NAME = 'master1' + LOCALHOST = '127.0.0.1' + + def build_sentinel_client(options = {}) + opts = { host: LOCALHOST, port: SENTINEL_PORT, timeout: TIMEOUT } + Redis.new(opts.merge(options)) + end + + def build_slave_role_client(options = {}) + _new_client(options.merge(role: :slave)) end private - def _format_options(options) + def wait_for_quorum + redis = build_sentinel_client + 50.times do + if redis.sentinel('ckquorum', MASTER_NAME).start_with?('OK 3 usable Sentinels') + return + else + sleep 0.1 + end + rescue + sleep 0.1 + end + raise "ckquorum timeout" + end + + def _format_options(options = {}) { - :timeout => OPTIONS[:timeout], - :logger => ::Logger.new(@log), + url: "redis://#{MASTER_NAME}", + sentinels: [{ host: LOCALHOST, port: SENTINEL_PORT }], + role: :master, timeout: TIMEOUT, }.merge(options) end def _new_client(options = {}) - Redis::Distributed.new(NODES, _format_options(options).merge(:driver => ENV["conn"])) + Redis.new(_format_options(options).merge(driver: ENV['DRIVER'])) end end - # Basic support for `skip` in 1.8.x - # Note: YOU MUST use `return skip(message)` in order to appropriately bail - # from a running test. - module Skipable - Skipped = Class.new(RuntimeError) + module Distributed + include Generic - def skip(message = nil, bt = caller) - return super if defined?(super) + NODES = ["redis://127.0.0.1:#{PORT}/#{DB}"].freeze - $stderr.puts("SKIPPED: #{self} #{message || 'no reason given'}") + def version + Version.new(redis.info.first["redis_version"]) + end + + private + + def _format_options(options) + { + timeout: OPTIONS[:timeout], + }.merge(options) + end + + def _new_client(options = {}) + Redis::Distributed.new(NODES, _format_options(options).merge(driver: ENV["conn"])) end end end diff --git a/test/internals_test.rb b/test/internals_test.rb deleted file mode 100644 index a49873054..000000000 --- a/test/internals_test.rb +++ /dev/null @@ -1,457 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestInternals < Test::Unit::TestCase - - include Helper::Client - include Helper::Skipable - - def test_logger - r.ping - - assert log.string["[Redis] command=PING"] - assert log.string =~ /\[Redis\] call_time=\d+\.\d+ ms/ - end - - def test_logger_with_pipelining - r.pipelined do - r.set "foo", "bar" - r.get "foo" - end - - assert log.string[" command=SET args=\"foo\" \"bar\""] - assert log.string[" command=GET args=\"foo\""] - end - - def test_recovers_from_failed_commands - # See https://github.com/redis/redis-rb/issues#issue/28 - - assert_raise(Redis::CommandError) do - r.command_that_doesnt_exist - end - - assert_nothing_raised do - r.info - end - end - - def test_raises_on_protocol_errors - redis_mock(:ping => lambda { |*_| "foo" }) do |redis| - assert_raise(Redis::ProtocolError) do - redis.ping - end - end - end - - def test_provides_a_meaningful_inspect - assert_equal "#", r.inspect - end - - def test_redis_current - assert_equal "127.0.0.1", Redis.current.client.host - assert_equal 6379, Redis.current.client.port - assert_equal 0, Redis.current.client.db - - Redis.current = Redis.new(OPTIONS.merge(:port => 6380, :db => 1)) - - t = Thread.new do - assert_equal "127.0.0.1", Redis.current.client.host - assert_equal 6380, Redis.current.client.port - assert_equal 1, Redis.current.client.db - end - - t.join - - assert_equal "127.0.0.1", Redis.current.client.host - assert_equal 6380, Redis.current.client.port - assert_equal 1, Redis.current.client.db - end - - def test_redis_connected? - fresh_client = _new_client - assert !fresh_client.connected? - - fresh_client.ping - assert fresh_client.connected? - - fresh_client.quit - assert !fresh_client.connected? - end - - def test_default_id_with_host_and_port - redis = Redis.new(OPTIONS.merge(:host => "host", :port => "1234", :db => 0)) - assert_equal "redis://host:1234/0", redis.client.id - end - - def test_default_id_with_host_and_port_and_explicit_scheme - redis = Redis.new(OPTIONS.merge(:host => "host", :port => "1234", :db => 0, :scheme => "foo")) - assert_equal "redis://host:1234/0", redis.client.id - end - - def test_default_id_with_path - redis = Redis.new(OPTIONS.merge(:path => "/tmp/redis.sock", :db => 0)) - assert_equal "redis:///tmp/redis.sock/0", redis.client.id - end - - def test_default_id_with_path_and_explicit_scheme - redis = Redis.new(OPTIONS.merge(:path => "/tmp/redis.sock", :db => 0, :scheme => "foo")) - assert_equal "redis:///tmp/redis.sock/0", redis.client.id - end - - def test_override_id - redis = Redis.new(OPTIONS.merge(:id => "test")) - assert_equal redis.client.id, "test" - end - - def test_timeout - assert_nothing_raised do - Redis.new(OPTIONS.merge(:timeout => 0)) - end - end - - def test_id_inside_multi - redis = Redis.new(OPTIONS) - id = nil - - redis.multi do - id = redis.id - end - - assert_equal id, "redis://127.0.0.1:6381/15" - end - - driver(:ruby) do - def test_tcp_keepalive - keepalive = {:time => 20, :intvl => 10, :probes => 5} - - redis = Redis.new(OPTIONS.merge(:tcp_keepalive => keepalive)) - redis.ping - - connection = redis.client.connection - actual_keepalive = connection.get_tcp_keepalive - - [:time, :intvl, :probes].each do |key| - if actual_keepalive.has_key?(key) - assert_equal actual_keepalive[key], keepalive[key] - end - end - end - end - - def test_time - target_version "2.5.4" do - # Test that the difference between the time that Ruby reports and the time - # that Redis reports is minimal (prevents the test from being racy). - rv = r.time - - redis_usec = rv[0] * 1_000_000 + rv[1] - ruby_usec = Integer(Time.now.to_f * 1_000_000) - - assert 500_000 > (ruby_usec - redis_usec).abs - end - end - - def test_connection_timeout - opts = OPTIONS.merge(:host => "10.255.255.254", :connect_timeout => 0.1, :timeout => 5.0) - start_time = Time.now - assert_raise Redis::CannotConnectError do - Redis.new(opts).ping - end - assert (Time.now - start_time) <= opts[:timeout] - end - - driver(:ruby) do - def test_write_timeout - return skip("Relies on buffer sizes, might be unreliable") - - server = TCPServer.new("127.0.0.1", 0) - port = server.addr[1] - - # Hacky, but we need the buffer size - val = TCPSocket.new("127.0.0.1", port).getsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF).unpack("i")[0] - - assert_raise(Redis::TimeoutError) do - Timeout.timeout(1) do - redis = Redis.new(:port => port, :timeout => 5, :write_timeout => 0.1) - redis.set("foo", "1" * val*2) - end - end - end - end - - def close_on_ping(seq, options = {}) - $request = 0 - - command = lambda do - idx = $request - $request += 1 - - rv = "+%d" % idx - rv = nil if seq.include?(idx) - rv - end - - redis_mock({:ping => command}, {:timeout => 0.1}.merge(options)) do |redis| - yield(redis) - end - end - - def test_retry_by_default - close_on_ping([0]) do |redis| - assert_equal "1", redis.ping - end - end - - def test_retry_when_wrapped_in_with_reconnect_true - close_on_ping([0]) do |redis| - redis.with_reconnect(true) do - assert_equal "1", redis.ping - end - end - end - - def test_dont_retry_when_wrapped_in_with_reconnect_false - close_on_ping([0]) do |redis| - assert_raise Redis::ConnectionError do - redis.with_reconnect(false) do - redis.ping - end - end - end - end - - def test_dont_retry_when_wrapped_in_without_reconnect - close_on_ping([0]) do |redis| - assert_raise Redis::ConnectionError do - redis.without_reconnect do - redis.ping - end - end - end - end - - def test_retry_only_once_when_read_raises_econnreset - close_on_ping([0, 1]) do |redis| - assert_raise Redis::ConnectionError do - redis.ping - end - - assert !redis.client.connected? - end - end - - def test_retry_with_custom_reconnect_attempts - close_on_ping([0, 1], :reconnect_attempts => 2) do |redis| - assert_equal "2", redis.ping - end - end - - def test_retry_with_custom_reconnect_attempts_can_still_fail - close_on_ping([0, 1, 2], :reconnect_attempts => 2) do |redis| - assert_raise Redis::ConnectionError do - redis.ping - end - - assert !redis.client.connected? - end - end - - def test_don_t_retry_when_second_read_in_pipeline_raises_econnreset - close_on_ping([1]) do |redis| - assert_raise Redis::ConnectionError do - redis.pipelined do - redis.ping - redis.ping # Second #read times out - end - end - - assert !redis.client.connected? - end - end - - def close_on_connection(seq) - $n = 0 - - read_command = lambda do |session| - Array.new(session.gets[1..-3].to_i) do - bytes = session.gets[1..-3].to_i - arg = session.read(bytes) - session.read(2) # Discard \r\n - arg - end - end - - handler = lambda do |session| - n = $n - $n += 1 - - select = read_command.call(session) - if select[0].downcase == "select" - session.write("+OK\r\n") - else - raise "Expected SELECT" - end - - if !seq.include?(n) - while read_command.call(session) - session.write("+#{n}\r\n") - end - end - end - - redis_mock_with_handler(handler) do |redis| - yield(redis) - end - end - - def test_retry_on_write_error_by_default - close_on_connection([0]) do |redis| - assert_equal "1", redis.client.call(["x" * 128 * 1024]) - end - end - - def test_retry_on_write_error_when_wrapped_in_with_reconnect_true - close_on_connection([0]) do |redis| - redis.with_reconnect(true) do - assert_equal "1", redis.client.call(["x" * 128 * 1024]) - end - end - end - - def test_dont_retry_on_write_error_when_wrapped_in_with_reconnect_false - close_on_connection([0]) do |redis| - assert_raise Redis::ConnectionError do - redis.with_reconnect(false) do - redis.client.call(["x" * 128 * 1024]) - end - end - end - end - - def test_dont_retry_on_write_error_when_wrapped_in_without_reconnect - close_on_connection([0]) do |redis| - assert_raise Redis::ConnectionError do - redis.without_reconnect do - redis.client.call(["x" * 128 * 1024]) - end - end - end - end - - def test_connecting_to_unix_domain_socket - assert_nothing_raised do - Redis.new(OPTIONS.merge(:path => "./test/db/redis.sock")).ping - end - end - - driver(:ruby, :hiredis) do - def test_bubble_timeout_without_retrying - serv = TCPServer.new(6380) - - redis = Redis.new(:port => 6380, :timeout => 0.1) - - assert_raise(Redis::TimeoutError) do - redis.ping - end - - ensure - serv.close if serv - end - end - - def test_client_options - redis = Redis.new(OPTIONS.merge(:host => "host", :port => 1234, :db => 1, :scheme => "foo")) - - assert_equal "host", redis.client.options[:host] - assert_equal 1234, redis.client.options[:port] - assert_equal 1, redis.client.options[:db] - assert_equal "foo", redis.client.options[:scheme] - end - - def test_does_not_change_self_client_options - redis = Redis.new(OPTIONS.merge(:host => "host", :port => 1234, :db => 1, :scheme => "foo")) - options = redis.client.options - - options[:host] << "new_host" - options[:scheme] << "bar" - options.merge!(:db => 0) - - assert_equal "host", redis.client.options[:host] - assert_equal 1, redis.client.options[:db] - assert_equal "foo", redis.client.options[:scheme] - end - - def test_resolves_localhost - assert_nothing_raised do - Redis.new(OPTIONS.merge(:host => 'localhost')).ping - end - end - - class << self - def af_family_supported(af) - hosts = { - Socket::AF_INET => "127.0.0.1", - Socket::AF_INET6 => "::1", - } - - begin - s = Socket.new(af, Socket::SOCK_STREAM, 0) - begin - tries = 5 - begin - sa = Socket.pack_sockaddr_in(1024 + Random.rand(63076), hosts[af]) - s.bind(sa) - rescue Errno::EADDRINUSE - tries -= 1 - retry if tries > 0 - - raise - end - yield - rescue Errno::EADDRNOTAVAIL - ensure - s.close - end - rescue Errno::ESOCKTNOSUPPORT - end - end - end - - def af_test(host) - commands = { - :ping => lambda { |*_| "+pong" }, - } - - redis_mock(commands, :host => host) do |redis| - assert_nothing_raised do - redis.ping - end - end - end - - driver(:ruby) do - af_family_supported(Socket::AF_INET) do - def test_connect_ipv4 - af_test("127.0.0.1") - end - end - end - - driver(:ruby) do - af_family_supported(Socket::AF_INET6) do - def test_connect_ipv6 - af_test("::1") - end - end - end - - def test_can_be_duped_to_create_a_new_connection - clients = r.info["connected_clients"].to_i - - r2 = r.dup - r2.ping - - assert_equal clients + 1, r.info["connected_clients"].to_i - end -end diff --git a/test/lint/authentication.rb b/test/lint/authentication.rb new file mode 100644 index 000000000..7f960e031 --- /dev/null +++ b/test/lint/authentication.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Lint + module Authentication + def test_auth_with_password + mock(auth: ->(*_) { '+OK' }) do |r| + assert_equal 'OK', r.auth('mysecret') + end + + mock(auth: ->(*_) { '-ERR some error' }) do |r| + assert_raises(Redis::BaseError) { r.auth('mysecret') } + end + end + + def test_auth_for_acl + target_version "6.0.0" do + with_acl do |username, password| + assert_raises(Redis::CannotConnectError) { redis.auth(username, 'wrongpassword') } + assert_equal 'OK', redis.auth(username, password) + assert_equal 'PONG', redis.ping + assert_raises(Redis::BaseError) { redis.echo('foo') } + end + end + end + + def mock(*args, &block) + redis_mock(*args, &block) + end + end +end diff --git a/test/lint/blocking_commands.rb b/test/lint/blocking_commands.rb index 531e8d98f..604400bf0 100644 --- a/test/lint/blocking_commands.rb +++ b/test/lint/blocking_commands.rb @@ -1,14 +1,17 @@ -module Lint +# frozen_string_literal: true +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,131 +21,167 @@ 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, { timeout: TIMEOUT }, &blk) + end + + def build_mock_commands(options = {}) + { + blmove: lambda do |*args| + sleep options[:delay] if options.key?(:delay) + to_protocol(args.last) + end, + 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 } + end - redis_mock(commands, &blk) + def test_blmove + target_version "6.2" do + assert_equal 's1', r.blmove('{zap}foo', '{zap}bar', 'LEFT', 'RIGHT') + assert_equal ['s2'], r.lrange('{zap}foo', 0, -1) + assert_equal ['s1', 's2', 's1'], r.lrange('{zap}bar', 0, -1) + end + end + + def test_blmove_timeout + target_version "6.2" do + mock do |r| + assert_equal '0', r.blmove('{zap}foo', '{zap}bar', 'LEFT', 'RIGHT') + assert_equal LOW_TIMEOUT.to_s, r.blmove('{zap}foo', '{zap}bar', 'LEFT', 'RIGHT', timeout: LOW_TIMEOUT) + end + end end def test_blpop - assert_equal ["{zap}foo", "s1"], r.blpop("{zap}foo") - assert_equal ["{zap}foo", "s2"], r.blpop(["{zap}foo"]) - assert_equal ["{zap}bar", "s1"], r.blpop(["{zap}bar", "{zap}foo"]) - assert_equal ["{zap}bar", "s2"], r.blpop(["{zap}foo", "{zap}bar"]) + assert_equal ['{zap}foo', 's1'], r.blpop('{zap}foo') + assert_equal ['{zap}foo', 's2'], r.blpop(['{zap}foo']) + assert_equal ['{zap}bar', 's1'], r.blpop(['{zap}bar', '{zap}foo']) + assert_equal ['{zap}bar', 's2'], r.blpop(['{zap}foo', '{zap}bar']) end def test_blpop_timeout mock do |r| - assert_equal ["{zap}foo", "0"], r.blpop("{zap}foo") - assert_equal ["{zap}foo", "1"], r.blpop("{zap}foo", :timeout => 1) + assert_equal ['{zap}foo', '0'], r.blpop('{zap}foo') + assert_equal ['{zap}foo', LOW_TIMEOUT.to_s], r.blpop('{zap}foo', timeout: LOW_TIMEOUT) end end - def test_blpop_with_old_prototype - assert_equal ["{zap}foo", "s1"], r.blpop("{zap}foo", 0) - assert_equal ["{zap}foo", "s2"], r.blpop("{zap}foo", 0) - assert_equal ["{zap}bar", "s1"], r.blpop("{zap}bar", "{zap}foo", 0) - assert_equal ["{zap}bar", "s2"], r.blpop("{zap}foo", "{zap}bar", 0) + class FakeDuration + def initialize(int) + @int = int + end + + def to_int + @int + end end - def test_blpop_timeout_with_old_prototype - mock do |r| - assert_equal ["{zap}foo", "0"], r.blpop("{zap}foo", 0) - assert_equal ["{zap}foo", "1"], r.blpop("{zap}foo", 1) + def test_blpop_integer_like_timeout + assert_raises ArgumentError do + assert_equal ["{zap}foo", "1"], r.blpop("{zap}foo", timeout: FakeDuration.new(1)) end end def test_brpop - assert_equal ["{zap}foo", "s2"], r.brpop("{zap}foo") - assert_equal ["{zap}foo", "s1"], r.brpop(["{zap}foo"]) - assert_equal ["{zap}bar", "s2"], r.brpop(["{zap}bar", "{zap}foo"]) - assert_equal ["{zap}bar", "s1"], r.brpop(["{zap}foo", "{zap}bar"]) + assert_equal ['{zap}foo', 's2'], r.brpop('{zap}foo') + assert_equal ['{zap}foo', 's1'], r.brpop(['{zap}foo']) + assert_equal ['{zap}bar', 's2'], r.brpop(['{zap}bar', '{zap}foo']) + assert_equal ['{zap}bar', 's1'], r.brpop(['{zap}foo', '{zap}bar']) end def test_brpop_timeout mock do |r| - assert_equal ["{zap}foo", "0"], r.brpop("{zap}foo") - assert_equal ["{zap}foo", "1"], r.brpop("{zap}foo", :timeout => 1) + assert_equal ['{zap}foo', '0'], r.brpop('{zap}foo') + assert_equal ['{zap}foo', LOW_TIMEOUT.to_s], r.brpop('{zap}foo', timeout: LOW_TIMEOUT) end end - def test_brpop_with_old_prototype - assert_equal ["{zap}foo", "s2"], r.brpop("{zap}foo", 0) - assert_equal ["{zap}foo", "s1"], r.brpop("{zap}foo", 0) - assert_equal ["{zap}bar", "s2"], r.brpop("{zap}bar", "{zap}foo", 0) - assert_equal ["{zap}bar", "s1"], r.brpop("{zap}foo", "{zap}bar", 0) + def test_brpoplpush + assert_equal 's2', r.brpoplpush('{zap}foo', '{zap}qux') + assert_equal ['s2'], r.lrange('{zap}qux', 0, -1) end - def test_brpop_timeout_with_old_prototype + def test_brpoplpush_timeout mock do |r| - assert_equal ["{zap}foo", "0"], r.brpop("{zap}foo", 0) - assert_equal ["{zap}foo", "1"], r.brpop("{zap}foo", 1) + assert_equal '0', r.brpoplpush('{zap}foo', '{zap}bar') + assert_equal LOW_TIMEOUT.to_s, r.brpoplpush('{zap}foo', '{zap}bar', timeout: LOW_TIMEOUT) end end - def test_brpoplpush - assert_equal "s2", r.brpoplpush("{zap}foo", "{zap}qux") - assert_equal ["s2"], r.lrange("{zap}qux", 0, -1) + def test_bzpopmin + assert_equal ['{szap}foo', 'a', 0.0], r.bzpopmin('{szap}foo', '{szap}bar', timeout: 1) end - def test_brpoplpush_timeout - mock do |r| - assert_equal "0", r.brpoplpush("{zap}foo", "{zap}bar") - assert_equal "1", r.brpoplpush("{zap}foo", "{zap}bar", :timeout => 1) + def test_bzpopmin_float_timeout + target_version "6.0" do + assert_nil r.bzpopmin('{szap}aaa', '{szap}bbb', timeout: LOW_TIMEOUT) end end - def test_brpoplpush_with_old_prototype - assert_equal "s2", r.brpoplpush("{zap}foo", "{zap}qux", 0) - assert_equal ["s2"], r.lrange("{zap}qux", 0, -1) + def test_bzpopmax + assert_equal ['{szap}foo', 'c', 2.0], r.bzpopmax('{szap}foo', '{szap}bar', timeout: 1) end - def test_brpoplpush_timeout_with_old_prototype - mock do |r| - assert_equal "0", r.brpoplpush("{zap}foo", "{zap}bar", 0) - assert_equal "1", r.brpoplpush("{zap}foo", "{zap}bar", 1) + def test_bzpopmax_float_timeout + target_version "6.0" do + assert_nil r.bzpopmax('{szap}aaa', '{szap}bbb', timeout: LOW_TIMEOUT) end end - driver(:ruby, :hiredis) do - def test_blpop_socket_timeout - mock(:delay => 1 + OPTIONS[:timeout] * 2) do |r| + def test_blmove_socket_timeout + target_version "6.2" do + mock(delay: TIMEOUT * 5) do |r| assert_raises(Redis::TimeoutError) do - r.blpop("{zap}foo", :timeout => 1) + r.blmove('{zap}foo', '{zap}bar', 'LEFT', 'RIGHT', timeout: LOW_TIMEOUT) end end end + end - def test_brpop_socket_timeout - mock(:delay => 1 + OPTIONS[:timeout] * 2) do |r| - assert_raises(Redis::TimeoutError) do - r.brpop("{zap}foo", :timeout => 1) - end + def test_blpop_socket_timeout + mock(delay: TIMEOUT * 5) do |r| + assert_raises(Redis::TimeoutError) do + r.blpop('{zap}foo', timeout: LOW_TIMEOUT) end end + end - def test_brpoplpush_socket_timeout - mock(:delay => 1 + OPTIONS[:timeout] * 2) do |r| - assert_raises(Redis::TimeoutError) do - r.brpoplpush("{zap}foo", "{zap}bar", :timeout => 1) - end + def test_brpop_socket_timeout + mock(delay: TIMEOUT * 5) do |r| + assert_raises(Redis::TimeoutError) do + r.brpop('{zap}foo', timeout: LOW_TIMEOUT) + end + end + end + + def test_brpoplpush_socket_timeout + mock(delay: TIMEOUT * 5) do |r| + assert_raises(Redis::TimeoutError) do + r.brpoplpush('{zap}foo', '{zap}bar', timeout: LOW_TIMEOUT) end end end diff --git a/test/lint/hashes.rb b/test/lint/hashes.rb index 649e6673f..a6d470220 100644 --- a/test/lint/hashes.rb +++ b/test/lint/hashes.rb @@ -1,13 +1,25 @@ -module Lint +# frozen_string_literal: true +module Lint module Hashes - def test_hset_and_hget - r.hset("foo", "f1", "s1") + assert_equal 1, r.hset("foo", "f1", "s1") assert_equal "s1", r.hget("foo", "f1") end + def test_variadic_hset + assert_equal 2, r.hset("foo", "f1", "s1", "f2", "s2") + + assert_equal "s1", r.hget("foo", "f1") + assert_equal "s2", r.hget("foo", "f2") + + assert_equal 2, r.hset("bar", { "f1" => "s1", "f2" => "s2" }) + + assert_equal "s1", r.hget("bar", "f1") + assert_equal "s2", r.hget("bar", "f2") + end + def test_hsetnx r.hset("foo", "f1", "s1") r.hsetnx("foo", "f1", "s2") @@ -27,22 +39,33 @@ def test_hdel assert_equal 1, r.hdel("foo", "f1") - assert_equal nil, r.hget("foo", "f1") + assert_nil r.hget("foo", "f1") + end + + def test_splat_hdel + r.hset("foo", "f1", "s1") + r.hset("foo", "f2", "s2") + + assert_equal "s1", r.hget("foo", "f1") + assert_equal "s2", r.hget("foo", "f2") + + assert_equal 2, r.hdel("foo", "f1", "f2") + + assert_nil r.hget("foo", "f1") + assert_nil r.hget("foo", "f2") end def test_variadic_hdel - target_version "2.3.9" do - r.hset("foo", "f1", "s1") - r.hset("foo", "f2", "s2") + r.hset("foo", "f1", "s1") + r.hset("foo", "f2", "s2") - assert_equal "s1", r.hget("foo", "f1") - assert_equal "s2", r.hget("foo", "f2") + assert_equal "s1", r.hget("foo", "f1") + assert_equal "s2", r.hget("foo", "f2") - assert_equal 2, r.hdel("foo", ["f1", "f2"]) + assert_equal 2, r.hdel("foo", ["f1", "f2"]) - assert_equal nil, r.hget("foo", "f1") - assert_equal nil, r.hget("foo", "f2") - end + assert_nil r.hget("foo", "f1") + assert_nil r.hget("foo", "f2") end def test_hexists @@ -74,6 +97,30 @@ def test_hkeys assert_equal ["f1", "f2"], r.hkeys("foo") end + def test_hrandfield + target_version("6.2") do + assert_nil r.hrandfield("foo") + assert_equal [], r.hrandfield("foo", 1) + + error = assert_raises(ArgumentError) do + r.hrandfield("foo", with_values: true) + end + assert_equal "count argument must be specified", error.message + + r.hset("foo", "f1", "s1") + r.hset("foo", "f2", "s2") + + assert ["f1", "f2"].include?(r.hrandfield("foo")) + assert_equal ["f1", "f2"], r.hrandfield("foo", 2).sort + assert_equal 4, r.hrandfield("foo", -4).size + + r.hrandfield("foo", 2, with_values: true) do |(field, value)| + assert ["f1", "f2"].include?(field) + assert ["s1", "s2"].include?(value) + end + end + end + def test_hvals assert_equal [], r.hvals("foo") @@ -84,12 +131,12 @@ def test_hvals end def test_hgetall - assert({} == r.hgetall("foo")) + assert_equal({}, r.hgetall("foo")) r.hset("foo", "f1", "s1") r.hset("foo", "f2", "s2") - assert({"f1" => "s1", "f2" => "s2"} == r.hgetall("foo")) + assert_equal({ "f1" => "s1", "f2" => "s2" }, r.hgetall("foo")) end def test_hmset @@ -100,13 +147,13 @@ def test_hmset end def test_hmset_with_invalid_arguments - assert_raise(Redis::CommandError) do + assert_raises(Redis::CommandError) do r.hmset("hash", "foo1", "bar1", "foo2", "bar2", "foo3") end end def test_mapped_hmset - r.mapped_hmset("foo", :f1 => "s1", :f2 => "s2") + r.mapped_hmset("foo", f1: "s1", f2: "s2") assert_equal "s1", r.hget("foo", "f1") assert_equal "s2", r.hget("foo", "f2") @@ -125,8 +172,19 @@ def test_hmget_mapped r.hset("foo", "f2", "s2") r.hset("foo", "f3", "s3") - assert({"f1" => "s1"} == r.mapped_hmget("foo", "f1")) - assert({"f1" => "s1", "f2" => "s2"} == r.mapped_hmget("foo", "f1", "f2")) + assert_equal({ "f1" => "s1" }, r.mapped_hmget("foo", "f1")) + assert_equal({ "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 |pipeline| + pipeline.mapped_hmget("foo", "f1", "f2") + end + + assert_equal({ "f1" => "s1", "f2" => "s2" }, result[0]) end def test_hincrby @@ -144,19 +202,30 @@ def test_hincrby end def test_hincrbyfloat - target_version "2.5.4" do - r.hincrbyfloat("foo", "f1", 1.23) + r.hincrbyfloat("foo", "f1", 1.23) - assert_equal "1.23", r.hget("foo", "f1") + assert_equal 1.23, Float(r.hget("foo", "f1")) - r.hincrbyfloat("foo", "f1", 0.77) + r.hincrbyfloat("foo", "f1", 0.77) - assert_equal "2", r.hget("foo", "f1") + assert_equal "2", r.hget("foo", "f1") - r.hincrbyfloat("foo", "f1", -0.1) + r.hincrbyfloat("foo", "f1", -0.1) - assert_equal "1.9", r.hget("foo", "f1") - end + assert_equal 1.9, Float(r.hget("foo", "f1")) + end + + def test_hstrlen + 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 + + 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..ba2f3c596 100644 --- a/test/lint/hyper_log_log.rb +++ b/test/lint/hyper_log_log.rb @@ -1,60 +1,62 @@ -module Lint +# frozen_string_literal: true +module Lint module HyperLogLog - def test_pfadd - target_version "2.8.9" do - assert_equal true, r.pfadd("foo", "s1") - assert_equal true, r.pfadd("foo", "s2") - assert_equal false, r.pfadd("foo", "s1") + assert_equal true, r.pfadd("foo", "s1") + assert_equal true, r.pfadd("foo", "s2") + assert_equal false, r.pfadd("foo", "s1") - assert_equal 2, r.pfcount("foo") - end + assert_equal 2, r.pfcount("foo") end def test_variadic_pfadd - target_version "2.8.9" do - assert_equal true, r.pfadd("foo", ["s1", "s2"]) - assert_equal true, r.pfadd("foo", ["s1", "s2", "s3"]) + assert_equal true, r.pfadd("foo", ["s1", "s2"]) + assert_equal true, r.pfadd("foo", ["s1", "s2", "s3"]) - assert_equal 3, r.pfcount("foo") - end + assert_equal 3, r.pfcount("foo") end def test_pfcount - target_version "2.8.9" do - assert_equal 0, r.pfcount("foo") + assert_equal 0, r.pfcount("foo") - assert_equal true, r.pfadd("foo", "s1") + assert_equal true, r.pfadd("foo", "s1") - assert_equal 1, r.pfcount("foo") - end + assert_equal 1, r.pfcount("foo") end def test_variadic_pfcount - target_version "2.8.9" do - assert_equal 0, r.pfcount(["{1}foo", "{1}bar"]) + assert_equal 0, r.pfcount(["{1}foo", "{1}bar"]) - assert_equal true, r.pfadd("{1}foo", "s1") - assert_equal true, r.pfadd("{1}bar", "s1") - assert_equal true, r.pfadd("{1}bar", "s2") + assert_equal true, r.pfadd("{1}foo", "s1") + assert_equal true, r.pfadd("{1}bar", "s1") + assert_equal true, r.pfadd("{1}bar", "s2") - assert_equal 2, r.pfcount("{1}foo", "{1}bar") - end + assert_equal 2, r.pfcount("{1}foo", "{1}bar") end def test_variadic_pfcount_expanded - target_version "2.8.9" do - assert_equal 0, r.pfcount("{1}foo", "{1}bar") + assert_equal 0, r.pfcount("{1}foo", "{1}bar") - assert_equal true, r.pfadd("{1}foo", "s1") - assert_equal true, r.pfadd("{1}bar", "s1") - assert_equal true, r.pfadd("{1}bar", "s2") + assert_equal true, r.pfadd("{1}foo", "s1") + assert_equal true, r.pfadd("{1}bar", "s1") + assert_equal true, r.pfadd("{1}bar", "s2") - assert_equal 2, r.pfcount("{1}foo", "{1}bar") - end + assert_equal 2, r.pfcount("{1}foo", "{1}bar") end - end + def test_pfmerge + r.pfadd 'foo', 's1' + r.pfadd 'bar', 's2' + assert_equal true, r.pfmerge('res', 'foo', 'bar') + assert_equal 2, r.pfcount('res') + 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..ea18254df 100644 --- a/test/lint/lists.rb +++ b/test/lint/lists.rb @@ -1,6 +1,33 @@ -module Lint +# frozen_string_literal: true +module Lint module Lists + def test_lmove + target_version "6.2" do + r.lpush("foo", "s1") + r.lpush("foo", "s2") # foo = [s2, s1] + r.lpush("bar", "s3") + r.lpush("bar", "s4") # bar = [s4, s3] + + assert_nil r.lmove("nonexistent", "foo", "LEFT", "LEFT") + + assert_equal "s2", r.lmove("foo", "foo", "LEFT", "RIGHT") # foo = [s1, s2] + assert_equal "s1", r.lmove("foo", "foo", "LEFT", "LEFT") # foo = [s1, s2] + + assert_equal "s1", r.lmove("foo", "bar", "LEFT", "RIGHT") # foo = [s2], bar = [s4, s3, s1] + assert_equal ["s2"], r.lrange("foo", 0, -1) + assert_equal ["s4", "s3", "s1"], r.lrange("bar", 0, -1) + + assert_equal "s2", r.lmove("foo", "bar", "LEFT", "LEFT") # foo = [], bar = [s2, s4, s3, s1] + assert_nil r.lmove("foo", "bar", "LEFT", "LEFT") # foo = [], bar = [s2, s4, s3, s1] + assert_equal ["s2", "s4", "s3", "s1"], r.lrange("bar", 0, -1) + + error = assert_raises(ArgumentError) do + r.lmove("foo", "bar", "LEFT", "MIDDLE") + end + assert_equal "where_destination must be 'LEFT' or 'RIGHT'", error.message + end + end def test_lpush r.lpush "foo", "s1" @@ -11,11 +38,9 @@ def test_lpush end def test_variadic_lpush - target_version "2.3.9" do # 2.4-rc6 - assert_equal 3, r.lpush("foo", ["s1", "s2", "s3"]) - assert_equal 3, r.llen("foo") - assert_equal "s3", r.lpop("foo") - end + assert_equal 3, r.lpush("foo", ["s1", "s2", "s3"]) + assert_equal 3, r.llen("foo") + assert_equal "s3", r.lpop("foo") end def test_lpushx @@ -36,11 +61,9 @@ def test_rpush end def test_variadic_rpush - target_version "2.3.9" do # 2.4-rc6 - assert_equal 3, r.rpush("foo", ["s1", "s2", "s3"]) - assert_equal 3, r.llen("foo") - assert_equal "s3", r.rpop("foo") - end + assert_equal 3, r.rpush("foo", ["s1", "s2", "s3"]) + assert_equal 3, r.llen("foo") + assert_equal "s3", r.rpop("foo") end def test_rpushx @@ -97,7 +120,7 @@ def test_lset assert r.lset("foo", 1, "s3") assert_equal "s3", r.lindex("foo", 1) - assert_raise Redis::CommandError do + assert_raises Redis::CommandError do r.lset("foo", 4, "s3") end end @@ -117,6 +140,18 @@ def test_lpop assert_equal 2, r.llen("foo") assert_equal "s1", r.lpop("foo") assert_equal 1, r.llen("foo") + assert_nil r.lpop("nonexistent") + end + + def test_lpop_count + target_version("6.2") do + r.rpush "foo", "s1" + r.rpush "foo", "s2" + + assert_equal 2, r.llen("foo") + assert_equal ["s1", "s2"], r.lpop("foo", 2) + assert_equal 0, r.llen("foo") + end end def test_rpop @@ -126,6 +161,18 @@ def test_rpop assert_equal 2, r.llen("foo") assert_equal "s2", r.rpop("foo") assert_equal 1, r.llen("foo") + assert_nil r.rpop("nonexistent") + end + + def test_rpop_count + target_version("6.2") do + r.rpush "foo", "s1" + r.rpush "foo", "s2" + + assert_equal 2, r.llen("foo") + assert_equal ["s2", "s1"], r.rpop("foo", 2) + assert_equal 0, r.llen("foo") + end end def test_linsert @@ -135,9 +182,51 @@ def test_linsert assert_equal ["s1", "s2", "s3"], r.lrange("foo", 0, -1) - assert_raise(Redis::CommandError) do + assert_raises(Redis::CommandError) do 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 + + def test_blmpop + target_version('7.0') do + assert_nil r.blmpop(1.0, '{1}foo') + + r.lpush('{1}foo', %w[a b c d e f g]) + assert_equal ['{1}foo', ['g']], r.blmpop(1.0, '{1}foo') + assert_equal ['{1}foo', ['f', 'e']], r.blmpop(1.0, '{1}foo', count: 2) + + r.lpush('{1}foo2', %w[a b]) + assert_equal ['{1}foo', ['a']], r.blmpop(1.0, '{1}foo', '{1}foo2', modifier: "RIGHT") + end + end + + def test_lmpop + target_version('7.0') do + assert_nil r.lmpop('{1}foo') + + r.lpush('{1}foo', %w[a b c d e f g]) + assert_equal ['{1}foo', ['g']], r.lmpop('{1}foo') + assert_equal ['{1}foo', ['f', 'e']], r.lmpop('{1}foo', count: 2) + + r.lpush('{1}foo2', %w[a b]) + assert_equal ['{1}foo', ['a']], r.lmpop('{1}foo', '{1}foo2', modifier: "RIGHT") + end + end end end diff --git a/test/lint/sets.rb b/test/lint/sets.rb index f32b1de0e..bfaa5cf84 100644 --- a/test/lint/sets.rb +++ b/test/lint/sets.rb @@ -1,46 +1,80 @@ -module Lint +# frozen_string_literal: true +module Lint module Sets - def test_sadd - assert_equal true, r.sadd("foo", "s1") - assert_equal true, r.sadd("foo", "s2") - assert_equal false, r.sadd("foo", "s1") + assert_equal 1, r.sadd("foo", "s1") + assert_equal 1, r.sadd("foo", "s2") + assert_equal 0, r.sadd("foo", "s1") + + assert_equal ["s1", "s2"], r.smembers("foo").sort + end + + def test_sadd? + assert_equal true, r.sadd?("foo", "s1") + assert_equal true, r.sadd?("foo", "s2") + assert_equal false, r.sadd?("foo", "s1") assert_equal ["s1", "s2"], r.smembers("foo").sort end def test_variadic_sadd - target_version "2.3.9" do # 2.4-rc6 - assert_equal 2, r.sadd("foo", ["s1", "s2"]) - assert_equal 1, r.sadd("foo", ["s1", "s2", "s3"]) + assert_equal 2, r.sadd("foo", ["s1", "s2"]) + assert_equal 1, r.sadd("foo", ["s1", "s2", "s3"]) - assert_equal ["s1", "s2", "s3"], r.smembers("foo").sort - end + assert_equal ["s1", "s2", "s3"], r.smembers("foo").sort + end + + def test_variadic_sadd? + assert_equal true, r.sadd?("foo", ["s1", "s2"]) + assert_equal true, r.sadd?("foo", ["s1", "s2", "s3"]) + assert_equal false, r.sadd?("foo", ["s1", "s2"]) + + assert_equal ["s1", "s2", "s3"], r.smembers("foo").sort end def test_srem r.sadd("foo", "s1") r.sadd("foo", "s2") - assert_equal true, r.srem("foo", "s1") - assert_equal false, r.srem("foo", "s3") + assert_equal 1, r.srem("foo", "s1") + assert_equal 0, r.srem("foo", "s3") + + assert_equal ["s2"], r.smembers("foo") + end + + def test_srem? + r.sadd("foo", "s1") + r.sadd("foo", "s2") + + assert_equal true, r.srem?("foo", "s1") + assert_equal false, r.srem?("foo", "s3") assert_equal ["s2"], r.smembers("foo") end def test_variadic_srem - target_version "2.3.9" do # 2.4-rc6 - r.sadd("foo", "s1") - r.sadd("foo", "s2") - r.sadd("foo", "s3") + r.sadd("foo", "s1") + r.sadd("foo", "s2") + r.sadd("foo", "s3") - assert_equal 1, r.srem("foo", ["s1", "aaa"]) - assert_equal 0, r.srem("foo", ["bbb", "ccc" "ddd"]) - assert_equal 1, r.srem("foo", ["eee", "s3"]) + assert_equal 1, r.srem("foo", ["s1", "aaa"]) + assert_equal 0, r.srem("foo", ["bbb", "ccc", "ddd"]) + assert_equal 1, r.srem("foo", ["eee", "s3"]) - assert_equal ["s2"], r.smembers("foo") - end + assert_equal ["s2"], r.smembers("foo") + end + + def test_variadic_srem? + r.sadd("foo", "s1") + r.sadd("foo", "s2") + r.sadd("foo", "s3") + + assert_equal true, r.srem?("foo", ["s1", "aaa"]) + assert_equal false, r.srem?("foo", ["bbb", "ccc", "ddd"]) + assert_equal true, r.srem?("foo", "eee", "s3") + + assert_equal ["s2"], r.smembers("foo") end def test_spop @@ -49,22 +83,20 @@ def test_spop assert ["s1", "s2"].include?(r.spop("foo")) assert ["s1", "s2"].include?(r.spop("foo")) - assert_equal nil, r.spop("foo") + assert_nil r.spop("foo") end def test_spop_with_positive_count - target_version "3.2.0" do - r.sadd "foo", "s1" - r.sadd "foo", "s2" - r.sadd "foo", "s3" - r.sadd "foo", "s4" + r.sadd "foo", "s1" + r.sadd "foo", "s2" + r.sadd "foo", "s3" + r.sadd "foo", "s4" - pops = r.spop("foo", 3) + pops = r.spop("foo", 3) - assert !(["s1", "s2", "s3", "s4"] & pops).empty? - assert_equal 3, pops.size - assert_equal 1, r.scard("foo") - end + assert !(["s1", "s2", "s3", "s4"] & pops).empty? + assert_equal 3, pops.size + assert_equal 1, r.scard("foo") end def test_scard @@ -88,6 +120,18 @@ def test_sismember assert_equal false, r.sismember("foo", "s2") end + def test_smismember + target_version("6.2") do + assert_equal [false], r.smismember("foo", "s1") + + r.sadd "foo", "s1" + assert_equal [true], r.smismember("foo", "s1") + + r.sadd "foo", "s3" + assert_equal [true, false, true], r.smismember("foo", "s1", "s2", "s3") + end + end + def test_smembers assert_equal [], r.smembers("foo") @@ -136,5 +180,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..10f8ce5e9 100644 --- a/test/lint/sorted_sets.rb +++ b/test/lint/sorted_sets.rb @@ -1,9 +1,7 @@ -module Lint +# frozen_string_literal: true +module Lint module SortedSets - - Infinity = 1.0/0.0 - def test_zadd assert_equal 0, r.zcard("foo") assert_equal true, r.zadd("foo", 1, "s1") @@ -11,103 +9,170 @@ def test_zadd assert_equal 1, r.zcard("foo") r.del "foo" - target_version "3.0.2" do - # XX option - assert_equal 0, r.zcard("foo") - assert_equal false, r.zadd("foo", 1, "s1", :xx => true) - r.zadd("foo", 1, "s1") - assert_equal false, r.zadd("foo", 2, "s1", :xx => true) - assert_equal 2, r.zscore("foo", "s1") - r.del "foo" + # XX option + assert_equal 0, r.zcard("foo") + assert_equal false, r.zadd("foo", 1, "s1", xx: true) + r.zadd("foo", 1, "s1") + assert_equal false, r.zadd("foo", 2, "s1", xx: true) + assert_equal 2, r.zscore("foo", "s1") + r.del "foo" - # NX option - assert_equal 0, r.zcard("foo") - assert_equal true, r.zadd("foo", 1, "s1", :nx => true) - assert_equal false, r.zadd("foo", 2, "s1", :nx => true) - assert_equal 1, r.zscore("foo", "s1") - assert_equal 1, r.zcard("foo") - r.del "foo" + # NX option + assert_equal 0, r.zcard("foo") + assert_equal true, r.zadd("foo", 1, "s1", nx: true) + assert_equal false, r.zadd("foo", 2, "s1", nx: true) + assert_equal 1, r.zscore("foo", "s1") + assert_equal 1, r.zcard("foo") + r.del "foo" - # CH option - assert_equal 0, r.zcard("foo") - assert_equal true, r.zadd("foo", 1, "s1", :ch => true) - assert_equal false, r.zadd("foo", 1, "s1", :ch => true) - assert_equal true, r.zadd("foo", 2, "s1", :ch => true) - assert_equal 1, r.zcard("foo") + # CH option + assert_equal 0, r.zcard("foo") + assert_equal true, r.zadd("foo", 1, "s1", ch: true) + assert_equal false, r.zadd("foo", 1, "s1", ch: true) + assert_equal true, r.zadd("foo", 2, "s1", ch: true) + assert_equal 1, r.zcard("foo") + r.del "foo" + + # INCR option + assert_equal 1.0, r.zadd("foo", 1, "s1", incr: true) + assert_equal 11.0, r.zadd("foo", 10, "s1", incr: true) + assert_equal(-Float::INFINITY, r.zadd("bar", "-inf", "s1", incr: true)) + assert_equal(+Float::INFINITY, r.zadd("bar", "+inf", "s2", incr: true)) + r.del 'foo' + r.del 'bar' + + # Incompatible options combination + assert_raises(Redis::CommandError) { r.zadd("foo", 1, "s1", xx: true, nx: true) } + end + + def test_zadd_keywords + target_version "6.2" do + # LT option + r.zadd("foo", 2, "s1") + + r.zadd("foo", 3, "s1", lt: true) + assert_equal 2.0, r.zscore("foo", "s1") + + r.zadd("foo", 1, "s1", lt: true) + assert_equal 1.0, r.zscore("foo", "s1") + + assert_equal true, r.zadd("foo", 3, "s2", lt: true) # adds new member r.del "foo" - # INCR option - assert_equal 1.0, r.zadd("foo", 1, "s1", :incr => true) - 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" + # GT option + r.zadd("foo", 2, "s1") + + r.zadd("foo", 1, "s1", gt: true) + assert_equal 2.0, r.zscore("foo", "s1") + + r.zadd("foo", 3, "s1", gt: true) + assert_equal 3.0, r.zscore("foo", "s1") + + assert_equal true, r.zadd("foo", 1, "s2", gt: true) # adds new member + r.del "foo" # Incompatible options combination - assert_raise(Redis::CommandError) { r.zadd("foo", 1, "s1", :xx => true, :nx => true) } + assert_raises(Redis::CommandError) { r.zadd("foo", 1, "s1", nx: true, gt: true) } end end def test_variadic_zadd - target_version "2.3.9" do # 2.4-rc6 - # Non-nested array with pairs - assert_equal 0, r.zcard("foo") - assert_equal 2, r.zadd("foo", [1, "s1", 2, "s2"]) - assert_equal 1, r.zadd("foo", [4, "s1", 5, "s2", 6, "s3"]) - assert_equal 3, r.zcard("foo") - r.del "foo" + # Non-nested array with pairs + assert_equal 0, r.zcard("foo") - # Nested array with pairs - assert_equal 0, r.zcard("foo") - assert_equal 2, r.zadd("foo", [[1, "s1"], [2, "s2"]]) - assert_equal 1, r.zadd("foo", [[4, "s1"], [5, "s2"], [6, "s3"]]) - assert_equal 3, r.zcard("foo") - r.del "foo" + assert_equal 2, r.zadd("foo", [1, "s1", 2, "s2"]) + assert_equal 2, r.zcard("foo") - # Wrong number of arguments - assert_raise(Redis::CommandError) { r.zadd("foo", ["bar"]) } - assert_raise(Redis::CommandError) { r.zadd("foo", ["bar", "qux", "zap"]) } - end + assert_equal 1, r.zadd("foo", [4, "s1", 5, "s2", 6, "s3"]) + assert_equal 3, r.zcard("foo") - target_version "3.0.2" do - # XX option - assert_equal 0, r.zcard("foo") - assert_equal 0, r.zadd("foo", [1, "s1", 2, "s2"], :xx => true) - r.zadd("foo", [1, "s1", 2, "s2"]) - assert_equal 0, r.zadd("foo", [2, "s1", 3, "s2", 4, "s3"], :xx => true) - assert_equal 2, r.zscore("foo", "s1") - assert_equal 3, r.zscore("foo", "s2") - assert_equal nil, r.zscore("foo", "s3") - assert_equal 2, r.zcard("foo") - r.del "foo" + r.del "foo" - # NX option - assert_equal 0, r.zcard("foo") - assert_equal 2, r.zadd("foo", [1, "s1", 2, "s2"], :nx => true) - assert_equal 1, r.zadd("foo", [2, "s1", 3, "s2", 4, "s3"], :nx => true) - assert_equal 1, r.zscore("foo", "s1") - assert_equal 2, r.zscore("foo", "s2") - assert_equal 4, r.zscore("foo", "s3") - assert_equal 3, r.zcard("foo") - r.del "foo" + # Nested array with pairs + assert_equal 0, r.zcard("foo") + + assert_equal 2, r.zadd("foo", [[1, "s1"], [2, "s2"]]) + assert_equal 2, r.zcard("foo") + + assert_equal 1, r.zadd("foo", [[4, "s1"], [5, "s2"], [6, "s3"]]) + assert_equal 3, r.zcard("foo") + + r.del "foo" + + # Empty array + assert_equal 0, r.zcard("foo") + + assert_equal 0, r.zadd("foo", []) + assert_equal 0, r.zcard("foo") + + r.del "foo" + + # Wrong number of arguments + assert_raises(Redis::CommandError) { r.zadd("foo", ["bar"]) } + assert_raises(Redis::CommandError) { r.zadd("foo", ["bar", "qux", "zap"]) } + + # XX option + assert_equal 0, r.zcard("foo") + assert_equal 0, r.zadd("foo", [1, "s1", 2, "s2"], xx: true) + r.zadd("foo", [1, "s1", 2, "s2"]) + assert_equal 0, r.zadd("foo", [2, "s1", 3, "s2", 4, "s3"], xx: true) + assert_equal 2, r.zscore("foo", "s1") + assert_equal 3, r.zscore("foo", "s2") + assert_nil r.zscore("foo", "s3") + assert_equal 2, r.zcard("foo") + r.del "foo" + + # NX option + assert_equal 0, r.zcard("foo") + assert_equal 2, r.zadd("foo", [1, "s1", 2, "s2"], nx: true) + assert_equal 1, r.zadd("foo", [2, "s1", 3, "s2", 4, "s3"], nx: true) + assert_equal 1, r.zscore("foo", "s1") + assert_equal 2, r.zscore("foo", "s2") + assert_equal 4, r.zscore("foo", "s3") + assert_equal 3, r.zcard("foo") + r.del "foo" + + # CH option + assert_equal 0, r.zcard("foo") + assert_equal 2, r.zadd("foo", [1, "s1", 2, "s2"], ch: true) + assert_equal 2, r.zadd("foo", [1, "s1", 3, "s2", 4, "s3"], ch: true) + assert_equal 3, r.zcard("foo") + r.del "foo" + + # INCR option + assert_equal 1.0, r.zadd("foo", [1, "s1"], incr: true) + assert_equal 11.0, r.zadd("foo", [10, "s1"], incr: true) + assert_equal(-Float::INFINITY, r.zadd("bar", ["-inf", "s1"], incr: true)) + assert_equal(+Float::INFINITY, r.zadd("bar", ["+inf", "s2"], incr: true)) + assert_raises(Redis::CommandError) { r.zadd("foo", [1, "s1", 2, "s2"], incr: true) } + r.del 'foo' + r.del 'bar' + + # Incompatible options combination + assert_raises(Redis::CommandError) { r.zadd("foo", [1, "s1"], xx: true, nx: true) } + end + + def test_variadic_zadd_keywords + target_version "6.2" do + # LT option + r.zadd("foo", 2, "s1") + + assert_equal 1, r.zadd("foo", [3, "s1", 2, "s2"], lt: true, ch: true) + assert_equal 2.0, r.zscore("foo", "s1") + + assert_equal 1, r.zadd("foo", [1, "s1"], lt: true, ch: true) - # CH option - assert_equal 0, r.zcard("foo") - assert_equal 2, r.zadd("foo", [1, "s1", 2, "s2"], :ch => true) - assert_equal 2, r.zadd("foo", [1, "s1", 3, "s2", 4, "s3"], :ch => true) - assert_equal 3, r.zcard("foo") r.del "foo" - # INCR option - assert_equal 1.0, r.zadd("foo", [1, "s1"], :incr => true) - 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)) - assert_raise(Redis::CommandError) { r.zadd("foo", [1, "s1", 2, "s2"], :incr => true) } - r.del "foo", "bar" + # GT option + r.zadd("foo", 2, "s1") - # Incompatible options combination - assert_raise(Redis::CommandError) { r.zadd("foo", [1, "s1"], :xx => true, :nx => true) } + assert_equal 1, r.zadd("foo", [1, "s1", 2, "s2"], gt: true, ch: true) + assert_equal 2.0, r.zscore("foo", "s1") + + assert_equal 1, r.zadd("foo", [3, "s1"], gt: true, ch: true) + + r.del "foo" end end @@ -122,17 +187,23 @@ def test_zrem end def test_variadic_zrem - target_version "2.3.9" do # 2.4-rc6 - r.zadd("foo", 1, "s1") - r.zadd("foo", 2, "s2") - r.zadd("foo", 3, "s3") - - assert_equal 3, r.zcard("foo") - assert_equal 1, r.zrem("foo", ["s1", "aaa"]) - assert_equal 0, r.zrem("foo", ["bbb", "ccc" "ddd"]) - assert_equal 1, r.zrem("foo", ["eee", "s3"]) - assert_equal 1, r.zcard("foo") - end + r.zadd("foo", 1, "s1") + r.zadd("foo", 2, "s2") + r.zadd("foo", 3, "s3") + + assert_equal 3, r.zcard("foo") + + assert_equal 0, r.zrem("foo", []) + assert_equal 3, r.zcard("foo") + + assert_equal 1, r.zrem("foo", ["s1", "aaa"]) + assert_equal 2, r.zcard("foo") + + assert_equal 0, r.zrem("foo", ["bbb", "ccc", "ddd"]) + assert_equal 2, r.zcard("foo") + + assert_equal 1, r.zrem("foo", ["eee", "s3"]) + assert_equal 1, r.zcard("foo") end def test_zincrby @@ -143,10 +214,10 @@ def test_zincrby assert_equal 11.0, rv rv = r.zincrby "bar", "-inf", "s1" - assert_equal(-Infinity, rv) + assert_equal(-Float::INFINITY, rv) rv = r.zincrby "bar", "+inf", "s2" - assert_equal(+Infinity, rv) + assert_equal(+Float::INFINITY, rv) end def test_zrank @@ -171,13 +242,52 @@ def test_zrange r.zadd "foo", 3, "s3" assert_equal ["s1", "s2"], r.zrange("foo", 0, 1) - assert_equal [["s1", 1.0], ["s2", 2.0]], r.zrange("foo", 0, 1, :with_scores => true) - assert_equal [["s1", 1.0], ["s2", 2.0]], r.zrange("foo", 0, 1, :withscores => true) + assert_equal [["s1", 1.0], ["s2", 2.0]], r.zrange("foo", 0, 1, with_scores: true) + assert_equal [["s1", 1.0], ["s2", 2.0]], r.zrange("foo", 0, 1, withscores: true) r.zadd "bar", "-inf", "s1" r.zadd "bar", "+inf", "s2" - assert_equal [["s1", -Infinity], ["s2", +Infinity]], r.zrange("bar", 0, 1, :with_scores => true) - assert_equal [["s1", -Infinity], ["s2", +Infinity]], r.zrange("bar", 0, 1, :withscores => true) + assert_equal [["s1", -Float::INFINITY], ["s2", +Float::INFINITY]], r.zrange("bar", 0, 1, with_scores: true) + assert_equal [["s1", -Float::INFINITY], ["s2", +Float::INFINITY]], r.zrange("bar", 0, 1, withscores: true) + end + + def test_zrange_with_byscore + target_version("6.2") do + r.zadd "foo", 1, "s1" + r.zadd "foo", 2, "s2" + r.zadd "foo", 3, "s3" + + assert_equal ["s2", "s3"], r.zrange("foo", 2, 3, byscore: true) + assert_equal ["s2", "s1"], r.zrange("foo", 2, 1, byscore: true, rev: true) + end + end + + def test_zrange_with_bylex + target_version("6.2") 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.zrange("foo", "[a", "[a\xff", bylex: true) + assert_equal %w[aaren abagael], r.zrange("foo", "[a", "[a\xff", bylex: true, limit: [0, 2]) + assert_equal %w[abby abbygail], r.zrange("foo", "(abb", "(abb\xff", bylex: true) + assert_equal %w[abbygail], r.zrange("foo", "(abby", "(abby\xff", bylex: true) + end + end + + def test_zrangestore + target_version("6.2") do + r.zadd "foo", 1, "s1" + r.zadd "foo", 2, "s2" + r.zadd "foo", 3, "s3" + + assert_equal 2, r.zrangestore("bar", "foo", 0, 1) + assert_equal ["s1", "s2"], r.zrange("bar", 0, -1) + + assert_equal 2, r.zrangestore("baz", "foo", 2, 3, by_score: true) + assert_equal ["s2", "s3"], r.zrange("baz", 0, -1) + end end def test_zrevrange @@ -186,13 +296,13 @@ def test_zrevrange r.zadd "foo", 3, "s3" assert_equal ["s3", "s2"], r.zrevrange("foo", 0, 1) - assert_equal [["s3", 3.0], ["s2", 2.0]], r.zrevrange("foo", 0, 1, :with_scores => true) - assert_equal [["s3", 3.0], ["s2", 2.0]], r.zrevrange("foo", 0, 1, :withscores => true) + assert_equal [["s3", 3.0], ["s2", 2.0]], r.zrevrange("foo", 0, 1, with_scores: true) + assert_equal [["s3", 3.0], ["s2", 2.0]], r.zrevrange("foo", 0, 1, withscores: true) r.zadd "bar", "-inf", "s1" r.zadd "bar", "+inf", "s2" - assert_equal [["s2", +Infinity], ["s1", -Infinity]], r.zrevrange("bar", 0, 1, :with_scores => true) - assert_equal [["s2", +Infinity], ["s1", -Infinity]], r.zrevrange("bar", 0, 1, :withscores => true) + assert_equal [["s2", +Float::INFINITY], ["s1", -Float::INFINITY]], r.zrevrange("bar", 0, 1, with_scores: true) + assert_equal [["s2", +Float::INFINITY], ["s1", -Float::INFINITY]], r.zrevrange("bar", 0, 1, withscores: true) end def test_zrangebyscore @@ -217,9 +327,9 @@ def test_zrangebyscore_with_limit r.zadd "foo", 3, "s3" r.zadd "foo", 4, "s4" - assert_equal ["s2"], r.zrangebyscore("foo", 2, 4, :limit => [0, 1]) - assert_equal ["s3"], r.zrangebyscore("foo", 2, 4, :limit => [1, 1]) - assert_equal ["s3", "s4"], r.zrangebyscore("foo", 2, 4, :limit => [1, 2]) + assert_equal ["s2"], r.zrangebyscore("foo", 2, 4, limit: [0, 1]) + assert_equal ["s3"], r.zrangebyscore("foo", 2, 4, limit: [1, 1]) + assert_equal ["s3", "s4"], r.zrangebyscore("foo", 2, 4, limit: [1, 2]) end def test_zrevrangebyscore_with_limit @@ -228,9 +338,9 @@ def test_zrevrangebyscore_with_limit r.zadd "foo", 3, "s3" r.zadd "foo", 4, "s4" - assert_equal ["s4"], r.zrevrangebyscore("foo", 4, 2, :limit => [0, 1]) - assert_equal ["s3"], r.zrevrangebyscore("foo", 4, 2, :limit => [1, 1]) - assert_equal ["s3", "s2"], r.zrevrangebyscore("foo", 4, 2, :limit => [1, 2]) + assert_equal ["s4"], r.zrevrangebyscore("foo", 4, 2, limit: [0, 1]) + assert_equal ["s3"], r.zrevrangebyscore("foo", 4, 2, limit: [1, 1]) + assert_equal ["s3", "s2"], r.zrevrangebyscore("foo", 4, 2, limit: [1, 2]) end def test_zrangebyscore_with_withscores @@ -239,17 +349,17 @@ def test_zrangebyscore_with_withscores r.zadd "foo", 3, "s3" r.zadd "foo", 4, "s4" - assert_equal [["s2", 2.0]], r.zrangebyscore("foo", 2, 4, :limit => [0, 1], :with_scores => true) - assert_equal [["s3", 3.0]], r.zrangebyscore("foo", 2, 4, :limit => [1, 1], :with_scores => true) - assert_equal [["s2", 2.0]], r.zrangebyscore("foo", 2, 4, :limit => [0, 1], :withscores => true) - assert_equal [["s3", 3.0]], r.zrangebyscore("foo", 2, 4, :limit => [1, 1], :withscores => true) + assert_equal [["s2", 2.0]], r.zrangebyscore("foo", 2, 4, limit: [0, 1], with_scores: true) + assert_equal [["s3", 3.0]], r.zrangebyscore("foo", 2, 4, limit: [1, 1], with_scores: true) + assert_equal [["s2", 2.0]], r.zrangebyscore("foo", 2, 4, limit: [0, 1], withscores: true) + assert_equal [["s3", 3.0]], r.zrangebyscore("foo", 2, 4, limit: [1, 1], withscores: true) r.zadd "bar", "-inf", "s1" r.zadd "bar", "+inf", "s2" - assert_equal [["s1", -Infinity]], r.zrangebyscore("bar", -Infinity, +Infinity, :limit => [0, 1], :with_scores => true) - assert_equal [["s2", +Infinity]], r.zrangebyscore("bar", -Infinity, +Infinity, :limit => [1, 1], :with_scores => true) - assert_equal [["s1", -Infinity]], r.zrangebyscore("bar", -Infinity, +Infinity, :limit => [0, 1], :withscores => true) - assert_equal [["s2", +Infinity]], r.zrangebyscore("bar", -Infinity, +Infinity, :limit => [1, 1], :withscores => true) + assert_equal [["s1", -Float::INFINITY]], r.zrangebyscore("bar", -Float::INFINITY, +Float::INFINITY, limit: [0, 1], with_scores: true) + assert_equal [["s2", +Float::INFINITY]], r.zrangebyscore("bar", -Float::INFINITY, +Float::INFINITY, limit: [1, 1], with_scores: true) + assert_equal [["s1", -Float::INFINITY]], r.zrangebyscore("bar", -Float::INFINITY, +Float::INFINITY, limit: [0, 1], withscores: true) + assert_equal [["s2", +Float::INFINITY]], r.zrangebyscore("bar", -Float::INFINITY, +Float::INFINITY, limit: [1, 1], withscores: true) end def test_zrevrangebyscore_with_withscores @@ -258,17 +368,17 @@ def test_zrevrangebyscore_with_withscores r.zadd "foo", 3, "s3" r.zadd "foo", 4, "s4" - assert_equal [["s4", 4.0]], r.zrevrangebyscore("foo", 4, 2, :limit => [0, 1], :with_scores => true) - assert_equal [["s3", 3.0]], r.zrevrangebyscore("foo", 4, 2, :limit => [1, 1], :with_scores => true) - assert_equal [["s4", 4.0]], r.zrevrangebyscore("foo", 4, 2, :limit => [0, 1], :withscores => true) - assert_equal [["s3", 3.0]], r.zrevrangebyscore("foo", 4, 2, :limit => [1, 1], :withscores => true) + assert_equal [["s4", 4.0]], r.zrevrangebyscore("foo", 4, 2, limit: [0, 1], with_scores: true) + assert_equal [["s3", 3.0]], r.zrevrangebyscore("foo", 4, 2, limit: [1, 1], with_scores: true) + assert_equal [["s4", 4.0]], r.zrevrangebyscore("foo", 4, 2, limit: [0, 1], withscores: true) + assert_equal [["s3", 3.0]], r.zrevrangebyscore("foo", 4, 2, limit: [1, 1], withscores: true) r.zadd "bar", "-inf", "s1" r.zadd "bar", "+inf", "s2" - assert_equal [["s2", +Infinity]], r.zrevrangebyscore("bar", +Infinity, -Infinity, :limit => [0, 1], :with_scores => true) - assert_equal [["s1", -Infinity]], r.zrevrangebyscore("bar", +Infinity, -Infinity, :limit => [1, 1], :with_scores => true) - assert_equal [["s2", +Infinity]], r.zrevrangebyscore("bar", +Infinity, -Infinity, :limit => [0, 1], :withscores => true) - assert_equal [["s1", -Infinity]], r.zrevrangebyscore("bar", +Infinity, -Infinity, :limit => [1, 1], :withscores => true) + assert_equal [["s2", +Float::INFINITY]], r.zrevrangebyscore("bar", +Float::INFINITY, -Float::INFINITY, limit: [0, 1], with_scores: true) + assert_equal [["s1", -Float::INFINITY]], r.zrevrangebyscore("bar", +Float::INFINITY, -Float::INFINITY, limit: [1, 1], with_scores: true) + assert_equal [["s2", +Float::INFINITY]], r.zrevrangebyscore("bar", +Float::INFINITY, -Float::INFINITY, limit: [0, 1], withscores: true) + assert_equal [["s1", -Float::INFINITY]], r.zrevrangebyscore("bar", +Float::INFINITY, -Float::INFINITY, limit: [1, 1], withscores: true) end def test_zcard @@ -284,13 +394,55 @@ def test_zscore assert_equal 1.0, r.zscore("foo", "s1") - assert_equal nil, r.zscore("foo", "s2") - assert_equal nil, r.zscore("bar", "s1") + assert_nil r.zscore("foo", "s2") + assert_nil r.zscore("bar", "s1") r.zadd "bar", "-inf", "s1" r.zadd "bar", "+inf", "s2" - assert_equal(-Infinity, r.zscore("bar", "s1")) - assert_equal(+Infinity, r.zscore("bar", "s2")) + assert_equal(-Float::INFINITY, r.zscore("bar", "s1")) + assert_equal(+Float::INFINITY, r.zscore("bar", "s2")) + end + + def test_zmscore + target_version("6.2") do + r.zadd "foo", 1, "s1" + + assert_equal [1.0], r.zmscore("foo", "s1") + assert_equal [nil], r.zmscore("foo", "s2") + + r.zadd "foo", "-inf", "s2" + r.zadd "foo", "+inf", "s3" + assert_equal [1.0, nil], r.zmscore("foo", "s1", "s4") + assert_equal [-Float::INFINITY, +Float::INFINITY], r.zmscore("foo", "s2", "s3") + end + end + + def test_zrandmember + target_version("6.2") do + assert_nil r.zrandmember("foo") + + r.zadd "foo", 1.0, "s1" + r.zrem "foo", "s1" + assert_nil r.zrandmember("foo") + assert_equal [], r.zrandmember("foo", 1) + + r.zadd "foo", 1.0, "s1" + r.zadd "foo", 2.0, "s2" + r.zadd "foo", 3.0, "s3" + + 3.times do + assert ["s1", "s2", "s3"].include?(r.zrandmember("foo")) + end + + assert_equal 2, r.zrandmember("foo", 2).size + assert_equal 3, r.zrandmember("foo", 4).size + assert_equal 5, r.zrandmember("foo", -5).size + + r.zrandmember("foo", 2, with_scores: true).each do |(member, score)| + assert ["s1", "s2", "s3"].include?(member) + assert_instance_of Float, score + end + end end def test_zremrangebyrank @@ -312,5 +464,332 @@ def test_zremrangebyscore assert_equal 3, r.zremrangebyscore("foo", 2, 4) assert_equal ["s1"], r.zrange("foo", 0, -1) end + + def test_zpopmax + r.zadd('foo', %w[0 a 1 b 2 c 3 d]) + assert_equal ['d', 3.0], r.zpopmax('foo') + assert_equal [['c', 2.0], ['b', 1.0]], r.zpopmax('foo', 2) + assert_equal [['a', 0.0]], r.zrange('foo', 0, -1, with_scores: true) + end + + def test_zpopmin + r.zadd('foo', %w[0 a 1 b 2 c 3 d]) + assert_equal ['a', 0.0], r.zpopmin('foo') + assert_equal [['b', 1.0], ['c', 2.0]], r.zpopmin('foo', 2) + assert_equal [['d', 3.0]], r.zrange('foo', 0, -1, with_scores: true) + end + + def test_bzmpop + target_version('7.0') do + assert_nil r.bzmpop(1.0, '{1}foo') + + r.zadd('{1}foo', %w[0 a 1 b 2 c 3 d]) + assert_equal ['{1}foo', [['a', 0.0]]], r.bzmpop(1.0, '{1}foo') + assert_equal ['{1}foo', [['b', 1.0], ['c', 2.0], ['d', 3.0]]], r.bzmpop(1.0, '{1}foo', count: 4) + + r.zadd('{1}foo', %w[0 a 1 b 2 c 3 d]) + r.zadd('{1}foo2', %w[0 a 1 b 2 c 3 d]) + assert_equal ['{1}foo', [['d', 3.0]]], r.bzmpop(1.0, '{1}foo', '{1}foo2', modifier: "MAX") + end + end + + def test_zmpop + target_version('7.0') do + assert_nil r.zmpop('{1}foo') + + r.zadd('{1}foo', %w[0 a 1 b 2 c 3 d]) + assert_equal ['{1}foo', [['a', 0.0]]], r.zmpop('{1}foo') + assert_equal ['{1}foo', [['b', 1.0], ['c', 2.0], ['d', 3.0]]], r.zmpop('{1}foo', count: 4) + + r.zadd('{1}foo', %w[0 a 1 b 2 c 3 d]) + r.zadd('{1}foo2', %w[0 a 1 b 2 c 3 d]) + assert_equal ['{1}foo', [['d', 3.0]]], r.zmpop('{1}foo', '{1}foo2', modifier: "MAX") + 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 + 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 + + def test_zrangebylex + 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 + + def test_zrevrangebylex + 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 + + 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_zunion + target_version("6.2") do + r.zadd 'foo', 1, 's1' + r.zadd 'foo', 2, 's2' + r.zadd 'bar', 3, 's1' + r.zadd 'bar', 5, 's3' + + assert_equal %w[s2 s1 s3], r.zunion('foo', 'bar') + assert_equal [['s2', 2.0], ['s1', 4.0], ['s3', 5.0]], r.zunion('foo', 'bar', with_scores: true) + end + end + + def test_zunion_with_weights + target_version("6.2") do + 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', 100, 's4' + + assert_equal %w[s1 s2 s3 s4], r.zunion('foo', 'bar') + assert_equal [['s1', 1.0], ['s2', 22.0], ['s3', 33.0], ['s4', 100.0]], r.zunion('foo', 'bar', with_scores: true) + + assert_equal %w[s1 s2 s3 s4], r.zunion('foo', 'bar', weights: [10, 1]) + assert_equal [['s1', 10.0], ['s2', 40.0], ['s3', 60.0], ['s4', 100.0]], r.zunion('foo', 'bar', weights: [10, 1], with_scores: true) + end + end + + def test_zunion_with_aggregate + target_version("6.2") do + r.zadd 'foo', 1, 's1' + r.zadd 'foo', 20, 's2' + r.zadd 'foo', 3, 's3' + r.zadd 'bar', 2, 's2' + r.zadd 'bar', 30, 's3' + r.zadd 'bar', 100, 's4' + + assert_equal %w[s1 s2 s3 s4], r.zunion('foo', 'bar', aggregate: :min) + assert_equal [['s1', 1.0], ['s2', 2.0], ['s3', 3.0], ['s4', 100.0]], r.zunion('foo', 'bar', aggregate: :min, with_scores: true) + + assert_equal %w[s1 s2 s3 s4], r.zunion('foo', 'bar', aggregate: :max) + assert_equal [['s1', 1.0], ['s2', 20.0], ['s3', 30.0], ['s4', 100.0]], r.zunion('foo', 'bar', aggregate: :max, with_scores: true) + end + 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_zdiff + target_version("6.2") do + r.zadd 'foo', 1, 's1' + r.zadd 'foo', 2, 's2' + r.zadd 'bar', 3, 's1' + r.zadd 'bar', 5, 's3' + + assert_equal [], r.zdiff('foo', 'foo') + assert_equal ['s1', 's2'], r.zdiff('foo') + + assert_equal ['s2'], r.zdiff('foo', 'bar') + assert_equal [['s2', 2.0]], r.zdiff('foo', 'bar', with_scores: true) + end + end + + def test_zdiffstore + target_version("6.2") do + r.zadd 'foo', 1, 's1' + r.zadd 'foo', 2, 's2' + r.zadd 'bar', 3, 's1' + r.zadd 'bar', 5, 's3' + + assert_equal 0, r.zdiffstore('baz', ['foo', 'foo']) + assert_equal 2, r.zdiffstore('baz', ['foo']) + assert_equal ['s1', 's2'], r.zrange('baz', 0, -1) + + assert_equal 1, r.zdiffstore('baz', ['foo', 'bar']) + assert_equal ['s2'], r.zrange('baz', 0, -1) + end + end + + def test_zinter + target_version("6.2") do + r.zadd 'foo', 1, 's1' + r.zadd 'bar', 2, 's1' + r.zadd 'foo', 3, 's3' + r.zadd 'bar', 4, 's4' + + assert_equal ['s1'], r.zinter('foo', 'bar') + assert_equal [['s1', 3.0]], r.zinter('foo', 'bar', with_scores: true) + end + end + + def test_zinter_with_weights + target_version("6.2") do + 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 %w[s2 s3], r.zinter('foo', 'bar') + assert_equal [['s2', 22.0], ['s3', 33.0]], r.zinter('foo', 'bar', with_scores: true) + + assert_equal %w[s2 s3], r.zinter('foo', 'bar', weights: [10, 1]) + assert_equal [['s2', 40.0], ['s3', 60.0]], r.zinter('foo', 'bar', weights: [10, 1], with_scores: true) + end + end + + def test_zinter_with_aggregate + target_version("6.2") do + 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 %w[s2 s3], r.zinter('foo', 'bar') + assert_equal [['s2', 22.0], ['s3', 33.0]], r.zinter('foo', 'bar', with_scores: true) + + assert_equal %w[s2 s3], r.zinter('foo', 'bar', aggregate: :min) + assert_equal [['s2', 2.0], ['s3', 3.0]], r.zinter('foo', 'bar', aggregate: :min, with_scores: true) + + assert_equal %w[s2 s3], r.zinter('foo', 'bar', aggregate: :max) + assert_equal [['s2', 20.0], ['s3', 30.0]], r.zinter('foo', 'bar', aggregate: :max, with_scores: true) + end + 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/streams.rb b/test/lint/streams.rb new file mode 100644 index 000000000..624915f58 --- /dev/null +++ b/test/lint/streams.rb @@ -0,0 +1,902 @@ +# frozen_string_literal: true + +module Lint + module Streams + MIN_REDIS_VERSION = '4.9.0' + ENTRY_ID_FORMAT = /\d+-\d+/.freeze + + def setup + super + omit_version(MIN_REDIS_VERSION) + end + + def test_xinfo_with_stream_subcommand + redis.xadd('s1', { f: 'v1' }) + redis.xadd('s1', { f: 'v2' }) + redis.xadd('s1', { f: 'v3' }) + redis.xadd('s1', { f: 'v4' }) + redis.xgroup(:create, 's1', 'g1', '$') + + actual = redis.xinfo(:stream, 's1') + + assert_match ENTRY_ID_FORMAT, actual['last-generated-id'] + assert_equal 4, actual['length'] + assert_equal 1, actual['groups'] + assert_equal true, actual.key?('radix-tree-keys') + assert_equal true, actual.key?('radix-tree-nodes') + assert_kind_of Array, actual['first-entry'] + assert_kind_of Array, actual['last-entry'] + end + + def test_xinfo_with_groups_subcommand + redis.xadd('s1', { f: 'v' }) + redis.xgroup(:create, 's1', 'g1', '$') + + actual = redis.xinfo(:groups, 's1').first + + assert_equal 0, actual['consumers'] + assert_equal 0, actual['pending'] + assert_equal 'g1', actual['name'] + assert_match ENTRY_ID_FORMAT, actual['last-delivered-id'] + end + + def test_xinfo_with_consumers_subcommand + redis.xadd('s1', { f: 'v' }) + redis.xgroup(:create, 's1', 'g1', '$') + assert_equal [], redis.xinfo(:consumers, 's1', 'g1') + end + + def test_xinfo_with_invalid_arguments + assert_raises(Redis::CommandError) { redis.xinfo('', '', '') } + assert_raises(Redis::CommandError) { redis.xinfo(nil, nil, nil) } + assert_raises(Redis::CommandError) { redis.xinfo(:stream, nil) } + assert_raises(Redis::CommandError) { redis.xinfo(:groups, nil) } + assert_raises(Redis::CommandError) { redis.xinfo(:consumers, nil) } + assert_raises(Redis::CommandError) { redis.xinfo(:consumers, 's1', nil) } + end + + def test_xadd_with_entry_as_splatted_params + assert_match ENTRY_ID_FORMAT, redis.xadd('s1', { f1: 'v1', f2: 'v2' }) + end + + def test_xadd_with_entry_as_a_hash_literal + entry = { f1: 'v1', f2: 'v2' } + assert_match ENTRY_ID_FORMAT, redis.xadd('s1', entry) + end + + def test_xadd_with_entry_id_option + entry_id = "#{Time.now.strftime('%s%L')}-14" + assert_equal entry_id, redis.xadd('s1', { f1: 'v1', f2: 'v2' }, id: entry_id) + end + + def test_xadd_with_invalid_entry_id_option + entry_id = 'invalid-format-entry-id' + assert_raises(Redis::CommandError, 'ERR Invalid stream ID specified as stream command argument') do + redis.xadd('s1', { f1: 'v1', f2: 'v2' }, id: entry_id) + end + end + + def test_xadd_with_old_entry_id_option + redis.xadd('s1', { f1: 'v1', f2: 'v2' }, id: '0-1') + err_msg = 'ERR The ID specified in XADD is equal or smaller than the target stream top item' + assert_raises(Redis::CommandError, err_msg) do + redis.xadd('s1', { f1: 'v1', f2: 'v2' }, id: '0-0') + end + end + + def test_xadd_with_maxlen_and_approximate_option + actual = redis.xadd('s1', { f1: 'v1', f2: 'v2' }, maxlen: 2, approximate: true) + assert_match ENTRY_ID_FORMAT, actual + end + + def test_xadd_with_nomkstream_option + omit_version('6.2.0') + + actual = redis.xadd('s1', { f1: 'v1', f2: 'v2' }, nomkstream: true) + assert_nil actual + + actual = redis.xadd('s1', { f1: 'v1', f2: 'v2' }, nomkstream: false) + assert_match ENTRY_ID_FORMAT, actual + end + + def test_xadd_with_invalid_arguments + assert_raises(TypeError) { redis.xadd(nil, {}) } + assert_raises(Redis::CommandError) { redis.xadd('', {}) } + assert_raises(Redis::CommandError) { redis.xadd('s1', {}) } + end + + def test_xtrim + redis.xadd('s1', { f: 'v1' }) + redis.xadd('s1', { f: 'v2' }) + redis.xadd('s1', { f: 'v3' }) + redis.xadd('s1', { f: 'v4' }) + assert_equal 2, redis.xtrim('s1', 2) + end + + def test_xtrim_with_approximate_option + redis.xadd('s1', { f: 'v1' }) + redis.xadd('s1', { f: 'v2' }) + redis.xadd('s1', { f: 'v3' }) + redis.xadd('s1', { f: 'v4' }) + assert_equal 0, redis.xtrim('s1', 2, approximate: true) + end + + def test_xtrim_with_limit_option + omit_version('6.2.0') + + begin + original = redis.config(:get, 'stream-node-max-entries')['stream-node-max-entries'] + redis.config(:set, 'stream-node-max-entries', 1) + + redis.xadd('s1', { f: 'v1' }) + redis.xadd('s1', { f: 'v2' }) + redis.xadd('s1', { f: 'v3' }) + redis.xadd('s1', { f: 'v4' }) + + assert_equal 1, redis.xtrim('s1', 0, approximate: true, limit: 1) + error = assert_raises(Redis::CommandError) { redis.xtrim('s1', 0, limit: 1) } + assert_equal "ERR syntax error, LIMIT cannot be used without the special ~ option", error.message + ensure + redis.config(:set, 'stream-node-max-entries', original) + end + end + + def test_xtrim_with_maxlen_strategy + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xadd('s1', { f: 'v1' }, id: '0-2') + redis.xadd('s1', { f: 'v1' }, id: '1-0') + redis.xadd('s1', { f: 'v1' }, id: '1-1') + assert_equal(2, redis.xtrim('s1', 2, strategy: 'MAXLEN')) + end + + def test_xtrim_with_minid_strategy + omit_version('6.2.0') + + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xadd('s1', { f: 'v1' }, id: '0-2') + redis.xadd('s1', { f: 'v1' }, id: '1-0') + redis.xadd('s1', { f: 'v1' }, id: '1-1') + assert_equal(2, redis.xtrim('s1', '1-0', strategy: 'MINID')) + end + + def test_xtrim_with_approximate_minid_strategy + omit_version('6.2.0') + + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xadd('s1', { f: 'v1' }, id: '0-2') + redis.xadd('s1', { f: 'v1' }, id: '1-0') + redis.xadd('s1', { f: 'v1' }, id: '1-1') + assert_equal(0, redis.xtrim('s1', '1-0', strategy: 'MINID', approximate: true)) + end + + def test_xtrim_with_invalid_strategy + omit_version('6.2.0') + + redis.xadd('s1', { f: 'v1' }) + error = assert_raises(Redis::CommandError) { redis.xtrim('s1', '1-0', strategy: '') } + assert_equal "ERR syntax error", error.message + end + + def test_xtrim_with_not_existed_stream + assert_equal 0, redis.xtrim('not-existed-stream', 2) + end + + def test_xtrim_with_invalid_arguments + if version >= '6.2' + assert_raises(Redis::CommandError) { redis.xtrim('', '') } + assert_equal 0, redis.xtrim('s1', 0) + assert_raises(Redis::CommandError) { redis.xtrim('s1', -1, approximate: true) } + else + assert_equal 0, redis.xtrim('', '') + assert_equal 0, redis.xtrim('s1', 0) + assert_equal 0, redis.xtrim('s1', -1, approximate: true) + end + end + + def test_xdel_with_splatted_entry_ids + redis.xadd('s1', { f: '1' }, id: '0-1') + redis.xadd('s1', { f: '2' }, id: '0-2') + assert_equal 2, redis.xdel('s1', '0-1', '0-2', '0-3') + end + + def test_xdel_with_arrayed_entry_ids + redis.xadd('s1', { f: '1' }, id: '0-1') + assert_equal 1, redis.xdel('s1', ['0-1', '0-2']) + end + + def test_xdel_with_invalid_entry_ids + assert_equal 0, redis.xdel('s1', 'invalid_format') + end + + def test_xdel_with_invalid_arguments + assert_raises(TypeError) { redis.xdel(nil, nil) } + assert_raises(TypeError) { redis.xdel(nil, [nil]) } + assert_equal 0, redis.xdel('', '') + assert_equal 0, redis.xdel('', ['']) + assert_raises(Redis::CommandError) { redis.xdel('s1', []) } + end + + def test_xrange + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + + actual = redis.xrange('s1') + + assert_equal(%w(v1 v2 v3), actual.map { |i| i.last['f'] }) + end + + def test_xrange_with_start_option + redis.xadd('s1', { f: 'v' }, id: '0-1') + redis.xadd('s1', { f: 'v' }, id: '0-2') + redis.xadd('s1', { f: 'v' }, id: '0-3') + + actual = redis.xrange('s1', '0-2') + + assert_equal %w(0-2 0-3), actual.map(&:first) + end + + def test_xrange_with_end_option + redis.xadd('s1', { f: 'v' }, id: '0-1') + redis.xadd('s1', { f: 'v' }, id: '0-2') + redis.xadd('s1', { f: 'v' }, id: '0-3') + + actual = redis.xrange('s1', '-', '0-2') + assert_equal %w(0-1 0-2), actual.map(&:first) + end + + def test_xrange_with_start_and_end_options + redis.xadd('s1', { f: 'v' }, id: '0-1') + redis.xadd('s1', { f: 'v' }, id: '0-2') + redis.xadd('s1', { f: 'v' }, id: '0-3') + + actual = redis.xrange('s1', '0-2', '0-2') + + assert_equal %w(0-2), actual.map(&:first) + end + + def test_xrange_with_incomplete_entry_id_options + redis.xadd('s1', { f: 'v' }, id: '0-1') + redis.xadd('s1', { f: 'v' }, id: '1-1') + redis.xadd('s1', { f: 'v' }, id: '2-1') + + actual = redis.xrange('s1', '0', '1') + + assert_equal 2, actual.size + assert_equal %w(0-1 1-1), actual.map(&:first) + end + + def test_xrange_with_count_option + redis.xadd('s1', { f: 'v' }, id: '0-1') + redis.xadd('s1', { f: 'v' }, id: '0-2') + redis.xadd('s1', { f: 'v' }, id: '0-3') + + actual = redis.xrange('s1', count: 2) + + assert_equal %w(0-1 0-2), actual.map(&:first) + end + + def test_xrange_with_not_existed_stream_key + assert_equal([], redis.xrange('not-existed')) + end + + def test_xrange_with_invalid_entry_id_options + assert_raises(Redis::CommandError) { redis.xrange('s1', 'invalid', 'invalid') } + end + + def test_xrange_with_invalid_arguments + assert_raises(TypeError) { redis.xrange(nil) } + assert_equal([], redis.xrange('')) + end + + def test_xrevrange + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + + actual = redis.xrevrange('s1') + + assert_equal %w(0-3 0-2 0-1), actual.map(&:first) + assert_equal(%w(v3 v2 v1), actual.map { |i| i.last['f'] }) + end + + def test_xrevrange_with_start_option + redis.xadd('s1', { f: 'v' }, id: '0-1') + redis.xadd('s1', { f: 'v' }, id: '0-2') + redis.xadd('s1', { f: 'v' }, id: '0-3') + + actual = redis.xrevrange('s1', '+', '0-2') + + assert_equal %w(0-3 0-2), actual.map(&:first) + end + + def test_xrevrange_with_end_option + redis.xadd('s1', { f: 'v' }, id: '0-1') + redis.xadd('s1', { f: 'v' }, id: '0-2') + redis.xadd('s1', { f: 'v' }, id: '0-3') + + actual = redis.xrevrange('s1', '0-2') + + assert_equal %w(0-2 0-1), actual.map(&:first) + end + + def test_xrevrange_with_start_and_end_options + redis.xadd('s1', { f: 'v' }, id: '0-1') + redis.xadd('s1', { f: 'v' }, id: '0-2') + redis.xadd('s1', { f: 'v' }, id: '0-3') + + actual = redis.xrevrange('s1', '0-2', '0-2') + + assert_equal %w(0-2), actual.map(&:first) + end + + def test_xrevrange_with_incomplete_entry_id_options + redis.xadd('s1', { f: 'v' }, id: '0-1') + redis.xadd('s1', { f: 'v' }, id: '1-1') + redis.xadd('s1', { f: 'v' }, id: '2-1') + + actual = redis.xrevrange('s1', '1', '0') + + assert_equal 2, actual.size + assert_equal '1-1', actual.first.first + end + + def test_xrevrange_with_count_option + redis.xadd('s1', { f: 'v' }, id: '0-1') + redis.xadd('s1', { f: 'v' }, id: '0-2') + redis.xadd('s1', { f: 'v' }, id: '0-3') + + actual = redis.xrevrange('s1', count: 2) + + assert_equal 2, actual.size + assert_equal '0-3', actual.first.first + end + + def test_xrevrange_with_not_existed_stream_key + assert_equal([], redis.xrevrange('not-existed')) + end + + def test_xrevrange_with_invalid_entry_id_options + assert_raises(Redis::CommandError) { redis.xrevrange('s1', 'invalid', 'invalid') } + end + + def test_xrevrange_with_invalid_arguments + assert_raises(TypeError) { redis.xrevrange(nil) } + assert_equal([], redis.xrevrange('')) + end + + def test_xlen + redis.xadd('s1', { f: 'v1' }) + redis.xadd('s1', { f: 'v2' }) + assert_equal 2, redis.xlen('s1') + end + + def test_xlen_with_not_existed_key + assert_equal 0, redis.xlen('not-existed') + end + + def test_xlen_with_invalid_key + assert_raises(TypeError) { redis.xlen(nil) } + assert_equal 0, redis.xlen('') + end + + def test_xread_with_a_key + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + + actual = redis.xread('s1', 0) + + assert_equal(%w(v1 v2), actual.fetch('s1').map { |i| i.last['f'] }) + end + + def test_xread_with_multiple_keys + redis.xadd('s1', { f: 'v01' }, id: '0-1') + redis.xadd('s1', { f: 'v02' }, id: '0-2') + redis.xadd('s2', { f: 'v11' }, id: '1-1') + redis.xadd('s2', { f: 'v12' }, id: '1-2') + + actual = redis.xread(%w[s1 s2], %w[0-1 1-1]) + + assert_equal 1, actual['s1'].size + assert_equal 1, actual['s2'].size + assert_equal 'v02', actual['s1'][0].last['f'] + assert_equal 'v12', actual['s2'][0].last['f'] + end + + def test_xread_with_count_option + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + + actual = redis.xread('s1', 0, count: 1) + + assert_equal 1, actual['s1'].size + end + + def test_xread_with_block_option + actual = redis.xread('s1', '$', block: LOW_TIMEOUT * 1000) + assert_equal({}, actual) + end + + def test_xread_does_not_raise_timeout_error_when_the_block_option_is_zero_msec + prepared = false + actual = nil + thread = Thread.new do + prepared = true + actual = redis.xread('s1', 0, block: 0) + end + Thread.pass until prepared + redis2 = init _new_client + redis2.xadd('s1', { f: 'v1' }, id: '0-1') + thread.join(3) + + assert_equal(['v1'], actual.fetch('s1').map { |i| i.last['f'] }) + end + + def test_xread_with_invalid_arguments + assert_raises(TypeError) { redis.xread(nil, nil) } + assert_raises(Redis::CommandError) { redis.xread('', '') } + assert_raises(Redis::CommandError) { redis.xread([], []) } + assert_raises(Redis::CommandError) { redis.xread([''], ['']) } + assert_raises(Redis::CommandError) { redis.xread('s1', '0-0', count: 'a') } + assert_raises(Redis::CommandError) { redis.xread('s1', %w[0-0 0-0]) } + end + + def test_xgroup_with_create_subcommand + redis.xadd('s1', { f: 'v' }) + assert_equal 'OK', redis.xgroup(:create, 's1', 'g1', '$') + end + + def test_xgroup_with_create_subcommand_and_mkstream_option + err_msg = 'ERR The XGROUP subcommand requires the key to exist. '\ + 'Note that for CREATE you may want to use the MKSTREAM option to create an empty stream automatically.' + assert_raises(Redis::CommandError, err_msg) { redis.xgroup(:create, 's2', 'g1', '$') } + assert_equal 'OK', redis.xgroup(:create, 's2', 'g1', '$', mkstream: true) + end + + def test_xgroup_with_create_subcommand_and_existed_stream_key + redis.xadd('s1', { f: 'v' }) + redis.xgroup(:create, 's1', 'g1', '$') + assert_raises(Redis::CommandError, 'BUSYGROUP Consumer Group name already exists') do + redis.xgroup(:create, 's1', 'g1', '$') + end + end + + def test_xgroup_with_setid_subcommand + redis.xadd('s1', { f: 'v' }) + redis.xgroup(:create, 's1', 'g1', '$') + assert_equal 'OK', redis.xgroup(:setid, 's1', 'g1', '0') + end + + def test_xgroup_with_destroy_subcommand + redis.xadd('s1', { f: 'v' }) + redis.xgroup(:create, 's1', 'g1', '$') + assert_equal 1, redis.xgroup(:destroy, 's1', 'g1') + end + + def test_xgroup_with_delconsumer_subcommand + redis.xadd('s1', { f: 'v' }) + redis.xgroup(:create, 's1', 'g1', '$') + assert_equal 0, redis.xgroup(:delconsumer, 's1', 'g1', 'c1') + end + + def test_xgroup_with_invalid_arguments + assert_raises(Redis::CommandError) { redis.xgroup(nil, nil, nil) } + assert_raises(Redis::CommandError) { redis.xgroup('', '', '') } + end + + def test_xreadgroup_with_a_key + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + + actual = redis.xreadgroup('g1', 'c1', 's1', '>') + + assert_equal 2, actual['s1'].size + assert_equal 'v2', actual['s1'][0].last['f'] + assert_equal 'v3', actual['s1'][1].last['f'] + end + + def test_xreadgroup_with_multiple_keys + redis.xadd('s1', { f: 'v01' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s2', { f: 'v11' }, id: '1-1') + redis.xgroup(:create, 's2', 'g1', '$') + redis.xadd('s1', { f: 'v02' }, id: '0-2') + redis.xadd('s2', { f: 'v12' }, id: '1-2') + + actual = redis.xreadgroup('g1', 'c1', %w[s1 s2], %w[> >]) + + assert_equal 1, actual['s1'].size + assert_equal 1, actual['s2'].size + assert_equal 'v02', actual['s1'][0].last['f'] + assert_equal 'v12', actual['s2'][0].last['f'] + end + + def test_xreadgroup_with_count_option + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + + actual = redis.xreadgroup('g1', 'c1', 's1', '>', count: 1) + + assert_equal 1, actual['s1'].size + end + + def test_xreadgroup_with_noack_option + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + + actual = redis.xreadgroup('g1', 'c1', 's1', '>', noack: true) + + assert_equal 2, actual['s1'].size + end + + def test_xreadgroup_with_block_option + redis.xadd('s1', { f: 'v' }) + redis.xgroup(:create, 's1', 'g1', '$') + + actual = redis.xreadgroup('g1', 'c1', 's1', '>', block: LOW_TIMEOUT * 1000) + + assert_equal({}, actual) + end + + def test_xreadgroup_with_invalid_arguments + assert_raises(TypeError) { redis.xreadgroup(nil, nil, nil, nil) } + assert_raises(Redis::CommandError) { redis.xreadgroup('', '', '', '') } + assert_raises(Redis::CommandError) { redis.xreadgroup('', '', [], []) } + assert_raises(Redis::CommandError) { redis.xreadgroup('', '', [''], ['']) } + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + assert_raises(Redis::CommandError) { redis.xreadgroup('g1', 'c1', 's1', '>', count: 'a') } + assert_raises(Redis::CommandError) { redis.xreadgroup('g1', 'c1', 's1', %w[> >]) } + end + + def test_xreadgroup_a_trimmed_entry + redis.xgroup(:create, 'k1', 'g1', '0', mkstream: true) + entry_id = redis.xadd('k1', { value: 'v1' }) + + assert_equal({ 'k1' => [[entry_id, { 'value' => 'v1' }]] }, redis.xreadgroup('g1', 'c1', 'k1', '>')) + assert_equal({ 'k1' => [[entry_id, { 'value' => 'v1' }]] }, redis.xreadgroup('g1', 'c1', 'k1', '0')) + redis.xtrim('k1', 0) + + assert_equal({ 'k1' => [[entry_id, nil]] }, redis.xreadgroup('g1', 'c1', 'k1', '0')) + end + + def test_xack_with_a_entry_id + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xreadgroup('g1', 'c1', 's1', '>') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + assert_equal 1, redis.xack('s1', 'g1', '0-2') + end + + def test_xack_with_splatted_entry_ids + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + redis.xadd('s1', { f: 'v4' }, id: '0-4') + redis.xadd('s1', { f: 'v5' }, id: '0-5') + assert_equal 2, redis.xack('s1', 'g1', '0-2', '0-3') + end + + def test_xack_with_arrayed_entry_ids + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + redis.xadd('s1', { f: 'v4' }, id: '0-4') + redis.xadd('s1', { f: 'v5' }, id: '0-5') + assert_equal 2, redis.xack('s1', 'g1', %w[0-2 0-3]) + end + + def test_xack_with_invalid_arguments + assert_raises(TypeError) { redis.xack(nil, nil, nil) } + assert_equal 0, redis.xack('', '', '') + assert_raises(Redis::CommandError) { redis.xack('', '', []) } + assert_equal 0, redis.xack('', '', ['']) + end + + def test_xclaim_with_splatted_entry_ids + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + sleep 0.01 + + actual = redis.xclaim('s1', 'g1', 'c2', 10, '0-2', '0-3') + + assert_equal %w(0-2 0-3), actual.map(&:first) + assert_equal(%w(v2 v3), actual.map { |i| i.last['f'] }) + end + + def test_xclaim_with_arrayed_entry_ids + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + sleep 0.01 + + actual = redis.xclaim('s1', 'g1', 'c2', 10, %w[0-2 0-3]) + + assert_equal %w(0-2 0-3), actual.map(&:first) + assert_equal(%w(v2 v3), actual.map { |i| i.last['f'] }) + end + + def test_xclaim_with_idle_option + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + sleep 0.01 + + actual = redis.xclaim('s1', 'g1', 'c2', 10, '0-2', '0-3', idle: 0) + + assert_equal %w(0-2 0-3), actual.map(&:first) + assert_equal(%w(v2 v3), actual.map { |i| i.last['f'] }) + end + + def test_xclaim_with_time_option + time = Time.now.strftime('%s%L') + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + sleep 0.01 + + actual = redis.xclaim('s1', 'g1', 'c2', 10, '0-2', '0-3', time: time) + + assert_equal %w(0-2 0-3), actual.map(&:first) + assert_equal(%w(v2 v3), actual.map { |i| i.last['f'] }) + end + + def test_xclaim_with_retrycount_option + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + sleep 0.01 + + actual = redis.xclaim('s1', 'g1', 'c2', 10, '0-2', '0-3', retrycount: 10) + + assert_equal %w(0-2 0-3), actual.map(&:first) + assert_equal(%w(v2 v3), actual.map { |i| i.last['f'] }) + end + + def test_xclaim_with_force_option + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + sleep 0.01 + + actual = redis.xclaim('s1', 'g1', 'c2', 10, '0-2', '0-3', force: true) + + assert_equal(%w(0-2 0-3), actual.map(&:first)) + assert_equal(%w(v2 v3), actual.map { |i| i.last['f'] }) + end + + def test_xclaim_with_justid_option + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + sleep 0.01 + + actual = redis.xclaim('s1', 'g1', 'c2', 10, '0-2', '0-3', justid: true) + + assert_equal 2, actual.size + assert_equal '0-2', actual[0] + assert_equal '0-3', actual[1] + end + + def test_xclaim_with_invalid_arguments + assert_raises(TypeError) { redis.xclaim(nil, nil, nil, nil, nil) } + assert_raises(Redis::CommandError) { redis.xclaim('', '', '', '', '') } + end + + def test_xautoclaim + omit_version('6.2.0') + + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + sleep 0.01 + + actual = redis.xautoclaim('s1', 'g1', 'c2', 10, '0-0') + + assert_equal '0-0', actual['next'] + assert_equal %w(0-2 0-3), actual['entries'].map(&:first) + assert_equal(%w(v2 v3), actual['entries'].map { |i| i.last['f'] }) + end + + def test_xautoclaim_with_justid_option + omit_version('6.2.0') + + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + sleep 0.01 + + actual = redis.xautoclaim('s1', 'g1', 'c2', 10, '0-0', justid: true) + + assert_equal '0-0', actual['next'] + assert_equal %w(0-2 0-3), actual['entries'] + end + + def test_xautoclaim_with_count_option + omit_version('6.2.0') + + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + sleep 0.01 + + actual = redis.xautoclaim('s1', 'g1', 'c2', 10, '0-0', count: 1) + + assert_equal '0-3', actual['next'] + assert_equal %w(0-2), actual['entries'].map(&:first) + assert_equal(%w(v2), actual['entries'].map { |i| i.last['f'] }) + end + + def test_xautoclaim_with_larger_interval + omit_version('6.2.0') + + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + sleep 0.01 + + actual = redis.xautoclaim('s1', 'g1', 'c2', 36_000, '0-0') + + assert_equal '0-0', actual['next'] + assert_equal [], actual['entries'] + end + + def test_xautoclaim_with_deleted_entry + omit_version('6.2.0') + + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xreadgroup('g1', 'c1', 's1', '>') + redis.xdel('s1', '0-2') + sleep 0.01 + + actual = redis.xautoclaim('s1', 'g1', 'c2', 0, '0-0') + + assert_equal '0-0', actual['next'] + assert_equal [], actual['entries'] + end + + def test_xpending + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + + actual = redis.xpending('s1', 'g1') + + assert_equal 2, actual['size'] + assert_equal '0-2', actual['min_entry_id'] + assert_equal '0-3', actual['max_entry_id'] + assert_equal '2', actual['consumers']['c1'] + end + + def test_xpending_with_range_options + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + redis.xadd('s1', { f: 'v4' }, id: '0-4') + redis.xreadgroup('g1', 'c2', 's1', '>') + + actual = redis.xpending('s1', 'g1', '-', '+', 10) + + assert_equal 3, actual.size + assert_equal '0-2', actual[0]['entry_id'] + assert_equal 'c1', actual[0]['consumer'] + assert_equal true, actual[0]['elapsed'] >= 0 + assert_equal 1, actual[0]['count'] + assert_equal '0-3', actual[1]['entry_id'] + assert_equal 'c1', actual[1]['consumer'] + assert_equal true, actual[1]['elapsed'] >= 0 + assert_equal 1, actual[1]['count'] + assert_equal '0-4', actual[2]['entry_id'] + assert_equal 'c2', actual[2]['consumer'] + assert_equal true, actual[2]['elapsed'] >= 0 + assert_equal 1, actual[2]['count'] + end + + def test_xpending_with_range_and_idle_options + target_version "6.2" do + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + + actual = redis.xpending('s1', 'g1', '-', '+', 10) + assert_equal 2, actual.size + actual = redis.xpending('s1', 'g1', '-', '+', 10, idle: 10) + assert_equal 0, actual.size + sleep 0.1 + actual = redis.xpending('s1', 'g1', '-', '+', 10, idle: 10) + assert_equal 2, actual.size + + redis.xadd('s1', { f: 'v4' }, id: '0-4') + redis.xreadgroup('g1', 'c2', 's1', '>') + + actual = redis.xpending('s1', 'g1', '-', '+', 10, idle: 1000) + assert_equal 0, actual.size + + actual = redis.xpending('s1', 'g1', '-', '+', 10) + assert_equal 3, actual.size + actual = redis.xpending('s1', 'g1', '-', '+', 10, idle: 10) + assert_equal 2, actual.size + sleep 0.01 + actual = redis.xpending('s1', 'g1', '-', '+', 10, idle: 10) + assert_equal 3, actual.size + + assert_equal '0-2', actual[0]['entry_id'] + assert_equal 'c1', actual[0]['consumer'] + assert_equal true, actual[0]['elapsed'] >= 0 + assert_equal 1, actual[0]['count'] + assert_equal '0-3', actual[1]['entry_id'] + assert_equal 'c1', actual[1]['consumer'] + assert_equal true, actual[1]['elapsed'] >= 0 + assert_equal 1, actual[1]['count'] + assert_equal '0-4', actual[2]['entry_id'] + assert_equal 'c2', actual[2]['consumer'] + assert_equal true, actual[2]['elapsed'] >= 0 + assert_equal 1, actual[2]['count'] + end + end + + def test_xpending_with_range_and_consumer_options + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + redis.xadd('s1', { f: 'v4' }, id: '0-4') + redis.xreadgroup('g1', 'c2', 's1', '>') + + actual = redis.xpending('s1', 'g1', '-', '+', 10, 'c1') + + assert_equal 2, actual.size + assert_equal '0-2', actual[0]['entry_id'] + assert_equal 'c1', actual[0]['consumer'] + assert_equal true, actual[0]['elapsed'] >= 0 + assert_equal 1, actual[0]['count'] + assert_equal '0-3', actual[1]['entry_id'] + assert_equal 'c1', actual[1]['consumer'] + assert_equal true, actual[1]['elapsed'] >= 0 + assert_equal 1, actual[1]['count'] + end + end +end diff --git a/test/lint/strings.rb b/test/lint/strings.rb index 381df3cdc..ebf10a8f8 100644 --- a/test/lint/strings.rb +++ b/test/lint/strings.rb @@ -1,6 +1,10 @@ -module Lint +# frozen_string_literal: true +module Lint module Strings + def mock(*args, &block) + redis_mock(*args, &block) + end def test_set_and_get r.set("foo", "s1") @@ -8,18 +12,6 @@ def test_set_and_get assert_equal "s1", r.get("foo") end - def test_set_and_get_with_brackets - r["foo"] = "s1" - - assert_equal "s1", r["foo"] - end - - def test_set_and_get_with_brackets_and_symbol - r[:foo] = "s1" - - assert_equal "s1", r[:foo] - end - def test_set_and_get_with_newline_characters r.set("foo", "1\n") @@ -35,52 +27,75 @@ def test_set_and_get_with_non_string_value end def test_set_and_get_with_ascii_characters - if defined?(Encoding) - with_external_encoding("ASCII-8BIT") do - (0..255).each do |i| - str = "#{i.chr}---#{i.chr}" - r.set("foo", str) - - assert_equal str, r.get("foo") - end - end + (0..255).each do |i| + str = "#{i.chr}---#{i.chr}" + r.set("foo", str) + + assert_equal str, r.get("foo") end end def test_set_with_ex - target_version "2.6.12" do - r.set("foo", "bar", :ex => 2) + r.set("foo", "bar", ex: 2) + assert_in_range 0..2, r.ttl("foo") + end + + def test_set_with_px + r.set("foo", "bar", px: 2000) + assert_in_range 0..2, r.ttl("foo") + end + + def test_set_with_exat + target_version "6.2" do + r.set("foo", "bar", exat: Time.now.to_i + 2) assert_in_range 0..2, r.ttl("foo") end end - def test_set_with_px - target_version "2.6.12" do - r.set("foo", "bar", :px => 2000) + def test_set_with_pxat + target_version "6.2" do + r.set("foo", "bar", pxat: (1000 * Time.now.to_i) + 2000) assert_in_range 0..2, r.ttl("foo") end end def test_set_with_nx - target_version "2.6.12" do - r.set("foo", "qux", :nx => true) - assert !r.set("foo", "bar", :nx => true) - assert_equal "qux", r.get("foo") + r.set("foo", "qux", nx: true) + assert !r.set("foo", "bar", nx: true) + assert_equal "qux", r.get("foo") - r.del("foo") - assert r.set("foo", "bar", :nx => true) - assert_equal "bar", r.get("foo") - end + r.del("foo") + assert r.set("foo", "bar", nx: true) + assert_equal "bar", r.get("foo") end def test_set_with_xx - target_version "2.6.12" do + r.set("foo", "qux") + assert r.set("foo", "bar", xx: true) + assert_equal "bar", r.get("foo") + + r.del("foo") + assert !r.set("foo", "bar", xx: true) + end + + def test_set_with_keepttl + target_version "6.0.0" do + r.set("foo", "qux", ex: 2) + assert_in_range 0..2, r.ttl("foo") + r.set("foo", "bar", keepttl: true) + assert_in_range 0..2, r.ttl("foo") + end + end + + def test_set_with_get + target_version "6.2" do r.set("foo", "qux") - assert r.set("foo", "bar", :xx => true) + + assert_equal "qux", r.set("foo", "bar", get: true) assert_equal "bar", r.get("foo") - r.del("foo") - assert !r.set("foo", "bar", :xx => true) + assert_nil r.set("baz", "bar", get: true) + assert_equal "bar", r.get("baz") end end @@ -99,20 +114,32 @@ def test_setex_with_non_string_value end def test_psetex - target_version "2.5.4" do - assert r.psetex("foo", 1000, "bar") - assert_equal "bar", r.get("foo") - assert [0, 1].include? r.ttl("foo") - end + assert r.psetex("foo", 1000, "bar") + assert_equal "bar", r.get("foo") + assert [0, 1].include? r.ttl("foo") end def test_psetex_with_non_string_value - target_version "2.5.4" do - value = ["b", "a", "r"] + value = ["b", "a", "r"] + + assert r.psetex("foo", 1000, value) + assert_equal value.to_s, r.get("foo") + assert [0, 1].include? r.ttl("foo") + end - assert r.psetex("foo", 1000, value) - assert_equal value.to_s, r.get("foo") - assert [0, 1].include? r.ttl("foo") + def test_getex + target_version "6.2" do + assert r.setex("foo", 1000, "bar") + assert_equal "bar", r.getex("foo", persist: true) + assert_equal(-1, r.ttl("foo")) + end + end + + def test_getdel + target_version "6.2" do + assert r.set("foo", "bar") + assert_equal "bar", r.getdel("foo") + assert_nil r.get("foo") end end @@ -167,11 +194,9 @@ def test_incrby end def test_incrbyfloat - target_version "2.5.4" do - assert_equal 1.23, r.incrbyfloat("foo", 1.23) - assert_equal 2 , r.incrbyfloat("foo", 0.77) - assert_equal 1.9 , r.incrbyfloat("foo", -0.1) - end + assert_equal 1.23, r.incrbyfloat("foo", 1.23) + assert_equal 2, r.incrbyfloat("foo", 0.77) + assert_equal 1.9, r.incrbyfloat("foo", -0.1) end def test_decr @@ -218,11 +243,18 @@ def test_setbit end def test_bitcount - target_version "2.5.10" do + r.set("foo", "abcde") + + assert_equal 10, r.bitcount("foo", 1, 3) + assert_equal 17, r.bitcount("foo", 0, -1) + end + + def test_bitcount_bits_range + target_version "7.0" do r.set("foo", "abcde") - assert_equal 10, r.bitcount("foo", 1, 3) - assert_equal 17, r.bitcount("foo", 0, -1) + assert_equal 10, r.bitcount("foo", 8, 31, scale: :bit) + assert_equal 17, r.bitcount("foo", 0, -1, scale: :byte) end end @@ -248,7 +280,7 @@ def test_setrange_with_non_string_value r.setrange("foo", 2, value) - assert_equal "ab#{value.to_s}", r.get("foo") + assert_equal "ab#{value}", r.get("foo") end def test_strlen @@ -256,5 +288,103 @@ def test_strlen assert_equal 5, r.strlen("foo") end + + def test_bitfield + 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 + + 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') + 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_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 |pipeline| + pipeline.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_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_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 + 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(: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".b, r.get('~foo{1}') + end end end diff --git a/test/lint/value_types.rb b/test/lint/value_types.rb index c4deb233d..5ee2f2ca8 100644 --- a/test/lint/value_types.rb +++ b/test/lint/value_types.rb @@ -1,13 +1,41 @@ -module Lint +# frozen_string_literal: true +module Lint module ValueTypes - def test_exists - assert_equal false, r.exists("foo") + assert_equal 0, r.exists("foo") r.set("foo", "s1") - assert_equal true, r.exists("foo") + assert_equal 1, r.exists("foo") + assert_equal 1, r.exists(["foo"]) + end + + def test_variadic_exists + assert_equal 0, r.exists("{1}foo", "{1}bar") + + r.set("{1}foo", "s1") + + assert_equal 1, r.exists("{1}foo", "{1}bar") + + r.set("{1}bar", "s2") + + assert_equal 2, r.exists("{1}foo", "{1}bar") + assert_equal 2, r.exists(["{1}foo", "{1}bar"]) + end + + def test_exists? + assert_equal false, r.exists?("{1}foo", "{1}bar") + + r.set("{1}foo", "s1") + + assert_equal true, r.exists?("{1}foo") + assert_equal true, r.exists?(["{1}foo"]) + + r.set("{1}bar", "s1") + + assert_equal true, r.exists?("{1}foo", "{1}bar") + assert_equal true, r.exists?(["{1}foo", "{1}bar"]) end def test_type @@ -23,20 +51,48 @@ def test_keys r.set("fo", "s2") r.set("foo", "s3") - assert_equal ["f","fo", "foo"], r.keys("f*").sort + assert_equal ["f", "fo", "foo"], r.keys("f*").sort end def test_expire r.set("foo", "s1") assert r.expire("foo", 2) assert_in_range 0..2, r.ttl("foo") + + target_version "7.0.0" do + r.set("bar", "s2") + refute r.expire("bar", 5, xx: true) + assert r.expire("bar", 5, nx: true) + refute r.expire("bar", 5, nx: true) + assert r.expire("bar", 5, xx: true) + + r.expire("bar", 10) + refute r.expire("bar", 15, lt: true) + refute r.expire("bar", 5, gt: true) + assert r.expire("bar", 15, gt: true) + assert r.expire("bar", 5, lt: true) + end end def test_pexpire - target_version "2.5.4" do - r.set("foo", "s1") - assert r.pexpire("foo", 2000) - assert_in_range 0..2, r.ttl("foo") + r.set("foo", "s1") + assert r.pexpire("foo", 2000) + assert_in_range 0..2, r.ttl("foo") + end + + def test_pexpire_keywords + target_version "7.0.0" do + r.set("bar", "s2") + refute r.pexpire("bar", 5_000, xx: true) + assert r.pexpire("bar", 5_000, nx: true) + refute r.pexpire("bar", 5_000, nx: true) + assert r.pexpire("bar", 5_000, xx: true) + + r.pexpire("bar", 10_000) + refute r.pexpire("bar", 15_000, lt: true) + refute r.pexpire("bar", 5_000, gt: true) + assert r.pexpire("bar", 15_000, gt: true) + assert r.pexpire("bar", 5_000, lt: true) end end @@ -46,11 +102,41 @@ def test_expireat assert_in_range 0..2, r.ttl("foo") end + def test_expireat_keywords + target_version "7.0.0" do + r.set("bar", "s2") + refute r.expireat("bar", (Time.now + 5).to_i, xx: true) + assert r.expireat("bar", (Time.now + 5).to_i, nx: true) + refute r.expireat("bar", (Time.now + 5).to_i, nx: true) + assert r.expireat("bar", (Time.now + 5).to_i, xx: true) + + r.expireat("bar", (Time.now + 10).to_i) + refute r.expireat("bar", (Time.now + 15).to_i, lt: true) + refute r.expireat("bar", (Time.now + 5).to_i, gt: true) + assert r.expireat("bar", (Time.now + 15).to_i, gt: true) + assert r.expireat("bar", (Time.now + 5).to_i, lt: true) + end + end + def test_pexpireat - target_version "2.5.4" do - r.set("foo", "s1") - assert r.pexpireat("foo", (Time.now + 2).to_i * 1_000) - assert_in_range 0..2, r.ttl("foo") + r.set("foo", "s1") + assert r.pexpireat("foo", (Time.now + 2).to_i * 1_000) + assert_in_range 0..2, r.ttl("foo") + end + + def test_pexpireat_keywords + target_version "7.0.0" do + r.set("bar", "s2") + refute r.pexpireat("bar", (Time.now + 5).to_i * 1_000, xx: true) + assert r.pexpireat("bar", (Time.now + 5).to_i * 1_000, nx: true) + refute r.pexpireat("bar", (Time.now + 5).to_i * 1_000, nx: true) + assert r.pexpireat("bar", (Time.now + 5).to_i * 1_000, xx: true) + + r.pexpireat("bar", (Time.now + 10).to_i * 1_000) + refute r.pexpireat("bar", (Time.now + 15).to_i * 1_000, lt: true) + refute r.pexpireat("bar", (Time.now + 5).to_i * 1_000, gt: true) + assert r.pexpireat("bar", (Time.now + 15).to_i * 1_000, gt: true) + assert r.pexpireat("bar", (Time.now + 5).to_i * 1_000, lt: true) end end @@ -59,7 +145,7 @@ def test_persist r.expire("foo", 1) r.persist("foo") - assert(-1 == r.ttl("foo")) + assert(r.ttl("foo") == -1) end def test_ttl @@ -69,31 +155,35 @@ def test_ttl end def test_pttl - target_version "2.5.4" do - r.set("foo", "s1") - r.expire("foo", 2) - assert_in_range 1..2000, r.pttl("foo") - end + r.set("foo", "s1") + r.expire("foo", 2) + assert_in_range 1..2000, r.pttl("foo") end def test_dump_and_restore - target_version "2.5.7" do - r.set("foo", "a") - v = r.dump("foo") - r.del("foo") - - assert r.restore("foo", 1000, v) - assert_equal "a", r.get("foo") - assert [0, 1].include? r.ttl("foo") - - r.rpush("bar", ["b", "c", "d"]) - w = r.dump("bar") - r.del("bar") - - assert r.restore("bar", 1000, w) - assert_equal ["b", "c", "d"], r.lrange("bar", 0, -1) - assert [0, 1].include? r.ttl("bar") - end + r.set("foo", "a") + v = r.dump("foo") + r.del("foo") + + assert r.restore("foo", 1000, v) + assert_equal "a", r.get("foo") + assert [0, 1].include? r.ttl("foo") + + r.rpush("bar", ["b", "c", "d"]) + w = r.dump("bar") + r.del("bar") + + assert r.restore("bar", 1000, w) + assert_equal ["b", "c", "d"], r.lrange("bar", 0, -1) + assert [0, 1].include? r.ttl("bar") + + r.set("bar", "somethingelse") + assert_raises(Redis::CommandError) { r.restore("bar", 1000, w) } # ensure by default replace is false + assert_raises(Redis::CommandError) { r.restore("bar", 1000, w, replace: false) } + assert_equal "somethingelse", r.get("bar") + assert r.restore("bar", 1000, w, replace: true) + assert_equal ["b", "c", "d"], r.lrange("bar", 0, -1) + assert [0, 1].include? r.ttl("bar") end def test_move @@ -108,7 +198,7 @@ def test_move r.set "bar", "s2" assert r.move("foo", 14) - assert_equal nil, r.get("foo") + assert_nil r.get("foo") assert !r.move("bar", 14) assert_equal "s2", r.get("bar") @@ -118,5 +208,41 @@ def test_move assert_equal "s1", r.get("foo") assert_equal "s3", r.get("bar") end + + def test_copy + target_version("6.2") do + with_db(14) do + r.flushdb + + r.set "foo", "s1" + r.set "bar", "s2" + + assert r.copy("foo", "baz") + assert_equal "s1", r.get("baz") + + assert !r.copy("foo", "bar") + assert r.copy("foo", "bar", replace: true) + assert_equal "s1", r.get("bar") + end + + with_db(15) do + r.set "foo", "s3" + r.set "bar", "s4" + end + + with_db(14) do + assert r.copy("foo", "baz", db: 15) + assert_equal "s1", r.get("foo") + + assert !r.copy("foo", "bar", db: 15) + assert r.copy("foo", "bar", db: 15, replace: true) + end + + with_db(15) do + assert_equal "s1", r.get("baz") + assert_equal "s1", r.get("bar") + end + end + end end end diff --git a/test/persistence_control_commands_test.rb b/test/persistence_control_commands_test.rb deleted file mode 100644 index 281657152..000000000 --- a/test/persistence_control_commands_test.rb +++ /dev/null @@ -1,26 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestPersistenceControlCommands < Test::Unit::TestCase - - include Helper::Client - - def test_save - redis_mock(:save => lambda { "+SAVE" }) do |redis| - assert_equal "SAVE", redis.save - end - end - - def test_bgsave - redis_mock(:bgsave => lambda { "+BGSAVE" }) do |redis| - assert_equal "BGSAVE", redis.bgsave - end - end - - def test_lastsave - redis_mock(:lastsave => lambda { "+LASTSAVE" }) do |redis| - assert_equal "LASTSAVE", redis.lastsave - end - end -end diff --git a/test/publish_subscribe_test.rb b/test/publish_subscribe_test.rb deleted file mode 100644 index e607e628a..000000000 --- a/test/publish_subscribe_test.rb +++ /dev/null @@ -1,282 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestPublishSubscribe < Test::Unit::TestCase - - include Helper::Client - - class TestError < StandardError - end - - def test_subscribe_and_unsubscribe - @subscribed = false - @unsubscribed = false - - wire = Wire.new do - r.subscribe("foo") do |on| - on.subscribe do |channel, total| - @subscribed = true - @t1 = total - end - - on.message do |channel, message| - if message == "s1" - r.unsubscribe - @message = message - end - end - - on.unsubscribe do |channel, total| - @unsubscribed = true - @t2 = total - end - end - end - - # Wait until the subscription is active before publishing - Wire.pass while !@subscribed - - Redis.new(OPTIONS).publish("foo", "s1") - - wire.join - - assert @subscribed - assert_equal 1, @t1 - assert @unsubscribed - assert_equal 0, @t2 - assert_equal "s1", @message - end - - def test_psubscribe_and_punsubscribe - @subscribed = false - @unsubscribed = false - - wire = Wire.new do - r.psubscribe("f*") do |on| - on.psubscribe do |pattern, total| - @subscribed = true - @t1 = total - end - - on.pmessage do |pattern, channel, message| - if message == "s1" - r.punsubscribe - @message = message - end - end - - on.punsubscribe do |pattern, total| - @unsubscribed = true - @t2 = total - end - end - end - - # Wait until the subscription is active before publishing - Wire.pass while !@subscribed - - Redis.new(OPTIONS).publish("foo", "s1") - - wire.join - - assert @subscribed - assert_equal 1, @t1 - assert @unsubscribed - assert_equal 0, @t2 - assert_equal "s1", @message - end - - def test_pubsub_with_numpat_subcommand - target_version("2.8.0") do - @subscribed = false - wire = Wire.new do - r.psubscribe("f*") do |on| - on.psubscribe { |channel, total| @subscribed = true } - on.pmessage { |pattern, channel, message| r.punsubscribe } - end - end - Wire.pass while !@subscribed - redis = Redis.new(OPTIONS) - numpat_result = redis.pubsub(:numpat) - - redis.publish("foo", "s1") - wire.join - - assert_equal redis.pubsub(:numpat), 0 - assert_equal numpat_result, 1 - end - end - - - def test_pubsub_with_channels_and_numsub_subcommnads - target_version("2.8.0") do - @subscribed = false - wire = Wire.new do - r.subscribe("foo") do |on| - on.subscribe { |channel, total| @subscribed = true } - on.message { |channel, message| r.unsubscribe } - end - end - Wire.pass while !@subscribed - redis = Redis.new(OPTIONS) - channels_result = redis.pubsub(:channels) - numsub_result = redis.pubsub(:numsub, 'foo', 'boo') - - redis.publish("foo", "s1") - wire.join - - assert_equal channels_result, ['foo'] - assert_equal numsub_result, ['foo', 1, 'boo', 0] - end - end - - def test_subscribe_connection_usable_after_raise - @subscribed = false - - wire = Wire.new do - begin - r.subscribe("foo") do |on| - on.subscribe do |channel, total| - @subscribed = true - end - - on.message do |channel, message| - raise TestError - end - end - rescue TestError - end - end - - # Wait until the subscription is active before publishing - Wire.pass while !@subscribed - - Redis.new(OPTIONS).publish("foo", "s1") - - wire.join - - assert_equal "PONG", r.ping - end - - def test_psubscribe_connection_usable_after_raise - @subscribed = false - - wire = Wire.new do - begin - r.psubscribe("f*") do |on| - on.psubscribe do |pattern, total| - @subscribed = true - end - - on.pmessage do |pattern, channel, message| - raise TestError - end - end - rescue TestError - end - end - - # Wait until the subscription is active before publishing - Wire.pass while !@subscribed - - Redis.new(OPTIONS).publish("foo", "s1") - - wire.join - - assert_equal "PONG", r.ping - end - - def test_subscribe_within_subscribe - @channels = [] - - wire = Wire.new do - r.subscribe("foo") do |on| - on.subscribe do |channel, total| - @channels << channel - - r.subscribe("bar") if channel == "foo" - r.unsubscribe if channel == "bar" - end - end - end - - wire.join - - assert_equal ["foo", "bar"], @channels - end - - def test_other_commands_within_a_subscribe - assert_raise Redis::CommandError do - r.subscribe("foo") do |on| - on.subscribe do |channel, total| - r.set("bar", "s2") - end - end - end - end - - def test_subscribe_without_a_block - assert_raise LocalJumpError do - r.subscribe("foo") - end - end - - def test_unsubscribe_without_a_subscribe - assert_raise RuntimeError do - r.unsubscribe - end - - assert_raise RuntimeError do - r.punsubscribe - end - end - - def test_subscribe_past_a_timeout - # For some reason, a thread here doesn't reproduce the issue. - sleep = %{sleep #{OPTIONS[:timeout] * 2}} - publish = %{ruby -rsocket -e 't=TCPSocket.new("127.0.0.1",#{OPTIONS[:port]});t.write("publish foo bar\\r\\n");t.read(4);t.close'} - cmd = [sleep, publish].join("; ") - - IO.popen(cmd, "r+") do |pipe| - received = false - - r.subscribe "foo" do |on| - on.message do |channel, message| - received = true - r.unsubscribe - end - end - - assert received - end - end - - def test_subscribe_with_timeout - received = false - - assert_raise Redis::TimeoutError do - r.subscribe_with_timeout(1, "foo") do |on| - on.message do |channel, message| - received = true - end - end - end - - assert !received - end - - def test_psubscribe_with_timeout - received = false - - assert_raise Redis::TimeoutError do - r.psubscribe_with_timeout(1, "f*") do |on| - on.message do |channel, message| - received = true - end - end - end - - assert !received - end -end diff --git a/test/redis/bitpos_test.rb b/test/redis/bitpos_test.rb new file mode 100644 index 000000000..89304493b --- /dev/null +++ b/test/redis/bitpos_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "helper" + +class TestBitpos < Minitest::Test + include Helper::Client + + def test_bitpos_empty_zero + r.del "foo" + assert_equal(0, r.bitpos("foo", 0)) + end + + def test_bitpos_empty_one + r.del "foo" + assert_equal(-1, r.bitpos("foo", 1)) + end + + def test_bitpos_zero + r.set "foo", "\xff\xf0\x00" + assert_equal(12, r.bitpos("foo", 0)) + end + + def test_bitpos_one + r.set "foo", "\x00\x0f\x00" + assert_equal(12, r.bitpos("foo", 1)) + end + + def test_bitpos_zero_end_is_given + r.set "foo", "\xff\xff\xff" + assert_equal(24, r.bitpos("foo", 0)) + assert_equal(24, r.bitpos("foo", 0, 0)) + assert_equal(-1, r.bitpos("foo", 0, 0, -1)) + end + + def test_bitpos_one_intervals + r.set "foo", "\x00\xff\x00" + assert_equal(8, r.bitpos("foo", 1, 0, -1)) + assert_equal(8, r.bitpos("foo", 1, 1, -1)) + assert_equal(-1, r.bitpos("foo", 1, 2, -1)) + assert_equal(-1, r.bitpos("foo", 1, 2, 200)) + assert_equal(8, r.bitpos("foo", 1, 1, 1)) + end + + def test_bitpos_one_intervals_bit_range + target_version "7.0" do + r.set "foo", "\x00\xff\x00" + assert_equal(8, r.bitpos("foo", 1, 8, -1, scale: 'bit')) + assert_equal(-1, r.bitpos("foo", 1, 8, -1, scale: 'byte')) + end + end + + def test_bitpos_raise_exception_if_stop_not_start + assert_raises(ArgumentError) do + r.bitpos("foo", 0, nil, 2) + end + end +end diff --git a/test/redis/blocking_commands_test.rb b/test/redis/blocking_commands_test.rb new file mode 100644 index 000000000..bdd64e8ec --- /dev/null +++ b/test/redis/blocking_commands_test.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "helper" + +class TestBlockingCommands < Minitest::Test + include Helper::Client + include Lint::BlockingCommands + + def assert_takes_longer_than_client_timeout + timeout = LOW_TIMEOUT + delay = timeout * 5 + + mock(delay: delay) do |r| + t1 = Time.now + yield(r) + t2 = Time.now + + assert_operator delay, :<=, (t2 - t1) + end + end + + def test_blmove_disable_client_timeout + target_version "6.2" do + assert_takes_longer_than_client_timeout do |r| + assert_equal '0', r.blmove('foo', 'bar', 'LEFT', 'RIGHT') + end + end + end + + def test_blpop_disable_client_timeout + assert_takes_longer_than_client_timeout do |r| + assert_equal %w[foo 0], r.blpop('foo') + end + end + + def test_brpop_disable_client_timeout + assert_takes_longer_than_client_timeout do |r| + assert_equal %w[foo 0], r.brpop('foo') + end + end + + def test_brpoplpush_disable_client_timeout + assert_takes_longer_than_client_timeout do |r| + assert_equal '0', r.brpoplpush('foo', 'bar') + end + end + + def test_brpoplpush_in_transaction + results = r.multi do |transaction| + transaction.brpoplpush('foo', 'bar') + transaction.brpoplpush('foo', 'bar', timeout: 2) + end + assert_equal [nil, nil], results + end + + def test_brpoplpush_in_pipeline + mock do |r| + results = r.pipelined do |transaction| + transaction.brpoplpush('foo', 'bar') + transaction.brpoplpush('foo', 'bar', timeout: 2) + end + assert_equal ['0', '2'], results + end + end +end diff --git a/test/redis/client_test.rb b/test/redis/client_test.rb new file mode 100644 index 000000000..bec97431c --- /dev/null +++ b/test/redis/client_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "helper" + +class TestClient < Minitest::Test + include Helper::Client + + def test_call + result = r.call("PING") + assert_equal result, "PONG" + end + + def test_call_with_arguments + result = r.call("SET", "foo", "bar") + assert_equal result, "OK" + end + + def test_call_integers + result = r.call("INCR", "foo") + assert_equal result, 1 + end + + def test_call_raise + assert_raises(Redis::CommandError) do + r.call("INCR") + end + end + + def test_error_translate_subclasses + error = Class.new(RedisClient::CommandError) + assert_equal Redis::CommandError, Redis::Client.send(:translate_error_class, error) + + assert_raises KeyError do + Redis::Client.send(:translate_error_class, StandardError) + end + end + + def test_mixed_encoding + r.call("MSET", "fée", "\x00\xFF".b, "じ案".encode(Encoding::SHIFT_JIS), "\t".encode(Encoding::ASCII)) + assert_equal "\x00\xFF".b, r.call("GET", "fée") + assert_equal "\t", r.call("GET", "じ案".encode(Encoding::SHIFT_JIS)) + + r.call("SET", "\x00\xFF", "fée") + assert_equal "fée", r.call("GET", "\x00\xFF".b) + end +end diff --git a/test/redis/commands_on_geo_test.rb b/test/redis/commands_on_geo_test.rb new file mode 100644 index 000000000..62ba99c0a --- /dev/null +++ b/test/redis/commands_on_geo_test.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "helper" + +class TestCommandsGeo < Minitest::Test + include Helper::Client + + def setup + super + + added_items_count = r.geoadd("Sicily", 13.361389, 38.115556, "Palermo", 15.087269, 37.502669, "Catania") + assert_equal 2, added_items_count + end + + def test_geoadd_with_array_params + added_items_count = r.geoadd("SicilyArray", [13.361389, 38.115556, "Palermo", 15.087269, 37.502669, "Catania"]) + assert_equal 2, added_items_count + end + + def test_georadius_with_same_params + r.geoadd("Chad", 15, 15, "Kanem") + nearest_cities = r.georadius("Chad", 15, 15, 15, 'km', sort: 'asc') + assert_equal %w(Kanem), nearest_cities + end + + def test_georadius_with_sort + nearest_cities = r.georadius("Sicily", 15, 37, 200, 'km', sort: 'asc') + assert_equal %w(Catania Palermo), nearest_cities + + farthest_cities = r.georadius("Sicily", 15, 37, 200, 'km', sort: 'desc') + assert_equal %w(Palermo Catania), farthest_cities + end + + def test_georadius_with_count + city = r.georadius("Sicily", 15, 37, 200, 'km', count: 1) + assert_equal %w(Catania), city + end + + def test_georadius_with_options_count_sort + city = r.georadius("Sicily", 15, 37, 200, 'km', sort: :desc, options: :WITHDIST, count: 1) + assert_equal [["Palermo", "190.4424"]], city + end + + def test_georadiusbymember_with_sort + nearest_cities = r.georadiusbymember("Sicily", "Catania", 200, 'km', sort: 'asc') + assert_equal %w(Catania Palermo), nearest_cities + + farthest_cities = r.georadiusbymember("Sicily", "Catania", 200, 'km', sort: 'desc') + assert_equal %w(Palermo Catania), farthest_cities + end + + def test_georadiusbymember_with_count + city = r.georadiusbymember("Sicily", "Catania", 200, 'km', count: 1) + assert_equal %w(Catania), city + end + + def test_georadiusbymember_with_options_count_sort + city = r.georadiusbymember("Sicily", "Catania", 200, 'km', sort: :desc, options: :WITHDIST, count: 1) + assert_equal [["Palermo", "166.2742"]], city + end + + def test_geopos + location = r.geopos("Sicily", "Catania") + assert_equal [["15.08726745843887329", "37.50266842333162032"]], location + + locations = r.geopos("Sicily", ["Palermo", "Catania"]) + assert_equal [["13.36138933897018433", "38.11555639549629859"], ["15.08726745843887329", "37.50266842333162032"]], locations + end + + def test_geopos_nonexistant_location + location = r.geopos("Sicily", "Rome") + assert_equal [nil], location + + locations = r.geopos("Sicily", ["Rome", "Catania"]) + assert_equal [nil, ["15.08726745843887329", "37.50266842333162032"]], locations + end + + def test_geodist + distination_in_meters = r.geodist("Sicily", "Palermo", "Catania") + assert_equal "166274.1516", distination_in_meters + + distination_in_feet = r.geodist("Sicily", "Palermo", "Catania", 'ft') + assert_equal "545518.8700", distination_in_feet + end + + def test_geodist_with_nonexistant_location + distination = r.geodist("Sicily", "Palermo", "Rome") + assert_nil distination + end + + def test_geohash + geohash = r.geohash("Sicily", "Palermo") + assert_equal ["sqc8b49rny0"], geohash + + geohashes = r.geohash("Sicily", ["Palermo", "Catania"]) + assert_equal %w(sqc8b49rny0 sqdtr74hyu0), geohashes + end + + def test_geohash_with_nonexistant_location + geohashes = r.geohash("Sicily", ["Palermo", "Rome"]) + assert_equal ["sqc8b49rny0", nil], geohashes + end +end diff --git a/test/redis/commands_on_hashes_test.rb b/test/redis/commands_on_hashes_test.rb new file mode 100644 index 000000000..18d75b7ad --- /dev/null +++ b/test/redis/commands_on_hashes_test.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "helper" + +class TestCommandsOnHashes < Minitest::Test + include Helper::Client + include Lint::Hashes +end diff --git a/test/redis/commands_on_hyper_log_log_test.rb b/test/redis/commands_on_hyper_log_log_test.rb new file mode 100644 index 000000000..5dc3b6209 --- /dev/null +++ b/test/redis/commands_on_hyper_log_log_test.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "helper" + +class TestCommandsOnHyperLogLog < Minitest::Test + include Helper::Client + include Lint::HyperLogLog +end diff --git a/test/redis/commands_on_lists_test.rb b/test/redis/commands_on_lists_test.rb new file mode 100644 index 000000000..c34fb091f --- /dev/null +++ b/test/redis/commands_on_lists_test.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "helper" + +class TestCommandsOnLists < Minitest::Test + include Helper::Client + include Lint::Lists +end diff --git a/test/redis/commands_on_sets_test.rb b/test/redis/commands_on_sets_test.rb new file mode 100644 index 000000000..8cc2388c6 --- /dev/null +++ b/test/redis/commands_on_sets_test.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "helper" + +class TestCommandsOnSets < Minitest::Test + include Helper::Client + include Lint::Sets +end diff --git a/test/redis/commands_on_sorted_sets_test.rb b/test/redis/commands_on_sorted_sets_test.rb new file mode 100644 index 000000000..f659875ad --- /dev/null +++ b/test/redis/commands_on_sorted_sets_test.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "helper" + +class TestCommandsOnSortedSets < Minitest::Test + include Helper::Client + include Lint::SortedSets +end diff --git a/test/redis/commands_on_streams_test.rb b/test/redis/commands_on_streams_test.rb new file mode 100644 index 000000000..a685baf5d --- /dev/null +++ b/test/redis/commands_on_streams_test.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "helper" + +# ruby -w -Itest test/commands_on_streams_test.rb +# @see https://redis.io/commands#stream +class TestCommandsOnStreams < Minitest::Test + include Helper::Client + include Lint::Streams +end diff --git a/test/redis/commands_on_strings_test.rb b/test/redis/commands_on_strings_test.rb new file mode 100644 index 000000000..a51766814 --- /dev/null +++ b/test/redis/commands_on_strings_test.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "helper" + +class TestCommandsOnStrings < Minitest::Test + include Helper::Client + include Lint::Strings +end diff --git a/test/redis/commands_on_value_types_test.rb b/test/redis/commands_on_value_types_test.rb new file mode 100644 index 000000000..b79d6898b --- /dev/null +++ b/test/redis/commands_on_value_types_test.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +require "helper" + +class TestCommandsOnValueTypes < Minitest::Test + include Helper::Client + include Lint::ValueTypes + + def test_del + r.set "foo", "s1" + r.set "bar", "s2" + r.set "baz", "s3" + + assert_equal ["bar", "baz", "foo"], r.keys("*").sort + + assert_equal 0, r.del("") + + assert_equal 1, r.del("foo") + + assert_equal ["bar", "baz"], r.keys("*").sort + + assert_equal 2, r.del("bar", "baz") + + assert_equal [], r.keys("*").sort + end + + def test_del_with_array_argument + r.set "foo", "s1" + r.set "bar", "s2" + r.set "baz", "s3" + + assert_equal ["bar", "baz", "foo"], r.keys("*").sort + + assert_equal 0, r.del([]) + + assert_equal 1, r.del(["foo"]) + + assert_equal ["bar", "baz"], r.keys("*").sort + + assert_equal 2, r.del(["bar", "baz"]) + + assert_equal [], r.keys("*").sort + end + + def test_unlink + r.set "foo", "s1" + r.set "bar", "s2" + r.set "baz", "s3" + + assert_equal ["bar", "baz", "foo"], r.keys("*").sort + + assert_equal 1, r.unlink("foo") + + assert_equal ["bar", "baz"], r.keys("*").sort + + assert_equal 2, r.unlink("bar", "baz") + + assert_equal [], r.keys("*").sort + end + + def test_unlink_with_array_argument + r.set "foo", "s1" + r.set "bar", "s2" + r.set "baz", "s3" + + assert_equal ["bar", "baz", "foo"], r.keys("*").sort + + assert_equal 1, r.unlink(["foo"]) + + assert_equal ["bar", "baz"], r.keys("*").sort + + assert_equal 2, r.unlink(["bar", "baz"]) + + assert_equal [], r.keys("*").sort + end + + def test_randomkey + assert r.randomkey.to_s.empty? + + r.set("foo", "s1") + + assert_equal "foo", r.randomkey + + r.set("bar", "s2") + + 4.times do + assert ["foo", "bar"].include?(r.randomkey) + end + end + + def test_rename + r.set("foo", "s1") + r.rename "foo", "bar" + + assert_equal "s1", r.get("bar") + assert_nil r.get("foo") + end + + def test_renamenx + r.set("foo", "s1") + r.set("bar", "s2") + + assert_equal false, r.renamenx("foo", "bar") + + assert_equal "s1", r.get("foo") + assert_equal "s2", r.get("bar") + end + + def test_dbsize + assert_equal 0, r.dbsize + + r.set("foo", "s1") + + assert_equal 1, r.dbsize + end + + def test_flushdb + # Test defaults + r.set("foo", "s1") + r.set("bar", "s2") + + assert_equal 2, r.dbsize + + r.flushdb + + assert_equal 0, r.dbsize + + # Test sync + r.set("foo", "s1") + r.set("bar", "s2") + + assert_equal 2, r.dbsize + + r.flushdb(async: false) + + assert_equal 0, r.dbsize + + # Test async + r.set("foo", "s1") + r.set("bar", "s2") + + assert_equal 2, r.dbsize + + r.flushdb(async: true) + + assert_equal 0, r.dbsize + + redis_mock(flushdb: ->(args) { "+FLUSHDB #{args.upcase}" }) do |redis| + assert_equal "FLUSHDB ASYNC", redis.flushdb(async: true) + end + end + + def test_flushall + # Test defaults + redis_mock(flushall: -> { "+FLUSHALL" }) do |redis| + assert_equal "FLUSHALL", redis.flushall + end + + # Test sync + redis_mock(flushall: -> { "+FLUSHALL" }) do |redis| + assert_equal "FLUSHALL", redis.flushall(async: false) + end + + # Test async + redis_mock(flushall: ->(args) { "+FLUSHALL #{args.upcase}" }) do |redis| + assert_equal "FLUSHALL ASYNC", redis.flushall(async: true) + end + end + + def test_migrate + redis_mock(migrate: ->(*args) { args }) do |redis| + options = { host: "127.0.0.1", port: 1234 } + + ex = assert_raises(RuntimeError) do + redis.migrate("foo", options.reject { |key, _| key == :host }) + end + assert ex.message =~ /host not specified/ + + ex = assert_raises(RuntimeError) do + redis.migrate("foo", options.reject { |key, _| key == :port }) + end + assert ex.message =~ /port not specified/ + + default_db = redis._client.db.to_i + default_timeout = redis._client.timeout.to_i + + # Test defaults + actual = redis.migrate("foo", options) + expected = ["127.0.0.1", "1234", "foo", default_db.to_s, default_timeout.to_s] + assert_equal expected, actual + + # Test db override + actual = redis.migrate("foo", options.merge(db: default_db + 1)) + expected = ["127.0.0.1", "1234", "foo", (default_db + 1).to_s, default_timeout.to_s] + assert_equal expected, actual + + # Test timeout override + actual = redis.migrate("foo", options.merge(timeout: default_timeout + 1)) + expected = ["127.0.0.1", "1234", "foo", default_db.to_s, (default_timeout + 1).to_s] + assert_equal expected, actual + + # Test copy override + actual = redis.migrate('foo', options.merge(copy: true)) + expected = ['127.0.0.1', '1234', 'foo', default_db.to_s, default_timeout.to_s, 'COPY'] + assert_equal expected, actual + + # Test replace override + actual = redis.migrate('foo', options.merge(replace: true)) + expected = ['127.0.0.1', '1234', 'foo', default_db.to_s, default_timeout.to_s, 'REPLACE'] + assert_equal expected, actual + + # Test multiple keys + actual = redis.migrate(%w[foo bar baz], options) + expected = ['127.0.0.1', '1234', '', default_db.to_s, default_timeout.to_s, 'KEYS', 'foo', 'bar', 'baz'] + assert_equal expected, actual + end + end +end diff --git a/test/redis/connection_handling_test.rb b/test/redis/connection_handling_test.rb new file mode 100644 index 000000000..04946c8ea --- /dev/null +++ b/test/redis/connection_handling_test.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require "helper" +require 'lint/authentication' + +class TestConnectionHandling < Minitest::Test + include Helper::Client + include Lint::Authentication + + def test_id + commands = { + client: ->(cmd, name) { @name = [cmd, name]; "+OK" }, + ping: -> { "+PONG" } + } + + redis_mock(commands, id: "client-name") do |redis| + assert_equal "PONG", redis.ping + end + + assert_equal ["SETNAME", "client-name"], @name + end + + def test_ping + assert_equal "PONG", r.ping + end + + def test_select + r.set "foo", "bar" + + r.select 14 + assert_nil r.get("foo") + + r._client.close + + assert_equal "bar", r.get("foo") + end + + def test_quit + r.quit + + assert !r._client.connected? + end + + def test_close + quit = 0 + + commands = { + quit: lambda do + quit += 1 + "+OK" + end + } + + redis_mock(commands) do |redis| + assert_equal 0, quit + + redis.quit + + assert_equal 1, quit + + redis.ping + + redis.close + + assert_equal 1, quit + + assert !redis.connected? + end + end + + def test_disconnect + quit = 0 + + commands = { + quit: lambda do + quit += 1 + "+OK" + end + } + + redis_mock(commands) do |redis| + assert_equal 0, quit + + redis.quit + + assert_equal 1, quit + + redis.ping + + redis.disconnect! + + assert_equal 1, quit + + assert !redis.connected? + end + end + + def test_shutdown + commands = { + shutdown: -> { :exit } + } + + redis_mock(commands) do |redis| + # SHUTDOWN does not reply: test that it does not raise here. + assert_nil redis.shutdown + end + end + + def test_shutdown_with_error + connections = 0 + commands = { + select: ->(*_) { connections += 1; "+OK\r\n" }, + connections: -> { ":#{connections}\r\n" }, + shutdown: -> { "-ERR could not shutdown\r\n" } + } + + redis_mock(commands) do |redis| + connections = redis.connections + + # SHUTDOWN replies with an error: test that it gets raised + assert_raises Redis::CommandError do + redis.shutdown + end + + # The connection should remain in tact + assert_equal connections, redis.connections + end + end + + def test_slaveof + redis_mock(slaveof: ->(host, port) { "+SLAVEOF #{host} #{port}" }) do |redis| + assert_equal "SLAVEOF somehost 6381", redis.slaveof("somehost", 6381) + end + end + + def test_bgrewriteaof + redis_mock(bgrewriteaof: -> { "+BGREWRITEAOF" }) do |redis| + assert_equal "BGREWRITEAOF", redis.bgrewriteaof + end + end + + def test_config_get + refute_nil r.config(:get, "*")["timeout"] + + config = r.config(:get, "timeout") + assert_equal ["timeout"], config.keys + assert !config.values.compact.empty? + end + + def test_config_set + assert_equal "OK", r.config(:set, "timeout", 200) + assert_equal "200", r.config(:get, "*")["timeout"] + + assert_equal "OK", r.config(:set, "timeout", 100) + assert_equal "100", r.config(:get, "*")["timeout"] + ensure + r.config :set, "timeout", 300 + end +end diff --git a/test/redis/connection_test.rb b/test/redis/connection_test.rb new file mode 100644 index 000000000..7178c3ccc --- /dev/null +++ b/test/redis/connection_test.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "helper" + +class TestConnection < Minitest::Test + include Helper::Client + + def test_provides_a_meaningful_inspect + assert_equal "#", r.inspect + end + + def test_connection_with_user_and_password + target_version "6.0" do + with_acl do |username, password| + redis = Redis.new(OPTIONS.merge(username: username, password: password)) + assert_equal "PONG", redis.ping + end + end + end + + def test_connection_with_default_user_and_password + target_version "6.0" do + with_default_user_password do |_username, password| + redis = Redis.new(OPTIONS.merge(password: password)) + assert_equal "PONG", redis.ping + end + end + end + + def test_connection_information + assert_equal "localhost", r.connection.fetch(:host) + assert_equal 6381, r.connection.fetch(:port) + assert_equal 15, r.connection.fetch(:db) + assert_equal "localhost:6381", r.connection.fetch(:location) + assert_equal "redis://localhost:6381/15", r.connection.fetch(:id) + end + + def test_default_id_with_host_and_port + redis = Redis.new(OPTIONS.merge(host: "host", port: "1234", db: 0)) + assert_equal "redis://host:1234/0", redis.connection.fetch(:id) + end + + def test_default_id_with_host_and_port_and_ssl + redis = Redis.new(OPTIONS.merge(host: 'host', port: '1234', db: 0, ssl: true)) + assert_equal "rediss://host:1234/0", redis.connection.fetch(:id) + end + + def test_default_id_with_host_and_port_and_explicit_scheme + redis = Redis.new(OPTIONS.merge(host: "host", port: "1234", db: 0)) + assert_equal "redis://host:1234/0", redis.connection.fetch(:id) + end + + def test_default_id_with_path + redis = Redis.new(OPTIONS.merge(path: "/tmp/redis.sock", db: 0)) + assert_equal "/tmp/redis.sock/0", redis.connection.fetch(:id) + end + + def test_default_id_with_path_and_explicit_scheme + redis = Redis.new(OPTIONS.merge(path: "/tmp/redis.sock", db: 0)) + assert_equal "/tmp/redis.sock/0", redis.connection.fetch(:id) + end + + def test_override_id + redis = Redis.new(OPTIONS.merge(id: "test")) + assert_equal "test", redis.connection.fetch(:id) + end + + def test_id_inside_multi + redis = Redis.new(OPTIONS) + id = nil + connection_id = nil + + redis.multi do + id = redis.id + connection_id = redis.connection.fetch(:id) + end + + assert_equal "redis://localhost:6381/15", id + assert_equal "redis://localhost:6381/15", connection_id + end +end diff --git a/test/redis/encoding_test.rb b/test/redis/encoding_test.rb new file mode 100644 index 000000000..302bb69bf --- /dev/null +++ b/test/redis/encoding_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "helper" + +class TestEncoding < Minitest::Test + include Helper::Client + + def test_returns_properly_encoded_strings + r.set "foo", "שלום" + + assert_equal "Shalom שלום", "Shalom #{r.get('foo')}" + + refute_predicate "\xFF", :valid_encoding? + r.set("bar", "\xFF") + bytes = r.get("bar") + assert_equal "\xFF".b, bytes + assert_predicate bytes, :valid_encoding? + end +end diff --git a/test/redis/error_replies_test.rb b/test/redis/error_replies_test.rb new file mode 100644 index 000000000..d7df0e862 --- /dev/null +++ b/test/redis/error_replies_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "helper" + +class TestErrorReplies < Minitest::Test + include Helper::Client + + # Every test shouldn't disconnect from the server. Also, when error replies are + # in play, the protocol should never get into an invalid state where there are + # pending replies in the connection. Calling INFO after every test ensures that + # the protocol is still in a valid state. + def with_reconnection_check + before = r.info["total_connections_received"] + yield(r) + after = r.info["total_connections_received"] + ensure + assert_equal before, after + end + + def test_error_reply_for_single_command + with_reconnection_check do + r.unknown_command + rescue => ex + ensure + assert ex.message =~ /unknown command/i + end + end + + def test_raise_first_error_reply_in_pipeline + with_reconnection_check do + r.pipelined do + r.set("foo", "s1") + r.incr("foo") # not an integer + r.lpush("foo", "value") # wrong kind of value + end + rescue => ex + ensure + assert ex.message =~ /not an integer/i + end + end +end diff --git a/test/redis/fork_safety_test.rb b/test/redis/fork_safety_test.rb new file mode 100644 index 000000000..6a7be2850 --- /dev/null +++ b/test/redis/fork_safety_test.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "helper" + +class TestForkSafety < Minitest::Test + include Helper::Client + + def setup + skip("Fork unavailable") unless Process.respond_to?(:fork) + end + + def test_fork_safety + redis = Redis.new(OPTIONS) + pid = fork do + 1000.times do + assert_equal "OK", redis.set("key", "foo") + end + end + 1000.times do + assert_equal "PONG", redis.ping + end + _, status = Process.wait2(pid) + assert_predicate(status, :success?) + end +end diff --git a/test/helper_test.rb b/test/redis/helper_test.rb similarity index 70% rename from test/helper_test.rb rename to test/redis/helper_test.rb index 23da68dce..a4a1b4757 100644 --- a/test/helper_test.rb +++ b/test/redis/helper_test.rb @@ -1,9 +1,8 @@ -# encoding: UTF-8 +# frozen_string_literal: true -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestHelper < Test::Unit::TestCase +require "helper" +class TestHelper < Minitest::Test include Helper def test_version_comparison diff --git a/test/redis/internals_test.rb b/test/redis/internals_test.rb new file mode 100644 index 000000000..1b718a1fc --- /dev/null +++ b/test/redis/internals_test.rb @@ -0,0 +1,328 @@ +# frozen_string_literal: true + +require "helper" + +class TestInternals < Minitest::Test + include Helper::Client + + def test_large_payload + # see: https://github.com/redis/redis-rb/issues/962 + # large payloads will trigger write_nonblock to write a portion + # of the payload in connection/ruby.rb _write_to_socket + + # We use a larger timeout for TruffleRuby + # https://github.com/redis/redis-rb/pull/1128#issuecomment-1218490684 + r = init(_new_client(timeout: TIMEOUT * 5)) + large = "\u3042" * 4_000_000 + r.setex("foo", 10, large) + result = r.get("foo") + assert_equal result, large + end + + def test_recovers_from_failed_commands + # See https://github.com/redis/redis-rb/issues#issue/28 + + assert_raises(Redis::CommandError) do + r.command_that_doesnt_exist + end + + r.info + end + + def test_raises_on_protocol_errors + redis_mock(ping: ->(*_) { "foo" }) do |redis| + assert_raises(Redis::ProtocolError) do + redis.ping + end + end + end + + def test_redis_connected? + fresh_client = _new_client + assert !fresh_client.connected? + + fresh_client.ping + assert fresh_client.connected? + + fresh_client.quit + assert !fresh_client.connected? + end + + def test_timeout + Redis.new(OPTIONS.merge(timeout: 0)) + end + + def test_time + # Test that the difference between the time that Ruby reports and the time + # that Redis reports is minimal (prevents the test from being racy). + rv = r.time + + redis_usec = rv[0] * 1_000_000 + rv[1] + ruby_usec = Integer(Time.now.to_f * 1_000_000) + + assert((ruby_usec - redis_usec).abs < 500_000) + end + + def test_connection_timeout + opts = OPTIONS.merge(host: "10.255.255.254", connect_timeout: 0.1, timeout: 5.0) + start_time = Time.now + assert_raises Redis::CannotConnectError do + Redis.new(opts).ping + end + assert((Time.now - start_time) <= opts[:timeout]) + end + + def test_missing_socket + opts = { path: '/missing.sock' } + assert_raises Redis::CannotConnectError do + Redis.new(opts).ping + end + end + + def close_on_ping(seq, options = {}, &block) + @request = 0 + + command = lambda do + idx = @request + @request += 1 + + rv = "+%d" % idx + rv = nil if seq.include?(idx) + rv + end + + redis_mock({ ping: command }, { timeout: 0.1 }.merge(options), &block) + end + + def test_retry_by_default + close_on_ping([0]) do |redis| + assert_equal "1", redis.ping + end + end + + def test_dont_retry_when_wrapped_in_without_reconnect + close_on_ping([0]) do |redis| + assert_raises Redis::ConnectionError do + redis.without_reconnect do + redis.ping + end + end + end + end + + def test_retry_only_once_when_read_raises_econnreset + close_on_ping([0, 1]) do |redis| + assert_raises Redis::ConnectionError do + redis.ping + end + + assert !redis._client.connected? + end + end + + def test_retry_with_custom_reconnect_attempts + close_on_ping([0, 1], reconnect_attempts: 2) do |redis| + assert_equal "2", redis.ping + end + end + + def test_retry_with_custom_reconnect_attempts_can_still_fail + close_on_ping([0, 1, 2], reconnect_attempts: 2) do |redis| + assert_raises Redis::ConnectionError do + redis.ping + end + + assert !redis._client.connected? + end + end + + def test_retry_with_custom_reconnect_attempts_and_exponential_backoff + close_on_ping([0, 1, 2], reconnect_attempts: [0.01, 0.02, 0.04]) do |redis| + redis._client.config.expects(:sleep).with(0.01).returns(true) + redis._client.config.expects(:sleep).with(0.02).returns(true) + redis._client.config.expects(:sleep).with(0.04).returns(true) + + assert_equal "3", redis.ping + end + end + + def test_retry_pipeline_first_command + close_on_ping([0]) do |redis| + results = redis.pipelined do |pipeline| + pipeline.ping + end + assert_equal ["1"], results + end + end + + def close_on_connection(seq, &block) + @n = 0 + + read_command = lambda do |session| + Array.new(session.gets[1..-3].to_i) do + bytes = session.gets[1..-3].to_i + arg = session.read(bytes) + session.read(2) # Discard \r\n + arg + end + end + + handler = lambda do |session| + n = @n + @n += 1 + + select = read_command.call(session) + if select[0].downcase == "select" + session.write("+OK\r\n") + else + raise "Expected SELECT" + end + unless seq.include?(n) + session.write("+#{n}\r\n") while read_command.call(session) + end + end + + redis_mock_with_handler(handler, &block) + end + + def test_retry_on_write_error_by_default + close_on_connection([0]) do |redis| + assert_equal "1", redis._client.call_v(["x" * 128 * 1024]) + end + end + + def test_dont_retry_on_write_error_when_wrapped_in_without_reconnect + close_on_connection([0]) do |redis| + assert_raises Redis::ConnectionError do + redis.without_reconnect do + redis._client.call_v(["x" * 128 * 1024]) + end + end + end + end + + def test_connecting_to_unix_domain_socket + Redis.new(OPTIONS.merge(path: ENV.fetch("REDIS_SOCKET_PATH"))).ping + end + + def test_bubble_timeout_without_retrying + serv = TCPServer.new(6380) + + redis = Redis.new(port: 6380, timeout: 0.1) + + assert_raises(Redis::TimeoutError) do + redis.ping + end + ensure + serv&.close + end + + def test_client_options + redis = Redis.new(OPTIONS.merge(host: "host", port: 1234, db: 1)) + + assert_equal "host", redis._client.host + assert_equal 1234, redis._client.port + assert_equal 1, redis._client.db + end + + def test_resolves_localhost + Redis.new(OPTIONS.merge(host: 'localhost')).ping + end + + class << self + def af_family_supported(af_type) + hosts = { + Socket::AF_INET => "127.0.0.1", + Socket::AF_INET6 => "::1" + } + + begin + s = Socket.new(af_type, Socket::SOCK_STREAM, 0) + begin + tries = 5 + begin + sa = Socket.pack_sockaddr_in(Random.rand(1024..64_099), hosts[af_type]) + s.bind(sa) + rescue Errno::EADDRINUSE => e + # On JRuby (9.1.15.0), if IPv6 is globally disabled on the system, + # we get an EADDRINUSE with belows message. + return if e.message =~ /Protocol family unavailable/ + + tries -= 1 + retry if tries > 0 + + raise + end + yield + rescue Errno::EADDRNOTAVAIL + ensure + s.close + end + rescue Errno::ESOCKTNOSUPPORT + end + end + end + + def af_test(host) + commands = { + ping: ->(*_) { "+pong" } + } + + redis_mock(commands, host: host, &:ping) + end + + af_family_supported(Socket::AF_INET) do + def test_connect_ipv4 + af_test("127.0.0.1") + end + end + + af_family_supported(Socket::AF_INET6) do + def test_connect_ipv6 + af_test("::1") + end + end + + def test_can_be_duped_to_create_a_new_connection + clients = r.info["connected_clients"].to_i + + r2 = r.dup + r2.ping + + assert_equal clients + 1, r.info["connected_clients"].to_i + end + + def test_reconnect_on_readonly_errors + tcp_server = TCPServer.new("127.0.0.1", 0) + tcp_server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true) + port = tcp_server.addr[1] + + server_thread = Thread.new do + session = tcp_server.accept + io = RedisClient::RubyConnection::BufferedIO.new(session, read_timeout: 1, write_timeout: 1) + 2.times do + command = RedisClient::RESP3.load(io) + case command.first.upcase + when "PING" + session.write("+PONG\r\n") + when "SET" + session.write("-READONLY You can't write against a read only replica.\r\n") + else + session.write("-ERR Unknown command #{command.first}\r\n") + end + end + session.close + end + + redis = Redis.new(host: "127.0.0.1", port: port, timeout: 2, reconnect_attempts: 0) + assert_equal "PONG", redis.ping + + assert_raises Redis::ReadOnlyError do + redis.set("foo", "bar") + end + + refute_predicate redis, :connected? + ensure + server_thread&.kill + end +end diff --git a/test/redis/persistence_control_commands_test.rb b/test/redis/persistence_control_commands_test.rb new file mode 100644 index 000000000..1b48a7e33 --- /dev/null +++ b/test/redis/persistence_control_commands_test.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "helper" + +class TestPersistenceControlCommands < Minitest::Test + include Helper::Client + + def test_save + redis_mock(save: -> { "+SAVE" }) do |redis| + assert_equal "SAVE", redis.save + end + end + + def test_bgsave + redis_mock(bgsave: -> { "+BGSAVE" }) do |redis| + assert_equal "BGSAVE", redis.bgsave + end + end + + def test_lastsave + redis_mock(lastsave: -> { "+LASTSAVE" }) do |redis| + assert_equal "LASTSAVE", redis.lastsave + end + end +end diff --git a/test/pipelining_commands_test.rb b/test/redis/pipelining_commands_test.rb similarity index 52% rename from test/pipelining_commands_test.rb rename to test/redis/pipelining_commands_test.rb index 82cd92f80..45663974b 100644 --- a/test/pipelining_commands_test.rb +++ b/test/redis/pipelining_commands_test.rb @@ -1,15 +1,14 @@ -# encoding: UTF-8 +# frozen_string_literal: true -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestPipeliningCommands < Test::Unit::TestCase +require "helper" +class TestPipeliningCommands < Minitest::Test include Helper::Client def test_bulk_commands - r.pipelined do - r.lpush "foo", "s1" - r.lpush "foo", "s2" + r.pipelined do |p| + p.lpush "foo", "s1" + p.lpush "foo", "s2" end assert_equal 2, r.llen("foo") @@ -18,9 +17,9 @@ def test_bulk_commands end def test_multi_bulk_commands - r.pipelined do - r.mset("foo", "s1", "bar", "s2") - r.mset("baz", "s3", "qux", "s4") + r.pipelined do |p| + p.mset("foo", "s1", "bar", "s2") + p.mset("baz", "s3", "qux", "s4") end assert_equal "s1", r.get("foo") @@ -30,10 +29,10 @@ def test_multi_bulk_commands end def test_bulk_and_multi_bulk_commands_mixed - r.pipelined do - r.lpush "foo", "s1" - r.lpush "foo", "s2" - r.mset("baz", "s3", "qux", "s4") + r.pipelined do |p| + p.lpush "foo", "s1" + p.lpush "foo", "s2" + p.mset("baz", "s3", "qux", "s4") end assert_equal 2, r.llen("foo") @@ -44,10 +43,10 @@ def test_bulk_and_multi_bulk_commands_mixed end def test_multi_bulk_and_bulk_commands_mixed - r.pipelined do - r.mset("baz", "s3", "qux", "s4") - r.lpush "foo", "s1" - r.lpush "foo", "s2" + r.pipelined do |p| + p.mset("baz", "s3", "qux", "s4") + p.lpush "foo", "s1" + p.lpush "foo", "s2" end assert_equal 2, r.llen("foo") @@ -58,28 +57,26 @@ def test_multi_bulk_and_bulk_commands_mixed end def test_pipelined_with_an_empty_block - assert_nothing_raised do - r.pipelined do - end + r.pipelined do end assert_equal 0, r.dbsize end def test_returning_the_result_of_a_pipeline - result = r.pipelined do - r.set "foo", "bar" - r.get "foo" - r.get "bar" + result = r.pipelined do |p| + p.set "foo", "bar" + p.get "foo" + p.get "bar" end assert_equal ["OK", "bar", nil], result end def test_assignment_of_results_inside_the_block - r.pipelined do - @first = r.sadd("foo", 1) - @second = r.sadd("foo", 1) + r.pipelined do |p| + @first = p.sadd?("foo", 1) + @second = p.sadd?("foo", 1) end assert_equal true, @first.value @@ -89,24 +86,24 @@ def test_assignment_of_results_inside_the_block # Although we could support accessing the values in these futures, # it doesn't make a lot of sense. def test_assignment_of_results_inside_the_block_with_errors - assert_raise(Redis::CommandError) do - r.pipelined do - r.doesnt_exist - @first = r.sadd("foo", 1) - @second = r.sadd("foo", 1) + assert_raises(Redis::CommandError) do + r.pipelined do |p| + p.doesnt_exist + @first = p.sadd?("foo", 1) + @second = p.sadd?("foo", 1) end end - assert_raise(Redis::FutureNotReady) { @first.value } - assert_raise(Redis::FutureNotReady) { @second.value } + assert_raises(Redis::FutureNotReady) { @first.value } + assert_raises(Redis::FutureNotReady) { @second.value } end def test_assignment_of_results_inside_a_nested_block - r.pipelined do - @first = r.sadd("foo", 1) + r.pipelined do |p| + @first = p.sadd?("foo", 1) - r.pipelined do - @second = r.sadd("foo", 1) + p.pipelined do |p2| + @second = p2.sadd?("foo", 1) end end @@ -115,30 +112,37 @@ def test_assignment_of_results_inside_a_nested_block end def test_futures_raise_when_confused_with_something_else - r.pipelined do - @result = r.sadd("foo", 1) + r.pipelined do |p| + @result = p.sadd?("foo", 1) end - assert_raise(NoMethodError) { @result.to_s } + assert_raises(NoMethodError) { @result.to_s } end def test_futures_raise_when_trying_to_access_their_values_too_early - r.pipelined do - assert_raise(Redis::FutureNotReady) do - r.sadd("foo", 1).value + r.pipelined do |p| + assert_raises(Redis::FutureNotReady) do + p.sadd("foo", 1).value + end + end + end + + def test_futures_raise_when_command_errors_and_needs_transformation + assert_raises(Redis::CommandError) do + r.pipelined do |p| + p.zadd("set", "1", "one") + @result = p.zincryby("set", "fail", "one") end end end def test_futures_can_be_identified - r.pipelined do - @result = r.sadd("foo", 1) + r.pipelined do |p| + @result = p.sadd("foo", 1) end assert_equal true, @result.is_a?(Redis::Future) - if defined?(::BasicObject) - assert_equal true, @result.is_a?(::BasicObject) - end + assert_equal true, @result.is_a?(::BasicObject) assert_equal Redis::Future, @result.class end @@ -150,10 +154,10 @@ def test_returning_the_result_of_an_empty_pipeline end def test_nesting_pipeline_blocks - r.pipelined do - r.set("foo", "s1") - r.pipelined do - r.set("bar", "s2") + r.pipelined do |p| + p.set("foo", "s1") + p.pipelined do |p2| + p2.set("bar", "s2") end end @@ -162,34 +166,47 @@ def test_nesting_pipeline_blocks end def test_info_in_a_pipeline_returns_hash - result = r.pipelined do - r.info + result = r.pipelined do |p| + p.info end - assert result.first.kind_of?(Hash) + assert result.first.is_a?(Hash) end def test_config_get_in_a_pipeline_returns_hash - result = r.pipelined do - r.config(:get, "*") + result = r.pipelined do |p| + p.config(:get, "*") end - assert result.first.kind_of?(Hash) + assert result.first.is_a?(Hash) end def test_hgetall_in_a_pipeline_returns_hash r.hmset("hash", "field", "value") - result = r.pipelined do - r.hgetall("hash") + future = nil + result = r.pipelined do |p| + future = p.hgetall("hash") end - assert_equal result.first, { "field" => "value" } + assert_equal([{ "field" => "value" }], result) + assert_equal({ "field" => "value" }, future.value) + end + + def test_zpopmax_in_a_pipeline_produces_future + r.zadd("sortedset", 1.0, "value") + future = nil + result = r.pipelined do |pipeline| + future = pipeline.zpopmax("sortedset") + end + + assert_equal [["value", 1.0]], result + assert_equal ["value", 1.0], future.value end def test_keys_in_a_pipeline r.set("key", "value") - result = r.pipelined do - r.keys("*") + result = r.pipelined do |p| + p.keys("*") end assert_equal ["key"], result.first @@ -219,24 +236,10 @@ def test_pipeline_select assert_equal "2", r.get("db") end - def test_pipeline_select_client_db - r.select 1 - r.pipelined do |p2| - p2.select 2 - end - - assert_equal 2, r.client.db - end - - def test_nested_pipeline_select_client_db - r.select 1 - r.pipelined do |p2| - p2.select 2 - p2.pipelined do |p3| - p3.select 3 - end - end - - assert_equal 3, r.client.db + def test_pipeline_interrupt_preserves_client + original = r._client + Redis::PipelinedConnection.stubs(:new).raises(Interrupt) + assert_raises(Interrupt) { r.pipelined {} } + assert_equal r._client, original end end diff --git a/test/redis/publish_subscribe_test.rb b/test/redis/publish_subscribe_test.rb new file mode 100644 index 000000000..4deea52cc --- /dev/null +++ b/test/redis/publish_subscribe_test.rb @@ -0,0 +1,358 @@ +# frozen_string_literal: true + +require "helper" + +class TestPublishSubscribe < Minitest::Test + include Helper::Client + + def setup + @threads = {} + super + end + + def teardown + super + @threads.each do |thread, redis| + if redis.subscribed? + redis.unsubscribe + redis.punsubscribe + end + redis.close + begin + thread.join(2) or warn("leaked thread") + rescue RedisClient::ConnectionError + end + end + end + + class TestError < StandardError + end + + def test_subscribe_and_unsubscribe + @subscribed = false + @unsubscribed = false + + thread = new_thread do |r| + r.subscribe(channel_name) do |on| + on.subscribe do |_channel, total| + @subscribed = true + @t1 = total + end + + on.message do |_channel, message| + if message == "s1" + r.unsubscribe + @message = message + end + end + + on.unsubscribe do |_channel, total| + @unsubscribed = true + @t2 = total + end + end + end + + # Wait until the subscription is active before publishing + Thread.pass until @subscribed + + redis.publish(channel_name, "s1") + thread.join + + assert @subscribed + assert_equal 1, @t1 + assert @unsubscribed + assert_equal 0, @t2 + assert_equal "s1", @message + end + + def test_psubscribe_and_punsubscribe + @subscribed = false + @unsubscribed = false + + thread = new_thread do |r| + r.psubscribe("channel:*") do |on| + on.psubscribe do |_pattern, total| + @subscribed = true + @t1 = total + end + + on.pmessage do |_pattern, _channel, message| + if message == "s1" + r.punsubscribe + @message = message + end + end + + on.punsubscribe do |_pattern, total| + @unsubscribed = true + @t2 = total + end + end + end + + # Wait until the subscription is active before publishing + Thread.pass until @subscribed + redis.publish(channel_name, "s1") + thread.join + + assert @subscribed + assert_equal 1, @t1 + assert @unsubscribed + assert_equal 0, @t2 + assert_equal "s1", @message + end + + def test_pubsub_with_channels_and_numsub_subcommnads + @subscribed = false + thread = new_thread do |r| + r.subscribe(channel_name) do |on| + on.subscribe { |_channel, _total| @subscribed = true } + on.message { |_channel, _message| r.unsubscribe } + end + end + Thread.pass until @subscribed + channels_result = redis.pubsub(:channels) + channels_result.delete('__sentinel__:hello') + numsub_result = redis.pubsub(:numsub, channel_name, 'boo') + + redis.publish(channel_name, "s1") + thread.join + + assert_includes channels_result, channel_name + assert_equal [channel_name, 1, 'boo', 0], numsub_result + end + + def test_subscribe_connection_usable_after_raise + @subscribed = false + + thread = new_thread do |r| + r.subscribe(channel_name) do |on| + on.subscribe do |_channel, _total| + @subscribed = true + end + + on.message do |_channel, _message| + r.unsubscribe + raise TestError + end + end + rescue TestError + end + + # Wait until the subscription is active before publishing + Thread.pass until @subscribed + + redis.publish(channel_name, "s1") + + thread.join + + assert_equal "PONG", r.ping + end + + def test_psubscribe_connection_usable_after_raise + @subscribed = false + + thread = new_thread do |r| + r.psubscribe("channel:*") do |on| + on.psubscribe do |_pattern, _total| + @subscribed = true + end + + on.pmessage do |_pattern, _channel, _message| + raise TestError + end + end + rescue TestError + end + + # Wait until the subscription is active before publishing + Thread.pass until @subscribed + + redis.publish(channel_name, "s1") + + thread.join + + assert_equal "PONG", r.ping + end + + def test_subscribe_within_subscribe + @channels = Queue.new + + thread = new_thread do |r| + r.subscribe(channel_name) do |on| + on.subscribe do |channel, _total| + @channels << channel + + r.subscribe("bar") if channel == channel_name + r.unsubscribe if channel == "bar" + end + end + end + + thread.join + + assert_equal [channel_name, "bar"], [@channels.pop, @channels.pop] + assert_empty @channels + end + + def test_other_commands_within_a_subscribe + r.subscribe(channel_name) do |on| + on.subscribe do |_channel, _total| + r.set("bar", "s2") + r.unsubscribe(channel_name) + end + end + end + + def test_subscribe_without_a_block + error = assert_raises Redis::SubscriptionError do + r.subscribe(channel_name) + end + assert_includes "This client is not subscribed", error.message + end + + def test_unsubscribe_without_a_subscribe + error = assert_raises Redis::SubscriptionError do + r.unsubscribe + end + assert_includes "This client is not subscribed", error.message + + error = assert_raises Redis::SubscriptionError do + r.punsubscribe + end + assert_includes "This client is not subscribed", error.message + end + + def test_subscribe_past_a_timeout + # For some reason, a thread here doesn't reproduce the issue. + sleep = %(sleep 0.05) + publish = %{ruby -rsocket -e 't=TCPSocket.new("127.0.0.1",#{OPTIONS[:port]});t.write("publish foo bar\\r\\n");t.read(4);t.close'} + cmd = [sleep, publish].join('; ') + + IO.popen(cmd, 'r+') do |_pipe| + received = false + + r.subscribe 'foo' do |on| + on.message do |_channel, _message| + received = true + r.unsubscribe + end + end + + assert received + end + end + + def test_subscribe_with_timeout + received = false + + r.subscribe_with_timeout(LOW_TIMEOUT, channel_name) do |on| + on.message do |_channel, _message| + received = true + end + end + + refute received + end + + def test_psubscribe_with_timeout + received = false + + r.psubscribe_with_timeout(LOW_TIMEOUT, "channel:*") do |on| + on.message do |_channel, _message| + received = true + end + end + + refute received + end + + def test_unsubscribe_from_another_thread + @unsubscribed = @subscribed = false + @subscribed_redis = nil + @messages = Queue.new + thread = new_thread do |r| + @subscribed_redis = r + r.subscribe(channel_name) do |on| + on.subscribe do |_channel, _total| + @subscribed = true + end + + on.message do |channel, message| + @messages << [channel, message] + end + + on.unsubscribe do |_channel, _total| + @unsubscribed = true + end + end + end + + Thread.pass until @subscribed + + redis.publish(channel_name, "test") + assert_equal [channel_name, "test"], @messages.pop + assert_empty @messages + + @subscribed_redis.unsubscribe # this shouldn't block + refute_nil thread.join(2) + assert_equal true, @unsubscribed + end + + def test_subscribe_from_another_thread + @events = Queue.new + @subscribed_redis = nil + thread = new_thread do |r| + r.subscribe(channel_name) do |on| + @subscribed_redis = r + on.subscribe do |channel, _total| + @events << ["subscribed", channel] + end + + on.message do |channel, message| + @events << ["message", channel, message] + end + + on.unsubscribe do |channel, _total| + @events << ["unsubscribed", channel] + end + end + end + + Thread.pass until @subscribed_redis&.subscribed? + + redis.publish(channel_name, "test") + @subscribed_redis.subscribe("#{channel_name}:2") + redis.publish("#{channel_name}:2", "test-2") + + @subscribed_redis.unsubscribe(channel_name) + @subscribed_redis.unsubscribe # this shouldn't block + + refute_nil thread.join(2) + expected = [ + ["subscribed", channel_name], + ["message", channel_name, "test"], + ["subscribed", "#{channel_name}:2"], + ["message", "#{channel_name}:2", "test-2"], + ["unsubscribed", channel_name], + ["unsubscribed", "#{channel_name}:2"] + ] + assert_equal(expected, expected.map { @events.pop }) + assert_empty @events + end + + private + + def new_thread(&block) + redis = Redis.new(OPTIONS) + thread = Thread.new(redis, &block) + thread.report_on_exception = true + @threads[thread] = redis + thread + end + + def channel_name + @channel_name ||= "channel:#{rand}" + end +end diff --git a/test/redis/remote_server_control_commands_test.rb b/test/redis/remote_server_control_commands_test.rb new file mode 100644 index 000000000..e778054c2 --- /dev/null +++ b/test/redis/remote_server_control_commands_test.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require "helper" + +class TestRemoteServerControlCommands < Minitest::Test + include Helper::Client + + def test_info + keys = [ + "redis_version", + "uptime_in_seconds", + "uptime_in_days", + "connected_clients", + "used_memory", + "total_connections_received", + "total_commands_processed" + ] + + info = r.info + + keys.each do |k| + msg = "expected #info to include #{k}" + assert info.keys.include?(k), msg + end + end + + def test_info_commandstats + r.config(:resetstat) + r.get("foo") + r.get("bar") + + result = r.info(:commandstats) + assert_equal '2', result['get']['calls'] + end + + def test_monitor_redis + log = [] + + thread = Thread.new do + Redis.new(OPTIONS).monitor do |line| + log << line + break if line =~ /set/ + end + end + + Thread.pass while log.empty? # Faster than sleep + + r.set "foo", "s1" + + thread.join + + assert log[-1] =~ /\b15\b.* "set" "foo" "s1"/ + end + + def test_monitor_returns_value_for_break + result = r.monitor do |line| + break line + end + + assert_equal "OK", result + end + + def test_echo + assert_equal "foo bar baz\n", r.echo("foo bar baz\n") + end + + def test_debug + r.set "foo", "s1" + + assert r.debug(:object, "foo").is_a?(String) + end + + def test_object + r.lpush "list", "value" + + assert_equal 1, r.object(:refcount, "list") + encoding = r.object(:encoding, "list") + assert encoding == "ziplist" || encoding == "quicklist" || encoding == "listpack", "Wrong encoding for list" + assert r.object(:idletime, "list").is_a?(Integer) + end + + def test_sync + redis_mock(sync: -> { "+OK" }) do |redis| + assert_equal "OK", redis.sync + end + end + + def test_slowlog + r.slowlog(:reset) + result = r.slowlog(:len) + assert_equal 0, result + end + + def test_client + assert_equal r.instance_variable_get(:@client), r._client + end + + def test_client_list + keys = [ + "addr", + "fd", + "name", + "age", + "idle", + "flags", + "db", + "sub", + "psub", + "multi", + "qbuf", + "qbuf-free", + "obl", + "oll", + "omem", + "events", + "cmd" + ] + + clients = r.client(:list) + clients.each do |client| + keys.each do |k| + msg = "expected #client(:list) to include #{k}" + assert client.keys.include?(k), msg + end + end + end + + def test_client_kill + r.client(:setname, 'redis-rb') + clients = r.client(:list) + i = clients.index { |client| client['name'] == 'redis-rb' } + assert_equal "OK", r.client(:kill, clients[i]["addr"]) + + clients = r.client(:list) + i = clients.index { |client| client['name'] == 'redis-rb' } + assert_nil i + end + + def test_client_getname_and_setname + assert_nil r.client(:getname) + + r.client(:setname, 'redis-rb') + name = r.client(:getname) + assert_equal 'redis-rb', name + end +end diff --git a/test/redis/scanning_test.rb b/test/redis/scanning_test.rb new file mode 100644 index 000000000..72eac1344 --- /dev/null +++ b/test/redis/scanning_test.rb @@ -0,0 +1,362 @@ +# frozen_string_literal: true + +require "helper" + +class TestScanning < Minitest::Test + include Helper::Client + + def test_scan_basic + r.debug :populate, 1000 + + cursor = 0 + all_keys = [] + loop do + cursor, keys = r.scan cursor + all_keys += keys + break if cursor == "0" + end + + assert_equal 1000, all_keys.uniq.size + end + + def test_scan_count + r.debug :populate, 1000 + + cursor = 0 + all_keys = [] + loop do + cursor, keys = r.scan cursor, count: 5 + all_keys += keys + break if cursor == "0" + end + + assert_equal 1000, all_keys.uniq.size + end + + def test_scan_match + r.debug :populate, 1000 + + cursor = 0 + all_keys = [] + loop do + cursor, keys = r.scan cursor, match: "key:1??" + all_keys += keys + break if cursor == "0" + end + + assert_equal 100, all_keys.uniq.size + end + + def test_scan_type + target_version "6.0.0" do + r.debug :populate, 1000 + r.zadd("foo", [1, "s1", 2, "s2", 3, "s3"]) + r.zadd("bar", [6, "s1", 5, "s2", 4, "s3"]) + r.hset("baz", "k1", "v1") + + cursor = 0 + all_keys = [] + loop do + cursor, keys = r.scan cursor, type: "zset" + all_keys += keys + break if cursor == "0" + end + + assert_equal 2, all_keys.uniq.size + end + end + + def test_scan_each_enumerator + r.debug :populate, 1000 + + scan_enumerator = r.scan_each + assert_equal true, scan_enumerator.is_a?(::Enumerator) + + keys_from_scan = scan_enumerator.to_a.uniq + all_keys = r.keys "*" + + assert all_keys.sort == keys_from_scan.sort + end + + def test_scan_each_enumerator_match + r.debug :populate, 1000 + + keys_from_scan = r.scan_each(match: "key:1??").to_a.uniq + all_keys = r.keys "key:1??" + + assert all_keys.sort == keys_from_scan.sort + end + + def test_scan_each_enumerator_type + target_version "6.0.0" do + r.debug :populate, 1000 + r.zadd("key:zset", [1, "s1", 2, "s2", 3, "s3"]) + r.hset("key:hash:1", "k1", "v1") + r.hset("key:hash:2", "k2", "v2") + + keys_from_scan = r.scan_each(type: "hash").to_a.uniq + all_keys = r.keys "key:hash:*" + + assert all_keys.sort == keys_from_scan.sort + end + end + + def test_scan_each_block + r.debug :populate, 100 + + keys_from_scan = [] + r.scan_each do |key| + keys_from_scan << key + end + + all_keys = r.keys "*" + + assert all_keys.sort == keys_from_scan.uniq.sort + end + + def test_scan_each_block_match + r.debug :populate, 100 + + keys_from_scan = [] + r.scan_each(match: "key:1?") do |key| + keys_from_scan << key + end + + all_keys = r.keys "key:1?" + + assert all_keys.sort == keys_from_scan.uniq.sort + end + + def test_sscan_each_enumerator + elements = [] + 100.times { |j| elements << "ele:#{j}" } + r.sadd "set", elements + + scan_enumerator = r.sscan_each("set") + assert_equal true, scan_enumerator.is_a?(::Enumerator) + + keys_from_scan = scan_enumerator.to_a.uniq + all_keys = r.smembers("set") + + assert all_keys.sort == keys_from_scan.sort + end + + def test_sscan_each_enumerator_match + elements = [] + 100.times { |j| elements << "ele:#{j}" } + r.sadd "set", elements + + keys_from_scan = r.sscan_each("set", match: "ele:1?").to_a.uniq + + all_keys = r.smembers("set").grep(/^ele:1.$/) + + assert all_keys.sort == keys_from_scan.sort + end + + def test_sscan_each_enumerator_block + elements = [] + 100.times { |j| elements << "ele:#{j}" } + r.sadd "set", elements + + keys_from_scan = [] + r.sscan_each("set") do |key| + keys_from_scan << key + end + + all_keys = r.smembers("set") + + assert all_keys.sort == keys_from_scan.uniq.sort + end + + def test_sscan_each_enumerator_block_match + elements = [] + 100.times { |j| elements << "ele:#{j}" } + r.sadd "set", elements + + keys_from_scan = [] + r.sscan_each("set", match: "ele:1?") do |key| + keys_from_scan << key + end + + all_keys = r.smembers("set").grep(/^ele:1.$/) + + assert all_keys.sort == keys_from_scan.uniq.sort + end + + def test_hscan_with_encoding + %i[ziplist hashtable].each do |enc| + r.del "set" + + count = 1000 + count = 30 if enc == :ziplist + + elements = [] + count.times { |j| elements << "key:#{j}" << j.to_s } + + r.hmset "hash", *elements + + cursor = 0 + all_key_values = [] + loop do + cursor, key_values = r.hscan "hash", cursor + all_key_values.concat key_values + break if cursor == "0" + end + + keys2 = [] + all_key_values.each do |k, v| + assert_equal "key:#{v}", k + keys2 << k + end + + assert_equal count, keys2.uniq.size + end + end + + def test_hscan_each_enumerator + count = 1000 + elements = [] + count.times { |j| elements << "key:#{j}" << j.to_s } + r.hmset "hash", *elements + + scan_enumerator = r.hscan_each("hash") + assert_equal true, scan_enumerator.is_a?(::Enumerator) + + keys_from_scan = scan_enumerator.to_a.uniq + all_keys = r.hgetall("hash").to_a + + assert all_keys.sort == keys_from_scan.sort + end + + def test_hscan_each_enumerator_match + count = 100 + elements = [] + count.times { |j| elements << "key:#{j}" << j.to_s } + r.hmset "hash", *elements + + keys_from_scan = r.hscan_each("hash", match: "key:1?").to_a.uniq + all_keys = r.hgetall("hash").to_a.select { |k, _v| k =~ /^key:1.$/ } + + assert all_keys.sort == keys_from_scan.sort + end + + def test_hscan_each_block + count = 1000 + elements = [] + count.times { |j| elements << "key:#{j}" << j.to_s } + r.hmset "hash", *elements + + keys_from_scan = [] + r.hscan_each("hash") do |field, value| + keys_from_scan << [field, value] + end + all_keys = r.hgetall("hash").to_a + + assert all_keys.sort == keys_from_scan.uniq.sort + end + + def test_hscan_each_block_match + count = 1000 + elements = [] + count.times { |j| elements << "key:#{j}" << j.to_s } + r.hmset "hash", *elements + + keys_from_scan = [] + r.hscan_each("hash", match: "key:1?") do |field, value| + keys_from_scan << [field, value] + end + all_keys = r.hgetall("hash").to_a.select { |k, _v| k =~ /^key:1.$/ } + + assert all_keys.sort == keys_from_scan.uniq.sort + end + + def test_zscan_with_encoding + %i[ziplist skiplist].each do |enc| + r.del "zset" + + count = 1000 + count = 30 if enc == :ziplist + + elements = [] + count.times { |j| elements << j << "key:#{j}" } + + r.zadd "zset", elements + + cursor = 0 + all_key_scores = [] + loop do + cursor, key_scores = r.zscan "zset", cursor + all_key_scores.concat key_scores + break if cursor == "0" + end + + keys2 = [] + all_key_scores.each do |k, v| + assert_equal true, v.is_a?(Float) + assert_equal "key:#{Integer(v)}", k + keys2 << k + end + + assert_equal count, keys2.uniq.size + end + end + + def test_zscan_each_enumerator + count = 1000 + elements = [] + count.times { |j| elements << j << "key:#{j}" } + r.zadd "zset", elements + + scan_enumerator = r.zscan_each "zset" + assert_equal true, scan_enumerator.is_a?(::Enumerator) + + scores_from_scan = scan_enumerator.to_a.uniq + member_scores = r.zrange("zset", 0, -1, with_scores: true) + + assert member_scores.sort == scores_from_scan.sort + end + + def test_zscan_each_enumerator_match + count = 1000 + elements = [] + count.times { |j| elements << j << "key:#{j}" } + r.zadd "zset", elements + + scores_from_scan = r.zscan_each("zset", match: "key:1??").to_a.uniq + member_scores = r.zrange("zset", 0, -1, with_scores: true) + filtered_members = member_scores.select { |k, _s| k =~ /^key:1..$/ } + + assert filtered_members.sort == scores_from_scan.sort + end + + def test_zscan_each_block + count = 1000 + elements = [] + count.times { |j| elements << j << "key:#{j}" } + r.zadd "zset", elements + + scores_from_scan = [] + r.zscan_each("zset") do |member, score| + scores_from_scan << [member, score] + end + member_scores = r.zrange("zset", 0, -1, with_scores: true) + + assert member_scores.sort == scores_from_scan.sort + end + + def test_zscan_each_block_match + count = 1000 + elements = [] + count.times { |j| elements << j << "key:#{j}" } + r.zadd "zset", elements + + scores_from_scan = [] + r.zscan_each("zset", match: "key:1??") do |member, score| + scores_from_scan << [member, score] + end + member_scores = r.zrange("zset", 0, -1, with_scores: true) + filtered_members = member_scores.select { |k, _s| k =~ /^key:1..$/ } + + assert filtered_members.sort == scores_from_scan.sort + end +end diff --git a/test/redis/scripting_test.rb b/test/redis/scripting_test.rb new file mode 100644 index 000000000..f01650122 --- /dev/null +++ b/test/redis/scripting_test.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "helper" + +class TestScripting < Minitest::Test + include Helper::Client + + def to_sha(script) + r.script(:load, script) + end + + def test_script_exists + a = to_sha("return 1") + b = a.succ + + assert_equal true, r.script(:exists, a) + assert_equal false, r.script(:exists, b) + assert_equal [true], r.script(:exists, [a]) + assert_equal [false], r.script(:exists, [b]) + assert_equal [true, false], r.script(:exists, [a, b]) + end + + def test_script_flush + sha = to_sha("return 1") + assert r.script(:exists, sha) + assert_equal "OK", r.script(:flush) + assert !r.script(:exists, sha) + end + + def test_script_kill + redis_mock(script: ->(arg) { "+#{arg.upcase}" }) do |redis| + assert_equal "KILL", redis.script(:kill) + end + end + + def test_eval + assert_equal 0, r.eval("return #KEYS") + assert_equal 0, r.eval("return #ARGV") + assert_equal ["k1", "k2"], r.eval("return KEYS", ["k1", "k2"]) + assert_equal ["a1", "a2"], r.eval("return ARGV", [], ["a1", "a2"]) + end + + def test_eval_with_options_hash + assert_equal 0, r.eval("return #KEYS", {}) + assert_equal 0, r.eval("return #ARGV", {}) + assert_equal ["k1", "k2"], r.eval("return KEYS", { keys: ["k1", "k2"] }) + assert_equal ["a1", "a2"], r.eval("return ARGV", { argv: ["a1", "a2"] }) + end + + def test_evalsha + assert_equal 0, r.evalsha(to_sha("return #KEYS")) + assert_equal 0, r.evalsha(to_sha("return #ARGV")) + assert_equal ["k1", "k2"], r.evalsha(to_sha("return KEYS"), ["k1", "k2"]) + assert_equal ["a1", "a2"], r.evalsha(to_sha("return ARGV"), [], ["a1", "a2"]) + end + + def test_evalsha_with_options_hash + assert_equal 0, r.evalsha(to_sha("return #KEYS"), {}) + assert_equal 0, r.evalsha(to_sha("return #ARGV"), {}) + assert_equal ["k1", "k2"], r.evalsha(to_sha("return KEYS"), { keys: ["k1", "k2"] }) + assert_equal ["a1", "a2"], r.evalsha(to_sha("return ARGV"), { argv: ["a1", "a2"] }) + end +end diff --git a/test/sorting_test.rb b/test/redis/sorting_test.rb similarity index 57% rename from test/sorting_test.rb rename to test/redis/sorting_test.rb index e8aec56a9..3e50d2ce8 100644 --- a/test/sorting_test.rb +++ b/test/redis/sorting_test.rb @@ -1,9 +1,8 @@ -# encoding: UTF-8 +# frozen_string_literal: true -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestSorting < Test::Unit::TestCase +require "helper" +class TestSorting < Minitest::Test include Helper::Client def test_sort @@ -13,8 +12,8 @@ def test_sort r.rpush("bar", "1") r.rpush("bar", "2") - assert_equal ["s1"], r.sort("bar", :get => "foo:*", :limit => [0, 1]) - assert_equal ["s2"], r.sort("bar", :get => "foo:*", :limit => [0, 1], :order => "desc alpha") + assert_equal ["s1"], r.sort("bar", get: "foo:*", limit: [0, 1]) + assert_equal ["s2"], r.sort("bar", get: "foo:*", limit: [0, 1], order: "desc alpha") end def test_sort_with_an_array_of_gets @@ -27,9 +26,9 @@ def test_sort_with_an_array_of_gets r.rpush("bar", "1") r.rpush("bar", "2") - assert_equal [["s1a", "s1b"]], r.sort("bar", :get => ["foo:*:a", "foo:*:b"], :limit => [0, 1]) - assert_equal [["s2a", "s2b"]], r.sort("bar", :get => ["foo:*:a", "foo:*:b"], :limit => [0, 1], :order => "desc alpha") - assert_equal [["s1a", "s1b"], ["s2a", "s2b"]], r.sort("bar", :get => ["foo:*:a", "foo:*:b"]) + assert_equal [["s1a", "s1b"]], r.sort("bar", get: ["foo:*:a", "foo:*:b"], limit: [0, 1]) + assert_equal [["s2a", "s2b"]], r.sort("bar", get: ["foo:*:a", "foo:*:b"], limit: [0, 1], order: "desc alpha") + assert_equal [["s1a", "s1b"], ["s2a", "s2b"]], r.sort("bar", get: ["foo:*:a", "foo:*:b"]) end def test_sort_with_store @@ -39,7 +38,7 @@ def test_sort_with_store r.rpush("bar", "1") r.rpush("bar", "2") - r.sort("bar", :get => "foo:*", :store => "baz") + r.sort("bar", get: "foo:*", store: "baz") assert_equal ["s1", "s2"], r.lrange("baz", 0, -1) end @@ -53,7 +52,7 @@ def test_sort_with_an_array_of_gets_and_with_store r.rpush("bar", "1") r.rpush("bar", "2") - r.sort("bar", :get => ["foo:*:a", "foo:*:b"], :store => 'baz') + r.sort("bar", get: ["foo:*:a", "foo:*:b"], store: 'baz') assert_equal ["s1a", "s1b", "s2a", "s2b"], r.lrange("baz", 0, -1) end end diff --git a/test/redis/ssl_test.rb b/test/redis/ssl_test.rb new file mode 100644 index 000000000..075297320 --- /dev/null +++ b/test/redis/ssl_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "helper" + +class SslTest < Minitest::Test + include Helper::Client + + def test_connection_to_non_ssl_server + assert_raises(Redis::CannotConnectError) do + redis = Redis.new(OPTIONS.merge(ssl: true, timeout: LOW_TIMEOUT)) + redis.ping + end + end + + def test_verified_ssl_connection + RedisMock.start({ ping: proc { "+PONG" } }, ssl_server_opts("trusted")) do |port| + redis = Redis.new(host: "127.0.0.1", port: port, ssl: true, ssl_params: { ca_file: ssl_ca_file }) + assert_equal redis.ping, "PONG" + end + end + + def test_unverified_ssl_connection + assert_raises(Redis::CannotConnectError) do + RedisMock.start({ ping: proc { "+PONG" } }, ssl_server_opts("untrusted")) do |port| + redis = Redis.new(port: port, ssl: true, ssl_params: { ca_file: ssl_ca_file }) + redis.ping + end + end + end + + def test_verify_certificates_by_default + assert_raises(Redis::CannotConnectError) do + RedisMock.start({ ping: proc { "+PONG" } }, ssl_server_opts("untrusted")) do |port| + redis = Redis.new(port: port, ssl: true) + redis.ping + end + end + end + + def test_ssl_blocking + RedisMock.start({}, ssl_server_opts("trusted")) do |port| + redis = Redis.new(host: "127.0.0.1", port: port, ssl: true, ssl_params: { ca_file: ssl_ca_file }) + assert_equal redis.set("boom", "a" * 10_000_000), "OK" + end + end + + private + + def ssl_server_opts(prefix) + ssl_cert = File.join(cert_path, "#{prefix}-cert.crt") + ssl_key = File.join(cert_path, "#{prefix}-cert.key") + + { + ssl: true, + ssl_params: { + cert: OpenSSL::X509::Certificate.new(File.read(ssl_cert)), + key: OpenSSL::PKey::RSA.new(File.read(ssl_key)) + } + } + end + + def ssl_ca_file + File.join(cert_path, "trusted-ca.crt") + end + + def cert_path + File.expand_path('../support/ssl', __dir__) + end +end diff --git a/test/redis/thread_safety_test.rb b/test/redis/thread_safety_test.rb new file mode 100644 index 000000000..57ec5e9c4 --- /dev/null +++ b/test/redis/thread_safety_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "helper" + +class TestThreadSafety < Minitest::Test + include Helper::Client + + def test_thread_safety + redis = Redis.new(OPTIONS) + redis.set "foo", 1 + redis.set "bar", 2 + + sample = 100 + + t1 = Thread.new do + @foos = Array.new(sample) { redis.get "foo" } + end + + t2 = Thread.new do + @bars = Array.new(sample) { redis.get "bar" } + end + + t1.join + t2.join + + assert_equal ["1"], @foos.uniq + assert_equal ["2"], @bars.uniq + end +end diff --git a/test/transactions_test.rb b/test/redis/transactions_test.rb similarity index 57% rename from test/transactions_test.rb rename to test/redis/transactions_test.rb index 3f588b29f..026d79cf3 100644 --- a/test/transactions_test.rb +++ b/test/redis/transactions_test.rb @@ -1,20 +1,14 @@ -# encoding: UTF-8 +# frozen_string_literal: true -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestTransactions < Test::Unit::TestCase +require "helper" +class TestTransactions < Minitest::Test include Helper::Client def test_multi_discard - r.multi - - assert_equal "QUEUED", r.set("foo", "1") - assert_equal "QUEUED", r.get("foo") - - r.discard - - assert_equal nil, r.get("foo") + assert_raises(LocalJumpError) do + r.multi + end end def test_multi_exec_with_a_block @@ -33,37 +27,56 @@ def test_multi_exec_with_a_block_doesn_t_return_replies_for_multi_and_exec assert_equal "OK", r1 assert_equal "s1", r2 - assert_equal nil, nothing_else + assert_nil nothing_else + end + + def test_multi_in_pipeline + foo_future = bar_future = nil + multi_future = nil + response = r.pipelined do |pipeline| + multi_future = pipeline.multi do |multi| + multi.set("foo", "s1") + foo_future = multi.get("foo") + end + + pipeline.multi do |multi| + multi.set("bar", "s2") + bar_future = multi.get("bar") + end + end + + assert_equal(["OK", "QUEUED", "QUEUED", ["OK", "s1"], "OK", "QUEUED", "QUEUED", ["OK", "s2"]], response) + + assert_equal ["OK", "s1"], multi_future.value + + assert_equal "s1", foo_future.value + assert_equal "s2", bar_future.value end def test_assignment_inside_multi_exec_block r.multi do |m| - @first = m.sadd("foo", 1) - @second = m.sadd("foo", 1) + @first = m.sadd?("foo", 1) + @second = m.sadd?("foo", 1) end assert_equal true, @first.value assert_equal false, @second.value end - # Although we could support accessing the values in these futures, - # it doesn't make a lot of sense. def test_assignment_inside_multi_exec_block_with_delayed_command_errors - assert_raise(Redis::CommandError) do + assert_raises(Redis::CommandError) do r.multi do |m| @first = m.set("foo", "s1") @second = m.incr("foo") # not an integer - @third = m.lpush("foo", "value") # wrong kind of value end end assert_equal "OK", @first.value - assert_raise(Redis::CommandError) { @second.value } - assert_raise(Redis::FutureNotReady) { @third.value } + assert_raises(Redis::FutureNotReady) { @second.value } end def test_assignment_inside_multi_exec_block_with_immediate_command_errors - assert_raise(Redis::CommandError) do + assert_raises(Redis::CommandError) do r.multi do |m| m.doesnt_exist @first = m.sadd("foo", 1) @@ -71,41 +84,40 @@ def test_assignment_inside_multi_exec_block_with_immediate_command_errors end end - assert_raise(Redis::FutureNotReady) { @first.value } - assert_raise(Redis::FutureNotReady) { @second.value } + assert_raises(Redis::FutureNotReady) { @first.value } + assert_raises(Redis::FutureNotReady) { @second.value } end def test_raise_immediate_errors_in_multi_exec - assert_raise(RuntimeError) do + assert_raises(RuntimeError) do r.multi do |multi| multi.set "bar", "s2" raise "Some error" - multi.set "baz", "s3" end end - assert_equal nil, r.get("bar") - assert_equal nil, r.get("baz") + assert_nil r.get("bar") + assert_nil r.get("baz") end def test_transformed_replies_as_return_values_for_multi_exec_block - info, _ = r.multi do |m| - r.info + info, = r.multi do |transaction| + transaction.info end - assert info.kind_of?(Hash) + assert_instance_of Hash, info end def test_transformed_replies_inside_multi_exec_block - r.multi do |m| - @info = r.info + r.multi do |transaction| + @info = transaction.info end - assert @info.value.kind_of?(Hash) + assert @info.value.is_a?(Hash) end - def test_raise_command_errors_in_multi_exec - assert_raise(Redis::CommandError) do + def test_raise_command_errors_when_reply_is_not_transformed + assert_raises(Redis::CommandError) do r.multi do |m| m.set("foo", "s1") m.incr("foo") # not an integer @@ -116,6 +128,59 @@ def test_raise_command_errors_in_multi_exec assert_equal "s1", r.get("foo") end + def test_empty_multi_exec + result = nil + + redis_mock(exec: ->(*_) { "-ERROR" }) do |redis| + result = redis.multi {} + end + + assert_equal [], result + end + + def test_raise_command_errors_when_reply_is_transformed_from_int_to_boolean + assert_raises(Redis::CommandError) do + r.multi do |m| + m.set("foo", 1) + m.sadd("foo", 2) + end + end + end + + def test_raise_command_errors_when_reply_is_transformed_from_ok_to_boolean + assert_raises(Redis::CommandError) do + r.multi do |m| + m.set("foo", 1, ex: 0, nx: true) + end + end + end + + def test_raise_command_errors_when_reply_is_transformed_to_float + assert_raises(Redis::CommandError) do + r.multi do |m| + m.set("foo", 1) + m.zscore("foo", "b") + end + end + end + + def test_raise_command_errors_when_reply_is_transformed_to_floats + assert_raises(Redis::CommandError) do + r.multi do |m| + m.zrange("a", "b", 5, with_scores: true) + end + end + end + + def test_raise_command_errors_when_reply_is_transformed_to_hash + assert_raises(Redis::CommandError) do + r.multi do |m| + m.set("foo", 1) + m.hgetall("foo") + end + end + end + def test_raise_command_errors_when_accessing_futures_after_multi_exec begin r.multi do |m| @@ -134,7 +199,7 @@ def test_raise_command_errors_when_accessing_futures_after_multi_exec rescue => err end - assert err.kind_of?(RuntimeError) + assert err.is_a?(RuntimeError) end def test_multi_with_a_block_yielding_the_client @@ -145,9 +210,16 @@ def test_multi_with_a_block_yielding_the_client assert_equal "s1", r.get("foo") end + def test_multi_with_interrupt_preserves_client + original = r._client + Redis::MultiConnection.stubs(:new).raises(Interrupt) + assert_raises(Interrupt) { r.multi {} } + assert_equal r._client, original + end + def test_raise_command_error_when_exec_fails - redis_mock(:exec => lambda { |*_| "-ERROR" }) do |redis| - assert_raise(Redis::CommandError) do + redis_mock(exec: ->(*_) { "-ERROR" }) do |redis| + assert_raises(Redis::CommandError) do redis.multi do |m| m.set "foo", "s1" end @@ -186,7 +258,7 @@ def test_watch_with_a_modified_key multi.set "foo", "s2" end - assert_equal nil, res + assert_nil res assert_equal "s1", r.get("foo") end @@ -197,13 +269,12 @@ def test_watch_with_a_modified_key_passed_as_array multi.set "foo", "s2" end - assert_equal nil, res + assert_nil res assert_equal "s1", r.get("foo") end def test_watch_with_a_block_and_an_unmodified_key result = r.watch "foo" do |rd| - assert_same r, rd rd.multi do |multi| @@ -217,7 +288,6 @@ def test_watch_with_a_block_and_an_unmodified_key def test_watch_with_a_block_and_a_modified_key result = r.watch "foo" do |rd| - assert_same r, rd rd.set "foo", "s1" @@ -226,7 +296,7 @@ def test_watch_with_a_block_and_a_modified_key end end - assert_equal nil, result + assert_nil result assert_equal "s1", r.get("foo") end diff --git a/test/redis/unknown_commands_test.rb b/test/redis/unknown_commands_test.rb new file mode 100644 index 000000000..2408ee04c --- /dev/null +++ b/test/redis/unknown_commands_test.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "helper" + +class TestUnknownCommands < Minitest::Test + include Helper::Client + + def test_should_try_to_work + assert_raises Redis::CommandError do + r.not_yet_implemented_command + end + end +end diff --git a/test/redis/url_param_test.rb b/test/redis/url_param_test.rb new file mode 100644 index 000000000..e14314ac2 --- /dev/null +++ b/test/redis/url_param_test.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "helper" + +class TestUrlParam < Minitest::Test + include Helper::Client + + def test_url_defaults_to_localhost + redis = Redis.new + + assert_equal "localhost", redis._client.host + assert_equal 6379, redis._client.port + assert_equal 0, redis._client.db + assert_nil redis._client.password + end + + def test_allows_to_pass_in_a_url + redis = Redis.new url: "redis://:secr3t@foo.com:999/2" + + assert_equal "foo.com", redis._client.host + assert_equal 999, redis._client.port + assert_equal 2, redis._client.db + assert_equal "secr3t", redis._client.password + end + + def test_unescape_password_from_url + redis = Redis.new url: "redis://:secr3t%3A@foo.com:999/2" + + assert_equal "secr3t:", redis._client.password + end + + def test_does_not_unescape_password_when_explicitly_passed + redis = Redis.new url: "redis://:secr3t%3A@foo.com:999/2", password: "secr3t%3A" + + assert_equal "secr3t%3A", redis._client.password + end + + def test_override_url_if_path_option_is_passed + redis = Redis.new url: "redis://:secr3t@foo.com/2", path: "/tmp/redis.sock" + + assert_equal "/tmp/redis.sock", redis._client.path + assert_nil redis._client.host + assert_nil redis._client.port + end + + def test_overrides_url_if_another_connection_option_is_passed + redis = Redis.new url: "redis://:secr3t@foo.com:999/2", port: 1000 + + assert_equal "foo.com", redis._client.host + assert_equal 1000, redis._client.port + assert_equal 2, redis._client.db + assert_equal "secr3t", redis._client.password + end + + def test_does_not_overrides_url_if_a_nil_option_is_passed + redis = Redis.new url: "redis://:secr3t@foo.com:999/2", port: nil + + assert_equal "foo.com", redis._client.host + assert_equal 999, redis._client.port + assert_equal 2, redis._client.db + assert_equal "secr3t", redis._client.password + end + + def test_does_not_modify_the_passed_options + options = { url: "redis://:secr3t@foo.com:999/2" } + + Redis.new(options) + + assert(options == { url: "redis://:secr3t@foo.com:999/2" }) + end + + def test_uses_redis_url_over_default_if_available + ENV["REDIS_URL"] = "redis://:secr3t@foo.com:999/2" + + redis = Redis.new + + assert_equal "foo.com", redis._client.host + assert_equal 999, redis._client.port + assert_equal 2, redis._client.db + assert_equal "secr3t", redis._client.password + + ENV.delete("REDIS_URL") + end + + def test_defaults_to_localhost + redis = Redis.new(url: "redis://") + + assert_equal "localhost", redis._client.host + end + + def test_ipv6_url + redis = Redis.new url: "redis://[::1]" + + assert_equal "::1", redis._client.host + end + + def test_user_and_password + redis = Redis.new(url: 'redis://johndoe:mysecret@foo.com:999/2') + + assert_equal('johndoe', redis._client.username) + assert_equal('mysecret', redis._client.password) + assert_equal('foo.com', redis._client.host) + assert_equal(999, redis._client.port) + assert_equal(2, redis._client.db) + end +end diff --git a/test/remote_server_control_commands_test.rb b/test/remote_server_control_commands_test.rb deleted file mode 100644 index 3ca9f879e..000000000 --- a/test/remote_server_control_commands_test.rb +++ /dev/null @@ -1,118 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestRemoteServerControlCommands < Test::Unit::TestCase - - include Helper::Client - - def test_info - keys = [ - "redis_version", - "uptime_in_seconds", - "uptime_in_days", - "connected_clients", - "used_memory", - "total_connections_received", - "total_commands_processed", - ] - - info = r.info - - keys.each do |k| - msg = "expected #info to include #{k}" - assert info.keys.include?(k), msg - end - end - - def test_info_commandstats - target_version "2.5.7" do - r.config(:resetstat) - r.ping - - result = r.info(:commandstats) - assert_equal "1", result["ping"]["calls"] - end - end - - def test_monitor_redis_lt_2_5_0 - return unless version < "2.5.0" - - log = [] - - wire = Wire.new do - Redis.new(OPTIONS).monitor do |line| - log << line - break if log.size == 3 - end - end - - Wire.pass while log.empty? # Faster than sleep - - r.set "foo", "s1" - - wire.join - - assert log[-1][%q{(db 15) "set" "foo" "s1"}] - end - - def test_monitor_redis_gte_2_5_0 - return unless version >= "2.5.0" - - log = [] - - wire = Wire.new do - Redis.new(OPTIONS).monitor do |line| - log << line - break if line =~ /set/ - end - end - - Wire.pass while log.empty? # Faster than sleep - - r.set "foo", "s1" - - wire.join - - assert log[-1] =~ /\b15\b.* "set" "foo" "s1"/ - end - - def test_monitor_returns_value_for_break - result = r.monitor do |line| - break line - end - - assert_equal "OK", result - end - - def test_echo - assert_equal "foo bar baz\n", r.echo("foo bar baz\n") - end - - def test_debug - r.set "foo", "s1" - - assert r.debug(:object, "foo").kind_of?(String) - end - - def test_object - r.lpush "list", "value" - - assert_equal 1, r.object(:refcount, "list") - encoding = r.object(:encoding, "list") - assert "ziplist" == encoding || "quicklist" == encoding, "Wrong encoding for list" - assert r.object(:idletime, "list").kind_of?(Integer) - end - - def test_sync - redis_mock(:sync => lambda { "+OK" }) do |redis| - assert_equal "OK", redis.sync - end - end - - def test_slowlog - r.slowlog(:reset) - result = r.slowlog(:len) - assert_equal 0, result - end -end diff --git a/test/scanning_test.rb b/test/scanning_test.rb deleted file mode 100644 index 9a4cf7dbf..000000000 --- a/test/scanning_test.rb +++ /dev/null @@ -1,413 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -unless defined?(Enumerator) - Enumerator = Enumerable::Enumerator -end - -class TestScanning < Test::Unit::TestCase - - include Helper::Client - - def test_scan_basic - target_version "2.7.105" do - r.debug :populate, 1000 - - cursor = 0 - all_keys = [] - loop { - cursor, keys = r.scan cursor - all_keys += keys - break if cursor == "0" - } - - assert_equal 1000, all_keys.uniq.size - end - end - - def test_scan_count - target_version "2.7.105" do - r.debug :populate, 1000 - - cursor = 0 - all_keys = [] - loop { - cursor, keys = r.scan cursor, :count => 5 - all_keys += keys - break if cursor == "0" - } - - assert_equal 1000, all_keys.uniq.size - end - end - - def test_scan_match - target_version "2.7.105" do - r.debug :populate, 1000 - - cursor = 0 - all_keys = [] - loop { - cursor, keys = r.scan cursor, :match => "key:1??" - all_keys += keys - break if cursor == "0" - } - - assert_equal 100, all_keys.uniq.size - end - end - - def test_scan_each_enumerator - target_version "2.7.105" do - - r.debug :populate, 1000 - - scan_enumerator = r.scan_each - assert_equal true, scan_enumerator.is_a?(::Enumerator) - - keys_from_scan = scan_enumerator.to_a.uniq - all_keys = r.keys "*" - - assert all_keys.sort == keys_from_scan.sort - end - end - - def test_scan_each_enumerator_match - target_version "2.7.105" do - - r.debug :populate, 1000 - - keys_from_scan = r.scan_each(:match => "key:1??").to_a.uniq - all_keys = r.keys "key:1??" - - assert all_keys.sort == keys_from_scan.sort - end - end - - def test_scan_each_block - target_version "2.7.105" do - - r.debug :populate, 100 - - keys_from_scan = [] - r.scan_each {|key| - keys_from_scan << key - } - - all_keys = r.keys "*" - - assert all_keys.sort == keys_from_scan.uniq.sort - end - end - - def test_scan_each_block_match - target_version "2.7.105" do - - r.debug :populate, 100 - - keys_from_scan = [] - r.scan_each(:match => "key:1?") {|key| - keys_from_scan << key - } - - all_keys = r.keys "key:1?" - - assert all_keys.sort == keys_from_scan.uniq.sort - end - end - - def test_sscan_with_encoding - target_version "2.7.105" do - [:intset, :hashtable].each do |enc| - r.del "set" - - prefix = "" - prefix = "ele:" if enc == :hashtable - - elements = [] - 100.times { |j| elements << "#{prefix}#{j}" } - - r.sadd "set", elements - - assert_equal enc.to_s, r.object("encoding", "set") - - cursor = 0 - all_keys = [] - loop { - cursor, keys = r.sscan "set", cursor - all_keys += keys - break if cursor == "0" - } - - assert_equal 100, all_keys.uniq.size - end - end - end - - def test_sscan_each_enumerator - target_version "2.7.105" do - elements = [] - 100.times { |j| elements << "ele:#{j}" } - r.sadd "set", elements - - scan_enumerator = r.sscan_each("set") - assert_equal true, scan_enumerator.is_a?(::Enumerator) - - keys_from_scan = scan_enumerator.to_a.uniq - all_keys = r.smembers("set") - - assert all_keys.sort == keys_from_scan.sort - end - end - - def test_sscan_each_enumerator_match - target_version "2.7.105" do - elements = [] - 100.times { |j| elements << "ele:#{j}" } - r.sadd "set", elements - - keys_from_scan = r.sscan_each("set", :match => "ele:1?").to_a.uniq - - all_keys = r.smembers("set").grep(/^ele:1.$/) - - assert all_keys.sort == keys_from_scan.sort - end - end - - def test_sscan_each_enumerator_block - target_version "2.7.105" do - elements = [] - 100.times { |j| elements << "ele:#{j}" } - r.sadd "set", elements - - keys_from_scan = [] - r.sscan_each("set") do |key| - keys_from_scan << key - end - - all_keys = r.smembers("set") - - assert all_keys.sort == keys_from_scan.uniq.sort - end - end - - def test_sscan_each_enumerator_block_match - target_version "2.7.105" do - elements = [] - 100.times { |j| elements << "ele:#{j}" } - r.sadd "set", elements - - keys_from_scan = [] - r.sscan_each("set", :match => "ele:1?") do |key| - keys_from_scan << key - end - - all_keys = r.smembers("set").grep(/^ele:1.$/) - - assert all_keys.sort == keys_from_scan.uniq.sort - end - end - - def test_hscan_with_encoding - target_version "2.7.105" do - [:ziplist, :hashtable].each do |enc| - r.del "set" - - count = 1000 - count = 30 if enc == :ziplist - - elements = [] - count.times { |j| elements << "key:#{j}" << j.to_s } - - r.hmset "hash", *elements - - assert_equal enc.to_s, r.object("encoding", "hash") - - cursor = 0 - all_key_values = [] - loop { - cursor, key_values = r.hscan "hash", cursor - all_key_values.concat key_values - break if cursor == "0" - } - - keys2 = [] - all_key_values.each do |k, v| - assert_equal "key:#{v}", k - keys2 << k - end - - assert_equal count, keys2.uniq.size - end - end - end - - def test_hscan_each_enumerator - target_version "2.7.105" do - count = 1000 - elements = [] - count.times { |j| elements << "key:#{j}" << j.to_s } - r.hmset "hash", *elements - - scan_enumerator = r.hscan_each("hash") - assert_equal true, scan_enumerator.is_a?(::Enumerator) - - keys_from_scan = scan_enumerator.to_a.uniq - all_keys = r.hgetall("hash").to_a - - assert all_keys.sort == keys_from_scan.sort - end - end - - def test_hscan_each_enumerator_match - target_version "2.7.105" do - count = 100 - elements = [] - count.times { |j| elements << "key:#{j}" << j.to_s } - r.hmset "hash", *elements - - keys_from_scan = r.hscan_each("hash", :match => "key:1?").to_a.uniq - all_keys = r.hgetall("hash").to_a.select{|k,v| k =~ /^key:1.$/} - - assert all_keys.sort == keys_from_scan.sort - end - end - - def test_hscan_each_block - target_version "2.7.105" do - count = 1000 - elements = [] - count.times { |j| elements << "key:#{j}" << j.to_s } - r.hmset "hash", *elements - - keys_from_scan = [] - r.hscan_each("hash") do |field, value| - keys_from_scan << [field, value] - end - all_keys = r.hgetall("hash").to_a - - assert all_keys.sort == keys_from_scan.uniq.sort - end - end - - def test_hscan_each_block_match - target_version "2.7.105" do - count = 1000 - elements = [] - count.times { |j| elements << "key:#{j}" << j.to_s } - r.hmset "hash", *elements - - keys_from_scan = [] - r.hscan_each("hash", :match => "key:1?") do |field, value| - keys_from_scan << [field, value] - end - all_keys = r.hgetall("hash").to_a.select{|k,v| k =~ /^key:1.$/} - - assert all_keys.sort == keys_from_scan.uniq.sort - end - end - - def test_zscan_with_encoding - target_version "2.7.105" do - [:ziplist, :skiplist].each do |enc| - r.del "zset" - - count = 1000 - count = 30 if enc == :ziplist - - elements = [] - count.times { |j| elements << j << "key:#{j}" } - - r.zadd "zset", elements - - assert_equal enc.to_s, r.object("encoding", "zset") - - cursor = 0 - all_key_scores = [] - loop { - cursor, key_scores = r.zscan "zset", cursor - all_key_scores.concat key_scores - break if cursor == "0" - } - - keys2 = [] - all_key_scores.each do |k, v| - assert_equal true, v.is_a?(Float) - assert_equal "key:#{Integer(v)}", k - keys2 << k - end - - assert_equal count, keys2.uniq.size - end - end - end - - def test_zscan_each_enumerator - target_version "2.7.105" do - count = 1000 - elements = [] - count.times { |j| elements << j << "key:#{j}" } - r.zadd "zset", elements - - scan_enumerator = r.zscan_each "zset" - assert_equal true, scan_enumerator.is_a?(::Enumerator) - - scores_from_scan = scan_enumerator.to_a.uniq - member_scores = r.zrange("zset", 0, -1, :with_scores => true) - - assert member_scores.sort == scores_from_scan.sort - end - end - - def test_zscan_each_enumerator_match - target_version "2.7.105" do - count = 1000 - elements = [] - count.times { |j| elements << j << "key:#{j}" } - r.zadd "zset", elements - - scores_from_scan = r.zscan_each("zset", :match => "key:1??").to_a.uniq - member_scores = r.zrange("zset", 0, -1, :with_scores => true) - filtered_members = member_scores.select{|k,s| k =~ /^key:1..$/} - - assert filtered_members.sort == scores_from_scan.sort - end - end - - def test_zscan_each_block - target_version "2.7.105" do - count = 1000 - elements = [] - count.times { |j| elements << j << "key:#{j}" } - r.zadd "zset", elements - - scores_from_scan = [] - r.zscan_each("zset") do |member, score| - scores_from_scan << [member, score] - end - member_scores = r.zrange("zset", 0, -1, :with_scores => true) - - assert member_scores.sort == scores_from_scan.sort - end - end - - def test_zscan_each_block_match - target_version "2.7.105" do - count = 1000 - elements = [] - count.times { |j| elements << j << "key:#{j}" } - r.zadd "zset", elements - - scores_from_scan = [] - r.zscan_each("zset", :match => "key:1??") do |member, score| - scores_from_scan << [member, score] - end - member_scores = r.zrange("zset", 0, -1, :with_scores => true) - filtered_members = member_scores.select{|k,s| k =~ /^key:1..$/} - - assert filtered_members.sort == scores_from_scan.sort - end - end - -end diff --git a/test/scripting_test.rb b/test/scripting_test.rb deleted file mode 100644 index 82d0d89e2..000000000 --- a/test/scripting_test.rb +++ /dev/null @@ -1,78 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestScripting < Test::Unit::TestCase - - include Helper::Client - - def to_sha(script) - r.script(:load, script) - end - - def test_script_exists - target_version "2.5.9" do # 2.6-rc1 - a = to_sha("return 1") - b = a.succ - - assert_equal true, r.script(:exists, a) - assert_equal false, r.script(:exists, b) - assert_equal [true], r.script(:exists, [a]) - assert_equal [false], r.script(:exists, [b]) - assert_equal [true, false], r.script(:exists, [a, b]) - end - end - - def test_script_flush - target_version "2.5.9" do # 2.6-rc1 - sha = to_sha("return 1") - assert r.script(:exists, sha) - assert_equal "OK", r.script(:flush) - assert !r.script(:exists, sha) - end - end - - def test_script_kill - target_version "2.5.9" do # 2.6-rc1 - redis_mock(:script => lambda { |arg| "+#{arg.upcase}" }) do |redis| - assert_equal "KILL", redis.script(:kill) - end - end - end - - def test_eval - target_version "2.5.9" do # 2.6-rc1 - assert_equal 0, r.eval("return #KEYS") - assert_equal 0, r.eval("return #ARGV") - assert_equal ["k1", "k2"], r.eval("return KEYS", ["k1", "k2"]) - assert_equal ["a1", "a2"], r.eval("return ARGV", [], ["a1", "a2"]) - end - end - - def test_eval_with_options_hash - target_version "2.5.9" do # 2.6-rc1 - assert_equal 0, r.eval("return #KEYS", {}) - assert_equal 0, r.eval("return #ARGV", {}) - assert_equal ["k1", "k2"], r.eval("return KEYS", { :keys => ["k1", "k2"] }) - assert_equal ["a1", "a2"], r.eval("return ARGV", { :argv => ["a1", "a2"] }) - end - end - - def test_evalsha - target_version "2.5.9" do # 2.6-rc1 - assert_equal 0, r.evalsha(to_sha("return #KEYS")) - assert_equal 0, r.evalsha(to_sha("return #ARGV")) - assert_equal ["k1", "k2"], r.evalsha(to_sha("return KEYS"), ["k1", "k2"]) - assert_equal ["a1", "a2"], r.evalsha(to_sha("return ARGV"), [], ["a1", "a2"]) - end - end - - def test_evalsha_with_options_hash - target_version "2.5.9" do # 2.6-rc1 - assert_equal 0, r.evalsha(to_sha("return #KEYS"), {}) - assert_equal 0, r.evalsha(to_sha("return #ARGV"), {}) - assert_equal ["k1", "k2"], r.evalsha(to_sha("return KEYS"), { :keys => ["k1", "k2"] }) - assert_equal ["a1", "a2"], r.evalsha(to_sha("return ARGV"), { :argv => ["a1", "a2"] }) - end - end -end diff --git a/test/sentinel/sentinel_command_test.rb b/test/sentinel/sentinel_command_test.rb new file mode 100644 index 000000000..98124801f --- /dev/null +++ b/test/sentinel/sentinel_command_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "helper" + +# @see https://redis.io/topics/sentinel#sentinel-commands Sentinel commands +class SentinelCommandsTest < Minitest::Test + include Helper::Sentinel + + def test_sentinel_command_master + wait_for_quorum + + redis = build_sentinel_client + result = redis.sentinel('master', MASTER_NAME) + + assert_equal result['name'], MASTER_NAME + assert_equal result['ip'], LOCALHOST + end + + def test_sentinel_command_masters + wait_for_quorum + + redis = build_sentinel_client + result = redis.sentinel('masters') + + assert_equal result[0]['name'], MASTER_NAME + assert_equal result[0]['ip'], LOCALHOST + assert_equal result[0]['port'], MASTER_PORT + end + + def test_sentinel_command_slaves + wait_for_quorum + + redis = build_sentinel_client + result = redis.sentinel('slaves', MASTER_NAME) + + assert_equal result[0]['name'], "#{LOCALHOST}:#{SLAVE_PORT}" + assert_equal result[0]['ip'], LOCALHOST + assert_equal result[0]['port'], SLAVE_PORT + end + + def test_sentinel_command_sentinels + wait_for_quorum + + redis = build_sentinel_client + result = redis.sentinel('sentinels', MASTER_NAME) + + assert_equal result[0]['ip'], LOCALHOST + + actual_ports = result.map { |r| r['port'] }.sort + expected_ports = SENTINEL_PORTS[1..-1] + assert_equal actual_ports, expected_ports + end + + def test_sentinel_command_get_master_by_name + redis = build_sentinel_client + result = redis.sentinel('get-master-addr-by-name', MASTER_NAME) + + assert_equal result, [LOCALHOST, MASTER_PORT] + end + + def test_sentinel_command_ckquorum + wait_for_quorum + + redis = build_sentinel_client + result = redis.sentinel('ckquorum', MASTER_NAME) + assert_equal result, 'OK 3 usable Sentinels. Quorum and failover authorization can be reached' + end +end diff --git a/test/sentinel/sentinel_test.rb b/test/sentinel/sentinel_test.rb new file mode 100644 index 000000000..541ae63b7 --- /dev/null +++ b/test/sentinel/sentinel_test.rb @@ -0,0 +1,486 @@ +# frozen_string_literal: true + +require "helper" + +class SentinelTest < Minitest::Test + include Helper::Sentinel + + def test_sentinel_master_role_connection + wait_for_quorum + + actual = redis.role + + assert_equal 'master', actual[0] + end + + def test_sentinel_slave_role_connection + wait_for_quorum + + redis = build_slave_role_client + actual = redis.role + + assert_equal 'slave', actual[0] + assert_equal MASTER_PORT.to_i, actual[2] + end + + def test_without_reconnect + wait_for_quorum + + redis.without_reconnect do + redis.get("key") + end + end + + def test_the_client_can_connect_to_available_slaves + commands = { + sentinel: lambda do |*_| + [ + ['ip', '127.0.0.1', 'port', '6382', 'flags', 'slave'], + ['ip', '127.0.0.1', 'port', '6383', 'flags', 's_down,slave,disconnected'] + ] + end + } + RedisMock.start(commands) do |port| + redis = build_slave_role_client(sentinels: [{ host: '127.0.0.1', port: port }]) + assert_equal 'PONG', redis.ping + end + end + + def test_the_client_raises_error_when_there_is_no_available_slaves + commands = { + sentinel: lambda do |*_| + [ + ['ip', '127.0.0.1', 'port', '6382', 'flags', 's_down,slave,disconnected'], + ['ip', '127.0.0.1', 'port', '6383', 'flags', 's_down,slave,disconnected'] + ] + end + } + RedisMock.start(commands) do |port| + redis = build_slave_role_client(sentinels: [{ host: '127.0.0.1', port: port }]) + assert_raises(Redis::CannotConnectError) { redis.ping } + end + end + + def test_sentinel_failover + sentinels = [{ host: "127.0.0.1", port: 26_381 }, + { host: "127.0.0.1", port: 26_382 }] + + commands = { + s1: [], + s2: [] + } + + s1 = { + sentinel: lambda do |command, *args| + commands[:s1] << [command, *args] + "$-1" # Nil + end + } + + s2 = { + sentinel: lambda do |command, *args| + commands[:s2] << [command, *args] + case command + when "get-master-addr-by-name" + ["127.0.0.1", "6381"] + when "sentinels" + [] + else + raise "Unexpected command #{[command, *args].inspect}" + end + end + } + + RedisMock.start(s1) do |s1_port| + RedisMock.start(s2) do |s2_port| + sentinels[0][:port] = s1_port + sentinels[1][:port] = s2_port + redis = Redis.new(url: "redis://master1", sentinels: sentinels, role: :master) + + assert redis.ping + end + end + + assert_equal commands[:s1], [%w[get-master-addr-by-name master1]] + assert_equal commands[:s2], [%w[get-master-addr-by-name master1], ["sentinels", "master1"]] + end + + def test_sentinel_failover_prioritize_healthy_sentinel + sentinels = [{ host: "127.0.0.1", port: 26_381 }, + { host: "127.0.0.1", port: 26_382 }] + + commands = { + s1: [], + s2: [] + } + + s1 = { + sentinel: lambda do |command, *args| + commands[:s1] << [command, *args] + "$-1" # Nil + end + } + + s2 = { + sentinel: lambda do |command, *args| + commands[:s2] << [command, *args] + case command + when "get-master-addr-by-name" + ["127.0.0.1", "6381"] + when "sentinels" + [ + ["ip", "127.0.0.1", "port", "26381"], + ["ip", "127.0.0.1", "port", "26382"], + ] + else + raise "Unexpected command #{[command, *args].inspect}" + end + end + } + + RedisMock.start(s1) do |s1_port| + RedisMock.start(s2) do |s2_port| + sentinels[0][:port] = s1_port + sentinels[1][:port] = s2_port + redis = Redis.new(url: "redis://master1", sentinels: sentinels, role: :master) + + assert redis.ping + + redis.quit + + assert redis.ping + end + end + + assert_equal [%w[get-master-addr-by-name master1]], commands[:s1] + assert_equal [%w[get-master-addr-by-name master1], ["sentinels", "master1"]], commands[:s2] + end + + def test_sentinel_with_non_sentinel_options + commands = { s1: [], m1: [] } + + sentinel = lambda do |port| + { + auth: lambda do |*args| + commands[:s1] << ['auth', *args] + '+OK' + end, + select: lambda do |db| + commands[:s1] << ['select', db] + "-ERR unknown command 'select'" + end, + sentinel: lambda do |command, *args| + commands[:s1] << [command, *args] + case command + when "get-master-addr-by-name" + ["127.0.0.1", port.to_s] + when "sentinels" + [ + ["ip", "127.0.0.1", "port", "26381"], + ["ip", "127.0.0.1", "port", "26382"], + ] + else + raise "Unexpected command #{[command, *args].inspect}" + end + end + } + end + + master = { + auth: lambda do |*args| + commands[:m1] << ['auth', *args] + '+OK' + end, + role: lambda do + commands[:m1] << ['role'] + ['master'] + end + } + + RedisMock.start(master) do |master_port| + RedisMock.start(sentinel.call(master_port)) do |sen_port| + s = [{ host: '127.0.0.1', port: sen_port }] + redis = Redis.new(url: 'redis://:foo@master1/15', sentinels: s, role: :master) + assert redis.ping + end + end + + assert_equal [%w[get-master-addr-by-name master1], ["sentinels", "master1"]], commands[:s1] + assert_equal [%w[auth foo], %w[role]], commands[:m1] + end + + def test_authentication_for_sentinel + commands = { s1: [], m1: [] } + + sentinel = lambda do |port| + { + auth: lambda do |*args| + commands[:s1] << ['auth', *args] + '+OK' + end, + select: lambda do |db| + commands[:s1] << ['select', db] + '-ERR unknown command `select`' + end, + sentinel: lambda do |command, *args| + commands[:s1] << [command, *args] + case command + when "get-master-addr-by-name" + ["127.0.0.1", port.to_s] + when "sentinels" + [ + ["ip", "127.0.0.1", "port", "26381"], + ["ip", "127.0.0.1", "port", "26382"], + ] + else + raise "Unexpected command #{[command, *args].inspect}" + end + end + } + end + + master = { + auth: lambda do |*args| + commands[:s1] << ['auth', *args] + '-ERR Client sent AUTH, but no password is set' + end, + role: lambda do + commands[:m1] << ['role'] + ['master'] + end + } + + RedisMock.start(master) do |master_port| + RedisMock.start(sentinel.call(master_port)) do |sen_port| + s = [{ host: '127.0.0.1', port: sen_port }] + r = Redis.new(name: 'master1', sentinels: s, role: :master, sentinel_password: 'foo') + assert r.ping + end + end + + assert_equal [%w[auth foo], %w[get-master-addr-by-name master1], ["sentinels", "master1"]], commands[:s1] + assert_equal [%w[role]], commands[:m1] + end + + def test_authentication_for_sentinel_and_redis + commands = { s1: [], m1: [] } + + sentinel = lambda do |port| + { + auth: lambda do |*args| + commands[:s1] << ['auth', *args] + '+OK' + end, + select: lambda do |db| + commands[:s1] << ['select', db] + '-ERR unknown command `select`' + end, + sentinel: lambda do |command, *args| + commands[:s1] << [command, *args] + case command + when "get-master-addr-by-name" + ["127.0.0.1", port.to_s] + when "sentinels" + [ + ["ip", "127.0.0.1", "port", "26381"], + ["ip", "127.0.0.1", "port", "26382"], + ] + else + raise "Unexpected command #{[command, *args].inspect}" + end + end, + } + end + + master = { + auth: lambda do |*args| + commands[:m1] << ['auth', *args] + '+OK' + end, + role: lambda do + commands[:m1] << ['role'] + ['master'] + end, + sentinel: lambda do |command, *args| + commands[:s2] << [command, *args] + end, + } + + RedisMock.start(master) do |master_port| + RedisMock.start(sentinel.call(master_port)) do |sen_port| + s = [{ host: '127.0.0.1', port: sen_port }] + r = Redis.new(name: 'master1', sentinels: s, role: :master, password: 'bar', sentinel_password: 'foo') + assert r.ping + end + end + + assert_equal [%w[auth foo], %w[get-master-addr-by-name master1], ["sentinels", "master1"]], commands[:s1] + assert_equal [%w[auth bar], %w[role]], commands[:m1] + end + + def test_authentication_with_acl + commands = { s1: [], m1: [] } + + sentinel = lambda do |port| + { + auth: lambda do |user, pass| + commands[:s1] << ['auth', user, pass] + '+OK' + end, + select: lambda do |db| + commands[:s1] << ['select', db] + '-ERR unknown command `select`' + end, + sentinel: lambda do |command, *args| + commands[:s1] << [command, *args] + case command + when "get-master-addr-by-name" + ["127.0.0.1", port.to_s] + when "sentinels" + [ + ["ip", "127.0.0.1", "port", "26381"], + ["ip", "127.0.0.1", "port", "26382"], + ] + else + raise "Unexpected command #{[command, *args].inspect}" + end + end + } + end + + master = { + auth: lambda do |user, pass| + commands[:m1] << ['auth', user, pass] + '+OK' + end, + role: lambda do + commands[:m1] << ['role'] + ['master'] + end + } + + RedisMock.start(master) do |master_port| + RedisMock.start(sentinel.call(master_port)) do |sen_port| + s = [{ host: '127.0.0.1', port: sen_port }] + r = Redis.new(name: 'master1', sentinels: s, role: :master, username: 'alice', password: 'bar', sentinel_username: 'bob', sentinel_password: 'foo') + assert r.ping + end + end + + assert_equal [%w[auth bob foo], %w[get-master-addr-by-name master1], ["sentinels", "master1"]], commands[:s1] + assert_equal [%w[auth alice bar], %w[role]], commands[:m1] + end + + def test_sentinel_role_mismatch + sentinels = [{ host: "127.0.0.1", port: 26_381 }] + + sentinel = lambda do |port| + { + sentinel: lambda do |command, *_args| + case command + when "get-master-addr-by-name" + ["127.0.0.1", port.to_s] + when "sentinels" + [ + ["ip", "127.0.0.1", "port", "26381"], + ] + else + raise "Unexpected command #{[command, *args].inspect}" + end + end + } + end + + master = { + role: lambda do + ["slave"] + end + } + + ex = assert_raises(Redis::CannotConnectError) do + RedisMock.start(master) do |master_port| + RedisMock.start(sentinel.call(master_port)) do |sen_port| + sentinels[0][:port] = sen_port + redis = Redis.new(url: "redis://master1", sentinels: sentinels, role: :master, reconnect_attempts: 0) + + assert redis.ping + end + end + end + + assert_match(/Expected to connect to a master, but the server is a replica/, ex.message) + end + + def test_sentinel_retries + sentinels = [{ host: "127.0.0.1", port: 26_381 }, + { host: "127.0.0.1", port: 26_382 }] + + connections = [] + + fails = Hash.new(0) + + handler = lambda do |id, port| + { + sentinel: lambda do |command, *_args| + connections << id + + if fails[id] < 2 + fails[id] += 1 + :close + else + case command + when "get-master-addr-by-name" + ["127.0.0.1", port.to_s] + when "sentinels" + [ + ["ip", "127.0.0.1", "port", "26381"], + ["ip", "127.0.0.1", "port", "26382"], + ] + else + raise "Unexpected command #{[command, *args].inspect}" + end + end + end + } + end + + master = { + role: lambda do + ["master"] + end + } + + RedisMock.start(master) do |master_port| + RedisMock.start(handler.call(:s1, master_port)) do |s1_port| + RedisMock.start(handler.call(:s2, master_port)) do |s2_port| + sentinels[0][:port] = s1_port + sentinels[1][:port] = s2_port + redis = Redis.new(url: "redis://master1", sentinels: sentinels, role: :master, reconnect_attempts: 1) + + assert redis.ping + end + end + end + + assert_equal %i[s1 s1 s2 s2 s1 s1], connections + + connections.clear + fails.clear + + ex = assert_raises(Redis::CannotConnectError) do + RedisMock.start(master) do |master_port| + RedisMock.start(handler.call(:s1, master_port)) do |s1_port| + RedisMock.start(handler.call(:s2, master_port)) do |s2_port| + sentinels[0][:port] = s1_port + 1 + sentinels[1][:port] = s2_port + 2 + redis = Redis.new(url: "redis://master1", sentinels: sentinels, role: :master, reconnect_attempts: 0) + + assert redis.ping + end + end + end + end + + assert_match(/No sentinels available/, ex.message) + end +end diff --git a/test/sentinel_command_test.rb b/test/sentinel_command_test.rb deleted file mode 100644 index 1b57b1ded..000000000 --- a/test/sentinel_command_test.rb +++ /dev/null @@ -1,80 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class SentinelCommandsTest < Test::Unit::TestCase - - include Helper::Client - - def test_sentinel_command_master - - handler = lambda do |id| - { - :sentinel => lambda do |command, *args| - ["name", "master1", "ip", "127.0.0.1"] - end - } - end - - RedisMock.start(handler.call(:s1)) do |port| - redis = Redis.new(:host => "127.0.0.1", :port => port) - - result = redis.sentinel('master', 'master1') - assert_equal result, { "name" => "master1", "ip" => "127.0.0.1" } - end - end - - def test_sentinel_command_masters - - handler = lambda do |id| - { - :sentinel => lambda do |command, *args| - [%w[name master1 ip 127.0.0.1 port 6381], %w[name master1 ip 127.0.0.1 port 6382]] - end - } - end - - RedisMock.start(handler.call(:s1)) do |port| - redis = Redis.new(:host => "127.0.0.1", :port => port) - - result = redis.sentinel('masters') - assert_equal result[0], { "name" => "master1", "ip" => "127.0.0.1", "port" => "6381" } - assert_equal result[1], { "name" => "master1", "ip" => "127.0.0.1", "port" => "6382" } - end - end - - def test_sentinel_command_get_master_by_name - - handler = lambda do |id| - { - :sentinel => lambda do |command, *args| - ["127.0.0.1", "6381"] - end - } - end - - RedisMock.start(handler.call(:s1)) do |port| - redis = Redis.new(:host => "127.0.0.1", :port => port) - - result = redis.sentinel('get-master-addr-by-name', 'master1') - assert_equal result, ["127.0.0.1", "6381"] - end - end - - def test_sentinel_command_ckquorum - handler = lambda do |id| - { - :sentinel => lambda do |command, *args| - "+OK 2 usable Sentinels. Quorum and failover authorization can be reached" - end - } - end - - RedisMock.start(handler.call(:s1)) do |port| - redis = Redis.new(:host => "127.0.0.1", :port => port) - - result = redis.sentinel('ckquorum', 'master1') - assert_equal result, "OK 2 usable Sentinels. Quorum and failover authorization can be reached" - end - end -end diff --git a/test/sentinel_test.rb b/test/sentinel_test.rb deleted file mode 100644 index 823e0762c..000000000 --- a/test/sentinel_test.rb +++ /dev/null @@ -1,255 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class SentinelTest < Test::Unit::TestCase - - include Helper::Client - - def test_sentinel_connection - sentinels = [{:host => "127.0.0.1", :port => 26381}, - {:host => "127.0.0.1", :port => 26382}] - - commands = { - :s1 => [], - :s2 => [], - } - - handler = lambda do |id| - { - :sentinel => lambda do |command, *args| - commands[id] << [command, *args] - ["127.0.0.1", "6381"] - end - } - end - - RedisMock.start(handler.call(:s1)) do |s1_port| - RedisMock.start(handler.call(:s2)) do |s2_port| - sentinels[0][:port] = s1_port - sentinels[1][:port] = s2_port - redis = Redis.new(:url => "redis://master1", :sentinels => sentinels, :role => :master) - - assert redis.ping - end - end - - assert_equal commands[:s1], [%w[get-master-addr-by-name master1]] - assert_equal commands[:s2], [] - end - - def test_sentinel_failover - sentinels = [{:host => "127.0.0.1", :port => 26381}, - {:host => "127.0.0.1", :port => 26382}] - - commands = { - :s1 => [], - :s2 => [], - } - - s1 = { - :sentinel => lambda do |command, *args| - commands[:s1] << [command, *args] - "$-1" # Nil - end - } - - s2 = { - :sentinel => lambda do |command, *args| - commands[:s2] << [command, *args] - ["127.0.0.1", "6381"] - end - } - - RedisMock.start(s1) do |s1_port| - RedisMock.start(s2) do |s2_port| - sentinels[0][:port] = s1_port - sentinels[1][:port] = s2_port - redis = Redis.new(:url => "redis://master1", :sentinels => sentinels, :role => :master) - - assert redis.ping - end - end - - assert_equal commands[:s1], [%w[get-master-addr-by-name master1]] - assert_equal commands[:s2], [%w[get-master-addr-by-name master1]] - end - - def test_sentinel_failover_prioritize_healthy_sentinel - sentinels = [{:host => "127.0.0.1", :port => 26381}, - {:host => "127.0.0.1", :port => 26382}] - - commands = { - :s1 => [], - :s2 => [], - } - - s1 = { - :sentinel => lambda do |command, *args| - commands[:s1] << [command, *args] - "$-1" # Nil - end - } - - s2 = { - :sentinel => lambda do |command, *args| - commands[:s2] << [command, *args] - ["127.0.0.1", "6381"] - end - } - - RedisMock.start(s1) do |s1_port| - RedisMock.start(s2) do |s2_port| - sentinels[0][:port] = s1_port - sentinels[1][:port] = s2_port - redis = Redis.new(:url => "redis://master1", :sentinels => sentinels, :role => :master) - - assert redis.ping - - redis.quit - - assert redis.ping - end - end - - assert_equal commands[:s1], [%w[get-master-addr-by-name master1]] - assert_equal commands[:s2], [%w[get-master-addr-by-name master1], %w[get-master-addr-by-name master1]] - end - - def test_sentinel_with_non_sentinel_options - sentinels = [{:host => "127.0.0.1", :port => 26381}] - - commands = { - :s1 => [], - :m1 => [] - } - - sentinel = lambda do |port| - { - :auth => lambda do |pass| - commands[:s1] << ["auth", pass] - "-ERR unknown command 'auth'" - end, - :select => lambda do |db| - commands[:s1] << ["select", db] - "-ERR unknown command 'select'" - end, - :sentinel => lambda do |command, *args| - commands[:s1] << [command, *args] - ["127.0.0.1", port.to_s] - end - } - end - - master = { - :auth => lambda do |pass| - commands[:m1] << ["auth", pass] - "+OK" - end, - :role => lambda do - commands[:m1] << ["role"] - ["master"] - end - } - - RedisMock.start(master) do |master_port| - RedisMock.start(sentinel.call(master_port)) do |sen_port| - sentinels[0][:port] = sen_port - redis = Redis.new(:url => "redis://:foo@master1/15", :sentinels => sentinels, :role => :master) - - assert redis.ping - end - end - - assert_equal [%w[get-master-addr-by-name master1]], commands[:s1] - assert_equal [%w[auth foo], %w[role]], commands[:m1] - end - - def test_sentinel_role_mismatch - sentinels = [{:host => "127.0.0.1", :port => 26381}] - - sentinel = lambda do |port| - { - :sentinel => lambda do |command, *args| - ["127.0.0.1", port.to_s] - end - } - end - - master = { - :role => lambda do - ["slave"] - end - } - - ex = assert_raise(Redis::ConnectionError) do - RedisMock.start(master) do |master_port| - RedisMock.start(sentinel.call(master_port)) do |sen_port| - sentinels[0][:port] = sen_port - redis = Redis.new(:url => "redis://master1", :sentinels => sentinels, :role => :master) - - assert redis.ping - end - end - end - - assert_match(/Instance role mismatch/, ex.message) - end - - def test_sentinel_retries - sentinels = [{:host => "127.0.0.1", :port => 26381}, - {:host => "127.0.0.1", :port => 26382}] - - connections = [] - - handler = lambda do |id, port| - { - :sentinel => lambda do |command, *args| - connections << id - - if connections.count(id) < 2 - :close - else - ["127.0.0.1", port.to_s] - end - end - } - end - - master = { - :role => lambda do - ["master"] - end - } - - RedisMock.start(master) do |master_port| - RedisMock.start(handler.call(:s1, master_port)) do |s1_port| - RedisMock.start(handler.call(:s2, master_port)) do |s2_port| - sentinels[0][:port] = s1_port - sentinels[1][:port] = s2_port - redis = Redis.new(:url => "redis://master1", :sentinels => sentinels, :role => :master, :reconnect_attempts => 1) - - assert redis.ping - end - end - end - - assert_equal [:s1, :s2, :s1], connections - - connections.clear - - ex = assert_raise(Redis::CannotConnectError) do - RedisMock.start(master) do |master_port| - RedisMock.start(handler.call(:s1, master_port)) do |s1_port| - RedisMock.start(handler.call(:s2, master_port)) do |s2_port| - redis = Redis.new(:url => "redis://master1", :sentinels => sentinels, :role => :master, :reconnect_attempts => 0) - - assert redis.ping - end - end - end - end - - assert_match(/No sentinels available/, ex.message) - end -end diff --git a/test/ssl_test.rb b/test/ssl_test.rb deleted file mode 100644 index a92901cae..000000000 --- a/test/ssl_test.rb +++ /dev/null @@ -1,73 +0,0 @@ -# encoding: UTF-8 - -if RUBY_VERSION >= "1.9.3" - require File.expand_path("helper", File.dirname(__FILE__)) - - class SslTest < Test::Unit::TestCase - - include Helper::Client - - driver(:ruby) do - - def test_verified_ssl_connection - RedisMock.start({ :ping => proc { "+PONG" } }, ssl_server_opts("trusted")) do |port| - redis = Redis.new(:port => port, :ssl => true, :ssl_params => { :ca_file => ssl_ca_file }) - assert_equal redis.ping, "PONG" - end - end - - def test_unverified_ssl_connection - assert_raise(OpenSSL::SSL::SSLError) do - RedisMock.start({ :ping => proc { "+PONG" } }, ssl_server_opts("untrusted")) do |port| - redis = Redis.new(:port => port, :ssl => true, :ssl_params => { :ca_file => ssl_ca_file }) - redis.ping - end - end - end - - def test_ssl_blocking - RedisMock.start({}, ssl_server_opts("trusted")) do |port| - redis = Redis.new(:port => port, :ssl => true, :ssl_params => { :ca_file => ssl_ca_file }) - assert_equal redis.set("boom", "a" * 10_000_000), "OK" - end - end - - end - - driver(:hiredis, :synchrony) do - - def test_ssl_not_implemented_exception - assert_raise(NotImplementedError) do - RedisMock.start({ :ping => proc { "+PONG" } }, ssl_server_opts("trusted")) do |port| - redis = Redis.new(:port => port, :ssl => true, :ssl_params => { :ca_file => ssl_ca_file }) - redis.ping - end - end - end - - end - - private - - def ssl_server_opts(prefix) - ssl_cert = File.join(cert_path, "#{prefix}-cert.crt") - ssl_key = File.join(cert_path, "#{prefix}-cert.key") - - { - :ssl => true, - :ssl_params => { - :cert => OpenSSL::X509::Certificate.new(File.read(ssl_cert)), - :key => OpenSSL::PKey::RSA.new(File.read(ssl_key)) - } - } - end - - def ssl_ca_file - File.join(cert_path, "trusted-ca.crt") - end - - def cert_path - File.expand_path("../support/ssl/", __FILE__) - end - end -end diff --git a/test/support/conf/redis-5.0.conf b/test/support/conf/redis-5.0.conf new file mode 100644 index 000000000..c69777807 --- /dev/null +++ b/test/support/conf/redis-5.0.conf @@ -0,0 +1,2 @@ +appendonly no +save "" diff --git a/test/support/conf/redis-6.0.conf b/test/support/conf/redis-6.0.conf new file mode 100644 index 000000000..c69777807 --- /dev/null +++ b/test/support/conf/redis-6.0.conf @@ -0,0 +1,2 @@ +appendonly no +save "" diff --git a/test/support/conf/redis-6.2.conf b/test/support/conf/redis-6.2.conf new file mode 100644 index 000000000..c69777807 --- /dev/null +++ b/test/support/conf/redis-6.2.conf @@ -0,0 +1,2 @@ +appendonly no +save "" diff --git a/test/support/conf/redis-7.0.conf b/test/support/conf/redis-7.0.conf new file mode 100644 index 000000000..261b18686 --- /dev/null +++ b/test/support/conf/redis-7.0.conf @@ -0,0 +1,3 @@ +appendonly no +save "" +enable-debug-command yes diff --git a/test/support/conf/redis-7.2.conf b/test/support/conf/redis-7.2.conf new file mode 100644 index 000000000..261b18686 --- /dev/null +++ b/test/support/conf/redis-7.2.conf @@ -0,0 +1,3 @@ +appendonly no +save "" +enable-debug-command yes diff --git a/test/support/connection/hiredis.rb b/test/support/connection/hiredis.rb deleted file mode 100644 index f2ccbca5f..000000000 --- a/test/support/connection/hiredis.rb +++ /dev/null @@ -1 +0,0 @@ -require "support/wire/thread" diff --git a/test/support/connection/ruby.rb b/test/support/connection/ruby.rb deleted file mode 100644 index f2ccbca5f..000000000 --- a/test/support/connection/ruby.rb +++ /dev/null @@ -1 +0,0 @@ -require "support/wire/thread" diff --git a/test/support/connection/synchrony.rb b/test/support/connection/synchrony.rb deleted file mode 100644 index 80acb0afe..000000000 --- a/test/support/connection/synchrony.rb +++ /dev/null @@ -1,17 +0,0 @@ -require "support/wire/synchrony" - -module Helper - def around - rv = nil - - EM.synchrony do - begin - rv = yield - ensure - EM.stop - end - end - - rv - end -end diff --git a/test/support/redis_mock.rb b/test/support/redis_mock.rb index 8034df67c..ac1029833 100644 --- a/test/support/redis_mock.rb +++ b/test/support/redis_mock.rb @@ -1,11 +1,15 @@ +# frozen_string_literal: true + require "socket" module RedisMock class Server - def initialize(options = {}, &block) + def initialize(options = {}) tcp_server = TCPServer.new(options[:host] || "127.0.0.1", 0) tcp_server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true) + @concurrent = options.delete(:concurrent) + if options[:ssl] ctx = OpenSSL::SSL::SSLContext.new @@ -30,24 +34,29 @@ def shutdown @thread.kill end - def run - begin - loop do + def run(&block) + loop do + if @concurrent + Thread.new(@server.accept) do |session| + block.call(session) + ensure + session.close + end + else session = @server.accept - begin return if yield(session) == :exit ensure session.close end end - rescue => ex - $stderr.puts "Error running mock server: #{ex.message}" - $stderr.puts ex.backtrace - retry - ensure - @server.close end + rescue => ex + warn "Error running mock server: #{ex.class}: #{ex.message}" + warn ex.backtrace + retry + ensure + @server.close end end @@ -93,19 +102,20 @@ def self.start(commands, options = {}, &blk) end command = argv.shift - blk = commands[command.to_sym] - blk ||= lambda { |*_| "+OK" } + blk = commands[command.downcase.to_sym] + blk ||= ->(*_) { "+OK" } response = blk.call(*argv) # Convert a nil response to :close response ||= :close - if response == :exit + case response + when :exit break :exit - elsif response == :close + when :close break :close - elsif response.is_a?(Array) + when Array session.write("*%d\r\n" % response.size) response.each do |resp| diff --git a/test/support/ssl/gen_certs.sh b/test/support/ssl/gen_certs.sh index 074d3e696..ed6168334 100755 --- a/test/support/ssl/gen_certs.sh +++ b/test/support/ssl/gen_certs.sh @@ -19,8 +19,8 @@ for type in trusted untrusted; do mkdir -p ./demoCA/private touch ./demoCA/index.txt - openssl genrsa -out ${type}-ca.key 2048 - openssl req -new -x509 -days 12500 -key ${type}-ca.key -out ${type}-ca.crt -subj "$(get_subject $type)" + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out ${type}-ca.key + openssl req -new -x509 -days 12500 -key ${type}-ca.key -sha256 -out ${type}-ca.crt -subj "$(get_subject $type)" openssl x509 -in ${type}-ca.crt -noout -next_serial -out ./demoCA/serial openssl req -newkey rsa:2048 -keyout ${type}-cert.key -nodes -out ${type}-cert.req -subj "$(get_subject $type)" diff --git a/test/support/ssl/trusted-ca.crt b/test/support/ssl/trusted-ca.crt index f1e1b97c7..43f77aa0c 100644 --- a/test/support/ssl/trusted-ca.crt +++ b/test/support/ssl/trusted-ca.crt @@ -1,25 +1,22 @@ -----BEGIN CERTIFICATE----- -MIIEIDCCAwigAwIBAgIJAM7kyjC89Qj/MA0GCSqGSIb3DQEBCwUAMGcxCzAJBgNV -BAYTAklUMQ8wDQYDVQQIEwZTaWNpbHkxEDAOBgNVBAcTB0NhdGFuaWExDjAMBgNV -BAoTBVJlZGlzMREwDwYDVQQLEwhTZWN1cml0eTESMBAGA1UEAxMJMTI3LjAuMC4x -MCAXDTE2MDQwMjAzMzQ0MVoYDzIwNTAwNjIzMDMzNDQxWjBnMQswCQYDVQQGEwJJ -VDEPMA0GA1UECBMGU2ljaWx5MRAwDgYDVQQHEwdDYXRhbmlhMQ4wDAYDVQQKEwVS -ZWRpczERMA8GA1UECxMIU2VjdXJpdHkxEjAQBgNVBAMTCTEyNy4wLjAuMTCCASIw -DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMeibFqEG38mtN9DSXy6NZdd7AjH -4/D+VdDzlbJlI5IBACCV9p6P2j5PFlFvkHFE6vr6biMaLXNAmUHYfDzeT95LODHH -t+8HlR51cNYrnt9B3eiVwEnJ7+axuDHg6nUgLXeKeog+vEqreZwLnFibxt2qpFze -xzyKJ37Pm+iAey5glCc/v7ECYQ4sWVVV+ciC+sAwmZDfZXCBQtRRokJ6ikqQDwWV -DugGcV46feTpu79OmkLLM8PI3E7ow2F/3iv67gmdlO5m9wX1ahWzJKUapBTxgf4X -QG0s60WbC9iJIvgXRGW7wWSsqSVJkfLYllDTPgfpLyl1+FR3A4awrsPiMVUCAwEA -AaOBzDCByTAdBgNVHQ4EFgQU+YG9kJR3Vy31d7QVyxRAYyKTK18wgZkGA1UdIwSB -kTCBjoAU+YG9kJR3Vy31d7QVyxRAYyKTK1+ha6RpMGcxCzAJBgNVBAYTAklUMQ8w -DQYDVQQIEwZTaWNpbHkxEDAOBgNVBAcTB0NhdGFuaWExDjAMBgNVBAoTBVJlZGlz -MREwDwYDVQQLEwhTZWN1cml0eTESMBAGA1UEAxMJMTI3LjAuMC4xggkAzuTKMLz1 -CP8wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAeFKB7DUixmxbdvNw -n/mNoHK+OOZXmfxZDCo0v2gcQ4WXUiCqL6MagrImCvkEz5RL6Fk2ZflEV2iGQ5Ds -CmF2n47ISpqG29bfI5R1rcbfqK/5tazUIhQu12ThNmkEh7hCuW/0LqJrnmxpuRLy -le9e3svCC96lwjFczzU/utWurKt7S7Di3C4P+AXAJJuszDMLMCBLaB/3j24cNpOx -zzeZo02x4rpsD2+MMfRDWMWezVEyk63KnI0kt3JGnepsKCFc48ZOk09LwFk3Rfaq -zuKSgEJJw1mfsdBfysM0HQw20yyjSdoTEfQq3bXctTNi+pEOgW6x7TMsnngYYLXV -9XTrpg== +MIIDsTCCApmgAwIBAgIUbNLy3vMeDQUSLVREVOSc8ElYPoMwDQYJKoZIhvcNAQEL +BQAwZzELMAkGA1UEBhMCSVQxDzANBgNVBAgMBlNpY2lseTEQMA4GA1UEBwwHQ2F0 +YW5pYTEOMAwGA1UECgwFUmVkaXMxETAPBgNVBAsMCFNlY3VyaXR5MRIwEAYDVQQD +DAkxMjcuMC4wLjEwIBcNMjAwODIxMDMxOTE1WhgPMjA1NDExMTEwMzE5MTVaMGcx +CzAJBgNVBAYTAklUMQ8wDQYDVQQIDAZTaWNpbHkxEDAOBgNVBAcMB0NhdGFuaWEx +DjAMBgNVBAoMBVJlZGlzMREwDwYDVQQLDAhTZWN1cml0eTESMBAGA1UEAwwJMTI3 +LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3aqkNfQcXHfp +YVZLE+EdMtR9cXlbAQAOX6LSAzh0ZC/LVTMXJvMrbjVqCE7Khm3A5lwXcnJ8EPJn +Tj7B4Nc3aOjyn5U+WtAikJizN1exFEuHl0h16oERpVj17nxDycSfoZcMoPyEzqXK +BQpiV4eNvWYu2NTeXQNbWPs84LUGJjb8WGhk1AhiGjeX5AN3yBnwGQN35+bWRCS0 +66ITMPqrEGx47PemN+2ECL5wkMTFIQIRPbiKdsm39pxVKqEl5dzc57CoCN5kI/AA +iiwjsDGApB6Xk9QzOpLRwdNEp96C3IjrWaJ//Obn4a4XkXUCLqtI8SGsQLujT9OH +opns8/nYKwIDAQABo1MwUTAdBgNVHQ4EFgQURpoiXGek1Dk2H3dLqEF1YntLsOcw +HwYDVR0jBBgwFoAURpoiXGek1Dk2H3dLqEF1YntLsOcwDwYDVR0TAQH/BAUwAwEB +/zANBgkqhkiG9w0BAQsFAAOCAQEAB8cAFmMS/WrSCsedpyYG3s4bZSx3yxaXDBbE +tseOQa1z3OUml1XH6DP0B3dioGRkL8O6C2wqqVdJyB4gqlG0kWD5nqFkYIh09pYM ++SaUa1FzQVdDENNTMqB20MeOLLk8BAFX1kKRkC8Jm+6VFKtB+bW5nZ4qDDP4KMfr +vZdL+Xo8+vYSsWztx0u4RCUKLlfUbcG8G7kTe4GoHXzwrvldmY9xARJgXXHMlLit +gTORsdLj0jAlheTvfmW9/nc0H3edDly7DbueT0tFoeY02gkqayRXUVrnJ/Otmvj1 +pzEBSVA7Ri6cohiQVxOHmurwvwsxgZamPlou6ZZWY0tzkeLEbQ== -----END CERTIFICATE----- diff --git a/test/support/ssl/trusted-ca.key b/test/support/ssl/trusted-ca.key index 2c30610dc..d1c4f5d9a 100644 --- a/test/support/ssl/trusted-ca.key +++ b/test/support/ssl/trusted-ca.key @@ -1,27 +1,28 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEpQIBAAKCAQEAx6JsWoQbfya030NJfLo1l13sCMfj8P5V0POVsmUjkgEAIJX2 -no/aPk8WUW+QcUTq+vpuIxotc0CZQdh8PN5P3ks4Mce37weVHnVw1iue30Hd6JXA -Scnv5rG4MeDqdSAtd4p6iD68Sqt5nAucWJvG3aqkXN7HPIonfs+b6IB7LmCUJz+/ -sQJhDixZVVX5yIL6wDCZkN9lcIFC1FGiQnqKSpAPBZUO6AZxXjp95Om7v06aQssz -w8jcTujDYX/eK/ruCZ2U7mb3BfVqFbMkpRqkFPGB/hdAbSzrRZsL2Iki+BdEZbvB -ZKypJUmR8tiWUNM+B+kvKXX4VHcDhrCuw+IxVQIDAQABAoIBAQCzbGHiQJXOA+XQ -O9OSjHGaJ8n6Yl2VvaE3eZXzjj8X/Fo271GGVVgbZE10x8aUZxKim+3dEqwCx+52 -ZbHTqyMxcX2CEDRaWwBFLdxKQU467iIZ5m26ZAp/1v7rpXBT8KWsqQNT7L6ihdd4 -zl6orOlhVPsAlSGQYcL5kHJZ1w/fL0phEbwdISd3PYhGHXMNmqfXorzJYHDQA4R+ -yR7WpP1dmnUeEKrHc9FFcBZ75BGlWjdCPZMFKc7IndZumarhBpWH9yZMUxrUIo4V -SCweRUFdD5H1lMZ0YiIAE25wKNEQ2iGd3Jfr8Vj1KFSHC9I2FJA3aFRRUgTwxx/W -h0mJy1ZJAoGBAPYsSSlwQdxZjnyZiVkNSD4MoLdof//nRxeKGejq6AiXDvcsLyJy -0MKk4YBFw2249TWm/KBbMAFiBE7d8uPtP5pPfjNVPX6VltH3AhSZ7Ugbpo6C3NFA -GpzFVtNaWgCVDloDVdmsY7ssDFuAIih0paklPAqnLY+Ua9m1BiEPrB+bAoGBAM+a -i+0NMR4AyKpuo1exdd+7BIHw5HNPwGmR1ggdGWduH0zsOhEawQKKFv1X9xKAcXxW -PyeD56/Tmn7fkWvuE8dOu9E6em0vgmxhYyn4nyLAFYF5uKXYo78MpIEThdpl1ldT -iHwG/25vunaBUHhwbHPUD+F989tmRuCjoFkuA5nPAoGAaqPIlcDhZvkMtoE0dHVC -hE6oGIuWV17y9wmGK9YG6iG2A/EKAhxGvur6HL0b6Z4j6zgJW9Xkt9SkFR4kqAQQ -d2JUQxx75SgcC5y7M/1yQrhnsHiT+7mPTbZW5HvRXUs0yl2DhSYeleiA+epJ4ciW -Mu3EUsEVBYvAJLE8lHnbkF0CgYEAhyxpz3+3a4G3JsHDOWYjCfoLhVAEb9CNyC9c -3QuVbvMVDlEBvgFdivm+3lZYWYOoYP0HQgNw59svzUxks5Hg7vUk9abN8CnvEgKX -PszTUR0g450NzW6xr8PbmO/NR9bnKRUK2Tb1OkMldePdMY6CDykU7g3EqiZ+H+Zq -kaaUUaECgYEAmk5W+S94q5jLemnfAChC5lva/0/aHdhtaoH4Lo+j0haMsdiy8/ZE -sh+3gQ8pqwaCAwnKxAcppt/FNZ7tHRsH3oyY6biypn3WppQj+BA41nuzbspOKJhR -ZDXKFCItbzUjyi23Dx4P4DgMivkpV+e88RMIuBnv4yjl5iOLq+vf4Rg= ------END RSA PRIVATE KEY----- +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDdqqQ19Bxcd+lh +VksT4R0y1H1xeVsBAA5fotIDOHRkL8tVMxcm8ytuNWoITsqGbcDmXBdycnwQ8mdO +PsHg1zdo6PKflT5a0CKQmLM3V7EUS4eXSHXqgRGlWPXufEPJxJ+hlwyg/ITOpcoF +CmJXh429Zi7Y1N5dA1tY+zzgtQYmNvxYaGTUCGIaN5fkA3fIGfAZA3fn5tZEJLTr +ohMw+qsQbHjs96Y37YQIvnCQxMUhAhE9uIp2ybf2nFUqoSXl3NznsKgI3mQj8ACK +LCOwMYCkHpeT1DM6ktHB00Sn3oLciOtZon/85ufhrheRdQIuq0jxIaxAu6NP04ei +mezz+dgrAgMBAAECggEARrXqkDOA4JZ34k8OwBato8tZANu/hgAolaVw7QoTRupg +KJuVpR0pG4z6eA/6Vwun31Q9Por6vMU24yTt3/WHfXXh/7oyG/INNKchdGQK3viB +FmdNBjOKF37bZOpLDZAlg/yVUL18+Ba27Qi0+ksJkgOIqi6tiGpLt4TdlKjqf0Gv +EPslFgvxIAoAjUZFhkanDY06FHe+1Bpue+1O5Cg+cL1YzNZy5XSDprvL4o8EsAuM +fOoWDxxq0Jt0Mq+asYmqkVTwvmsiQzJoaTh8gM28Owkp9PSk4L/uY1gXO015InQ4 +ZyK+ypETfTmtfVXrHrfWS96FQvXZmbyRP/fszVsFEQKBgQD0mBgiY5Bc6/0bdEOc +dBTyhJvxdQ+PxOHQP5mmgJJlCfwby4VdwapFgpZ3OZBiVsb79C8sueIqsgxN2zIx +fB5Q7iHqkslVCvRkE0LdWAce8sWZgHqSnKoUTSZTReU4BJis0AwaSR8Nrbxb1UWm +GWX7ppgZYnabhkf8MHLtmPqRBwKBgQDoANf2A56//rPzvS22MZuqL9FzkfIy+t/S +WUHek6p9He2QtJ6W+tyjwLhKFOwMyl/1nYH7il/mQQhdijaVxHo9w6KmZiPw30Zc +eaSn01mpBj1ID2ZXDffWOYAeO32PhBcyw+85ucIMIrBJJ+CXqS6ceQj1t/PpJ5Y8 +KdE41/mKvQKBgQDTdrtG3/VroMtO9RGPLfz+Pw/jjWVK0ti4BoR8oyPuHtfL4AUJ +renb9q7HnQjrPEMEiXRPotWaPBzPIvceOUSsi3TfLNDLqZDpBI4Gd5iQdSvJLn7K +So/wxVKhJAisiazFm4kbIKSsWsxCSPzSQZseGkXdjHcmts19hxWVvXDD+QKBgQC7 +m5MHub2x/EGApEZGwq7iXHC/SBHW78/2xX7igf6n1n+5OJXV+V5afQmJvolzfmNC +tu/ZfPg3tfcRzSZ+zbccIwtwC8Cck7DOLv/bRqmGaSk9EFbtprn3XeAgknLijypD +PvZAc9pa/eIYBksz2Pd8SNPZ/7sZm419cUNi+CMu8QKBgQDh4xL3a9soUptVftlp +Mjw0ww9mNVsIAOWKq7KRUyVPJhCcJwDKr3D6i1hONqzP9jQe+qpiCJ1lRcFop7b1 +SjXA38BdZ2YDAJzQHEmkJCg+ZJx08hfBbFt9XQpKZ/3zKw8hQpme7TsF0NaH7M0e +HdX2uqhE7Go49EbvEMRu+jWEUw== +-----END PRIVATE KEY----- diff --git a/test/support/ssl/trusted-cert.crt b/test/support/ssl/trusted-cert.crt index 8a26c1ae1..30be7ac67 100644 --- a/test/support/ssl/trusted-cert.crt +++ b/test/support/ssl/trusted-cert.crt @@ -1,35 +1,36 @@ Certificate: Data: Version: 3 (0x2) - Serial Number: 14908262977180600576 (0xcee4ca30bcf50900) - Signature Algorithm: sha1WithRSAEncryption + Serial Number: + 6c:d2:f2:de:f3:1e:0d:05:12:2d:54:44:54:e4:9c:f0:49:58:3e:84 + Signature Algorithm: sha256WithRSAEncryption Issuer: C=IT, ST=Sicily, L=Catania, O=Redis, OU=Security, CN=127.0.0.1 Validity - Not Before: Apr 2 03:34:42 2016 GMT - Not After : Jun 23 03:34:42 2050 GMT + Not Before: Aug 21 03:19:15 2020 GMT + Not After : Nov 11 03:19:15 2054 GMT Subject: C=IT, ST=Sicily, O=Redis, OU=Security, CN=127.0.0.1 Subject Public Key Info: Public Key Algorithm: rsaEncryption - Public-Key: (2048 bit) + RSA Public-Key: (2048 bit) Modulus: - 00:ab:bf:ac:ef:dc:99:35:fa:07:3f:d5:33:86:f1: - 7d:9e:57:8b:d5:c1:10:04:0c:35:95:7c:61:ff:05: - a6:f9:ef:71:5c:c5:83:68:a2:ad:5d:0f:a5:2b:b4: - 76:9f:36:8f:df:75:fb:d6:48:00:c0:f0:68:56:f6: - 49:84:4d:4e:e1:ca:dd:24:9f:2f:5e:7c:35:26:57: - d6:d5:95:d1:3f:40:32:22:43:2c:8c:b7:8c:89:56: - 7c:d0:94:e5:f7:cf:4a:51:3f:60:b2:fe:1f:3b:38: - d6:47:5d:2e:4f:38:75:d9:9b:c8:0f:d1:fd:91:5a: - 07:c3:94:95:1f:7b:f1:ae:dc:a1:83:e2:6b:78:05: - 34:b3:8b:87:86:31:9f:cc:8b:15:cd:18:2e:06:36: - ca:f8:29:f8:6e:93:60:78:ec:8a:e8:a6:94:ad:24: - a8:e3:d4:ac:42:da:52:0f:34:e8:d0:10:e5:53:db: - f8:3a:56:48:10:33:df:80:70:1c:72:5e:1f:c3:11: - bb:3b:b9:6b:0a:e0:82:eb:67:d4:8f:5c:30:d3:cf: - 17:6d:86:01:0e:ae:43:c1:d8:c0:5e:99:ef:fa:60: - 0a:f2:62:68:62:8b:05:f3:8b:b1:34:d8:70:78:35: - 74:76:c2:46:13:a3:1f:5d:7b:3b:49:20:1e:98:54: - 63:77 + 00:e0:e6:bf:ae:5a:e7:f7:01:4a:53:dd:4b:d8:d2: + 47:5b:20:b4:01:31:c2:bf:0a:07:e6:2a:60:a8:bf: + 1f:01:3a:3f:b0:96:8b:0a:1a:2c:88:b9:bb:dc:3b: + e7:9c:86:bd:43:f1:87:0d:56:5c:cf:58:31:ec:a4: + 91:0b:a8:2c:76:57:f0:c4:98:c7:f8:bd:74:b2:d5: + 30:ff:12:e3:2a:f0:c3:e9:18:81:9f:d1:43:46:c2: + 89:61:3b:62:cb:8a:6b:21:a5:8a:59:4c:af:c8:8e: + d2:3d:4a:77:7f:ac:f6:69:f6:e4:b7:47:30:a2:30: + a0:2c:21:6b:a3:f8:c3:de:f1:63:62:09:72:71:38: + 6d:02:5b:3a:3d:03:22:67:36:4f:97:91:55:e0:9c: + c7:e8:63:bf:2c:d9:8d:53:fe:ae:d0:de:10:87:ef: + 99:76:84:4e:bb:a6:fe:22:3e:09:98:54:2d:e7:a3: + 54:a4:57:b2:53:a9:df:56:da:b5:1b:be:7f:e3:ae: + 08:f8:f8:20:33:4f:29:4b:6d:24:d1:10:c4:e0:05: + 25:07:cb:be:6d:c7:ff:89:e0:17:77:76:db:cb:4d: + 75:e7:13:c1:6f:1f:5f:a4:9b:4c:b8:a9:38:e9:0a: + 39:de:41:45:96:71:6b:eb:7a:27:6f:92:93:b0:aa: + 35:71 Exponent: 65537 (0x10001) X509v3 extensions: X509v3 Basic Constraints: @@ -37,45 +38,46 @@ Certificate: Netscape Comment: OpenSSL Generated Certificate X509v3 Subject Key Identifier: - 81:DE:C0:39:F9:8A:57:50:DB:B1:6A:B3:D0:5F:E9:2C:87:5A:1E:3D + 56:AD:FB:49:9F:F6:B2:C0:07:21:57:D7:CE:6A:B2:ED:D7:30:74:57 X509v3 Authority Key Identifier: - keyid:F9:81:BD:90:94:77:57:2D:F5:77:B4:15:CB:14:40:63:22:93:2B:5F + keyid:46:9A:22:5C:67:A4:D4:39:36:1F:77:4B:A8:41:75:62:7B:4B:B0:E7 - Signature Algorithm: sha1WithRSAEncryption - a3:0a:d7:22:5a:bc:cc:f6:ed:2f:f2:9f:dd:e0:46:02:73:14: - dd:a7:f5:39:b9:16:19:16:36:b6:22:5c:66:14:c0:d3:ac:55: - fc:52:2d:c3:b2:70:5f:cf:3d:23:71:78:e9:31:88:65:2c:2e: - 4a:09:6e:4b:97:bb:4d:38:87:d8:25:ed:bb:ed:62:19:08:50: - f2:40:cc:39:ee:f9:a8:3a:5d:2b:e7:34:eb:8a:74:c7:c9:bc: - 88:9b:9b:ca:5b:11:20:ca:53:b2:0b:20:49:fc:b9:f7:ec:03: - c9:5d:c1:24:75:27:f8:7c:70:dc:6a:2c:98:48:93:5f:7f:7e: - 94:a1:cf:79:b3:24:e3:de:9e:f0:0f:d8:d6:3e:c9:52:30:31: - 87:90:c2:d2:23:be:d8:7a:e9:e6:bb:4b:00:75:30:49:4b:98: - d5:f6:7d:b5:83:b5:57:85:20:98:00:51:55:c3:a2:81:ec:6c: - 11:91:33:60:14:7b:d2:01:ee:5b:bf:5b:68:f5:e0:4e:45:0a: - 68:cd:33:4f:29:72:fa:fe:6a:19:b6:84:70:90:a4:d5:7a:04: - 2e:da:5b:98:4f:e4:aa:a6:c4:68:aa:5c:8c:a5:5e:df:20:94: - 22:f7:37:45:71:a4:bc:72:34:ee:42:cf:9d:0f:fb:4a:39:d1: - 8e:41:f3:3f + Signature Algorithm: sha256WithRSAEncryption + 5f:29:4e:fc:07:63:13:fd:84:91:90:cb:c5:f5:76:b2:b9:98: + 15:42:d4:ef:44:fc:7a:60:35:9a:fb:ac:d4:c1:18:5b:c3:19: + b3:4b:29:ee:e2:15:85:d5:1b:05:f5:62:86:87:aa:81:86:42: + 12:25:ac:9e:f3:c6:51:c3:3d:0e:d2:00:db:74:bb:0f:d0:5f: + bb:c5:8f:8f:79:45:0b:78:82:40:0c:fb:aa:fc:ef:5e:48:6c: + e9:2b:4c:ac:a5:ab:e6:18:d7:8b:a6:4f:44:31:d3:81:d9:71: + 2d:ed:76:9b:91:f5:ca:38:4e:ad:a9:66:00:a7:27:31:74:65: + 11:a9:fa:11:91:03:d5:64:f5:43:98:6b:31:e5:f2:87:8c:4f: + 52:8a:5c:a7:92:07:89:ab:44:8a:c4:87:07:1f:6a:f6:e4:7c: + 42:31:bf:47:1a:5c:98:00:d2:aa:6c:75:ba:3d:24:9b:f3:e4: + 04:c8:28:ea:97:4d:47:99:fc:69:f8:2c:62:44:a9:c8:82:0b: + 26:85:bd:fb:e4:97:df:58:73:bf:09:1e:5d:73:05:16:42:47: + 02:fb:b8:9d:81:1f:23:8b:b5:6c:5b:02:6d:3f:07:44:44:24: + 19:ec:6e:57:10:7e:4a:cc:ac:18:79:e0:08:67:cd:6c:ee:61: + fb:7d:46:22 -----BEGIN CERTIFICATE----- -MIIDvDCCAqSgAwIBAgIJAM7kyjC89QkAMA0GCSqGSIb3DQEBBQUAMGcxCzAJBgNV -BAYTAklUMQ8wDQYDVQQIEwZTaWNpbHkxEDAOBgNVBAcTB0NhdGFuaWExDjAMBgNV -BAoTBVJlZGlzMREwDwYDVQQLEwhTZWN1cml0eTESMBAGA1UEAxMJMTI3LjAuMC4x -MCAXDTE2MDQwMjAzMzQ0MloYDzIwNTAwNjIzMDMzNDQyWjBVMQswCQYDVQQGEwJJ -VDEPMA0GA1UECBMGU2ljaWx5MQ4wDAYDVQQKEwVSZWRpczERMA8GA1UECxMIU2Vj -dXJpdHkxEjAQBgNVBAMTCTEyNy4wLjAuMTCCASIwDQYJKoZIhvcNAQEBBQADggEP -ADCCAQoCggEBAKu/rO/cmTX6Bz/VM4bxfZ5Xi9XBEAQMNZV8Yf8FpvnvcVzFg2ii -rV0PpSu0dp82j991+9ZIAMDwaFb2SYRNTuHK3SSfL158NSZX1tWV0T9AMiJDLIy3 -jIlWfNCU5ffPSlE/YLL+Hzs41kddLk84ddmbyA/R/ZFaB8OUlR978a7coYPia3gF -NLOLh4Yxn8yLFc0YLgY2yvgp+G6TYHjsiuimlK0kqOPUrELaUg806NAQ5VPb+DpW -SBAz34BwHHJeH8MRuzu5awrggutn1I9cMNPPF22GAQ6uQ8HYwF6Z7/pgCvJiaGKL -BfOLsTTYcHg1dHbCRhOjH117O0kgHphUY3cCAwEAAaN7MHkwCQYDVR0TBAIwADAs -BglghkgBhvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlmaWNhdGUwHQYD -VR0OBBYEFIHewDn5ildQ27Fqs9Bf6SyHWh49MB8GA1UdIwQYMBaAFPmBvZCUd1ct -9Xe0FcsUQGMikytfMA0GCSqGSIb3DQEBBQUAA4IBAQCjCtciWrzM9u0v8p/d4EYC -cxTdp/U5uRYZFja2IlxmFMDTrFX8Ui3DsnBfzz0jcXjpMYhlLC5KCW5Ll7tNOIfY -Je277WIZCFDyQMw57vmoOl0r5zTrinTHybyIm5vKWxEgylOyCyBJ/Ln37APJXcEk -dSf4fHDcaiyYSJNff36Uoc95syTj3p7wD9jWPslSMDGHkMLSI77Yeunmu0sAdTBJ -S5jV9n21g7VXhSCYAFFVw6KB7GwRkTNgFHvSAe5bv1to9eBORQpozTNPKXL6/moZ -toRwkKTVegQu2luYT+SqpsRoqlyMpV7fIJQi9zdFcaS8cjTuQs+dD/tKOdGOQfM/ +MIIDxzCCAq+gAwIBAgIUbNLy3vMeDQUSLVREVOSc8ElYPoQwDQYJKoZIhvcNAQEL +BQAwZzELMAkGA1UEBhMCSVQxDzANBgNVBAgMBlNpY2lseTEQMA4GA1UEBwwHQ2F0 +YW5pYTEOMAwGA1UECgwFUmVkaXMxETAPBgNVBAsMCFNlY3VyaXR5MRIwEAYDVQQD +DAkxMjcuMC4wLjEwIBcNMjAwODIxMDMxOTE1WhgPMjA1NDExMTEwMzE5MTVaMFUx +CzAJBgNVBAYTAklUMQ8wDQYDVQQIDAZTaWNpbHkxDjAMBgNVBAoMBVJlZGlzMREw +DwYDVQQLDAhTZWN1cml0eTESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4Oa/rlrn9wFKU91L2NJHWyC0ATHCvwoH5ipg +qL8fATo/sJaLChosiLm73DvnnIa9Q/GHDVZcz1gx7KSRC6gsdlfwxJjH+L10stUw +/xLjKvDD6RiBn9FDRsKJYTtiy4prIaWKWUyvyI7SPUp3f6z2afbkt0cwojCgLCFr +o/jD3vFjYglycThtAls6PQMiZzZPl5FV4JzH6GO/LNmNU/6u0N4Qh++ZdoROu6b+ +Ij4JmFQt56NUpFeyU6nfVtq1G75/464I+PggM08pS20k0RDE4AUlB8u+bcf/ieAX +d3bby0115xPBbx9fpJtMuKk46Qo53kFFlnFr63onb5KTsKo1cQIDAQABo3sweTAJ +BgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdlbmVyYXRlZCBDZXJ0 +aWZpY2F0ZTAdBgNVHQ4EFgQUVq37SZ/2ssAHIVfXzmqy7dcwdFcwHwYDVR0jBBgw +FoAURpoiXGek1Dk2H3dLqEF1YntLsOcwDQYJKoZIhvcNAQELBQADggEBAF8pTvwH +YxP9hJGQy8X1drK5mBVC1O9E/HpgNZr7rNTBGFvDGbNLKe7iFYXVGwX1YoaHqoGG +QhIlrJ7zxlHDPQ7SANt0uw/QX7vFj495RQt4gkAM+6r8715IbOkrTKylq+YY14um +T0Qx04HZcS3tdpuR9co4Tq2pZgCnJzF0ZRGp+hGRA9Vk9UOYazHl8oeMT1KKXKeS +B4mrRIrEhwcfavbkfEIxv0caXJgA0qpsdbo9JJvz5ATIKOqXTUeZ/Gn4LGJEqciC +CyaFvfvkl99Yc78JHl1zBRZCRwL7uJ2BHyOLtWxbAm0/B0REJBnsblcQfkrMrBh5 +4AhnzWzuYft9RiI= -----END CERTIFICATE----- diff --git a/test/support/ssl/trusted-cert.key b/test/support/ssl/trusted-cert.key index 7dee292bd..8465e1bc0 100644 --- a/test/support/ssl/trusted-cert.key +++ b/test/support/ssl/trusted-cert.key @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCrv6zv3Jk1+gc/ -1TOG8X2eV4vVwRAEDDWVfGH/Bab573FcxYNooq1dD6UrtHafNo/fdfvWSADA8GhW -9kmETU7hyt0kny9efDUmV9bVldE/QDIiQyyMt4yJVnzQlOX3z0pRP2Cy/h87ONZH -XS5POHXZm8gP0f2RWgfDlJUfe/Gu3KGD4mt4BTSzi4eGMZ/MixXNGC4GNsr4Kfhu -k2B47IroppStJKjj1KxC2lIPNOjQEOVT2/g6VkgQM9+AcBxyXh/DEbs7uWsK4ILr -Z9SPXDDTzxdthgEOrkPB2MBeme/6YAryYmhiiwXzi7E02HB4NXR2wkYTox9deztJ -IB6YVGN3AgMBAAECggEASmOxIgtoiQqMzUcpFE/Q2x6MQL9okng/VUoUoALwudzO -OyKJsm6TrHU0U2PM5VUap+1QcRWqzebTKqduXFGn0wCtHEmemMwvsTXmpYhIo57I -mDKEP0bZJjtBwI5dtSIhzGMpHR4YpOwPU8W2YzXPRbvFwaRwsd5O8pWOqZ5jphrQ -DtkLNz4hIFsMihPeYFpuAjsZ2cMIGPtlY2qbfjyno7hd7LxNzL/2vMlDw5MHHtw4 -snxLN92KomC6rSUUydNDyemyMpg8iRwm7gmYzVoZf6aTbI2RdFcv2KZfpUWYdB+I -yU8ZV1Sch7VQ+xLVy74SuY8AZ2Rq4S3M+EmEa5ghoQKBgQDfgOIyStulfYn6UC1A -OYwcGoSOaVNfPE/m9BZN58xK0+XnEqQECMsyg/GYS65Zri4+KJYPxqv6f9ljLTGE -0PxiA7wq+QWnv4EM+3aGShxwyVlmgJZyyBfJtAMr1iDm4JsidTT5GMdfxRICPGZY -WVggcz/cVu39OxRrumREuTWAzwKBgQDEuGheZv68cYt5EkzOWxeFQyt1bzXn1CJg -IXnIFZIekJhVGpBG+zMAYri9+hSheiDrwfIcSfTq9LxF6JNUvaU4qMrkwvW21jKs -n7ofcA+VYq2mggoIuuvKVqXemLHorC0U/zCMnM6rycaa9sB5tsF+Js93uvf1TEJt -veV0yCeM2QKBgF1M0iAoe7SDyXuCyMEMxN5ee4NvmGwjIz/IGR+Aahm6hziE4Y8F -lL2LsujefvPU8FzmWG5Rgy1Y/YiXLxrAmvrXkE9oEOJL4TVoK7w3Z9P1Waqedy+H -M9bxnHlKNAXtMRWbU/fATko+XBwu1pJ/CXjSY5A5gbO6W/X0ozLFFf6lAoGABRZ7 -5I0nY3pQUCZQBDpI5nJxSk1BCKjs5q2W97zPFalJt1HDj4JptEXZX1h7dh2xgkd2 -2pJzGiyQPgKg5N0uy8NZ1AbS0hLCJsLOzodYb9Wohhjw537mIEqTaalrWIgzdkqP -V+OqWLkUQOfG3J8EbB3W2dLlHNwHD82MhLO0iikCgYEAvdK5LmpUdZXMVtiOMZO5 -t3M0vwi6vPhW7DV1QET51x/U+SyH4rvZGeqRl+gcKrZ8SreOlyICvsPgVmvLCrHE -gJLPWJIzI8Mg6u91/KpiVmRahnJjOn3oHNuLSqFjn9lIhmA5dN7zQDXzPdYrWPNR -u1QX+JLhlP33ejgdkdLsNiM= +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDg5r+uWuf3AUpT +3UvY0kdbILQBMcK/CgfmKmCovx8BOj+wlosKGiyIubvcO+echr1D8YcNVlzPWDHs +pJELqCx2V/DEmMf4vXSy1TD/EuMq8MPpGIGf0UNGwolhO2LLimshpYpZTK/IjtI9 +Snd/rPZp9uS3RzCiMKAsIWuj+MPe8WNiCXJxOG0CWzo9AyJnNk+XkVXgnMfoY78s +2Y1T/q7Q3hCH75l2hE67pv4iPgmYVC3no1SkV7JTqd9W2rUbvn/jrgj4+CAzTylL +bSTREMTgBSUHy75tx/+J4Bd3dtvLTXXnE8FvH1+km0y4qTjpCjneQUWWcWvreidv +kpOwqjVxAgMBAAECggEAULL7vLhp27vey9DwUlDBwfUuIe+VDa+vvese2+4YVfxs +thSOt4VEzZq3ygLEzOmcKDEWYLbIfq4K2/sBAMnLintrrV+VAbAZm8Hb3usMEHBs +G8vrV0ljdpR/byA8BwUYA+6+geR+ftygm9WIo4uQr90jnJAy5z/DeZJUaXXt8qTG +pCnflCLrsBhsJFNQjqDvUnw08Cd34Nkx9gNlsGQYmWnRgqHERQuHN4bw/1Tx3Vnu +memX77TlNoMttXca3cHjJ6UnKSjTxGBfco3VLlO0QpTdFaQVO6svvLjNFtodVQM3 +RrL5cyWk+2qOLHLY+YUE8zImZmvK7JClr7JomQQwQQKBgQD+NSMbhaTKoWfN/1NH +Efrw1vTF62nfbC41jk5eenxhOhgqWT9vfeAi7lKXW3pY2ZA3dMjN/XCFr/xexVQ6 +3R0p48lscmN5cslFB6vks4O/iK4J6t+xCLZzgi2XRCg2UT+nRPcbW8bPFTxpb2+x +++SFEHy0DC7pR1+XYDj5iqkHrQKBgQDifLY5rgVJxpVR42GCkUOUMC20GJisCqHv +ABG9X8gjYKn/prSeEx5mCCKfwHkKo+aJrOWQ9TspjduG1Pelmxx4g5G3EBhobSIW +vqsAfqVr3UWawlYNfA4+ek0m+xl55d1s/CNaXx+xmVjyzIBQwKqUAx67gtVULMCH +9PX36B5tVQKBgQDxB/kt014ZM0l1rS6NKKNDUM3uC/Tq/2whI7lzI7hjh+352X2o +fTXUaRyunvI25LM1oen0RuY2HFOymG/xEE7itTT7OsrPEON+LHPz+bJmHXbHuIg5 +GAXHKBuKXfmy5v7v3xhePHsZRw1s+1hw7mITOTrEjPi+AArHQVlEYxE6UQKBgQCD ++B0KEO895LtfArnvpYsWDtiipu5W2L8wjv7HNMdebdXAhDecIBHHbBgYs8MTwxry +v87oHyyA8wqmTvOaCH6Xbjp6y6MdPfHuBN2JJUJoTn9fRLt1kgKOvx6zhv56O8lA +1s4Wu3SxPGRK3YQrCYibRBIlOn/pU0ZAMikccaFBHQKBgQCRsNhjKfHDRPCzhMnu +SyOSIcB43JHOl9JeTvjmAnjE3m06pjppp+sHyR2FxghIz5IGepAVLKy6jbYyJATp +ubb0br+1Jk8MdjprAd82dN9xqw/kt/rbCsJof0e38+aoRY06HjFRz4zXScOLG0ag +Ym1C4aDdCQRZQmSmSHiVBRN70Q== -----END PRIVATE KEY----- diff --git a/test/support/ssl/untrusted-ca.crt b/test/support/ssl/untrusted-ca.crt index 3495dcbb9..c376bcae2 100644 --- a/test/support/ssl/untrusted-ca.crt +++ b/test/support/ssl/untrusted-ca.crt @@ -1,26 +1,23 @@ -----BEGIN CERTIFICATE----- -MIIEXDCCA0SgAwIBAgIJAIgFm03l5AJkMA0GCSqGSIb3DQEBCwUAMHsxCzAJBgNV -BAYTAlhYMRIwEAYDVQQIEwlVbnRydXN0ZWQxEjAQBgNVBAcTCUV2aWx2aWxsZTEU -MBIGA1UEChMLRXZpbCBIYWNrZXIxGjAYBgNVBAsTEUF0dGFjayBEZXBhcnRtZW50 -MRIwEAYDVQQDEwkxMjcuMC4wLjEwIBcNMTYwNDAyMDMzNDUxWhgPMjA1MDA2MjMw -MzM0NTFaMHsxCzAJBgNVBAYTAlhYMRIwEAYDVQQIEwlVbnRydXN0ZWQxEjAQBgNV -BAcTCUV2aWx2aWxsZTEUMBIGA1UEChMLRXZpbCBIYWNrZXIxGjAYBgNVBAsTEUF0 -dGFjayBEZXBhcnRtZW50MRIwEAYDVQQDEwkxMjcuMC4wLjEwggEiMA0GCSqGSIb3 -DQEBAQUAA4IBDwAwggEKAoIBAQCtXxcEGUrGqAqlBK94B8IwSkB8OnLYC/c/6Tde -WG46pzmZWZvffi0akcKKubRNcfoavOuqLuNnpQDkIlnJ37K/LZk8Q5+aMoUGBiQ2 -jSN1707sFqH3eTFvXOUzlDEcsBa7Y7RuaI8SXg1UGsnnCcj6H3BW2xKcXPN6/s30 -vhNw2CPqtXm4NOD3Zb5FkB9epAEejRg0OPn5DJ3mESVp/H2EqkptMZ+6cOk2/CMc -e8AAfcxBGwKuOMXNODszTNxN+OuGCHOxx8+vR/eV35tonISwbkmO9WI6DC+pWT2s -PvDhuQtqsrVofCP/pireb5Ce/7bP/FsZcNSMMfV5dponcYrrAgMBAAGjgeAwgd0w -HQYDVR0OBBYEFLeDNvKpJKmuyPsamax2AZTijdkwMIGtBgNVHSMEgaUwgaKAFLeD -NvKpJKmuyPsamax2AZTijdkwoX+kfTB7MQswCQYDVQQGEwJYWDESMBAGA1UECBMJ -VW50cnVzdGVkMRIwEAYDVQQHEwlFdmlsdmlsbGUxFDASBgNVBAoTC0V2aWwgSGFj -a2VyMRowGAYDVQQLExFBdHRhY2sgRGVwYXJ0bWVudDESMBAGA1UEAxMJMTI3LjAu -MC4xggkAiAWbTeXkAmQwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEA -FWYTrxi/h7PYIpp09QsbDiGdC7gmp04HTx82NvBaUFaLk8ygz4DUz5u7QyTDdAga -yWviHghuyZ6vv5Ubaj7XLOzLM6rYsQjkVq5ltwP+9V/U/b5jOHvZdYqdatVXUXxR -SO+e3QYiMpM4Vs/NNXhpUp6apD7VcoB2LgK3vGDJ526PBJjgw24311t8O7kDTwkt -AwX56/KTolMI+k9rT8Ee6aucT6gBNf0judhNkPVo+6CYgjmEVRrN/xaFCUNSpv5E -O6uIcxSSX6a5iOZ/EH+GyHb6kDmztn/Hes+UN9+gMuAK7+LgsD2mYbxn9Pnaerrs -2nER8XurylLxi0GLvNWNdQ== +MIID2TCCAsGgAwIBAgIUDRXLZuA0a5kneb7e8vKxFhCnawUwDQYJKoZIhvcNAQEL +BQAwezELMAkGA1UEBhMCWFgxEjAQBgNVBAgMCVVudHJ1c3RlZDESMBAGA1UEBwwJ +RXZpbHZpbGxlMRQwEgYDVQQKDAtFdmlsIEhhY2tlcjEaMBgGA1UECwwRQXR0YWNr +IERlcGFydG1lbnQxEjAQBgNVBAMMCTEyNy4wLjAuMTAgFw0yMDA4MjEwMzE5MjRa +GA8yMDU0MTExMTAzMTkyNFowezELMAkGA1UEBhMCWFgxEjAQBgNVBAgMCVVudHJ1 +c3RlZDESMBAGA1UEBwwJRXZpbHZpbGxlMRQwEgYDVQQKDAtFdmlsIEhhY2tlcjEa +MBgGA1UECwwRQXR0YWNrIERlcGFydG1lbnQxEjAQBgNVBAMMCTEyNy4wLjAuMTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL0fgnv0EeX0CAaiBw1CqnBA +w6Z7jtu9siTEbUE6rUkTVkwnqFoPcIEu/zj/vGlmHK3+GjnFIK9y4TIsyPKPqneC +SLlYaF5Y/0B1Kho5NLk0oJrZEuco6cUJ+Ip8FHhvFVmftkGCZo28gFOH8OvARVIP +6PdcY0oLT6V8LIMW8VzZj+WNqSOGGnJ4GJwE6euI79gUs21KSIFkq9hjvK8MPUQs +8CaebCR+Z4DkoOAqhQjKevCAss0nXQYxuWYgM/ZiCqUEFRP8wR3a10kuE2gdePK7 +AgE2QCR1FIUONTwEh5diiycWVTBC3Yp/gNys2de7AZ7K5tjAzqH1C6R8uHMGFXEC +AwEAAaNTMFEwHQYDVR0OBBYEFC5GEu92pkUiyhhx2BDcBKeMbm72MB8GA1UdIwQY +MBaAFC5GEu92pkUiyhhx2BDcBKeMbm72MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADggEBAAOpKzMjFjPC/j5YF7RpC5DguzoaCEV/IydsNWiObLAKU9u2 +25eZzBIQFSQRUxCdWeI9jbXtF5ngy052Y5Ih9VKHIVWifUrZYO8s1xHG295Z2jaW +hz8i9jdqK8U+1k6teLSjUo/87eL8hKptELv9net0T7aykx1e87rZy9sZm4G12uZc +goW30H0F8M6nkyYLApSWjx/gibdWkDlCQXCbY8YXuZDuwhnB53/WGv5R9ym55plp +MzmLu8xi0Ow3XbyvUzWNtSTpDMfcSrNc69+qr1DLDHW7ZZMsLZj7ONYrkqAbuKhi +weYzff5/gaTxILtIRJx9Z7Vc0IUtA+lcZHjwRms= -----END CERTIFICATE----- diff --git a/test/support/ssl/untrusted-ca.key b/test/support/ssl/untrusted-ca.key index 3e302189f..e6a4805ab 100644 --- a/test/support/ssl/untrusted-ca.key +++ b/test/support/ssl/untrusted-ca.key @@ -1,27 +1,28 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEArV8XBBlKxqgKpQSveAfCMEpAfDpy2Av3P+k3XlhuOqc5mVmb -334tGpHCirm0TXH6Grzrqi7jZ6UA5CJZyd+yvy2ZPEOfmjKFBgYkNo0jde9O7Bah -93kxb1zlM5QxHLAWu2O0bmiPEl4NVBrJ5wnI+h9wVtsSnFzzev7N9L4TcNgj6rV5 -uDTg92W+RZAfXqQBHo0YNDj5+Qyd5hElafx9hKpKbTGfunDpNvwjHHvAAH3MQRsC -rjjFzTg7M0zcTfjrhghzscfPr0f3ld+baJyEsG5JjvViOgwvqVk9rD7w4bkLarK1 -aHwj/6Yq3m+Qnv+2z/xbGXDUjDH1eXaaJ3GK6wIDAQABAoIBAQCeC0QxAVlwNTnW -6qmGsxPr75RPavzMREQ1p8VIpTZ/E3hneg+lMiGtydhdnCJoQxGrFDOFJU86aWmh -jkrpw5nvu4KoNEEnUQyAzFJwxELiPLBmec9WiM1u5nEujtYif8eJNcACsiBSrxhZ -Zj5N9laW5NgE5ZpWnkl7AxL/G9MfFvifr9KtyDcs+wnYD6ffz/bRwS54veMccj/q -SkVQRL7FM4NJczG0TTp+LT/1R3s8YVv9GHnJ6K7Gol3E0PbFS1HztDuMVonhWiac -9Rjt7w0rNgeH6ZbCMXrUd+8I8amazA78p1ky0Mh8d6UUVFU1jjtyxlgDh06IPsnE -+exeAClxAoGBAOMZ7LEFr3VcFwym7RvgckeQhd6Rmz8Bh7kGfB9pDsHFprGJ0rm4 -XgNETJXOky5wUCPZmMBN1iAU/ehyyXqPykXiKjAQLxQNHR9/Z6P58PsHs2Uw8VZa -XdZwlBME5+/yl5DiirO5rCt804DdCQgSu7denudwWbbtzAsodSKj5zEJAoGBAMNu -21hZnsvhtZlvQVHZ4sQttrv9e5VpWWHkDPRN3sdLZPfK/+3L0BmUrGotgNWpTZwR -8YvKRT2Xn1unzpKlkHtIVuHU7khOj+tYHLIx3rezVanw9QzbIANMel6STlUr3jwX -fjnibgkJixxHTOBs8/zm219Q1sNTos9GUOAZQb1TAoGALwGFsVpo59TI3JCMkXGS -led/HgNra84oRo7mECZRrJ/5kdPiLxjPNMPlSji41CrhG5qFeIBj6r4NlBh2RY0P -pAldDBe9dtwEBCn9zL4GOB9u7WoE+ge4VpN0wr8INu0ynAWYCf1LerDaolid7vLZ -sem+4E6r8yYjTsfv/tyIFOkCgYEAlCZobxxZLbNn5+2X9cWXiyIgYXgyBDy9fmDT -lSum0yuLWfDwfELB+XJkFYVzIgVbCRHtKwxl2uAi9OdLyI1r7pkTC9VP4U50+XJt -JoR5koaHTPGVwm4mYXnLVf/RE+3SZXllvdmxknZCl2hRldviRfh3mlT8yUuQo1Jp -oshitnMCgYAXTQLA7B5YLmhCG8HRM/h/xM55ObdDX1SIWUbk3p1uxak1W0abfvpi -FPBy2riOdSDA6Uv7V8y1j4tENGVMyyMsEpFdLDX4Lkbh9niOoPeHCWdO0boPk0Fw -aPXtT7gdTPWJulKOxtLuGqBjZZ77TO49uqWlEMaerulWyjhRm8zzvA== ------END RSA PRIVATE KEY----- +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC9H4J79BHl9AgG +ogcNQqpwQMOme47bvbIkxG1BOq1JE1ZMJ6haD3CBLv84/7xpZhyt/ho5xSCvcuEy +LMjyj6p3gki5WGheWP9AdSoaOTS5NKCa2RLnKOnFCfiKfBR4bxVZn7ZBgmaNvIBT +h/DrwEVSD+j3XGNKC0+lfCyDFvFc2Y/ljakjhhpyeBicBOnriO/YFLNtSkiBZKvY +Y7yvDD1ELPAmnmwkfmeA5KDgKoUIynrwgLLNJ10GMblmIDP2YgqlBBUT/MEd2tdJ +LhNoHXjyuwIBNkAkdRSFDjU8BIeXYosnFlUwQt2Kf4DcrNnXuwGeyubYwM6h9Quk +fLhzBhVxAgMBAAECggEBALaaq/RezsFHBFDTNRfanJJSFhazClalLFJPzmXC7/m0 +0Agr6mM6sRgqdodkdVkXHO3qgQvyiAKfW0yE7Wk2yhMmGm3LLMqcB6kG96XmQj/o +zoF0wsmrOTvkyrN75o/6QZUNnn5WGAsWTJlakoYuWUBI2FmuPLgLf9V6tcfE6TsJ +s/ovMBxq/bDd+QEvgVXqNNClLKWhbN1vSEfGQxkrZQGbo5iQdoJjQI1dR6xRJR5n +COrKw9AWRLpW/c8xsmuSEayKn+tJURKBAw0xhituUtKPJD+0uWwRQBCi72we8kv+ +0MYLGBvIiU98J16EEimHQXtt7GU/uaAG1CD4NTBAIyECgYEA67hFC232j0U9skf9 +WA7vHGuu9tOdQyO6t2VpWPuKpvXqDz+y/qB+v6JInGlqF+DmgEtdvThv2C2BxpDe +512szEzLL13BcIPJE2XYXWf79Y6zpY1rIJfcDC0smlSEd0SGv0/lvSNtNVewR9/j +F1siw8+hp3l6zx88mZKEU35uSCUCgYEAzWTyax/HUQA98bhZ7cXdwd64GcjIcsny +6kQaZSCn02gw8YEnxrwWn4I/h6hS2TVnAQFpKjYUuBYHRvMAKGpPg9Jsc/1Af/oc +z8Pjx7uUYENOyaYXzs2ZtCE0VpPHPbZBZTUSzzBLyxqq0QUXkA4s4+2zNF8SFsg7 +GEg2fonIYF0CgYEArlhPsSF3IQbMmEWIy43YK0Q2V9ey1IrjumvmnGsIZW8z3G13 +3b8loGXOoOmTD/BHbJLR1Xedud4Gw7A5PhVaDo2qJvGIdsjye0dz3bpgcIJIu2U6 +3BOWLOdouwlSJMjphSz6Noeyaabe+npNA+RjdULoRO+j9vgaoVfuSbcUqIUCgYBd +Dis2lYM8E5v888TqkQbTWxCVvf3y48QGlyxOPOlMQpxKDnXy+CxXwC8ASyad+i/c +qML4uN/SN0i8wEOGDARSePdh5Y9fa/W5u8prJ3Ul19jOS03mCAhnL9QClZljQDuI +mu8Wp47vSfmyEViHj6SO75aNV7VeVQFREwZ9dfcukQKBgCOC7OVPF8lmOCFKDonp +NWutEY5YFUnnDBi7CWLZxwesSWF7RBRvOQD+Pe0uedMeHZEMld9WZ2tO9fT+4HBu +QtqJ3fCqxZkrkPOCrQK/A9orYw+9VAXuerqVyolYE7hjWuJtx43NIHgz3jJ/G7pK +MS782OTQErMtMg/jN6HOryM6 +-----END PRIVATE KEY----- diff --git a/test/support/ssl/untrusted-cert.crt b/test/support/ssl/untrusted-cert.crt index 127f8e4aa..ef27a74c8 100644 --- a/test/support/ssl/untrusted-cert.crt +++ b/test/support/ssl/untrusted-cert.crt @@ -1,35 +1,36 @@ Certificate: Data: Version: 3 (0x2) - Serial Number: 9801410922913464933 (0x88059b4de5e40265) - Signature Algorithm: sha1WithRSAEncryption + Serial Number: + 0d:15:cb:66:e0:34:6b:99:27:79:be:de:f2:f2:b1:16:10:a7:6b:06 + Signature Algorithm: sha256WithRSAEncryption Issuer: C=XX, ST=Untrusted, L=Evilville, O=Evil Hacker, OU=Attack Department, CN=127.0.0.1 Validity - Not Before: Apr 2 03:34:51 2016 GMT - Not After : Jun 23 03:34:51 2050 GMT + Not Before: Aug 21 03:19:25 2020 GMT + Not After : Nov 11 03:19:25 2054 GMT Subject: C=XX, ST=Untrusted, O=Evil Hacker, OU=Attack Department, CN=127.0.0.1 Subject Public Key Info: Public Key Algorithm: rsaEncryption - Public-Key: (2048 bit) + RSA Public-Key: (2048 bit) Modulus: - 00:9a:73:e7:45:fc:d3:b5:4a:bd:bd:ad:30:e5:24: - 74:38:01:89:8f:a9:90:bf:3c:4a:bf:d1:f1:5e:db: - c8:aa:26:59:e6:ec:b3:a0:0f:4d:74:59:dd:c9:27: - 2f:e1:48:7d:30:d9:59:06:2f:29:f0:d1:25:33:79: - 5f:58:9d:d7:54:c8:a7:aa:1a:84:00:a2:85:63:32: - cc:ef:73:7d:b0:26:c6:95:f1:86:16:68:38:63:57: - 09:0d:6f:6a:70:e8:75:3b:72:b4:b1:4d:01:0e:01: - 0e:bf:bf:6a:8c:88:fe:0d:cb:88:43:1b:da:ed:0c: - 88:25:33:f7:b9:b1:fc:32:b8:94:c9:20:7c:ac:49: - e4:c1:58:93:69:0e:41:e3:df:96:e3:47:11:14:8c: - e4:4b:b6:56:df:6f:5e:d2:48:dc:a1:8a:98:cc:4b: - 02:89:95:ea:f6:de:a5:3a:9c:06:7c:f0:7c:09:6f: - 27:11:f2:b1:1b:47:6b:a3:ea:d6:ee:a1:65:91:84: - cf:2e:81:d3:55:4a:e8:01:4e:72:41:ac:92:e0:7d: - 7c:fe:85:f0:2e:f1:ee:4a:80:f9:4e:5a:b4:95:6c: - bb:fe:ff:46:58:4a:7b:fc:a0:63:59:5d:01:5b:63: - 06:5c:94:83:30:27:81:f0:1a:13:89:5a:5a:a2:e2: - 0f:eb + 00:c4:9c:33:ed:3d:fe:f9:0c:b7:46:56:04:a8:56: + 0e:3b:55:34:a5:e2:22:ab:90:e9:f1:f9:25:44:01: + 74:00:cb:25:27:e4:53:21:14:91:2b:9a:00:60:31: + 6f:e7:65:88:93:99:5c:0b:b7:44:b0:b1:b6:5f:5d: + d2:db:ab:84:51:31:2a:c3:73:67:a0:aa:04:47:c5: + 60:5b:2f:39:fa:09:3b:09:47:97:ae:a8:ec:a1:7e: + d1:22:7c:f1:1c:6d:b5:fe:3d:6e:96:fb:b4:70:25: + 81:94:50:c9:ac:6f:dc:cd:5d:f9:1e:ed:18:8a:57: + 3a:05:7f:f1:dd:12:af:86:b7:8e:b7:5d:2c:d7:c0: + 6f:6d:98:5f:40:e4:fa:a3:ed:2c:43:a0:ac:6a:6a: + 6c:41:e8:84:d2:1c:59:63:ec:d0:a5:c7:1f:50:85: + e3:a8:54:95:bd:04:cb:99:5c:2a:6d:ee:04:ad:d7: + 93:89:37:7c:a2:fd:f6:4e:c2:7a:4c:b2:f3:82:13: + c3:a7:ef:c3:5a:ce:fb:de:08:b7:57:fb:18:c2:57: + 40:9b:1a:b1:00:85:49:5e:93:c9:9c:02:1f:e9:76: + 76:0f:59:2e:84:be:31:bd:09:73:c8:a3:92:23:3a: + c0:03:99:d9:7e:98:9a:83:ea:69:39:69:d2:e0:b2: + 48:0b Exponent: 65537 (0x10001) X509v3 extensions: X509v3 Basic Constraints: @@ -37,46 +38,47 @@ Certificate: Netscape Comment: OpenSSL Generated Certificate X509v3 Subject Key Identifier: - 1B:71:91:99:43:12:0F:D3:59:FC:00:EF:99:F3:42:CF:41:FD:40:1D + E9:0A:98:2C:F0:CA:F7:5B:4B:D4:2C:64:62:44:65:17:5D:AE:71:0E X509v3 Authority Key Identifier: - keyid:B7:83:36:F2:A9:24:A9:AE:C8:FB:1A:99:AC:76:01:94:E2:8D:D9:30 + keyid:2E:46:12:EF:76:A6:45:22:CA:18:71:D8:10:DC:04:A7:8C:6E:6E:F6 - Signature Algorithm: sha1WithRSAEncryption - a4:cd:88:c3:19:b7:cd:7e:7a:e7:85:1f:fb:3e:31:0b:ff:9d: - 6f:b1:a2:72:56:4a:b1:ec:6c:f3:99:bd:65:08:0a:e9:47:1d: - 79:55:5b:29:b1:d4:85:69:85:65:3f:30:37:a1:0e:76:d2:1f: - b0:76:2a:23:75:c9:05:a4:89:cf:c1:68:42:16:46:d6:c9:a8: - e5:06:5b:52:45:d4:41:5d:f3:c7:00:d1:ca:cc:3e:4c:63:e6: - 7a:fe:ce:20:a4:df:e3:7c:e3:75:6e:f7:18:84:1c:9b:56:ce: - 55:fb:04:b9:de:11:6e:7d:5d:47:de:a9:ed:3e:79:48:a5:4f: - 32:d5:96:8d:ea:e2:a6:8a:c2:e9:f5:b0:8d:da:ef:71:96:60: - b0:7e:c3:3d:e9:37:91:27:bf:ae:5c:e8:c0:9a:6f:c8:38:62: - 90:d0:49:c1:7f:28:13:da:29:bb:5b:d1:72:6f:23:7c:a0:87: - 44:96:47:53:0e:0d:1d:74:d9:26:6b:b3:01:24:9c:5e:c8:f4: - 11:fe:35:14:6c:ec:e7:42:5f:32:56:f0:9d:8d:11:02:21:07: - cc:ce:7b:f0:e9:bc:83:c8:93:b0:8c:a7:e9:b1:c2:12:6b:30: - 2b:75:dc:61:b8:d4:87:6b:07:2d:75:b0:7a:18:6e:19:7f:04: - 78:c6:c7:b7 + Signature Algorithm: sha256WithRSAEncryption + a9:0a:60:a5:79:13:e8:ba:90:0e:49:73:59:bb:28:29:1c:36: + 29:ff:dd:16:11:5c:8e:a3:dd:7c:9a:cc:26:df:f4:07:23:79: + 5f:30:b9:e3:47:33:25:92:ce:ef:6d:37:a5:01:f5:a2:58:32: + a9:24:7b:df:22:fb:c4:c5:e2:92:ac:94:ab:c5:38:ef:70:21: + dd:a2:b4:9e:49:d9:32:23:87:ef:44:69:23:63:6f:96:73:73: + b3:3d:ba:52:b9:94:dc:5d:50:13:d0:8d:af:6d:34:98:c0:ad: + e1:b6:78:06:85:2a:e0:2c:a6:d0:f7:f4:79:79:04:72:ea:3b: + 3c:43:0f:9e:5f:c5:11:64:9a:93:cb:df:0d:e6:3a:bc:5a:9c: + 0e:6d:4b:2e:c3:5d:d9:8e:8d:93:8b:48:fa:85:87:ce:4b:88: + 45:a7:c3:e2:eb:26:28:09:9f:58:cd:b0:a8:fb:4a:51:d8:13: + 18:50:31:9e:20:0e:26:4c:be:10:54:62:34:2a:ca:23:88:0d: + 81:6a:65:37:1c:14:b3:bf:63:11:cd:0b:1b:a2:fd:1e:f8:55: + 82:e8:92:1f:59:f2:07:90:32:a4:c0:f9:cb:b8:9d:b2:f7:26: + 73:0b:24:54:44:0a:96:20:f8:bd:4b:2b:ef:6b:79:00:c0:d8: + 1f:24:50:b3 -----BEGIN CERTIFICATE----- -MIID4jCCAsqgAwIBAgIJAIgFm03l5AJlMA0GCSqGSIb3DQEBBQUAMHsxCzAJBgNV -BAYTAlhYMRIwEAYDVQQIEwlVbnRydXN0ZWQxEjAQBgNVBAcTCUV2aWx2aWxsZTEU -MBIGA1UEChMLRXZpbCBIYWNrZXIxGjAYBgNVBAsTEUF0dGFjayBEZXBhcnRtZW50 -MRIwEAYDVQQDEwkxMjcuMC4wLjEwIBcNMTYwNDAyMDMzNDUxWhgPMjA1MDA2MjMw -MzM0NTFaMGcxCzAJBgNVBAYTAlhYMRIwEAYDVQQIEwlVbnRydXN0ZWQxFDASBgNV -BAoTC0V2aWwgSGFja2VyMRowGAYDVQQLExFBdHRhY2sgRGVwYXJ0bWVudDESMBAG -A1UEAxMJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA -mnPnRfzTtUq9va0w5SR0OAGJj6mQvzxKv9HxXtvIqiZZ5uyzoA9NdFndyScv4Uh9 -MNlZBi8p8NElM3lfWJ3XVMinqhqEAKKFYzLM73N9sCbGlfGGFmg4Y1cJDW9qcOh1 -O3K0sU0BDgEOv79qjIj+DcuIQxva7QyIJTP3ubH8MriUySB8rEnkwViTaQ5B49+W -40cRFIzkS7ZW329e0kjcoYqYzEsCiZXq9t6lOpwGfPB8CW8nEfKxG0dro+rW7qFl -kYTPLoHTVUroAU5yQayS4H18/oXwLvHuSoD5Tlq0lWy7/v9GWEp7/KBjWV0BW2MG -XJSDMCeB8BoTiVpaouIP6wIDAQABo3sweTAJBgNVHRMEAjAAMCwGCWCGSAGG+EIB -DQQfFh1PcGVuU1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQUG3GR -mUMSD9NZ/ADvmfNCz0H9QB0wHwYDVR0jBBgwFoAUt4M28qkkqa7I+xqZrHYBlOKN -2TAwDQYJKoZIhvcNAQEFBQADggEBAKTNiMMZt81+eueFH/s+MQv/nW+xonJWSrHs -bPOZvWUICulHHXlVWymx1IVphWU/MDehDnbSH7B2KiN1yQWkic/BaEIWRtbJqOUG -W1JF1EFd88cA0crMPkxj5nr+ziCk3+N843Vu9xiEHJtWzlX7BLneEW59XUfeqe0+ -eUilTzLVlo3q4qaKwun1sI3a73GWYLB+wz3pN5Env65c6MCab8g4YpDQScF/KBPa -Kbtb0XJvI3ygh0SWR1MODR102SZrswEknF7I9BH+NRRs7OdCXzJW8J2NEQIhB8zO -e/DpvIPIk7CMp+mxwhJrMCt13GG41IdrBy11sHoYbhl/BHjGx7c= +MIID7TCCAtWgAwIBAgIUDRXLZuA0a5kneb7e8vKxFhCnawYwDQYJKoZIhvcNAQEL +BQAwezELMAkGA1UEBhMCWFgxEjAQBgNVBAgMCVVudHJ1c3RlZDESMBAGA1UEBwwJ +RXZpbHZpbGxlMRQwEgYDVQQKDAtFdmlsIEhhY2tlcjEaMBgGA1UECwwRQXR0YWNr +IERlcGFydG1lbnQxEjAQBgNVBAMMCTEyNy4wLjAuMTAgFw0yMDA4MjEwMzE5MjVa +GA8yMDU0MTExMTAzMTkyNVowZzELMAkGA1UEBhMCWFgxEjAQBgNVBAgMCVVudHJ1 +c3RlZDEUMBIGA1UECgwLRXZpbCBIYWNrZXIxGjAYBgNVBAsMEUF0dGFjayBEZXBh +cnRtZW50MRIwEAYDVQQDDAkxMjcuMC4wLjEwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQDEnDPtPf75DLdGVgSoVg47VTSl4iKrkOnx+SVEAXQAyyUn5FMh +FJErmgBgMW/nZYiTmVwLt0SwsbZfXdLbq4RRMSrDc2egqgRHxWBbLzn6CTsJR5eu +qOyhftEifPEcbbX+PW6W+7RwJYGUUMmsb9zNXfke7RiKVzoFf/HdEq+Gt463XSzX +wG9tmF9A5Pqj7SxDoKxqamxB6ITSHFlj7NClxx9QheOoVJW9BMuZXCpt7gSt15OJ +N3yi/fZOwnpMsvOCE8On78NazvveCLdX+xjCV0CbGrEAhUlek8mcAh/pdnYPWS6E +vjG9CXPIo5IjOsADmdl+mJqD6mk5adLgskgLAgMBAAGjezB5MAkGA1UdEwQCMAAw +LAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0G +A1UdDgQWBBTpCpgs8Mr3W0vULGRiRGUXXa5xDjAfBgNVHSMEGDAWgBQuRhLvdqZF +IsoYcdgQ3ASnjG5u9jANBgkqhkiG9w0BAQsFAAOCAQEAqQpgpXkT6LqQDklzWbso +KRw2Kf/dFhFcjqPdfJrMJt/0ByN5XzC540czJZLO7203pQH1olgyqSR73yL7xMXi +kqyUq8U473Ah3aK0nknZMiOH70RpI2NvlnNzsz26UrmU3F1QE9CNr200mMCt4bZ4 +BoUq4Cym0Pf0eXkEcuo7PEMPnl/FEWSak8vfDeY6vFqcDm1LLsNd2Y6Nk4tI+oWH +zkuIRafD4usmKAmfWM2wqPtKUdgTGFAxniAOJky+EFRiNCrKI4gNgWplNxwUs79j +Ec0LG6L9HvhVguiSH1nyB5AypMD5y7idsvcmcwskVEQKliD4vUsr72t5AMDYHyRQ +sw== -----END CERTIFICATE----- diff --git a/test/support/ssl/untrusted-cert.key b/test/support/ssl/untrusted-cert.key index a52934a8e..2e3610cfc 100644 --- a/test/support/ssl/untrusted-cert.key +++ b/test/support/ssl/untrusted-cert.key @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCac+dF/NO1Sr29 -rTDlJHQ4AYmPqZC/PEq/0fFe28iqJlnm7LOgD010Wd3JJy/hSH0w2VkGLynw0SUz -eV9YnddUyKeqGoQAooVjMszvc32wJsaV8YYWaDhjVwkNb2pw6HU7crSxTQEOAQ6/ -v2qMiP4Ny4hDG9rtDIglM/e5sfwyuJTJIHysSeTBWJNpDkHj35bjRxEUjORLtlbf -b17SSNyhipjMSwKJler23qU6nAZ88HwJbycR8rEbR2uj6tbuoWWRhM8ugdNVSugB -TnJBrJLgfXz+hfAu8e5KgPlOWrSVbLv+/0ZYSnv8oGNZXQFbYwZclIMwJ4HwGhOJ -Wlqi4g/rAgMBAAECggEAPX3fmfGqqAb1u8p0KQZ2bsXN6rBrvHdYmz4OhuGh5nwW -VuXuLc9p2uTcc/VyDpM5pHUkCF5GqGXcFb5Aw5sz28F3XzXnUAlkabYT+VFVvQfz -EEd0Rv9/U62XIQ42pnUmF2D3p48s2FJ7eMPQu9reqsdZnL4+TxoqKgWinv/JlLdh -zBxjgVgaDMsvVc4cuuT6bcI3DUe2F9ALBKfaCxZoOUSsmgieuXog00Bzv0NmZoUD -WsAX0syzUlwjVmCr8J4I0IByYAbn1S/ozU141Z+H+VUyuEpYw0zDqDNrlmdYclc8 -neoq8Xj9Cx1zHdF5H3aT9SLUGxdHPJpED9wQNx2toQKBgQDJcgJEG39u3h3mW/At -f8jl8evar5nUOOn5AIxVGFAWx4ZvoboxHSRlS6UkF0AImlH4L7xQESb9BMzOrObN -PBNQrccH+fz1o1fHDhob7EvyMMwzmDCPpQnN/6KXRzapu2MDFvlMkEMITTN7J0En -c9BOxo06Q4DKXGVCiWmbIwXihQKBgQDER/KfaWRZWOA2mQ26giJVjUX4+s1GeQM0 -V4AIo1KS6fDzh68RsAQpMTx/N8aHEcxf+2qGIOTCvFY3Nxqe5aw/Xiz47MPlYulM -OecovSO96nidhyv2Zux+HpvI85tcWTyORi+RWho51gTOLth6BJ4uvSsaooWmO0Va -GoIxKcaLrwKBgH/guuWHWy8DG5H3fRE1FFA8cc+iN5HMC2NBYNRIGddME+BblznE -WS1ghtXRWJnddPmLPAzLxqdJ28W7ZsyUPWKy3i0HGfjJF1jKb/KX32JAbfC2xOT7 -DK1TgWBtGZtH1EPK2rkqvxLPB0Y/lhG4aF0Jl++LmH9dhf5mAr8zzXGNAoGBAKEi -l7H66aDX76mi2LxmnR0yz2DpNKBINDNCKh/tRJrLZz3mA/k3URMoEow2E8tK90dM -tVTLqEGeMAFAQaB02IVlIPJyHRgxrWkgl/6/15nP5ZkdISA1uqyHIElGhCK6N5Zt -VBu1ppYYdvV1S85QADRKpBpHlgSz3+lqnbsSmqaNAoGAacA3XSIzTHj6+06VzEHN -aO2LJbBxl6Eduj6eTEgcZBlQOX6cVvaleTAT2g2xM0aGMV4StmdijUdktjBQVLpH -8PBTqlfVuLXEXQd+qWMpUJwEkh/pdmf9EPoLSfp3zQLaNI/kCg3jQtR4n6/68hfi -5Q6L0mN+SoB+jRNPDSV7JWA= +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDEnDPtPf75DLdG +VgSoVg47VTSl4iKrkOnx+SVEAXQAyyUn5FMhFJErmgBgMW/nZYiTmVwLt0SwsbZf +XdLbq4RRMSrDc2egqgRHxWBbLzn6CTsJR5euqOyhftEifPEcbbX+PW6W+7RwJYGU +UMmsb9zNXfke7RiKVzoFf/HdEq+Gt463XSzXwG9tmF9A5Pqj7SxDoKxqamxB6ITS +HFlj7NClxx9QheOoVJW9BMuZXCpt7gSt15OJN3yi/fZOwnpMsvOCE8On78Nazvve +CLdX+xjCV0CbGrEAhUlek8mcAh/pdnYPWS6EvjG9CXPIo5IjOsADmdl+mJqD6mk5 +adLgskgLAgMBAAECggEAenQXW2HLlm43EBWvHPFMN+QfwFmR4m2FZ/IXJb4J9ByS +bcAljmry58cpCMCBxAtW/yb7T0i7/ZkRz1/uXmb7KF6JFeag2k5KEDF8jA5j+7kY +DfWLIXuQthz4QJS0z1H9kfXNFTh774VMqYWPtliNm1M2P+7H5BHjz10a1Og4bpx4 +UzUpFQcPaao0Bu9Vvwj9kjzu8siZPWbqXexqt3S0sgpyCgvjozAUHMsLK8PJm08J +5QhOG0as5siEKnNYrRNxgbaebxDxanSuQAYnLsku2rlyZqnDtoCVzVgPfF3/hzD1 +Qs9W3bdiolRYxxo6rhjGrvv+KVG/wavJSYbBL6l4wQKBgQDz845M/XYlxYl27s5U +i/BkB4yJge7DsfTWJpR7Zf3snlda3QEF/BsBRyFErA7stRfhOGEYDFrYaYgoVJGQ +oZrVqVuwKgdmbsJoVOek0Ab4PguIEJYPBLy3KHCIAoeMZXBiUk3o/pww4kvTyFcB +8FiJRlLFd2298Lvowf2k6iBZOwKBgQDOUhRdD4Lkyi3N1Y+NGtFQsAPSiABbF/9f +0QF45Gkp53TCWnhZeF82+yGHnJ2y7xusC15nfv1HTkLL/UeibmVBX//v1SoAAPIq +9/+ftvOnEkLVQJ+WGmmtgazqcdg5/3lC1zfw4u2OjCZOVYArJXpi8bFQerf04BN6 +Jh2NcQpfcQKBgQDdFcPHHoXuoWF9edtgYBqSbQz+qdS7YhHj6r7yPnKr+KxuWpBM +3jeTJuWNmOlFuLFVmYTVCI1kR+/vrQTnMK5kKMJBmzVtrb9eUmREx4spewF0ZKO6 +JK7qxymE+dXidSQu1yxolibzXoMeAhhoV2vFrQfikePRGdUSkozO4qhCdQKBgQCl +d459VANWGg/CFJScRfW5EHEAV7JxXD2jSqwzmHv+73HkrUn392HlZmLtr92Js9ot +kLCVsHLQzSMlFmxtCLyMQcGxRvP4LMoLS/nmzYN7alnPTZSvfV9jl6xmGgef/BP0 +V0a2GkkLGbte95NjBxuwXsYmFUWTTmJQhGEPHqmDAQKBgDMNCGWVVceGB6UBeXfW +kU7Egr8b3/wMJBy4wmilHIlCxtka6hLzx3+kTqLFIYlCq2sy7fvyLc8dX5bEQ7tZ +v1Zd10mqvfWKBFm/8D691fxiwfBHAXNFRACmBtRb2NJVGL7CFCuuIAt/cyQTb+7l +NsZKEc1x306JFtvKhdqIAWeY -----END PRIVATE KEY----- diff --git a/test/support/wire/synchrony.rb b/test/support/wire/synchrony.rb deleted file mode 100644 index f27d4481c..000000000 --- a/test/support/wire/synchrony.rb +++ /dev/null @@ -1,24 +0,0 @@ -class Wire < Fiber - # We cannot run this fiber explicitly because EM schedules it. Resuming the - # current fiber on the next tick to let the reactor do work. - def self.pass - f = Fiber.current - EM.next_tick { f.resume } - Fiber.yield - end - - def self.sleep(sec) - EM::Synchrony.sleep(sec) - end - - def initialize(&blk) - super - - # Schedule run in next tick - EM.next_tick { resume } - end - - def join - self.class.pass while alive? - end -end diff --git a/test/support/wire/thread.rb b/test/support/wire/thread.rb deleted file mode 100644 index aa5a67c92..000000000 --- a/test/support/wire/thread.rb +++ /dev/null @@ -1,5 +0,0 @@ -class Wire < Thread - def self.sleep(sec) - Kernel.sleep(sec) - end -end diff --git a/test/synchrony_driver.rb b/test/synchrony_driver.rb deleted file mode 100644 index 82b13a794..000000000 --- a/test/synchrony_driver.rb +++ /dev/null @@ -1,88 +0,0 @@ -# encoding: UTF-8 - -require 'em-synchrony' -require 'em-synchrony/connection_pool' - -require 'redis' -require 'redis/connection/synchrony' - - -require File.expand_path("./helper", File.dirname(__FILE__)) - -PORT = 6381 -OPTIONS = {:port => PORT, :db => 15} - -# -# if running under Eventmachine + Synchrony (Ruby 1.9+), then -# we can simulate the blocking API while performing the network -# IO via the EM reactor. -# - -EM.synchrony do - r = Redis.new OPTIONS - r.flushdb - - r.rpush "foo", "s1" - r.rpush "foo", "s2" - - assert_equal 2, r.llen("foo") - assert_equal "s2", r.rpop("foo") - - r.set("foo", "bar") - - assert_equal "bar", r.getset("foo", "baz") - assert_equal "baz", r.get("foo") - - r.set("foo", "a") - - assert_equal 1, r.getbit("foo", 1) - assert_equal 1, r.getbit("foo", 2) - assert_equal 0, r.getbit("foo", 3) - assert_equal 0, r.getbit("foo", 4) - assert_equal 0, r.getbit("foo", 5) - assert_equal 0, r.getbit("foo", 6) - assert_equal 1, r.getbit("foo", 7) - - r.flushdb - - # command pipelining - r.pipelined do - r.lpush "foo", "s1" - r.lpush "foo", "s2" - end - - assert_equal 2, r.llen("foo") - assert_equal "s2", r.lpop("foo") - assert_equal "s1", r.lpop("foo") - - assert_equal "OK", r.client.call(:quit) - assert_equal "PONG", r.ping - - - rpool = EM::Synchrony::ConnectionPool.new(size: 5) { Redis.new OPTIONS } - - result = rpool.watch 'foo' do |rd| - assert_kind_of Redis, rd - - rd.set "foo", "s1" - rd.multi do |multi| - multi.set "foo", "s2" - end - end - - assert_equal nil, result - assert_equal "s1", rpool.get("foo") - - result = rpool.watch "foo" do |rd| - assert_kind_of Redis, rd - - rd.multi do |multi| - multi.set "foo", "s3" - end - end - - assert_equal ["OK"], result - assert_equal "s3", rpool.get("foo") - - EM.stop -end diff --git a/test/thread_safety_test.rb b/test/thread_safety_test.rb deleted file mode 100644 index f03ad7762..000000000 --- a/test/thread_safety_test.rb +++ /dev/null @@ -1,62 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestThreadSafety < Test::Unit::TestCase - - include Helper::Client - - driver(:ruby, :hiredis) do - def test_thread_safety - redis = Redis.new(OPTIONS) - redis.set "foo", 1 - redis.set "bar", 2 - - sample = 100 - - t1 = Thread.new do - $foos = Array.new(sample) { redis.get "foo" } - end - - t2 = Thread.new do - $bars = Array.new(sample) { redis.get "bar" } - end - - t1.join - t2.join - - assert_equal ["1"], $foos.uniq - assert_equal ["2"], $bars.uniq - end - - def test_thread_safety_queue_commit - redis = Redis.new(OPTIONS) - redis.set "foo", 1 - redis.set "bar", 2 - - sample = 100 - - t1 = Thread.new do - sample.times do - r.queue("get", "foo") - end - - $foos = r.commit - end - - t2 = Thread.new do - sample.times do - r.queue("get", "bar") - end - - $bars = r.commit - end - - t1.join - t2.join - - assert_equal ["1"], $foos.uniq - assert_equal ["2"], $bars.uniq - end - end -end diff --git a/test/unknown_commands_test.rb b/test/unknown_commands_test.rb deleted file mode 100644 index 0d1ca5827..000000000 --- a/test/unknown_commands_test.rb +++ /dev/null @@ -1,14 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestUnknownCommands < Test::Unit::TestCase - - include Helper::Client - - def test_should_try_to_work - assert_raise Redis::CommandError do - r.not_yet_implemented_command - end - end -end diff --git a/test/url_param_test.rb b/test/url_param_test.rb deleted file mode 100644 index 468bbf1f7..000000000 --- a/test/url_param_test.rb +++ /dev/null @@ -1,138 +0,0 @@ -# encoding: UTF-8 - -require File.expand_path("helper", File.dirname(__FILE__)) - -class TestUrlParam < Test::Unit::TestCase - - include Helper::Client - - def test_url_defaults_to_______________ - redis = Redis.new - - assert_equal "127.0.0.1", redis.client.host - assert_equal 6379, redis.client.port - assert_equal 0, redis.client.db - assert_equal nil, redis.client.password - end - - def test_allows_to_pass_in_a_url - redis = Redis.new :url => "redis://:secr3t@foo.com:999/2" - - assert_equal "foo.com", redis.client.host - assert_equal 999, redis.client.port - assert_equal 2, redis.client.db - assert_equal "secr3t", redis.client.password - end - - def test_allows_to_pass_in_a_url_with_string_key - redis = Redis.new "url" => "redis://:secr3t@foo.com:999/2" - - assert_equal "foo.com", redis.client.host - assert_equal 999, redis.client.port - assert_equal 2, redis.client.db - assert_equal "secr3t", redis.client.password - end - - def test_unescape_password_from_url - redis = Redis.new :url => "redis://:secr3t%3A@foo.com:999/2" - - assert_equal "secr3t:", redis.client.password - end - - def test_unescape_password_from_url_with_string_key - redis = Redis.new "url" => "redis://:secr3t%3A@foo.com:999/2" - - assert_equal "secr3t:", redis.client.password - end - - def test_does_not_unescape_password_when_explicitly_passed - redis = Redis.new :url => "redis://:secr3t%3A@foo.com:999/2", :password => "secr3t%3A" - - assert_equal "secr3t%3A", redis.client.password - end - - def test_does_not_unescape_password_when_explicitly_passed_with_string_key - redis = Redis.new :url => "redis://:secr3t%3A@foo.com:999/2", "password" => "secr3t%3A" - - assert_equal "secr3t%3A", redis.client.password - end - - def test_override_url_if_path_option_is_passed - redis = Redis.new :url => "redis://:secr3t@foo.com/foo:999/2", :path => "/tmp/redis.sock" - - assert_equal "/tmp/redis.sock", redis.client.path - assert_equal nil, redis.client.host - assert_equal nil, redis.client.port - end - - def test_override_url_if_path_option_is_passed_with_string_key - redis = Redis.new :url => "redis://:secr3t@foo.com/foo:999/2", "path" => "/tmp/redis.sock" - - assert_equal "/tmp/redis.sock", redis.client.path - assert_equal nil, redis.client.host - assert_equal nil, redis.client.port - end - - def test_overrides_url_if_another_connection_option_is_passed - redis = Redis.new :url => "redis://:secr3t@foo.com:999/2", :port => 1000 - - assert_equal "foo.com", redis.client.host - assert_equal 1000, redis.client.port - assert_equal 2, redis.client.db - assert_equal "secr3t", redis.client.password - end - - def test_overrides_url_if_another_connection_option_is_passed_with_string_key - redis = Redis.new :url => "redis://:secr3t@foo.com:999/2", "port" => 1000 - - assert_equal "foo.com", redis.client.host - assert_equal 1000, redis.client.port - assert_equal 2, redis.client.db - assert_equal "secr3t", redis.client.password - end - - def test_does_not_overrides_url_if_a_nil_option_is_passed - redis = Redis.new :url => "redis://:secr3t@foo.com:999/2", :port => nil - - assert_equal "foo.com", redis.client.host - assert_equal 999, redis.client.port - assert_equal 2, redis.client.db - assert_equal "secr3t", redis.client.password - end - - def test_does_not_overrides_url_if_a_nil_option_is_passed_with_string_key - redis = Redis.new :url => "redis://:secr3t@foo.com:999/2", "port" => nil - - assert_equal "foo.com", redis.client.host - assert_equal 999, redis.client.port - assert_equal 2, redis.client.db - assert_equal "secr3t", redis.client.password - end - - def test_does_not_modify_the_passed_options - options = { :url => "redis://:secr3t@foo.com:999/2" } - - Redis.new(options) - - assert({ :url => "redis://:secr3t@foo.com:999/2" } == options) - end - - def test_uses_redis_url_over_default_if_available - ENV["REDIS_URL"] = "redis://:secr3t@foo.com:999/2" - - redis = Redis.new - - assert_equal "foo.com", redis.client.host - assert_equal 999, redis.client.port - assert_equal 2, redis.client.db - assert_equal "secr3t", redis.client.password - - ENV.delete("REDIS_URL") - end - - def test_defaults_to_localhost - redis = Redis.new(:url => "redis:///") - - assert_equal "127.0.0.1", redis.client.host - end -end