├── .editorconfig ├── .github └── workflows │ ├── documentation-coverage.yaml │ ├── documentation.yaml │ ├── rubocop.yaml │ ├── test-cluster.yaml │ ├── test-coverage.yaml │ ├── test-sentinel.yaml │ ├── test-valkey.yaml │ └── test.yaml ├── .gitignore ├── .mailmap ├── .rubocop.yml ├── async-redis.gemspec ├── bake.rb ├── benchmark └── performance.rb ├── cluster ├── docker-compose.yaml ├── node-a │ └── cluster.conf ├── node-b │ └── cluster.conf ├── node-c │ └── cluster.conf ├── readme.md └── test │ └── async │ └── redis │ └── cluster_client.rb ├── config └── sus.rb ├── examples ├── auth │ ├── protocol.rb │ └── wrapper.rb ├── redis │ ├── pop.rb │ └── pres │ │ └── pop.rb ├── slow-log │ ├── analysis.rb │ └── queues.rb └── subscribe │ └── pubsub.rb ├── fixtures └── client_context.rb ├── gems.rb ├── guides ├── getting-started │ └── readme.md └── links.yaml ├── lib └── async │ ├── redis.rb │ └── redis │ ├── client.rb │ ├── cluster_client.rb │ ├── context │ ├── generic.rb │ ├── pipeline.rb │ ├── subscribe.rb │ └── transaction.rb │ ├── endpoint.rb │ ├── key.rb │ ├── protocol │ ├── authenticated.rb │ ├── resp2.rb │ └── selected.rb │ ├── sentinel_client.rb │ └── version.rb ├── license.md ├── readme.md ├── release.cert ├── releases.md ├── sentinel ├── docker-compose.yaml ├── readme.md ├── sentinel.conf └── test │ └── async │ └── redis │ └── sentinel_client.rb └── test └── async └── redis ├── client.rb ├── cluster_client.rb ├── context ├── pipeline.rb ├── subscribe.rb └── transaction.rb ├── disconnect.rb ├── endpoint.rb ├── methods ├── generic.rb ├── hashes.rb ├── lists.rb └── strings.rb └── protocol ├── authenticated.rb └── selected.rb /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | 7 | [*.{yml,yaml}] 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.github/workflows/documentation-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | validate: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: "3.4" 21 | bundler-cache: true 22 | 23 | - name: Validate coverage 24 | timeout-minutes: 5 25 | run: bundle exec bake decode:index:coverage lib 26 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages: 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | # Allow one concurrent deployment: 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: true 18 | 19 | env: 20 | CONSOLE_OUTPUT: XTerm 21 | BUNDLE_WITH: maintenance 22 | 23 | jobs: 24 | generate: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: "3.4" 33 | bundler-cache: true 34 | 35 | - name: Installing packages 36 | run: sudo apt-get install wget 37 | 38 | - name: Generate documentation 39 | timeout-minutes: 5 40 | run: bundle exec bake utopia:project:static --force no 41 | 42 | - name: Upload documentation artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | path: docs 46 | 47 | deploy: 48 | runs-on: ubuntu-latest 49 | 50 | environment: 51 | name: github-pages 52 | url: ${{steps.deployment.outputs.page_url}} 53 | 54 | needs: generate 55 | steps: 56 | - name: Deploy to GitHub Pages 57 | id: deployment 58 | uses: actions/deploy-pages@v4 59 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yaml: -------------------------------------------------------------------------------- 1 | name: RuboCop 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | check: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ruby 20 | bundler-cache: true 21 | 22 | - name: Run RuboCop 23 | timeout-minutes: 10 24 | run: bundle exec rubocop 25 | -------------------------------------------------------------------------------- /.github/workflows/test-cluster.yaml: -------------------------------------------------------------------------------- 1 | name: Test Cluster 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | continue-on-error: ${{matrix.experimental}} 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | 22 | ruby: 23 | - "3.1" 24 | - "3.2" 25 | - "3.3" 26 | 27 | experimental: [false] 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - name: Install Docker Compose 33 | run: | 34 | sudo apt-get update 35 | sudo apt-get install -y docker-compose 36 | 37 | - name: Run tests 38 | timeout-minutes: 10 39 | env: 40 | RUBY_VERSION: ${{matrix.ruby}} 41 | run: docker-compose -f cluster/docker-compose.yaml up --exit-code-from tests 42 | -------------------------------------------------------------------------------- /.github/workflows/test-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Test Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | test: 14 | name: ${{matrix.ruby}} on ${{matrix.os}} 15 | runs-on: ${{matrix.os}}-latest 16 | 17 | services: 18 | redis: 19 | image: redis 20 | options: >- 21 | --health-cmd "redis-cli ping" 22 | --health-interval 10s 23 | --health-timeout 5s 24 | --health-retries 5 25 | ports: 26 | - 6379:6379 27 | 28 | strategy: 29 | matrix: 30 | os: 31 | - ubuntu 32 | 33 | ruby: 34 | - "3.4" 35 | 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: ruby/setup-ruby@v1 39 | with: 40 | ruby-version: ${{matrix.ruby}} 41 | bundler-cache: true 42 | 43 | - name: Run tests 44 | timeout-minutes: 5 45 | run: bundle exec bake test 46 | 47 | - uses: actions/upload-artifact@v4 48 | with: 49 | include-hidden-files: true 50 | if-no-files-found: error 51 | name: coverage-${{matrix.os}}-${{matrix.ruby}} 52 | path: .covered.db 53 | 54 | test-sentinel: 55 | name: ${{matrix.ruby}} on ${{matrix.os}} (Sentinel) 56 | runs-on: ${{matrix.os}}-latest 57 | 58 | strategy: 59 | matrix: 60 | os: 61 | - ubuntu 62 | 63 | ruby: 64 | - "3.4" 65 | 66 | steps: 67 | - uses: actions/checkout@v4 68 | 69 | - name: Install Docker Compose 70 | run: | 71 | sudo apt-get update 72 | sudo apt-get install -y docker-compose 73 | 74 | - name: Run tests 75 | timeout-minutes: 10 76 | env: 77 | RUBY_VERSION: ${{matrix.ruby}} 78 | run: docker-compose -f sentinel/docker-compose.yaml up --exit-code-from tests 79 | 80 | - uses: actions/upload-artifact@v4 81 | with: 82 | include-hidden-files: true 83 | if-no-files-found: error 84 | name: coverage-${{matrix.os}}-${{matrix.ruby}}-sentinel 85 | path: .covered.db 86 | 87 | test-cluster: 88 | name: ${{matrix.ruby}} on ${{matrix.os}} (Cluster) 89 | runs-on: ${{matrix.os}}-latest 90 | 91 | strategy: 92 | matrix: 93 | os: 94 | - ubuntu 95 | 96 | ruby: 97 | - "3.4" 98 | 99 | steps: 100 | - uses: actions/checkout@v4 101 | 102 | - name: Install Docker Compose 103 | run: | 104 | sudo apt-get update 105 | sudo apt-get install -y docker-compose 106 | 107 | - name: Run tests 108 | timeout-minutes: 10 109 | env: 110 | RUBY_VERSION: ${{matrix.ruby}} 111 | run: docker-compose -f cluster/docker-compose.yaml up --exit-code-from tests 112 | 113 | - uses: actions/upload-artifact@v4 114 | with: 115 | include-hidden-files: true 116 | if-no-files-found: error 117 | name: coverage-${{matrix.os}}-${{matrix.ruby}}-cluster 118 | path: .covered.db 119 | 120 | validate: 121 | needs: 122 | - test 123 | - test-sentinel 124 | - test-cluster 125 | runs-on: ubuntu-latest 126 | 127 | steps: 128 | - uses: actions/checkout@v4 129 | - uses: ruby/setup-ruby@v1 130 | with: 131 | ruby-version: "3.4" 132 | bundler-cache: true 133 | 134 | - uses: actions/download-artifact@v4 135 | 136 | - name: Validate coverage 137 | timeout-minutes: 5 138 | run: bundle exec bake covered:validate --paths */.covered.db \; 139 | -------------------------------------------------------------------------------- /.github/workflows/test-sentinel.yaml: -------------------------------------------------------------------------------- 1 | name: Test Sentinel 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | continue-on-error: ${{matrix.experimental}} 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | 22 | ruby: 23 | - "3.1" 24 | - "3.2" 25 | - "3.3" 26 | 27 | experimental: [false] 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - name: Install Docker Compose 33 | run: | 34 | sudo apt-get update 35 | sudo apt-get install -y docker-compose 36 | 37 | - name: Run tests 38 | timeout-minutes: 10 39 | env: 40 | RUBY_VERSION: ${{matrix.ruby}} 41 | run: docker-compose -f sentinel/docker-compose.yaml up --exit-code-from tests 42 | -------------------------------------------------------------------------------- /.github/workflows/test-valkey.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} (Valkey) 14 | runs-on: ${{matrix.os}}-latest 15 | 16 | services: 17 | valkey: 18 | image: valkey/valkey 19 | options: >- 20 | --health-cmd "valkey-cli ping" 21 | --health-interval 10s 22 | --health-timeout 5s 23 | --health-retries 5 24 | ports: 25 | - 6379:6379 26 | 27 | strategy: 28 | matrix: 29 | os: 30 | - ubuntu 31 | 32 | ruby: 33 | - "3.1" 34 | - "3.2" 35 | - "3.3" 36 | 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: ruby/setup-ruby@v1 40 | with: 41 | ruby-version: ${{matrix.ruby}} 42 | bundler-cache: true 43 | 44 | - name: Run tests 45 | timeout-minutes: 10 46 | run: bundle exec bake test 47 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | continue-on-error: ${{matrix.experimental}} 16 | 17 | services: 18 | redis: 19 | image: redis 20 | options: >- 21 | --health-cmd "redis-cli ping" 22 | --health-interval 10s 23 | --health-timeout 5s 24 | --health-retries 5 25 | ports: 26 | - 6379:6379 27 | 28 | strategy: 29 | matrix: 30 | os: 31 | - ubuntu 32 | 33 | ruby: 34 | - "3.1" 35 | - "3.2" 36 | - "3.3" 37 | - "3.4" 38 | 39 | experimental: [false] 40 | 41 | include: 42 | - os: ubuntu 43 | ruby: truffleruby 44 | experimental: true 45 | - os: ubuntu 46 | ruby: jruby 47 | experimental: true 48 | - os: ubuntu 49 | ruby: head 50 | experimental: true 51 | 52 | steps: 53 | - uses: actions/checkout@v4 54 | - uses: ruby/setup-ruby@v1 55 | with: 56 | ruby-version: ${{matrix.ruby}} 57 | bundler-cache: true 58 | 59 | - name: Run tests 60 | timeout-minutes: 10 61 | run: bundle exec bake test 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /pkg/ 3 | /gems.locked 4 | /.covered.db 5 | /external 6 | 7 | /.github/workflows/test-external.yaml 8 | /dump.rdb 9 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Pierre Montelle <45673607+k1tsu@users.noreply.github.com> 2 | Alex Matchneer 3 | Mikael Henriksson 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisabledByDefault: true 3 | 4 | Layout/IndentationStyle: 5 | Enabled: true 6 | EnforcedStyle: tabs 7 | 8 | Layout/InitialIndentation: 9 | Enabled: true 10 | 11 | Layout/IndentationWidth: 12 | Enabled: true 13 | Width: 1 14 | 15 | Layout/IndentationConsistency: 16 | Enabled: true 17 | EnforcedStyle: normal 18 | 19 | Layout/BlockAlignment: 20 | Enabled: true 21 | 22 | Layout/EndAlignment: 23 | Enabled: true 24 | EnforcedStyleAlignWith: start_of_line 25 | 26 | Layout/BeginEndAlignment: 27 | Enabled: true 28 | EnforcedStyleAlignWith: start_of_line 29 | 30 | Layout/ElseAlignment: 31 | Enabled: true 32 | 33 | Layout/DefEndAlignment: 34 | Enabled: true 35 | 36 | Layout/CaseIndentation: 37 | Enabled: true 38 | 39 | Layout/CommentIndentation: 40 | Enabled: true 41 | 42 | Layout/EmptyLinesAroundClassBody: 43 | Enabled: true 44 | 45 | Layout/EmptyLinesAroundModuleBody: 46 | Enabled: true 47 | 48 | Style/FrozenStringLiteralComment: 49 | Enabled: true 50 | 51 | Style/StringLiterals: 52 | Enabled: true 53 | EnforcedStyle: double_quotes 54 | -------------------------------------------------------------------------------- /async-redis.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/async/redis/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "async-redis" 7 | spec.version = Async::Redis::VERSION 8 | 9 | spec.summary = "A Redis client library." 10 | spec.authors = ["Samuel Williams", "Huba Nagy", "David Ortiz", "Gleb Sinyavskiy", "Mikael Henriksson", "Troex Nevelin", "Alex Matchneer", "Jeremy Jung", "Joan Lledó", "Olle Jonsson", "Pierre Montelle", "Salim Semaoune", "Tim Willard", "Travis Bell"] 11 | spec.license = "MIT" 12 | 13 | spec.cert_chain = ["release.cert"] 14 | spec.signing_key = File.expand_path("~/.gem/release.pem") 15 | 16 | spec.homepage = "https://github.com/socketry/async-redis" 17 | 18 | spec.metadata = { 19 | "documentation_uri" => "https://socketry.github.io/async-redis/", 20 | "source_code_uri" => "https://github.com/socketry/async-redis.git", 21 | } 22 | 23 | spec.files = Dir.glob(["{lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) 24 | 25 | spec.required_ruby_version = ">= 3.1" 26 | 27 | spec.add_dependency "async", "~> 2.10" 28 | spec.add_dependency "async-pool", "~> 0.2" 29 | spec.add_dependency "io-endpoint", "~> 0.10" 30 | spec.add_dependency "io-stream", "~> 0.4" 31 | spec.add_dependency "protocol-redis", "~> 0.9" 32 | end 33 | -------------------------------------------------------------------------------- /bake.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | 6 | def client 7 | require "irb" 8 | require "async/redis/client" 9 | 10 | endpoint = Async::Redis.local_endpoint 11 | client = Async::Redis::Client.new(endpoint) 12 | 13 | Async do 14 | binding.irb 15 | end 16 | end 17 | 18 | # Update the project documentation with the new version number. 19 | # 20 | # @parameter version [String] The new version number. 21 | def after_gem_release_version_increment(version) 22 | context["releases:update"].call(version) 23 | context["utopia:project:readme:update"].call 24 | end 25 | -------------------------------------------------------------------------------- /benchmark/performance.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019, by Pierre Montelle. 5 | # Copyright, 2019-2024, by Samuel Williams. 6 | # Copyright, 2019, by David Ortiz. 7 | 8 | require "async/redis" 9 | 10 | require "redis" 11 | require "redis/connection/hiredis" 12 | 13 | require "benchmark" 14 | require "benchmark/ips" 15 | 16 | keys = ["X","Y","Z"].freeze 17 | endpoint = Async::Redis.local_endpoint 18 | async_client = Async::Redis::Client.new(endpoint) 19 | redis_client = Redis.new 20 | redis_client_hiredis = Redis.new(driver: :hiredis) 21 | 22 | Sync do 23 | Benchmark.ips do |benchmark| 24 | benchmark.report("async-redis (pool)") do |times| 25 | key = keys.sample 26 | value = times.to_s 27 | 28 | i = 0 29 | while i < times 30 | i += 1 31 | async_client.set(key, value) 32 | end 33 | end 34 | 35 | benchmark.report("async-redis (pipeline)") do |times| 36 | key = keys.sample 37 | value = times.to_s 38 | 39 | async_client.pipeline do |pipeline| 40 | sync = pipeline.sync 41 | 42 | i = 0 43 | while i < times 44 | i += 1 45 | pipeline.set(key, value) 46 | end 47 | end 48 | end 49 | 50 | benchmark.report("redis-rb") do |times| 51 | key = keys.sample 52 | value = times.to_s 53 | 54 | i = 0 55 | while i < times 56 | i += 1 57 | redis_client.set(key, value) 58 | end 59 | end 60 | 61 | benchmark.report("redis-rb (hiredis)") do |times| 62 | key = keys.sample 63 | value = times.to_s 64 | 65 | i = 0 66 | while i < times 67 | i += 1 68 | redis_client_hiredis.set(key, value) 69 | end 70 | end 71 | 72 | benchmark.compare! 73 | end 74 | 75 | async_client.close 76 | redis_client.close 77 | end 78 | -------------------------------------------------------------------------------- /cluster/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | redis-a: 3 | image: redis 4 | command: redis-server /etc/redis/redis.conf 5 | volumes: 6 | - ./node-a/cluster.conf:/etc/redis/redis.conf 7 | redis-b: 8 | image: redis 9 | command: redis-server /etc/redis/redis.conf 10 | volumes: 11 | - ./node-b/cluster.conf:/etc/redis/redis.conf 12 | redis-c: 13 | image: redis 14 | command: redis-server /etc/redis/redis.conf 15 | volumes: 16 | - ./node-c/cluster.conf:/etc/redis/redis.conf 17 | controller: 18 | image: redis 19 | command: > 20 | bash -c " 21 | redis-cli --cluster create --cluster-yes --cluster-replicas 0 redis-a:6379 redis-b:6379 redis-c:6379; 22 | while true; do 23 | redis-cli -h redis-a cluster info | grep cluster_state:fail; 24 | sleep 1; 25 | done" 26 | healthcheck: 27 | test: "redis-cli -h redis-a cluster info | grep cluster_state:ok" 28 | interval: 1s 29 | timeout: 3s 30 | retries: 30 31 | depends_on: 32 | - redis-a 33 | - redis-b 34 | - redis-c 35 | tests: 36 | image: ruby:${RUBY_VERSION:-latest} 37 | volumes: 38 | - ../:/code 39 | command: bash -c "cd /code && bundle install && bundle exec sus cluster/test" 40 | environment: 41 | - COVERAGE=${COVERAGE} 42 | depends_on: 43 | - controller 44 | -------------------------------------------------------------------------------- /cluster/node-a/cluster.conf: -------------------------------------------------------------------------------- 1 | cluster-enabled yes 2 | cluster-config-file nodes.conf 3 | cluster-node-timeout 5000 4 | appendonly yes -------------------------------------------------------------------------------- /cluster/node-b/cluster.conf: -------------------------------------------------------------------------------- 1 | cluster-enabled yes 2 | cluster-config-file nodes.conf 3 | cluster-node-timeout 5000 4 | appendonly yes -------------------------------------------------------------------------------- /cluster/node-c/cluster.conf: -------------------------------------------------------------------------------- 1 | cluster-enabled yes 2 | cluster-config-file nodes.conf 3 | cluster-node-timeout 5000 4 | appendonly yes -------------------------------------------------------------------------------- /cluster/readme.md: -------------------------------------------------------------------------------- 1 | # Cluster Testing 2 | 3 | To test clusters, you need to set up three redis instances (shards) and bind them together into a cluster. 4 | 5 | ## Running Tests 6 | 7 | ``` bash 8 | $ cd cluster 9 | $ docker-compose up tests 10 | [+] Running 5/0 11 | ✔ Container cluster-redis-b-1 Running 12 | ✔ Container cluster-redis-c-1 Running 13 | ✔ Container cluster-redis-a-1 Running 14 | ✔ Container cluster-controller-1 Running 15 | ✔ Container cluster-tests-1 Created 16 | Attaching to tests-1 17 | tests-1 | Bundle complete! 13 Gemfile dependencies, 41 gems now installed. 18 | tests-1 | Use `bundle info [gemname]` to see where a bundled gem is installed. 19 | tests-1 | 6 installed gems you directly depend on are looking for funding. 20 | tests-1 | Run `bundle fund` for details 21 | tests-1 | 0 assertions 22 | tests-1 | 🏁 Finished in 4.9ms; 0.0 assertions per second. 23 | tests-1 | 🐇 No slow tests found! Well done! 24 | tests-1 exited with code 0 25 | ``` -------------------------------------------------------------------------------- /cluster/test/async/redis/cluster_client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require "async/redis/cluster_client" 7 | require "sus/fixtures/async" 8 | require "securerandom" 9 | 10 | describe Async::Redis::ClusterClient do 11 | include Sus::Fixtures::Async::ReactorContext 12 | 13 | let(:node_a) {"redis://redis-a:6379"} 14 | let(:node_b) {"redis://redis-b:6379"} 15 | let(:node_c) {"redis://redis-c:6379"} 16 | 17 | let(:endpoints) {[ 18 | Async::Redis::Endpoint.parse(node_a), 19 | Async::Redis::Endpoint.parse(node_b), 20 | Async::Redis::Endpoint.parse(node_c) 21 | ]} 22 | 23 | let(:cluster) {subject.new(endpoints)} 24 | 25 | let(:key) {"cluster-test:fixed"} 26 | let(:value) {"cluster-test-value"} 27 | 28 | it "can get a client for a given key" do 29 | slot = cluster.slot_for(key) 30 | client = cluster.client_for(slot) 31 | 32 | expect(client).not.to be_nil 33 | end 34 | 35 | it "can get and set values" do 36 | result = nil 37 | 38 | cluster.clients_for(key) do |client| 39 | client.set(key, value) 40 | 41 | result = client.get(key) 42 | end 43 | 44 | expect(result).to be == value 45 | end 46 | 47 | it "can map every slot to a client" do 48 | clients = Async::Redis::ClusterClient::HASH_SLOTS.times.map do |slot| 49 | client = cluster.client_for(slot) 50 | end.uniq 51 | 52 | expect(clients.size).to be == 3 53 | expect(clients).not.to have_value(be_nil) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /config/sus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2025, by Samuel Williams. 5 | 6 | require "covered/sus" 7 | include Covered::Sus 8 | -------------------------------------------------------------------------------- /examples/auth/protocol.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2025, by Samuel Williams. 5 | 6 | require_relative "../../lib/async/redis" 7 | 8 | class AuthenticatedRESP2 9 | def initialize(credentials, protocol: Async::Redis::Protocol::RESP2) 10 | @credentials = credentials 11 | @protocol = protocol 12 | end 13 | 14 | def client(stream) 15 | client = @protocol.client(stream) 16 | 17 | client.write_request(["AUTH", *@credentials]) 18 | client.read_response # Ignore response. 19 | 20 | return client 21 | end 22 | end 23 | 24 | Async do 25 | endpoint = Async::Redis.local_endpoint 26 | 27 | client = Async::Redis::Client.new(endpoint, protocol: AuthenticatedRESP2.new(["username", "password"])) 28 | 29 | pp client.info 30 | end 31 | -------------------------------------------------------------------------------- /examples/auth/wrapper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021, by Troex Nevelin. 5 | # Copyright, 2023-2024, by Samuel Williams. 6 | 7 | require_relative "../../lib/async/redis" 8 | 9 | # Friendly client wrapper that supports SSL, AUTH and db SELECT 10 | class AsyncRedisClientWrapper 11 | class << self 12 | # @param url [String] Redis URL connection string 13 | # @param ssl_params [Hash] passed to OpenSSL::SSL::SSLContext 14 | # @param options [Hash] passed to Async::Redis::Client.new 15 | # @return [Async::Redis::Client] 16 | def call(url = "redis://localhost:6379", ssl_params: nil, **options) 17 | uri = URI(url) 18 | 19 | endpoint = prepare_endpoint(uri, ssl_params) 20 | 21 | credentials = [] 22 | credentials.push(uri.user) if uri.user && !uri.user.empty? 23 | credentials.push(uri.password) if uri.password && !uri.password.empty? 24 | 25 | db = uri.path[1..-1].to_i if uri.path 26 | 27 | protocol = AsyncRedisProtocolWrapper.new(db: db, credentials: credentials) 28 | 29 | Async::Redis::Client.new(endpoint, protocol: protocol, **options) 30 | end 31 | alias :connect :call 32 | 33 | # @param uri [URI] 34 | # @param ssl_params [Hash] 35 | # @return [::IO::Endpoint::Generic] 36 | def prepare_endpoint(uri, ssl_params = nil) 37 | tcp_endpoint = ::IO::Endpoint.tcp(uri.hostname, uri.port) 38 | case uri.scheme 39 | when "redis" 40 | tcp_endpoint 41 | when "rediss" 42 | ssl_context = OpenSSL::SSL::SSLContext.new 43 | ssl_context.set_params(ssl_params) if ssl_params 44 | ::IO::SSLEndpoint.new(tcp_endpoint, ssl_context: ssl_context) 45 | else 46 | raise ArgumentError 47 | end 48 | end 49 | end 50 | end 51 | 52 | class AsyncRedisProtocolWrapper 53 | def initialize(db: 0, credentials: [], protocol: Async::Redis::Protocol::RESP2) 54 | @db = db 55 | @credentials = credentials 56 | @protocol = protocol 57 | end 58 | 59 | def client(stream) 60 | client = @protocol.client(stream) 61 | 62 | if @credentials.any? 63 | client.write_request(["AUTH", *@credentials]) 64 | client.read_response 65 | end 66 | 67 | if @db 68 | client.write_request(["SELECT", @db]) 69 | client.read_response 70 | end 71 | 72 | return client 73 | end 74 | end 75 | 76 | # can pass "redis://:pass@localhost:port/2" will connect using requirepass `AUTH password` and select DB 77 | # "rediss://user:pass@localhost:port/0" will use SSL to connect and use ACL `AUTH user pass` in Redis 6+ 78 | Async do 79 | url = ENV["REDIS_URL"] || "redis://localhost:6379/0" 80 | client = AsyncRedisClientWrapper.connect(url) 81 | pp client.info 82 | end 83 | -------------------------------------------------------------------------------- /examples/redis/pop.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2022-2025, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/barrier" 9 | require "redis" 10 | 11 | Async do |parent| 12 | child = Async do |task| 13 | redis = Redis.new 14 | Console.logger.info(task, "blpop") 15 | puts redis.blpop("mylist") 16 | end 17 | 18 | redis = Redis.new 19 | Console.logger.info(parent, "lpush") 20 | redis.lpush("mylist", "Hello World") 21 | 22 | child.wait 23 | end 24 | -------------------------------------------------------------------------------- /examples/redis/pres/pop.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2022-2025, by Samuel Williams. 6 | 7 | require "async" 8 | require "redis" 9 | 10 | Async do |parent| 11 | child = Async do |task| 12 | redis = Redis.new 13 | Console.logger.info(task, "blpop") 14 | puts redis.blpop("mylist") 15 | end 16 | 17 | redis = Redis.new 18 | Console.logger.info(parent, "lpush") 19 | redis.lpush("mylist", "Hello World") 20 | 21 | child.wait 22 | end 23 | 24 | -------------------------------------------------------------------------------- /examples/slow-log/analysis.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2022-2025, by Samuel Williams. 6 | 7 | require "set" 8 | require "async" 9 | require_relative "../../lib/async/redis" 10 | 11 | endpoint = Async::Redis.local_endpoint(port: 6380) 12 | client = Async::Redis::Client.new(endpoint) 13 | 14 | IGNORE = Set["evalsha", "lpush"] 15 | 16 | Async do 17 | results = client.call("SLOWLOG", "GET", 10_000) 18 | 19 | histogram = Hash.new{|h,k| h[k] = 0} 20 | timestamps = [] 21 | count = 0 22 | 23 | results.each do |event| 24 | count += 1 25 | 26 | id, timestamp, duration, command, host, client, name = *event 27 | 28 | # next if IGNORE.include?(command.first.downcase) 29 | 30 | # Duration in milliseconds: 31 | duration = duration / 1_000.0 32 | 33 | # Time from timestamp: 34 | timestamp = Time.at(timestamp, in: 0) 35 | 36 | # if command.first.downcase == "keys" && command[1] =~ /active-test/ 37 | # timestamps << timestamp 38 | # end 39 | 40 | histogram[command.first] += duration 41 | end 42 | 43 | pp histogram 44 | # pp timestamps.sort 45 | end 46 | -------------------------------------------------------------------------------- /examples/slow-log/queues.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2022-2025, by Samuel Williams. 6 | 7 | require "async" 8 | require_relative "../../lib/async/redis" 9 | 10 | endpoint = Async::Redis.local_endpoint(port: 6380) 11 | client = Async::Redis::Client.new(endpoint) 12 | 13 | Async do 14 | pipeline = client.pipeline 15 | pipeline.call("SELECT", 7) 16 | 17 | queues = pipeline.sync.keys("queue:*") 18 | 19 | queues.each do |queue_key| 20 | length = pipeline.sync.llen(queue_key) 21 | 22 | if length > 0 23 | puts "#{queue_key} -> #{length} items" 24 | 25 | items = pipeline.sync.lrange(queue_key, 0, -1) 26 | items.each_with_index do |item, index| 27 | puts "\tItem #{index}: #{item.bytesize}b" if item.bytesize > 1024 28 | end 29 | end 30 | rescue 31 | puts "Could not inspect queue: #{queue_key}" 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /examples/subscribe/pubsub.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2023-2025, by Samuel Williams. 6 | 7 | require_relative "../../lib/async/redis" 8 | 9 | class Subscription 10 | def initialize(topic, endpoint = Async::Redis.local_endpoint) 11 | @topic = topic 12 | @endpoint = endpoint 13 | @client = nil 14 | end 15 | 16 | def client 17 | @client ||= Async::Redis::Client.new(@endpoint) 18 | end 19 | 20 | def subscribe 21 | client.subscribe(@topic) do |context| 22 | while event = context.listen 23 | yield event 24 | end 25 | end 26 | end 27 | 28 | def publish(message) 29 | client.publish @topic, message 30 | end 31 | end 32 | 33 | Sync do |task| 34 | subscription = Subscription.new("my-topic") 35 | 36 | subscriber = task.async do 37 | subscription.subscribe do |message| 38 | pp message 39 | end 40 | end 41 | 42 | 10.times do |i| 43 | subscription.publish("Hello World #{i}") 44 | end 45 | 46 | subscriber.stop 47 | end 48 | -------------------------------------------------------------------------------- /fixtures/client_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018, by Huba Nagy. 5 | # Copyright, 2018-2024, by Samuel Williams. 6 | 7 | require "async/redis" 8 | require "async/redis/client" 9 | require "async/redis/key" 10 | 11 | require "sus/fixtures/async" 12 | 13 | require "securerandom" 14 | 15 | ClientContext = Sus::Shared("client context") do 16 | include Sus::Fixtures::Async::ReactorContext 17 | 18 | let(:endpoint) {Async::Redis.local_endpoint} 19 | let(:client) {@client = Async::Redis::Client.new(endpoint)} 20 | 21 | let(:root) {Async::Redis::Key["async-redis:test:#{SecureRandom.uuid}"]} 22 | 23 | before do 24 | keys = client.keys("#{root}:*") 25 | client.del(*keys) if keys.any? 26 | end 27 | 28 | after do 29 | @client&.close 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gemspec 9 | 10 | # gem "protocol-redis", path: "../protocol-redis" 11 | 12 | group :maintenance, optional: true do 13 | gem "bake-modernize" 14 | gem "bake-gem" 15 | gem "bake-releases" 16 | 17 | gem "utopia-project" 18 | end 19 | 20 | group :test do 21 | gem "sus" 22 | gem "covered" 23 | gem "decode" 24 | gem "rubocop" 25 | 26 | gem "bake-test" 27 | gem "bake-test-external" 28 | 29 | gem "sus-fixtures-async" 30 | 31 | gem "redis" 32 | gem "hiredis" 33 | end 34 | -------------------------------------------------------------------------------- /guides/getting-started/readme.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide explains how to use the `async-redis` gem to connect to a Redis server and perform basic operations. 4 | 5 | ## Installation 6 | 7 | Add the gem to your project: 8 | 9 | ``` shell 10 | $ bundle add async-redis 11 | ``` 12 | 13 | ## Usage 14 | 15 | ### Basic Local Connection 16 | 17 | ``` ruby 18 | require 'async/redis' 19 | 20 | Async do 21 | endpoint = Async::Redis.local_endpoint( 22 | # Optional database index: 23 | database: 1, 24 | # Optional credentials: 25 | credentials: ["username", "password"] 26 | ) 27 | 28 | client = Async::Redis::Client.new(endpoint) 29 | puts client.info 30 | end 31 | ``` 32 | 33 | You can also encode this information in a URL: 34 | 35 | 36 | 37 | ### Connecting to Redis SSL Endpoint 38 | 39 | This example demonstrates parsing an environment variable with a `redis://` or SSL `rediss://` scheme, and demonstrates how you can specify SSL parameters on the SSLContext object. 40 | 41 | ``` ruby 42 | require 'async/redis' 43 | 44 | ssl_context = OpenSSL::SSL::SSLContext.new.tap do |context| 45 | # Load the certificate store: 46 | context.cert_store = OpenSSL::X509::Store.new.tap do |store| 47 | store.add_file(Rails.root.join("config/redis.pem").to_s) 48 | end 49 | 50 | # Load the certificate: 51 | context.cert = OpenSSL::X509::Certificate.new(File.read( 52 | Rails.root.join("config/redis.crt") 53 | )) 54 | 55 | # Load the private key: 56 | context.key = OpenSSL::PKey::RSA.new( 57 | Rails.application.credentials.services.redis.private_key 58 | ) 59 | 60 | # Ensure the connection is verified according to the above certificates: 61 | context.verify_mode = OpenSSL::SSL::VERIFY_PEER 62 | end 63 | 64 | # e.g. REDIS_URL=rediss://:PASSWORD@redis.example.com:12345 65 | endpoint = Async::Redis::Endpoint.parse(ENV["REDIS_URL"], ssl_context: ssl_context) 66 | client = Async::Redis::Client.new(endpoint) 67 | Sync do 68 | puts client.call("PING") 69 | end 70 | ``` 71 | 72 | ### Variables 73 | 74 | ``` ruby 75 | require 'async' 76 | require 'async/redis' 77 | 78 | endpoint = Async::Redis.local_endpoint 79 | client = Async::Redis::Client.new(endpoint) 80 | 81 | Async do 82 | client.set('X', 10) 83 | puts client.get('X') 84 | ensure 85 | client.close 86 | end 87 | ``` 88 | 89 | ### Subscriptions 90 | 91 | ``` ruby 92 | require 'async' 93 | require 'async/redis' 94 | 95 | endpoint = Async::Redis.local_endpoint 96 | client = Async::Redis::Client.new(endpoint) 97 | 98 | Async do |task| 99 | condition = Async::Condition.new 100 | 101 | publisher = task.async do 102 | condition.wait 103 | 104 | client.publish 'status.frontend', 'good' 105 | end 106 | 107 | subscriber = task.async do 108 | client.subscribe 'status.frontend' do |context| 109 | condition.signal # We are waiting for messages. 110 | 111 | type, name, message = context.listen 112 | 113 | pp type, name, message 114 | end 115 | end 116 | ensure 117 | client.close 118 | end 119 | ``` -------------------------------------------------------------------------------- /guides/links.yaml: -------------------------------------------------------------------------------- 1 | getting-started: 2 | order: 1 3 | -------------------------------------------------------------------------------- /lib/async/redis.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | # Copyright, 2020, by David Ortiz. 6 | 7 | require_relative "redis/version" 8 | require_relative "redis/client" 9 | 10 | require_relative "redis/cluster_client" 11 | require_relative "redis/sentinel_client" 12 | -------------------------------------------------------------------------------- /lib/async/redis/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | # Copyright, 2018, by Huba Nagy. 6 | # Copyright, 2019, by Mikael Henriksson. 7 | # Copyright, 2019, by David Ortiz. 8 | # Copyright, 2020, by Salim Semaoune. 9 | 10 | require_relative "context/pipeline" 11 | require_relative "context/transaction" 12 | require_relative "context/subscribe" 13 | require_relative "endpoint" 14 | 15 | require "io/endpoint/host_endpoint" 16 | require "async/pool/controller" 17 | require "protocol/redis/methods" 18 | 19 | require "io/stream" 20 | 21 | module Async 22 | module Redis 23 | # Legacy. 24 | ServerError = ::Protocol::Redis::ServerError 25 | 26 | class Client 27 | include ::Protocol::Redis::Methods 28 | 29 | module Methods 30 | def subscribe(*channels) 31 | context = Context::Subscribe.new(@pool, channels) 32 | 33 | return context unless block_given? 34 | 35 | begin 36 | yield context 37 | ensure 38 | context.close 39 | end 40 | end 41 | 42 | def transaction(&block) 43 | context = Context::Transaction.new(@pool) 44 | 45 | return context unless block_given? 46 | 47 | begin 48 | yield context 49 | ensure 50 | context.close 51 | end 52 | end 53 | 54 | alias multi transaction 55 | 56 | def pipeline(&block) 57 | context = Context::Pipeline.new(@pool) 58 | 59 | return context unless block_given? 60 | 61 | begin 62 | yield context 63 | ensure 64 | context.close 65 | end 66 | end 67 | 68 | # Deprecated. 69 | alias nested pipeline 70 | 71 | def call(*arguments) 72 | @pool.acquire do |connection| 73 | connection.write_request(arguments) 74 | 75 | connection.flush 76 | 77 | return connection.read_response 78 | end 79 | end 80 | 81 | def close 82 | @pool.close 83 | end 84 | end 85 | 86 | include Methods 87 | 88 | def initialize(endpoint = Endpoint.local, protocol: endpoint.protocol, **options) 89 | @endpoint = endpoint 90 | @protocol = protocol 91 | 92 | @pool = make_pool(**options) 93 | end 94 | 95 | attr :endpoint 96 | attr :protocol 97 | 98 | # @return [client] if no block provided. 99 | # @yield [client, task] yield the client in an async task. 100 | def self.open(*arguments, **options, &block) 101 | client = self.new(*arguments, **options) 102 | 103 | return client unless block_given? 104 | 105 | Async do |task| 106 | begin 107 | yield client, task 108 | ensure 109 | client.close 110 | end 111 | end.wait 112 | end 113 | 114 | protected 115 | 116 | def assign_default_tags(tags) 117 | tags[:endpoint] = @endpoint.to_s 118 | tags[:protocol] = @protocol.to_s 119 | end 120 | 121 | def make_pool(**options) 122 | self.assign_default_tags(options[:tags] ||= {}) 123 | 124 | Async::Pool::Controller.wrap(**options) do 125 | peer = @endpoint.connect 126 | 127 | # We will manage flushing ourselves: 128 | peer.sync = true 129 | 130 | stream = ::IO::Stream(peer) 131 | 132 | @protocol.client(stream) 133 | end 134 | end 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /lib/async/redis/cluster_client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024-2025, by Samuel Williams. 5 | # Copyright, 2025, by Travis Bell. 6 | 7 | require_relative "client" 8 | require "io/stream" 9 | 10 | module Async 11 | module Redis 12 | class ClusterClient 13 | class ReloadError < StandardError 14 | end 15 | 16 | class SlotError < StandardError 17 | end 18 | 19 | Node = Struct.new(:id, :endpoint, :role, :health, :client) 20 | 21 | class RangeMap 22 | def initialize 23 | @ranges = [] 24 | end 25 | 26 | def add(range, value) 27 | @ranges << [range, value] 28 | 29 | return value 30 | end 31 | 32 | def find(key) 33 | @ranges.each do |range, value| 34 | return value if range.include?(key) 35 | end 36 | 37 | if block_given? 38 | return yield 39 | end 40 | 41 | return nil 42 | end 43 | 44 | def each 45 | @ranges.each do |range, value| 46 | yield value 47 | end 48 | end 49 | 50 | def clear 51 | @ranges.clear 52 | end 53 | end 54 | 55 | # Create a new instance of the cluster client. 56 | # 57 | # @property endpoints [Array(Endpoint)] The list of cluster endpoints. 58 | def initialize(endpoints, **options) 59 | @endpoints = endpoints 60 | @options = options 61 | @shards = nil 62 | end 63 | 64 | def clients_for(*keys, role: :master, attempts: 3) 65 | slots = slots_for(keys) 66 | 67 | slots.each do |slot, keys| 68 | yield client_for(slot, role), keys 69 | end 70 | rescue ServerError => error 71 | Console.warn(self, error) 72 | 73 | if error.message =~ /MOVED|ASK/ 74 | reload_cluster! 75 | 76 | attempts -= 1 77 | 78 | retry if attempts > 0 79 | 80 | raise 81 | else 82 | raise 83 | end 84 | end 85 | 86 | def client_for(slot, role = :master) 87 | unless @shards 88 | reload_cluster! 89 | end 90 | 91 | if nodes = @shards.find(slot) 92 | nodes = nodes.select{|node| node.role == role} 93 | else 94 | raise SlotError, "No nodes found for slot #{slot}" 95 | end 96 | 97 | if node = nodes.sample 98 | return (node.client ||= Client.new(node.endpoint, **@options)) 99 | end 100 | end 101 | 102 | protected 103 | 104 | def reload_cluster!(endpoints = @endpoints) 105 | @endpoints.each do |endpoint| 106 | client = Client.new(endpoint, **@options) 107 | 108 | shards = RangeMap.new 109 | endpoints = [] 110 | 111 | client.call("CLUSTER", "SHARDS").each do |shard| 112 | shard = shard.each_slice(2).to_h 113 | 114 | slots = shard["slots"] 115 | range = Range.new(*slots) 116 | 117 | nodes = shard["nodes"].map do |node| 118 | node = node.each_slice(2).to_h 119 | endpoint = Endpoint.remote(node["ip"], node["port"]) 120 | 121 | # Collect all endpoints: 122 | endpoints << endpoint 123 | 124 | Node.new(node["id"], endpoint, node["role"].to_sym, node["health"].to_sym) 125 | end 126 | 127 | shards.add(range, nodes) 128 | end 129 | 130 | @shards = shards 131 | # @endpoints = @endpoints | endpoints 132 | 133 | return true 134 | rescue Errno::ECONNREFUSED 135 | next 136 | end 137 | 138 | raise ReloadError, "Failed to reload cluster configuration." 139 | end 140 | 141 | XMODEM_CRC16_LOOKUP = [ 142 | 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7, 143 | 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef, 144 | 0x1231, 0x0210, 0x3273, 0x2252, 0x52b5, 0x4294, 0x72f7, 0x62d6, 145 | 0x9339, 0x8318, 0xb37b, 0xa35a, 0xd3bd, 0xc39c, 0xf3ff, 0xe3de, 146 | 0x2462, 0x3443, 0x0420, 0x1401, 0x64e6, 0x74c7, 0x44a4, 0x5485, 147 | 0xa56a, 0xb54b, 0x8528, 0x9509, 0xe5ee, 0xf5cf, 0xc5ac, 0xd58d, 148 | 0x3653, 0x2672, 0x1611, 0x0630, 0x76d7, 0x66f6, 0x5695, 0x46b4, 149 | 0xb75b, 0xa77a, 0x9719, 0x8738, 0xf7df, 0xe7fe, 0xd79d, 0xc7bc, 150 | 0x48c4, 0x58e5, 0x6886, 0x78a7, 0x0840, 0x1861, 0x2802, 0x3823, 151 | 0xc9cc, 0xd9ed, 0xe98e, 0xf9af, 0x8948, 0x9969, 0xa90a, 0xb92b, 152 | 0x5af5, 0x4ad4, 0x7ab7, 0x6a96, 0x1a71, 0x0a50, 0x3a33, 0x2a12, 153 | 0xdbfd, 0xcbdc, 0xfbbf, 0xeb9e, 0x9b79, 0x8b58, 0xbb3b, 0xab1a, 154 | 0x6ca6, 0x7c87, 0x4ce4, 0x5cc5, 0x2c22, 0x3c03, 0x0c60, 0x1c41, 155 | 0xedae, 0xfd8f, 0xcdec, 0xddcd, 0xad2a, 0xbd0b, 0x8d68, 0x9d49, 156 | 0x7e97, 0x6eb6, 0x5ed5, 0x4ef4, 0x3e13, 0x2e32, 0x1e51, 0x0e70, 157 | 0xff9f, 0xefbe, 0xdfdd, 0xcffc, 0xbf1b, 0xaf3a, 0x9f59, 0x8f78, 158 | 0x9188, 0x81a9, 0xb1ca, 0xa1eb, 0xd10c, 0xc12d, 0xf14e, 0xe16f, 159 | 0x1080, 0x00a1, 0x30c2, 0x20e3, 0x5004, 0x4025, 0x7046, 0x6067, 160 | 0x83b9, 0x9398, 0xa3fb, 0xb3da, 0xc33d, 0xd31c, 0xe37f, 0xf35e, 161 | 0x02b1, 0x1290, 0x22f3, 0x32d2, 0x4235, 0x5214, 0x6277, 0x7256, 162 | 0xb5ea, 0xa5cb, 0x95a8, 0x8589, 0xf56e, 0xe54f, 0xd52c, 0xc50d, 163 | 0x34e2, 0x24c3, 0x14a0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405, 164 | 0xa7db, 0xb7fa, 0x8799, 0x97b8, 0xe75f, 0xf77e, 0xc71d, 0xd73c, 165 | 0x26d3, 0x36f2, 0x0691, 0x16b0, 0x6657, 0x7676, 0x4615, 0x5634, 166 | 0xd94c, 0xc96d, 0xf90e, 0xe92f, 0x99c8, 0x89e9, 0xb98a, 0xa9ab, 167 | 0x5844, 0x4865, 0x7806, 0x6827, 0x18c0, 0x08e1, 0x3882, 0x28a3, 168 | 0xcb7d, 0xdb5c, 0xeb3f, 0xfb1e, 0x8bf9, 0x9bd8, 0xabbb, 0xbb9a, 169 | 0x4a75, 0x5a54, 0x6a37, 0x7a16, 0x0af1, 0x1ad0, 0x2ab3, 0x3a92, 170 | 0xfd2e, 0xed0f, 0xdd6c, 0xcd4d, 0xbdaa, 0xad8b, 0x9de8, 0x8dc9, 171 | 0x7c26, 0x6c07, 0x5c64, 0x4c45, 0x3ca2, 0x2c83, 0x1ce0, 0x0cc1, 172 | 0xef1f, 0xff3e, 0xcf5d, 0xdf7c, 0xaf9b, 0xbfba, 0x8fd9, 0x9ff8, 173 | 0x6e17, 0x7e36, 0x4e55, 0x5e74, 0x2e93, 0x3eb2, 0x0ed1, 0x1ef0 174 | ].freeze 175 | 176 | # This is the CRC16 algorithm used by Redis Cluster to hash keys. 177 | # Copied from https://github.com/antirez/redis-rb-cluster/blob/master/crc16.rb 178 | def crc16(bytes) 179 | sum = 0 180 | 181 | bytes.each_byte do |byte| 182 | sum = ((sum << 8) & 0xffff) ^ XMODEM_CRC16_LOOKUP[((sum >> 8) ^ byte) & 0xff] 183 | end 184 | 185 | return sum 186 | end 187 | 188 | public 189 | 190 | HASH_SLOTS = 16_384 191 | 192 | # Return Redis::Client for a given key. 193 | # Modified from https://github.com/antirez/redis-rb-cluster/blob/master/cluster.rb#L104-L117 194 | def slot_for(key) 195 | key = key.to_s 196 | 197 | if s = key.index("{") 198 | if e = key.index("}", s + 1) and e != s + 1 199 | key = key[s + 1..e - 1] 200 | end 201 | end 202 | 203 | return crc16(key) % HASH_SLOTS 204 | end 205 | 206 | def slots_for(keys) 207 | slots = Hash.new{|hash, key| hash[key] = []} 208 | 209 | keys.each do |key| 210 | slots[slot_for(key)] << key 211 | end 212 | 213 | return slots 214 | end 215 | end 216 | end 217 | end 218 | -------------------------------------------------------------------------------- /lib/async/redis/context/generic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019, by Mikael Henriksson. 5 | # Copyright, 2019-2025, by Samuel Williams. 6 | 7 | require "protocol/redis/methods" 8 | 9 | module Async 10 | module Redis 11 | module Context 12 | class Generic 13 | def initialize(pool, *arguments) 14 | @pool = pool 15 | @connection = pool.acquire 16 | end 17 | 18 | def close 19 | if @connection 20 | @pool.release(@connection) 21 | @connection = nil 22 | end 23 | end 24 | 25 | def write_request(command, *arguments) 26 | @connection.write_request([command, *arguments]) 27 | end 28 | 29 | def read_response 30 | @connection.flush 31 | 32 | return @connection.read_response 33 | end 34 | 35 | def call(command, *arguments) 36 | write_request(command, *arguments) 37 | 38 | return read_response 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/async/redis/context/pipeline.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019, by David Ortiz. 5 | # Copyright, 2019-2024, by Samuel Williams. 6 | # Copyright, 2022, by Tim Willard. 7 | 8 | require_relative "generic" 9 | 10 | module Async 11 | module Redis 12 | module Context 13 | # Send multiple commands without waiting for the response, instead of sending them one by one. 14 | class Pipeline < Generic 15 | include ::Protocol::Redis::Methods 16 | 17 | class Sync 18 | include ::Protocol::Redis::Methods 19 | 20 | def initialize(pipeline) 21 | @pipeline = pipeline 22 | end 23 | 24 | # This method just accumulates the commands and their params. 25 | def call(...) 26 | @pipeline.call(...) 27 | 28 | @pipeline.flush(1) 29 | 30 | return @pipeline.read_response 31 | end 32 | end 33 | 34 | def initialize(pool) 35 | super(pool) 36 | 37 | @count = 0 38 | @sync = nil 39 | end 40 | 41 | # Flush responses. 42 | # @param count [Integer] leave this many responses. 43 | def flush(count = 0) 44 | while @count > count 45 | read_response 46 | end 47 | end 48 | 49 | def collect 50 | if block_given? 51 | flush 52 | yield 53 | end 54 | 55 | @count.times.map{read_response} 56 | end 57 | 58 | def sync 59 | @sync ||= Sync.new(self) 60 | end 61 | 62 | # This method just accumulates the commands and their params. 63 | def write_request(*) 64 | super 65 | 66 | @count += 1 67 | end 68 | 69 | # This method just accumulates the commands and their params. 70 | def call(command, *arguments) 71 | write_request(command, *arguments) 72 | 73 | return nil 74 | end 75 | 76 | def read_response 77 | if @count > 0 78 | @count -= 1 79 | super 80 | else 81 | raise RuntimeError, "No more responses available!" 82 | end 83 | end 84 | 85 | def close 86 | flush 87 | ensure 88 | super 89 | end 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/async/redis/context/subscribe.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018, by Huba Nagy. 5 | # Copyright, 2018-2024, by Samuel Williams. 6 | 7 | require_relative "generic" 8 | 9 | module Async 10 | module Redis 11 | module Context 12 | class Subscribe < Generic 13 | MESSAGE = "message" 14 | 15 | def initialize(pool, channels) 16 | super(pool) 17 | 18 | subscribe(channels) 19 | end 20 | 21 | def close 22 | # There is no way to reset subscription state. On Redis v6+ you can use RESET, but this is not supported in <= v6. 23 | @connection&.close 24 | 25 | super 26 | end 27 | 28 | def listen 29 | while response = @connection.read_response 30 | return response if response.first == MESSAGE 31 | end 32 | end 33 | 34 | def each 35 | return to_enum unless block_given? 36 | 37 | while response = self.listen 38 | yield response 39 | end 40 | end 41 | 42 | def subscribe(channels) 43 | @connection.write_request ["SUBSCRIBE", *channels] 44 | @connection.flush 45 | end 46 | 47 | def unsubscribe(channels) 48 | @connection.write_request ["UNSUBSCRIBE", *channels] 49 | @connection.flush 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/async/redis/context/transaction.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018, by Huba Nagy. 5 | # Copyright, 2018-2025, by Samuel Williams. 6 | 7 | require_relative "pipeline" 8 | 9 | module Async 10 | module Redis 11 | module Context 12 | class Transaction < Pipeline 13 | def initialize(pool, *arguments) 14 | super(pool) 15 | end 16 | 17 | def multi 18 | call("MULTI") 19 | end 20 | 21 | def watch(*keys) 22 | sync.call("WATCH", *keys) 23 | end 24 | 25 | # Execute all queued commands, provided that no watched keys have been modified. It's important to note that even when a command fails, all the other commands in the queue are processed – Redis will not stop the processing of commands. 26 | def execute 27 | sync.call("EXEC") 28 | end 29 | 30 | def discard 31 | sync.call("DISCARD") 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/async/redis/endpoint.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require "io/endpoint" 7 | require "io/endpoint/host_endpoint" 8 | require "io/endpoint/ssl_endpoint" 9 | 10 | require_relative "protocol/resp2" 11 | require_relative "protocol/authenticated" 12 | require_relative "protocol/selected" 13 | 14 | module Async 15 | module Redis 16 | def self.local_endpoint(**options) 17 | Endpoint.local(**options) 18 | end 19 | 20 | # Represents a way to connect to a remote Redis server. 21 | class Endpoint < ::IO::Endpoint::Generic 22 | LOCALHOST = URI.parse("redis://localhost").freeze 23 | 24 | def self.local(**options) 25 | self.new(LOCALHOST, **options) 26 | end 27 | 28 | def self.remote(host, port = 6379, **options) 29 | self.new(URI.parse("redis://#{host}:#{port}"), **options) 30 | end 31 | 32 | SCHEMES = { 33 | "redis" => URI::Generic, 34 | "rediss" => URI::Generic, 35 | } 36 | 37 | def self.parse(string, endpoint = nil, **options) 38 | url = URI.parse(string).normalize 39 | 40 | return self.new(url, endpoint, **options) 41 | end 42 | 43 | # Construct an endpoint with a specified scheme, hostname, optional path, and options. 44 | # 45 | # @parameter scheme [String] The scheme to use, e.g. "redis" or "rediss". 46 | # @parameter hostname [String] The hostname to connect to (or bind to). 47 | # @parameter options [Hash] Additional options, passed to {#initialize}. 48 | def self.for(scheme, hostname, credentials: nil, port: nil, database: nil, **options) 49 | uri_klass = SCHEMES.fetch(scheme.downcase) do 50 | raise ArgumentError, "Unsupported scheme: #{scheme.inspect}" 51 | end 52 | 53 | if database 54 | path = "/#{database}" 55 | end 56 | 57 | self.new( 58 | uri_klass.new(scheme, credentials&.join(":"), hostname, port, nil, path, nil, nil, nil).normalize, 59 | **options 60 | ) 61 | end 62 | 63 | # Coerce the given object into an endpoint. 64 | # @parameter url [String | Endpoint] The URL or endpoint to convert. 65 | def self.[](object) 66 | if object.is_a?(self) 67 | return object 68 | else 69 | self.parse(object.to_s) 70 | end 71 | end 72 | 73 | # Create a new endpoint. 74 | # 75 | # @parameter url [URI] The URL to connect to. 76 | # @parameter endpoint [Endpoint] The underlying endpoint to use. 77 | # @option scheme [String] The scheme to use, e.g. "redis" or "rediss". 78 | # @option hostname [String] The hostname to connect to (or bind to), overrides the URL hostname (used for SNI). 79 | # @option port [Integer] The port to bind to, overrides the URL port. 80 | # @option ssl_context [OpenSSL::SSL::SSLContext] The SSL context to use for secure connections. 81 | def initialize(url, endpoint = nil, **options) 82 | super(**options) 83 | 84 | raise ArgumentError, "URL must be absolute (include scheme, host): #{url}" unless url.absolute? 85 | 86 | @url = url 87 | 88 | if endpoint 89 | @endpoint = self.build_endpoint(endpoint) 90 | else 91 | @endpoint = nil 92 | end 93 | end 94 | 95 | def to_url 96 | url = @url.dup 97 | 98 | unless default_port? 99 | url.port = self.port 100 | end 101 | 102 | return url 103 | end 104 | 105 | def to_s 106 | "\#<#{self.class} #{self.to_url} #{@options}>" 107 | end 108 | 109 | def inspect 110 | "\#<#{self.class} #{self.to_url} #{@options.inspect}>" 111 | end 112 | 113 | attr :url 114 | 115 | def address 116 | endpoint.address 117 | end 118 | 119 | def secure? 120 | ["rediss"].include?(self.scheme) 121 | end 122 | 123 | def protocol 124 | protocol = @options.fetch(:protocol, Protocol::RESP2) 125 | 126 | if credentials = self.credentials 127 | protocol = Protocol::Authenticated.new(credentials, protocol) 128 | end 129 | 130 | if database = self.database 131 | protocol = Protocol::Selected.new(database, protocol) 132 | end 133 | 134 | return protocol 135 | end 136 | 137 | def default_port 138 | 6379 139 | end 140 | 141 | def default_port? 142 | port == default_port 143 | end 144 | 145 | def port 146 | @options[:port] || @url.port || default_port 147 | end 148 | 149 | # The hostname is the server we are connecting to: 150 | def hostname 151 | @options[:hostname] || @url.hostname 152 | end 153 | 154 | def scheme 155 | @options[:scheme] || @url.scheme 156 | end 157 | 158 | def database 159 | @options[:database] || extract_database(@url.path) 160 | end 161 | 162 | private def extract_database(path) 163 | if path =~ /\/(\d+)$/ 164 | return $1.to_i 165 | end 166 | end 167 | 168 | def credentials 169 | @options[:credentials] || extract_userinfo(@url.userinfo) 170 | end 171 | 172 | private def extract_userinfo(userinfo) 173 | if userinfo 174 | credentials = userinfo.split(":").reject(&:empty?) 175 | 176 | if credentials.any? 177 | return credentials 178 | end 179 | end 180 | end 181 | 182 | def localhost? 183 | @url.hostname =~ /^(.*?\.)?localhost\.?$/ 184 | end 185 | 186 | # We don't try to validate peer certificates when talking to localhost because they would always be self-signed. 187 | def ssl_verify_mode 188 | if self.localhost? 189 | OpenSSL::SSL::VERIFY_NONE 190 | else 191 | OpenSSL::SSL::VERIFY_PEER 192 | end 193 | end 194 | 195 | def ssl_context 196 | @options[:ssl_context] || OpenSSL::SSL::SSLContext.new.tap do |context| 197 | context.set_params( 198 | verify_mode: self.ssl_verify_mode 199 | ) 200 | end 201 | end 202 | 203 | def build_endpoint(endpoint = nil) 204 | endpoint ||= tcp_endpoint 205 | 206 | if secure? 207 | # Wrap it in SSL: 208 | return ::IO::Endpoint::SSLEndpoint.new(endpoint, 209 | ssl_context: self.ssl_context, 210 | hostname: @url.hostname, 211 | timeout: self.timeout, 212 | ) 213 | end 214 | 215 | return endpoint 216 | end 217 | 218 | def endpoint 219 | @endpoint ||= build_endpoint 220 | end 221 | 222 | def endpoint=(endpoint) 223 | @endpoint = build_endpoint(endpoint) 224 | end 225 | 226 | def bind(*arguments, &block) 227 | endpoint.bind(*arguments, &block) 228 | end 229 | 230 | def connect(&block) 231 | endpoint.connect(&block) 232 | end 233 | 234 | def each 235 | return to_enum unless block_given? 236 | 237 | self.tcp_endpoint.each do |endpoint| 238 | yield self.class.new(@url, endpoint, **@options) 239 | end 240 | end 241 | 242 | def key 243 | [@url, @options] 244 | end 245 | 246 | def eql? other 247 | self.key.eql? other.key 248 | end 249 | 250 | def hash 251 | self.key.hash 252 | end 253 | 254 | protected 255 | 256 | def tcp_options 257 | options = @options.dup 258 | 259 | options.delete(:scheme) 260 | options.delete(:port) 261 | options.delete(:hostname) 262 | options.delete(:ssl_context) 263 | options.delete(:protocol) 264 | 265 | return options 266 | end 267 | 268 | def tcp_endpoint 269 | ::IO::Endpoint.tcp(self.hostname, port, **tcp_options) 270 | end 271 | end 272 | end 273 | end 274 | -------------------------------------------------------------------------------- /lib/async/redis/key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2023, by Samuel Williams. 5 | 6 | module Async 7 | module Redis 8 | class Key 9 | def self.[] path 10 | self.new(path) 11 | end 12 | 13 | include Comparable 14 | 15 | def initialize(path) 16 | @path = path 17 | end 18 | 19 | def size 20 | @path.bytesize 21 | end 22 | 23 | attr :path 24 | 25 | def to_s 26 | @path 27 | end 28 | 29 | def to_str 30 | @path 31 | end 32 | 33 | def [] key 34 | self.class.new("#{@path}:#{key}") 35 | end 36 | 37 | def <=> other 38 | @path <=> other.to_str 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/async/redis/protocol/authenticated.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require "protocol/redis" 7 | 8 | module Async 9 | module Redis 10 | module Protocol 11 | # Executes AUTH after the user has established a connection. 12 | class Authenticated 13 | # Authentication has failed for some reason. 14 | class AuthenticationError < StandardError 15 | end 16 | 17 | # Create a new authenticated protocol. 18 | # 19 | # @parameter credentials [Array] The credentials to use for authentication. 20 | # @parameter protocol [Object] The delegated protocol for connecting. 21 | def initialize(credentials, protocol = Async::Redis::Protocol::RESP2) 22 | @credentials = credentials 23 | @protocol = protocol 24 | end 25 | 26 | attr :credentials 27 | 28 | # Create a new client and authenticate it. 29 | def client(stream) 30 | client = @protocol.client(stream) 31 | 32 | client.write_request(["AUTH", *@credentials]) 33 | response = client.read_response 34 | 35 | if response != "OK" 36 | raise AuthenticationError, "Could not authenticate: #{response}" 37 | end 38 | 39 | return client 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/async/redis/protocol/resp2.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require "protocol/redis" 7 | 8 | module Async 9 | module Redis 10 | module Protocol 11 | module RESP2 12 | class Connection < ::Protocol::Redis::Connection 13 | def concurrency 14 | 1 15 | end 16 | 17 | def viable? 18 | @stream.readable? 19 | end 20 | 21 | def reusable? 22 | !@stream.closed? 23 | end 24 | end 25 | 26 | def self.client(stream) 27 | Connection.new(stream) 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/async/redis/protocol/selected.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require "protocol/redis" 7 | 8 | module Async 9 | module Redis 10 | module Protocol 11 | # Executes AUTH after the user has established a connection. 12 | class Selected 13 | # Authentication has failed for some reason. 14 | class SelectionError < StandardError 15 | end 16 | 17 | # Create a new authenticated protocol. 18 | # 19 | # @parameter index [Integer] The database index to select. 20 | # @parameter protocol [Object] The delegated protocol for connecting. 21 | def initialize(index, protocol = Async::Redis::Protocol::RESP2) 22 | @index = index 23 | @protocol = protocol 24 | end 25 | 26 | attr :index 27 | 28 | # Create a new client and authenticate it. 29 | def client(stream) 30 | client = @protocol.client(stream) 31 | 32 | client.write_request(["SELECT", @index]) 33 | response = client.read_response 34 | 35 | if response != "OK" 36 | raise SelectionError, "Could not select database: #{response}" 37 | end 38 | 39 | return client 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/async/redis/sentinel_client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020, by David Ortiz. 5 | # Copyright, 2023-2024, by Samuel Williams. 6 | # Copyright, 2024, by Joan Lledó. 7 | 8 | require_relative "client" 9 | require "io/stream" 10 | 11 | module Async 12 | module Redis 13 | class SentinelClient 14 | DEFAULT_MASTER_NAME = "mymaster" 15 | 16 | include ::Protocol::Redis::Methods 17 | include Client::Methods 18 | 19 | # Create a new instance of the SentinelClient. 20 | # 21 | # @property endpoints [Array(Endpoint)] The list of sentinel endpoints. 22 | # @property master_name [String] The name of the master instance, defaults to 'mymaster'. 23 | # @property role [Symbol] The role of the instance that you want to connect to, either `:master` or `:slave`. 24 | # @property protocol [Protocol] The protocol to use when connecting to the actual Redis server, defaults to {Protocol::RESP2}. 25 | def initialize(endpoints, master_name: DEFAULT_MASTER_NAME, role: :master, protocol: Protocol::RESP2, **options) 26 | @endpoints = endpoints 27 | @master_name = master_name 28 | @role = role 29 | @protocol = protocol 30 | 31 | # A cache of sentinel connections. 32 | @sentinels = {} 33 | 34 | @pool = make_pool(**options) 35 | end 36 | 37 | # @attribute [String] The name of the master instance. 38 | attr :master_name 39 | 40 | # @attribute [Symbol] The role of the instance that you want to connect to. 41 | attr :role 42 | 43 | def resolve_address(role = @role) 44 | case role 45 | when :master 46 | resolve_master 47 | when :slave 48 | resolve_slave 49 | else 50 | raise ArgumentError, "Unknown instance role #{role}" 51 | end => address 52 | 53 | Console.debug(self, "Resolved #{@role} address: #{address}") 54 | 55 | address or raise RuntimeError, "Unable to fetch #{role} via Sentinel." 56 | end 57 | 58 | def close 59 | super 60 | 61 | @sentinels.each do |_, client| 62 | client.close 63 | end 64 | end 65 | 66 | def failover(name = @master_name) 67 | sentinels do |client| 68 | return client.call("SENTINEL", "FAILOVER", name) 69 | end 70 | end 71 | 72 | def masters 73 | sentinels do |client| 74 | return client.call("SENTINEL", "MASTERS").map{|fields| fields.each_slice(2).to_h} 75 | end 76 | end 77 | 78 | def master(name = @master_name) 79 | sentinels do |client| 80 | return client.call("SENTINEL", "MASTER", name).each_slice(2).to_h 81 | end 82 | end 83 | 84 | def resolve_master 85 | sentinels do |client| 86 | begin 87 | address = client.call("SENTINEL", "GET-MASTER-ADDR-BY-NAME", @master_name) 88 | rescue Errno::ECONNREFUSED 89 | next 90 | end 91 | 92 | return Endpoint.remote(address[0], address[1]) if address 93 | end 94 | 95 | return nil 96 | end 97 | 98 | def resolve_slave 99 | sentinels do |client| 100 | begin 101 | reply = client.call("SENTINEL", "SLAVES", @master_name) 102 | rescue Errno::ECONNREFUSED 103 | next 104 | end 105 | 106 | slaves = available_slaves(reply) 107 | next if slaves.empty? 108 | 109 | slave = select_slave(slaves) 110 | return Endpoint.remote(slave["ip"], slave["port"]) 111 | end 112 | 113 | return nil 114 | end 115 | 116 | protected 117 | 118 | def assign_default_tags(tags) 119 | tags[:protocol] = @protocol.to_s 120 | end 121 | 122 | # Override the parent method. The only difference is that this one needs to resolve the master/slave address. 123 | def make_pool(**options) 124 | self.assign_default_tags(options[:tags] ||= {}) 125 | 126 | Async::Pool::Controller.wrap(**options) do 127 | endpoint = resolve_address 128 | peer = endpoint.connect 129 | stream = ::IO::Stream(peer) 130 | 131 | @protocol.client(stream) 132 | end 133 | end 134 | 135 | def sentinels 136 | @endpoints.map do |endpoint| 137 | @sentinels[endpoint] ||= Client.new(endpoint) 138 | 139 | yield @sentinels[endpoint] 140 | end 141 | end 142 | 143 | def available_slaves(reply) 144 | # The reply is an array with the format: [field1, value1, field2, 145 | # value2, etc.]. 146 | # When a slave is marked as down by the sentinel, the "flags" field 147 | # (comma-separated array) contains the "s_down" value. 148 | slaves = reply.map{|fields| fields.each_slice(2).to_h} 149 | 150 | slaves.reject do |slave| 151 | slave["flags"].split(",").include?("s_down") 152 | end 153 | end 154 | 155 | def select_slave(available_slaves) 156 | available_slaves.sample 157 | end 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /lib/async/redis/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | module Async 7 | module Redis 8 | VERSION = "0.11.1" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright, 2018-2025, by Samuel Williams. 4 | Copyright, 2018, by Huba Nagy. 5 | Copyright, 2019-2020, by David Ortiz. 6 | Copyright, 2019, by Pierre Montelle. 7 | Copyright, 2019, by Jeremy Jung. 8 | Copyright, 2019, by Mikael Henriksson. 9 | Copyright, 2020, by Salim Semaoune. 10 | Copyright, 2021, by Alex Matchneer. 11 | Copyright, 2021, by Olle Jonsson. 12 | Copyright, 2021, by Troex Nevelin. 13 | Copyright, 2022, by Tim Willard. 14 | Copyright, 2022, by Gleb Sinyavskiy. 15 | Copyright, 2024, by Joan Lledó. 16 | Copyright, 2025, by Travis Bell. 17 | 18 | Permission is hereby granted, free of charge, to any person obtaining a copy 19 | of this software and associated documentation files (the "Software"), to deal 20 | in the Software without restriction, including without limitation the rights 21 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 22 | copies of the Software, and to permit persons to whom the Software is 23 | furnished to do so, subject to the following conditions: 24 | 25 | The above copyright notice and this permission notice shall be included in all 26 | copies or substantial portions of the Software. 27 | 28 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 29 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 30 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 31 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 32 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 33 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 34 | SOFTWARE. 35 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Async::Redis 2 | 3 | An asynchronous client for Redis including TLS. Support for streaming requests and responses. Built on top of [async](https://github.com/socketry/async). 4 | 5 | [![Development Status](https://github.com/socketry/async-redis/workflows/Test/badge.svg)](https://github.com/socketry/async-redis/actions?workflow=Test) 6 | 7 | ## Support 8 | 9 | This gem supports both Valkey and Redis. It is designed to be compatible with the latest versions of both libraries. We also test Redis sentinel and cluster configurations. 10 | 11 | ## Usage 12 | 13 | Please see the [project documentation](https://socketry.github.io/async-redis/) for more details. 14 | 15 | - [Getting Started](https://socketry.github.io/async-redis/guides/getting-started/index) - This guide explains how to use the `async-redis` gem to connect to a Redis server and perform basic operations. 16 | 17 | ## Releases 18 | 19 | Please see the [project releases](https://socketry.github.io/async-redis/releases/index) for all releases. 20 | 21 | ### v0.11.1 22 | 23 | - Correctly pass `@options` to `Async::Redis::Client` instances created by `Async::Redis::ClusterClient`. 24 | 25 | ### v0.10.0 26 | 27 | - [Add support for Redis Clusters](https://socketry.github.io/async-redis/releases/index#add-support-for-redis-clusters) 28 | - [Add support for Redis Sentinels](https://socketry.github.io/async-redis/releases/index#add-support-for-redis-sentinels) 29 | - [Improved Integration Tests](https://socketry.github.io/async-redis/releases/index#improved-integration-tests) 30 | 31 | ## Contributing 32 | 33 | We welcome contributions to this project. 34 | 35 | 1. Fork it. 36 | 2. Create your feature branch (`git checkout -b my-new-feature`). 37 | 3. Commit your changes (`git commit -am 'Add some feature'`). 38 | 4. Push to the branch (`git push origin my-new-feature`). 39 | 5. Create new Pull Request. 40 | 41 | ### Developer Certificate of Origin 42 | 43 | In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed. 44 | 45 | ### Community Guidelines 46 | 47 | This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers. 48 | -------------------------------------------------------------------------------- /release.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11 3 | ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK 4 | CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz 5 | MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd 6 | MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj 7 | bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB 8 | igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2 9 | 9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW 10 | sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE 11 | e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN 12 | XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss 13 | RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn 14 | tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM 15 | zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW 16 | xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O 17 | BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs 18 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs 19 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE 20 | cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl 21 | xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/ 22 | c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp 23 | 8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws 24 | JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP 25 | eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt 26 | Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8 27 | voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg= 28 | -----END CERTIFICATE----- 29 | -------------------------------------------------------------------------------- /releases.md: -------------------------------------------------------------------------------- 1 | # Releases 2 | 3 | ## v0.11.1 4 | 5 | - Correctly pass `@options` to `Async::Redis::Client` instances created by `Async::Redis::ClusterClient`. 6 | 7 | ## v0.10.0 8 | 9 | ### Add support for Redis Clusters 10 | 11 | `Async::Redis::ClusterClient` is a new class that provides a high-level interface to a Redis Cluster. Due to the way clustering works, it does not provide the same interface as the `Async::Redis::Client` class. Instead, you must request an appropriate client for the key you are working with. 12 | 13 | ``` ruby 14 | endpoints = [ 15 | Async::Redis::Endpoint.parse("redis://redis-a"), 16 | Async::Redis::Endpoint.parse("redis://redis-b"), 17 | Async::Redis::Endpoint.parse("redis://redis-c"), 18 | ] 19 | 20 | cluster_client = Async::Redis::ClusterClient.new(endpoints) 21 | 22 | cluster_client.clients_for("key") do |client| 23 | puts client.get("key") 24 | end 25 | ``` 26 | 27 | ### Add support for Redis Sentinels 28 | 29 | The previous implementation `Async::Redis::SentinelsClient` has been replaced with `Async::Redis::SentinelClient`. This new class uses `Async::Redis::Endpoint` objects to represent the sentinels and the master. 30 | 31 | ``` ruby 32 | sentinels = [ 33 | Async::Redis::Endpoint.parse("redis://redis-sentinel-a"), 34 | Async::Redis::Endpoint.parse("redis://redis-sentinel-b"), 35 | Async::Redis::Endpoint.parse("redis://redis-sentinel-c"), 36 | ] 37 | 38 | master_client = Async::Redis::SentinelClient.new(sentinels) 39 | slave_client = Async::Redis::SentinelClient.new(sentinels, role: :slave) 40 | 41 | master_client.session do |session| 42 | session.set("key", "value") 43 | end 44 | 45 | slave_client.session do |session| 46 | puts session.get("key") 47 | end 48 | ``` 49 | 50 | ### Improved Integration Tests 51 | 52 | Integration tests for Redis Cluster and Sentinel have been added, using `docker-compose` to start the required services and run the tests. These tests are not part of the default test suite and must be run separately. See the documentation in the `sentinel/` and `cluster/` directories for more information. 53 | -------------------------------------------------------------------------------- /sentinel/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | redis-master: 3 | image: redis 4 | redis-slave: 5 | image: redis 6 | command: redis-server --slaveof redis-master 6379 7 | depends_on: 8 | - redis-master 9 | redis-sentinel: 10 | image: redis 11 | command: redis-sentinel /etc/redis/sentinel.conf 12 | volumes: 13 | - ./sentinel.conf:/etc/redis/sentinel.conf 14 | depends_on: 15 | - redis-master 16 | - redis-slave 17 | tests: 18 | image: ruby:${RUBY_VERSION:-latest} 19 | volumes: 20 | - ../:/code 21 | command: bash -c "cd /code && bundle install && bundle exec sus sentinel/test" 22 | environment: 23 | - COVERAGE=${COVERAGE} 24 | depends_on: 25 | - redis-master 26 | - redis-slave 27 | - redis-sentinel 28 | -------------------------------------------------------------------------------- /sentinel/readme.md: -------------------------------------------------------------------------------- 1 | # Sentinel Testing 2 | 3 | To test sentinels, you need to set up master, slave and sentinel instances. 4 | 5 | ## Setup 6 | 7 | ``` bash 8 | $ cd sentinel 9 | $ docker-compose up tests 10 | [+] Running 4/0 11 | ✔ Container sentinel-redis-master-1 Created 12 | ✔ Container sentinel-redis-slave-1 Created 13 | ✔ Container sentinel-redis-sentinel-1 Created 14 | ✔ Container sentinel-tests-1 Created 15 | Attaching to tests-1 16 | tests-1 | Bundle complete! 13 Gemfile dependencies, 41 gems now installed. 17 | tests-1 | Use `bundle info [gemname]` to see where a bundled gem is installed. 18 | tests-1 | 6 installed gems you directly depend on are looking for funding. 19 | tests-1 | Run `bundle fund` for details 20 | tests-1 | 3 passed out of 3 total (3 assertions) 21 | tests-1 | 🏁 Finished in 4.1s; 0.74 assertions per second. 22 | tests-1 | 🐢 Slow tests: 23 | tests-1 | 4.1s: describe Async::Redis::SentinelClient it should resolve slave address sentinel/test/async/redis/sentinel_client.rb:35 24 | tests-1 exited with code 0 25 | 26 | ``` 27 | 28 | -------------------------------------------------------------------------------- /sentinel/sentinel.conf: -------------------------------------------------------------------------------- 1 | port 26379 2 | sentinel resolve-hostnames yes 3 | sentinel monitor mymaster redis-master 6379 1 4 | sentinel down-after-milliseconds mymaster 100 5 | sentinel failover-timeout mymaster 100 6 | sentinel parallel-syncs mymaster 1 7 | -------------------------------------------------------------------------------- /sentinel/test/async/redis/sentinel_client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require "async/clock" 7 | require "async/redis/sentinel_client" 8 | require "sus/fixtures/async" 9 | require "securerandom" 10 | 11 | describe Async::Redis::SentinelClient do 12 | include Sus::Fixtures::Async::ReactorContext 13 | 14 | let(:master_host) {"redis://redis-master:6379"} 15 | let(:slave_host) {"redis://redis-slave:6379"} 16 | let(:sentinel_host) {"redis://redis-sentinel:26379"} 17 | 18 | let(:sentinels) {[ 19 | Async::Redis::Endpoint.parse(sentinel_host) 20 | ]} 21 | 22 | let(:client) {subject.new(sentinels)} 23 | let(:slave_client) {subject.new(sentinels, role: :slave)} 24 | 25 | let(:master_client) {Async::Redis::Client.new(Endpoint.parse(master_host))} 26 | 27 | let(:key) {"sentinel-test:#{SecureRandom.hex(8)}"} 28 | let(:value) {"sentinel-test-value"} 29 | 30 | it "should resolve master address" do 31 | client.set(key, value) 32 | expect(client.get(key)).to be == value 33 | end 34 | 35 | it "should resolve slave address" do 36 | client.set(key, value) 37 | 38 | # It takes a while to replicate: 39 | while true 40 | break if slave_client.get(key) == value 41 | sleep 0.01 42 | end 43 | 44 | expect(slave_client.get(key)).to be == value 45 | end 46 | 47 | it "can handle failover" do 48 | client.failover 49 | 50 | # We can still connect and do stuff: 51 | client.set(key, value) 52 | expect(client.get(key)).to be == value 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/async/redis/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | # Copyright, 2018, by Huba Nagy. 6 | # Copyright, 2019, by David Ortiz. 7 | 8 | require "async/clock" 9 | require "async/redis/client" 10 | require "sus/fixtures/async" 11 | require "securerandom" 12 | 13 | describe Async::Redis::Client do 14 | include Sus::Fixtures::Async::ReactorContext 15 | 16 | let(:endpoint) {Async::Redis.local_endpoint} 17 | let(:client) {Async::Redis::Client.new(endpoint)} 18 | 19 | # Some of these tests are a little slow. 20 | let(:timeout) {10} 21 | 22 | it "should connect to redis server" do 23 | result = client.call("INFO") 24 | 25 | expect(result).to be(:include?, "redis_version") 26 | 27 | client.close 28 | end 29 | 30 | let(:string_key) {"async-redis:test:#{SecureRandom.uuid}:string"} 31 | let(:test_string) {"beep-boop"} 32 | 33 | it "can set simple string and retrieve it" do 34 | client.call("SET", string_key, test_string) 35 | 36 | response = client.call("GET", string_key) 37 | expect(response).to be == test_string 38 | 39 | client.close 40 | end 41 | 42 | let(:list_key) {"async-redis:test::#{SecureRandom.uuid}:list"} 43 | 44 | it "can add items to list and retrieve them" do 45 | client.call("LTRIM", list_key, 0, 0) 46 | 47 | response = client.call("LPUSH", list_key, "World", "Hello") 48 | expect(response).to be > 0 49 | 50 | response = client.call("LRANGE", list_key, 0, 1) 51 | expect(response).to be == ["Hello", "World"] 52 | 53 | client.close 54 | end 55 | 56 | it "can timeout" do 57 | duration = Async::Clock.measure do 58 | result = client.call("BLPOP", "SLEEP", 0.1) 59 | end 60 | 61 | expect(duration).to be_within(100).percent_of(0.1) 62 | 63 | client.close 64 | end 65 | 66 | it "can propagate errors back from the server" do 67 | # ERR 68 | expect{client.call("NOSUCHTHING", 0, 85)}.to raise_exception(Async::Redis::ServerError) 69 | 70 | # WRONGTYPE 71 | client.call("LPUSH", list_key, "World", "Hello") 72 | expect{client.call("GET", list_key)}.to raise_exception(Async::Redis::ServerError) 73 | 74 | client.close 75 | end 76 | 77 | it "retrieves large responses from redis" do 78 | size = 1000 79 | 80 | client.call("DEL", list_key) 81 | size.times {|i| client.call("RPUSH", list_key, i) } 82 | 83 | response = client.call("LRANGE", list_key, 0, size - 1) 84 | 85 | expect(response).to be == (0...size).map(&:to_s) 86 | 87 | client.close 88 | end 89 | 90 | it "can use pipelining" do 91 | client.pipeline do |context| 92 | client.set "async_redis_test_key_1", "a" 93 | client.set "async_redis_test_key_2", "b" 94 | 95 | results = context.collect do 96 | context.get "async_redis_test_key_1" 97 | context.get "async_redis_test_key_2" 98 | end 99 | 100 | expect(results).to be == ["a", "b"] 101 | end 102 | 103 | client.close 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /test/async/redis/cluster_client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require "async/redis/cluster_client" 7 | require "sus/fixtures/async" 8 | require "securerandom" 9 | 10 | describe Async::Redis::ClusterClient do 11 | let(:client) {subject.new([])} 12 | 13 | with "#slot_for" do 14 | it "can compute the correct slot for a given key" do 15 | expect(client.slot_for("helloworld")).to be == 2739 16 | expect(client.slot_for("test1234")).to be == 15785 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/async/redis/context/pipeline.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019, by David Ortiz. 5 | # Copyright, 2019-2024, by Samuel Williams. 6 | 7 | require "async/redis/client" 8 | require "async/redis/context/pipeline" 9 | require "sus/fixtures/async" 10 | 11 | describe Async::Redis::Context::Pipeline do 12 | include Sus::Fixtures::Async::ReactorContext 13 | 14 | let(:endpoint) {Async::Redis.local_endpoint} 15 | let(:client) {Async::Redis::Client.new(endpoint)} 16 | let(:pool) {client.instance_variable_get(:@pool)} 17 | let(:pipeline) {Async::Redis::Context::Pipeline.new(pool)} 18 | 19 | let(:pairs) do 20 | {pipeline_key_1: "123", pipeline_key_2: "456"} 21 | end 22 | 23 | with "#call" do 24 | it "accumulates commands without running them" do 25 | pairs.each do |key, value| 26 | pipeline.call("SET", key, value) 27 | end 28 | 29 | pipeline.close 30 | 31 | pairs.each do |key, value| 32 | expect(client.get(key)).to be == value 33 | end 34 | ensure 35 | client.close 36 | end 37 | end 38 | 39 | with "#collect" do 40 | it "accumulates commands and runs them" do 41 | pairs.each do |key, value| 42 | pipeline.call("SET", key, value) 43 | end 44 | 45 | pipeline.flush 46 | 47 | pairs.each do |key, value| 48 | pipeline.call("GET", key) 49 | end 50 | 51 | expect(pipeline.collect).to be == pairs.values 52 | ensure 53 | pipeline.close 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/async/redis/context/subscribe.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require "async/redis/client" 7 | require "sus/fixtures/async" 8 | require "securerandom" 9 | 10 | describe Async::Redis::Context::Subscribe do 11 | include Sus::Fixtures::Async::ReactorContext 12 | 13 | let(:endpoint) {Async::Redis.local_endpoint} 14 | let(:client) {Async::Redis::Client.new(endpoint)} 15 | 16 | let(:channel_root) {"async-redis:test:#{SecureRandom.uuid}"} 17 | let(:news_channel) {"#{channel_root}:news"} 18 | let(:weather_channel) {"#{channel_root}:weather"} 19 | let(:sport_channel) {"#{channel_root}:sport"} 20 | let(:channels) {[news_channel, weather_channel, sport_channel]} 21 | 22 | it "should subscribe to channels and report incoming messages" do 23 | condition = Async::Condition.new 24 | 25 | publisher = reactor.async do 26 | condition.wait 27 | Console.logger.debug("Publishing message...") 28 | client.publish(news_channel, "AAA") 29 | end 30 | 31 | listener = reactor.async do 32 | Console.logger.debug("Subscribing...") 33 | client.subscribe(*channels) do |context| 34 | Console.logger.debug("Waiting for message...") 35 | condition.signal 36 | 37 | type, name, message = context.listen 38 | 39 | Console.logger.debug("Got: #{type} #{name} #{message}") 40 | expect(type).to be == "message" 41 | expect(name).to be == news_channel 42 | expect(message).to be == "AAA" 43 | end 44 | end 45 | 46 | publisher.wait 47 | listener.wait 48 | 49 | # At this point, we should check if the client is still working. i.e. we don't leak the state of the subscriptions: 50 | 51 | expect(client.info).to be_a(Hash) 52 | 53 | client.close 54 | end 55 | 56 | it "can add subscriptions" do 57 | subscription = client.subscribe(news_channel) 58 | 59 | listener = reactor.async do 60 | type, name, message = subscription.listen 61 | expect(message).to be == "Sunny" 62 | end 63 | 64 | subscription.subscribe([weather_channel]) 65 | client.publish(weather_channel, "Sunny") 66 | 67 | listener.wait 68 | ensure 69 | subscription.close 70 | end 71 | 72 | with "#each" do 73 | it "should iterate over messages" do 74 | subscription = client.subscribe(news_channel) 75 | 76 | listener = reactor.async do 77 | subscription.each do |type, name, message| 78 | expect(type).to be == "message" 79 | expect(name).to be == news_channel 80 | expect(message).to be == "Hello" 81 | 82 | break 83 | end 84 | end 85 | 86 | client.publish(news_channel, "Hello") 87 | 88 | listener.wait 89 | ensure 90 | subscription.close 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/async/redis/context/transaction.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | 6 | require "client_context" 7 | 8 | describe Async::Redis::Context::Transaction do 9 | include_context ClientContext 10 | 11 | describe "#execute" do 12 | let(:value) {"3"} 13 | 14 | it "can atomically execute commands" do 15 | response = nil 16 | 17 | client.transaction do |context| 18 | context.multi 19 | 20 | (0..5).each do |id| 21 | response = context.sync.set root[id], value 22 | expect(response).to be == "QUEUED" 23 | end 24 | 25 | response = context.execute 26 | end 27 | 28 | # all 5 SET + 1 EXEC commands should return OK 29 | expect(response).to be == ["OK"] * 6 30 | 31 | (0..5).each do |id| 32 | expect(client.call("GET", root[id])).to be == value 33 | end 34 | end 35 | 36 | it "can atomically increment integers" do 37 | client.transaction do |context| 38 | context.multi 39 | context.incr root[:foo] 40 | context.incr root[:bar] 41 | 42 | expect(context.execute).to be == [1, 1] 43 | end 44 | end 45 | 46 | with "an invalid command" do 47 | let(:key) {root[:thing]} 48 | 49 | it "results in error" do 50 | client.transaction do |context| 51 | context.multi 52 | context.set key, value 53 | context.lpop key 54 | 55 | expect do 56 | context.execute 57 | end.to raise_exception(::Protocol::Redis::ServerError, message: be =~ /WRONGTYPE Operation against a key holding the wrong kind of value/) 58 | end 59 | 60 | # Even thought lpop failed, set was still applied: 61 | expect(client.get(key)).to be == value 62 | end 63 | end 64 | end 65 | 66 | describe "#discard" do 67 | it "ignores increment" do 68 | client.transaction do |context| 69 | context.set root["foo"], "1" 70 | 71 | context.multi 72 | context.incr root["foo"] 73 | context.discard 74 | 75 | expect(context.sync.get(root["foo"])).to be == "1" 76 | end 77 | end 78 | end 79 | 80 | describe "#watch" do 81 | it "can atomically increment" do 82 | foo_key = root[:foo] 83 | 84 | client.transaction do |context| 85 | context.watch foo_key 86 | foo = context.sync.get(foo_key) || 0 87 | 88 | foo = foo + 1 89 | 90 | context.multi 91 | context.set foo_key, foo 92 | context.execute 93 | end 94 | 95 | expect(client.get(foo_key)).to be == "1" 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /test/async/redis/disconnect.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019, by Jeremy Jung. 5 | # Copyright, 2019, by David Ortiz. 6 | # Copyright, 2019-2024, by Samuel Williams. 7 | 8 | require "async/redis/client" 9 | require "sus/fixtures/async" 10 | 11 | describe Async::Redis::Client do 12 | include Sus::Fixtures::Async::ReactorContext 13 | 14 | # Intended to not be connected: 15 | let(:endpoint) {Async::Redis::Endpoint.local(port: 5555)} 16 | 17 | before do 18 | @server_endpoint = ::IO::Endpoint.tcp("localhost").bound 19 | end 20 | 21 | after do 22 | @server_endpoint&.close 23 | end 24 | 25 | it "should raise error on unexpected disconnect" do 26 | server_task = Async do 27 | @server_endpoint.accept do |connection| 28 | connection.read(8) 29 | connection.close 30 | end 31 | end 32 | 33 | client = Async::Redis::Client.new( 34 | @server_endpoint.local_address_endpoint, 35 | protocol: Async::Redis::Protocol::RESP2, 36 | ) 37 | 38 | expect do 39 | client.call("GET", "test") 40 | end.to raise_exception(Errno::ECONNRESET) 41 | 42 | client.close 43 | server_task.stop 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/async/redis/endpoint.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require "async/redis/client" 7 | require "async/redis/protocol/authenticated" 8 | require "sus/fixtures/async" 9 | 10 | describe Async::Redis::Endpoint do 11 | include Sus::Fixtures::Async::ReactorContext 12 | 13 | let(:endpoint) {Async::Redis.local_endpoint} 14 | 15 | with "#credentials" do 16 | it "can parse a url with username and password" do 17 | endpoint = Async::Redis::Endpoint.parse("redis://testuser:testpassword@localhost") 18 | expect(endpoint.credentials).to be == ["testuser", "testpassword"] 19 | end 20 | 21 | it "can parse a url with a blank username and password" do 22 | endpoint = Async::Redis::Endpoint.parse("redis://:testpassword@localhost") 23 | expect(endpoint.credentials).to be == ["testpassword"] 24 | end 25 | 26 | it "can parse a url with a password only" do 27 | endpoint = Async::Redis::Endpoint.parse("redis://testpassword@localhost") 28 | expect(endpoint.credentials).to be == ["testpassword"] 29 | end 30 | end 31 | 32 | with "#protocol" do 33 | it "defaults to RESP2" do 34 | expect(endpoint.protocol).to be == Async::Redis::Protocol::RESP2 35 | end 36 | 37 | with "database selection" do 38 | let(:endpoint) {Async::Redis.local_endpoint(database: 1)} 39 | 40 | it "selects the database" do 41 | expect(endpoint.protocol).to be_a(Async::Redis::Protocol::Selected) 42 | expect(endpoint.protocol.index).to be == 1 43 | end 44 | end 45 | 46 | with "credentials" do 47 | let(:credentials) {["testuser", "testpassword"]} 48 | let(:endpoint) {Async::Redis.local_endpoint(credentials: credentials)} 49 | 50 | it "authenticates with credentials" do 51 | expect(endpoint.protocol).to be_a(Async::Redis::Protocol::Authenticated) 52 | expect(endpoint.protocol.credentials).to be == credentials 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/async/redis/methods/generic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018, by Huba Nagy. 5 | # Copyright, 2018-2025, by Samuel Williams. 6 | 7 | require "client_context" 8 | 9 | describe Protocol::Redis::Methods::Generic do 10 | include_context ClientContext 11 | 12 | let(:test_string) {"beep-boop"} 13 | let(:string_key) {root["string_key"]} 14 | 15 | it "can delete keys" do 16 | client.set(string_key, test_string) 17 | 18 | expect(client.del string_key).to be == 1 19 | expect(client.get string_key).to be_nil 20 | end 21 | 22 | let(:other_key) {root["other_key"]} 23 | 24 | it "can rename keys" do 25 | client.set(string_key, test_string) 26 | 27 | expect(client.rename string_key, other_key).to be == "OK" 28 | expect(client.get other_key).to be == test_string 29 | expect(client.get string_key).to be_nil 30 | 31 | client.set(string_key, test_string) 32 | 33 | expect(client.renamenx string_key, other_key).to be == 0 34 | end 35 | 36 | let(:whole_day) {24 * 60 * 60} 37 | let(:one_hour) {60 * 60} 38 | 39 | it "can modify and query the expiry of keys" do 40 | client.set string_key, test_string 41 | # make the key expire tomorrow 42 | client.expireat string_key, DateTime.now + 1 43 | 44 | ttl = client.ttl(string_key) 45 | expect(ttl).to be_within(10).of(whole_day) 46 | 47 | client.persist string_key 48 | expect(client.ttl string_key).to be == -1 49 | 50 | client.expire string_key, one_hour 51 | expect(client.ttl string_key).to be_within(10).of(one_hour) 52 | end 53 | 54 | it "can serialize and restore values" do 55 | client.set(string_key, test_string) 56 | serialized = client.dump string_key 57 | 58 | expect(client.restore other_key, serialized).to be == "OK" 59 | expect(client.get other_key).to be == test_string 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/async/redis/methods/hashes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019, by Mikael Henriksson. 5 | # Copyright, 2019-2025, by Samuel Williams. 6 | 7 | require "client_context" 8 | 9 | describe Protocol::Redis::Methods::Hashes do 10 | include_context ClientContext 11 | 12 | let(:hash_field_one) {"beep-boop"} 13 | let(:hash_field_two) {"cowboy"} 14 | let(:hash_value) { "la la land" } 15 | let(:hash_key) {root["hash_key"]} 16 | 17 | it "can set a fields value" do 18 | client.hset(hash_key, hash_field_one, hash_value) 19 | 20 | expect(client.hget(hash_key, hash_field_one)).to be == hash_value 21 | expect(client.hexists(hash_key, hash_field_one)).to be == true 22 | expect(client.hexists(hash_key, "notafield")).to be == false 23 | expect(client.hlen(hash_key)).to be == 1 24 | end 25 | 26 | it "can set multiple field values" do 27 | client.hmset(hash_key, hash_field_two, hash_value, hash_field_one, hash_value) 28 | 29 | expect(client.hmget(hash_key, hash_field_one, hash_field_two)).to be == [hash_value, hash_value] 30 | expect(client.hexists(hash_key, hash_field_one)).to be == true 31 | expect(client.hexists(hash_key, hash_field_two)).to be == true 32 | expect(client.hlen(hash_key)).to be == 2 33 | end 34 | 35 | it "can get keys" do 36 | client.hset(hash_key, hash_field_one, hash_value) 37 | 38 | expect(client.hkeys hash_key).to be ==([hash_field_one]) 39 | end 40 | 41 | it "can get values" do 42 | client.hset(hash_key, hash_field_one, hash_value) 43 | 44 | expect(client.hvals hash_key).to be ==([hash_value]) 45 | end 46 | 47 | it "can delete fields" do 48 | client.hset(hash_key, hash_field_one, hash_value) 49 | 50 | expect(client.hdel hash_key, hash_field_one).to be == 1 51 | expect(client.hget hash_key, hash_field_one).to be_nil 52 | expect(client.hlen hash_key).to be == 0 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/async/redis/methods/lists.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018, by Huba Nagy. 5 | # Copyright, 2018-2025, by Samuel Williams. 6 | # Copyright, 2019, by David Ortiz. 7 | 8 | require "client_context" 9 | 10 | describe Protocol::Redis::Methods::Lists do 11 | include_context ClientContext 12 | 13 | let(:list_a) {root["list_a"]} 14 | let(:list_b) {root["list_b"]} 15 | let(:test_list) {(0..4).to_a} 16 | 17 | it "can do non blocking push/pop operations" do 18 | expect(client.lpush list_a, test_list).to be == test_list.length 19 | expect(client.rpush list_b, test_list).to be == test_list.length 20 | 21 | expect(client.llen list_a).to be == client.llen(list_b) 22 | 23 | test_list.each do |i| 24 | item_a = client.lpop list_a 25 | item_b = client.rpop list_b 26 | expect(item_a).to be == item_b 27 | end 28 | end 29 | 30 | it "can conditionally push and pop items from lists" do 31 | 32 | end 33 | 34 | it "can get, set and remove values at specific list indexes" do 35 | 36 | end 37 | 38 | it "can get a list slice" do 39 | client.rpush(list_a, test_list) 40 | 41 | slice_size = list_a.size/2 42 | 43 | expect(client.lrange(list_a, 0, slice_size - 1)).to be == test_list.take(slice_size).map(&:to_s) 44 | end 45 | 46 | it "can trim lists" do 47 | 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/async/redis/methods/strings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018, by Huba Nagy. 5 | # Copyright, 2018-2025, by Samuel Williams. 6 | 7 | require "client_context" 8 | 9 | describe Protocol::Redis::Methods::Strings do 10 | include_context ClientContext 11 | 12 | let(:string_key) {root["my_string"]} 13 | let(:other_string_key) {root["other_string"]} 14 | let(:test_string) {"beep-boop"} 15 | let(:other_string) {"doot"} 16 | 17 | it "can perform string manipulation" do 18 | expect(client.set(string_key, test_string)).to be == "OK" 19 | expect(client.get(string_key)).to be == test_string 20 | expect(client.strlen(string_key)).to be == test_string.length 21 | 22 | expect(client.setrange(string_key, 5, "beep")).to be == test_string.length 23 | expect(client.getrange(string_key, 5, 8)).to be == "beep" 24 | 25 | expect(client.append(string_key, "-boop")).to be == "beep-beep-boop".length 26 | expect(client.get(string_key)).to be == "beep-beep-boop" 27 | 28 | expect(client.getset(string_key, test_string)).to be == "beep-beep-boop" 29 | expect(client.get(string_key)).to be == test_string 30 | end 31 | 32 | it "can conditionally set values based on whether they exist or not" do 33 | expect(client.set(string_key, test_string)).to be == "OK" 34 | 35 | # only set if it doesn't exist, which it does already 36 | expect(client.setnx(string_key, other_string)).to be == false 37 | expect(client.get(string_key)).to be == test_string 38 | 39 | # only set if it exists, which it doesn't yet 40 | expect(client.set other_string_key, other_string, update: true).to be_nil 41 | expect(client.get other_string_key).to be_nil 42 | 43 | # only set if it doesn't exist, which it doesn't 44 | expect(client.setnx(other_string_key, test_string)).to be == true 45 | expect(client.get(other_string_key)).to be == test_string 46 | 47 | # only set if it exists, which it does 48 | expect(client.set other_string_key, other_string, update: true).to be == "OK" 49 | expect(client.get other_string_key).to be == other_string 50 | end 51 | 52 | let(:seconds) {3} 53 | let(:milliseconds) {3500} 54 | 55 | it "can set values with a time-to-live" do 56 | expect(client.set(string_key, test_string)).to be == "OK" 57 | expect(client.call("TTL", string_key)).to be == -1 58 | 59 | expect(client.setex(string_key, seconds, test_string)).to be == "OK" 60 | expect(client.call("TTL", string_key)).to be >= 0 61 | 62 | expect(client.psetex(other_string_key, milliseconds, other_string)).to be == "OK" 63 | expect(client.call("TTL", other_string_key)).to be >= 0 64 | 65 | expect{ 66 | client.set string_key, test_string, seconds: seconds, milliseconds: milliseconds 67 | }.to raise_exception(Async::Redis::ServerError) 68 | end 69 | 70 | let(:integer_key) {"async-redis:test:integer"} 71 | let(:test_integer) {555} 72 | 73 | it "can perform manipulations on string representation of integers" do 74 | expect(client.set(integer_key, test_integer)).to be == "OK" 75 | expect(client.get(integer_key)).to be == "#{test_integer}" 76 | 77 | expect(client.incr(integer_key)).to be == test_integer + 1 78 | expect(client.decr(integer_key)).to be == test_integer 79 | 80 | expect(client.incrby(integer_key, 5)).to be == test_integer + 5 81 | expect(client.decrby(integer_key, 5)).to be == test_integer 82 | end 83 | 84 | let(:float_key) {"async-redis:test:float"} 85 | let(:test_float) {554.4} 86 | 87 | it "can perform manipulations on string representation of floats" do 88 | expect(client.set(float_key, test_float)).to be == "OK" 89 | 90 | expect(client.incrbyfloat(float_key, 1.1)).to be == "555.5" 91 | end 92 | 93 | let(:test_pairs) do 94 | { 95 | root["key_a"] => "a", 96 | root["key_b"] => "b", 97 | root["key_c"] => "c" 98 | } 99 | end 100 | 101 | let(:overlapping_pairs) do 102 | { 103 | root["key_a"] => "x", 104 | root["key_d"] => "y", 105 | root["key_e"] => "z", 106 | } 107 | end 108 | 109 | let(:disjoint_pairs) do 110 | { 111 | root["key_d"] => "d", 112 | root["key_e"] => "e", 113 | } 114 | end 115 | 116 | it "can set and get multiple key value pairs" do 117 | expect(client.mset(test_pairs)).to be == "OK" 118 | expect(client.mget(*test_pairs.keys)).to be == test_pairs.values 119 | 120 | expect(client.msetnx(overlapping_pairs)).to be == 0 121 | expect(client.mget(*test_pairs.keys)).to be == test_pairs.values 122 | 123 | expect(client.msetnx(disjoint_pairs)).to be == 1 124 | expect(client.mget(*disjoint_pairs.keys)).to be == disjoint_pairs.values 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /test/async/redis/protocol/authenticated.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require "async/redis/client" 7 | require "async/redis/protocol/authenticated" 8 | require "sus/fixtures/async" 9 | 10 | describe Async::Redis::Protocol::Authenticated do 11 | include Sus::Fixtures::Async::ReactorContext 12 | 13 | let(:endpoint) {Async::Redis.local_endpoint} 14 | let(:credentials) {["testuser", "testpassword"]} 15 | let(:protocol) {subject.new(credentials)} 16 | let(:client) {Async::Redis::Client.new(endpoint, protocol: protocol)} 17 | 18 | before do 19 | # Setup ACL user with limited permissions for testing. 20 | admin_client = Async::Redis::Client.new(endpoint) 21 | admin_client.call("ACL", "SETUSER", "testuser", "on", ">" + credentials[1], "+ping", "+auth") 22 | ensure 23 | admin_client.close 24 | end 25 | 26 | after do 27 | # Cleanup ACL user after tests. 28 | admin_client = Async::Redis::Client.new(endpoint) 29 | admin_client.call("ACL", "DELUSER", "testuser") 30 | admin_client.close 31 | end 32 | 33 | it "can authenticate and send allowed commands" do 34 | response = client.call("PING") 35 | expect(response).to be == "PONG" 36 | end 37 | 38 | it "rejects commands not allowed by ACL" do 39 | expect do 40 | client.call("SET", "key", "value") 41 | end.to raise_exception(Protocol::Redis::ServerError, message: be =~ /NOPERM/) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/async/redis/protocol/selected.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require "async/redis/client" 7 | require "async/redis/protocol/selected" 8 | require "sus/fixtures/async" 9 | 10 | describe Async::Redis::Protocol::Selected do 11 | include Sus::Fixtures::Async::ReactorContext 12 | 13 | let(:endpoint) {Async::Redis.local_endpoint} 14 | let(:index) {1} 15 | let(:protocol) {subject.new(index)} 16 | let(:client) {Async::Redis::Client.new(endpoint, protocol: protocol)} 17 | 18 | it "can select a specific database" do 19 | response = client.client_info 20 | expect(response[:db].to_i).to be == index 21 | end 22 | end 23 | --------------------------------------------------------------------------------