├── .github ├── dependabot.yml └── workflows │ ├── lint.yml │ └── tests.yml ├── .gitignore ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── dalli-elasticache.gemspec ├── lib ├── dalli-elasticache.rb └── dalli │ ├── elasticache.rb │ └── elasticache │ ├── auto_discovery │ ├── base_command.rb │ ├── config_command.rb │ ├── config_response.rb │ ├── endpoint.rb │ ├── node.rb │ ├── stats_command.rb │ └── stats_response.rb │ └── version.rb └── spec ├── config_command_spec.rb ├── config_response_spec.rb ├── elasticache_spec.rb ├── endpoint_spec.rb ├── node_spec.rb ├── spec_helper.rb ├── stats_command_spec.rb └── stats_response_spec.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | name: Lint - Ruby 2.6 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Set up Ruby 2.6 12 | uses: ruby/setup-ruby@v1 13 | with: 14 | ruby-version: 2.6 15 | bundler-cache: true # 'bundle install' and cache 16 | - name: Run Rubocop 17 | run: bundle exec rubocop 18 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | ruby-version: [2.6, 2.7, '3.0', 3.1, 3.2, 3.3, jruby-9.3, ruby-head, jruby-head] 12 | 13 | name: Specs - Ruby ${{ matrix.ruby-version }} 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Ruby ${{ matrix.ruby-version }} 17 | uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ${{ matrix.ruby-version }} 20 | bundler-cache: true # 'bundle install' and cache 21 | - name: Run tests 22 | run: bundle exec rake 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .ruby-version 6 | coverage 7 | InstalledFiles 8 | lib/bundler/man 9 | pkg 10 | rdoc 11 | spec/reports 12 | test/tmp 13 | test/version_tmp 14 | tmp 15 | 16 | # YARD artifacts 17 | .yardoc 18 | _yardoc 19 | doc/ 20 | 21 | Gemfile.lock 22 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-performance 3 | - rubocop-rake 4 | - rubocop-rspec 5 | 6 | AllCops: 7 | NewCops: enable 8 | TargetRubyVersion: 2.6 9 | 10 | Metrics/BlockLength: 11 | Max: 50 12 | Exclude: 13 | - 'spec/**/*' 14 | 15 | Naming/FileName: 16 | Exclude: 17 | - 'lib/dalli-elasticache.rb' 18 | 19 | Style/Documentation: 20 | Exclude: 21 | - 'spec/**/*' 22 | 23 | RSpec/ExampleLength: 24 | Enabled: false 25 | 26 | RSpec/IndexedLet: 27 | Enabled: false 28 | 29 | RSpec/MultipleExpectations: 30 | Enabled: false 31 | 32 | RSpec/MultipleMemoizedHelpers: 33 | Enabled: false 34 | 35 | RSpec/NestedGroups: 36 | Enabled: false 37 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.0.0] 9 | 10 | ### Fixed 11 | 12 | - Loading error because of incorrect path in a require_relative. [@petervandoros](https://github.com/petervandoros) 13 | 14 | ## [1.0.0] 15 | 16 | ### Added 17 | 18 | - Rubocop linting. [@petergoldstein](https://github.com/petergoldstein) 19 | - Specs for reading data off the socket, to ensure that code functions as expected. [@petergoldstein](https://github.com/petergoldstein) 20 | - This CHANGELOG.md. [@petergoldstein](https://github.com/petergoldstein) 21 | - Support for Google Cloud MemoryStore. [@petergoldstein](https://github.com/petergoldstein) 22 | 23 | ### Fixed 24 | 25 | - Parsing error when retrieving the version. Version string is now parsed correctly. [@petergoldstein](https://github.com/petergoldstein) 26 | - Library would error when the engine version was non-numeric or included additional data. Now treats such situations as a "modern" engine version. [@petergoldstein](https://github.com/petergoldstein) 27 | 28 | ### Changed 29 | 30 | - BREAKING: engine_version is now returned as a string rather than a Gem::Version to support potentially non-numeric versions. [@petergoldstein](https://github.com/petergoldstein) 31 | - Updated README to reflect deprecation of DalliStore and preferred use of MemCacheStore. [@xiaoronglv](https://github.com/xiaoronglv) 32 | - Switched to GitHub Actions from Travis for CI. Added Ruby 2.6, 2.7, 3.0, 3.1, ruby-head, jruby-9.3, jruby-head. [@petergoldstein](https://github.com/petergoldstein) 33 | - Dalli::Elasticache now raises an ArgumentError if it cannot parse the config endpoint argument. [@petergoldstein](https://github.com/petergoldstein) 34 | - Now use default port of 11211 for configuration endpoint when not explicitly specified. [@petergoldstein](https://github.com/petergoldstein) 35 | - Refactored internal classes to better enable testing, shrink individual class responsibilities. [@petergoldstein](https://github.com/petergoldstein) 36 | - Allow underscores in hostnames. 37 | 38 | ### Removed 39 | 40 | - Support for all Rubies before 2.6 was dropped. [@petergoldstein](https://github.com/petergoldstein) 41 | 42 | 43 | ## [0.2.0] - 2016-02-24 44 | 45 | ### Changed 46 | 47 | - Node connections now use hostnames as opposed to IPs (which may change over time). [@BanjoInc](https://github.com/BanjoInc) 48 | - Ruby 2.2 and 2.3 was added to CI. [@ktheory](https://github.com/ktheory) 49 | 50 | ### Removed 51 | 52 | - Support for Ruby 1.9.2 and 1.9.3 was dropped. [@ktheory](https://github.com/ktheory) 53 | 54 | ## [0.1.2] - 2014-07-08 55 | 56 | ### Changed 57 | 58 | - Added Ruby 2.0 and 2.1 to CI. [@petergoldstein](https://github.com/petergoldstein) 59 | 60 | ### Fixed 61 | 62 | - Addressed NameError on refresh. [@ryo0301](https://github.com/ryo0301) 63 | 64 | ## [0.1.1] - 2014-05-03 65 | 66 | ### Added 67 | 68 | - Ability to retrieve configuration version (indication of how many times the node set has changed) from endpoint. [@zmillman](https://github.com/zmillman) 69 | - Specs for existing functionality. [@zmillman](https://github.com/zmillman) 70 | - Continuous Integration using Travis CI. [@zmillman](https://github.com/zmillman) 71 | - Refresh capability for the node set. [@zmillman](https://github.com/zmillman) 72 | 73 | ### Changed 74 | 75 | - Refactoring and repackaging of the endpoint classes. [@zmillman](https://github.com/zmillman) 76 | 77 | 78 | ## [0.1.0] - 2013-01-27 79 | 80 | ### Added 81 | 82 | - Initial implementation for fetching node addresses from an Amazon ElastiCache endpoint. [@ktheory](https://github.com/ktheory) 83 | 84 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | gemspec 5 | 6 | group :development, :test do 7 | gem 'faker', '~> 2.19' 8 | gem 'rake', '>= 13.0' 9 | gem 'rspec', '~> 3.10' 10 | gem 'rubocop', '~> 1.0' 11 | gem 'rubocop-performance' 12 | gem 'rubocop-rake' 13 | gem 'rubocop-rspec' 14 | end 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (2017-2022) Aaron Suggs, Peter M. Goldstein 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Dalli ElastiCache [![Gem Version](https://badge.fury.io/rb/dalli-elasticache.svg)](http://badge.fury.io/rb/dalli-elasticache) [![Build Status](https://github.com/ktheory/dalli-elasticache/actions/workflows/tests.yml/badge.svg)](https://github.com/ktheory/dalli-elasticache/actions/workflows/tests.yml) [![Code Climate](https://codeclimate.com/github/ktheory/dalli-elasticache.png)](https://codeclimate.com/github/ktheory/dalli-elasticache) 2 | ================= 3 | 4 | Use [AWS ElastiCache AutoDiscovery](https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/AutoDiscovery.html) or [Google Cloud MemoryStore Auto Discovery](https://cloud.google.com/memorystore/docs/memcached/using-auto-discovery) to automatically configure your [Dalli memcached client](https://github.com/petergoldstein/dalli) with all the nodes in your cluster. 5 | 6 | Installation 7 | ------------ 8 | 9 | Install the [gem](https://rubygems.org/gems/dalli-elasticache): 10 | 11 | ```ruby 12 | # in your Gemfile 13 | gem 'dalli-elasticache' 14 | ``` 15 | 16 | Using Dalli Elasticache in Rails 17 | --------------------------------- 18 | 19 | Note that the list of memcached servers used by Rails will be refreshed each time an app server process starts. If the list of nodes in your cluster changes, this configuration will not be reflected in the Rails configuraiton without such a server process restart. 20 | 21 | ### Configuring a Cache Store 22 | 23 | The most common use of Dalli in Rails is to support a cache store. To set up your cache store with a cluster, you'll need to generate the list of servers with Dalli ElastiCache and pass them to the `cache_store` configuration. This needs to be done in your `config/environments/RAILS_ENV.rb` file for each Rails environment where you want to use a cluster. 24 | 25 | ```ruby 26 | # in config/environments/production.rb 27 | endpoint = "my-cluster-name.abc123.cfg.use1.cache.amazonaws.com:11211" 28 | elasticache = Dalli::ElastiCache.new(endpoint) 29 | 30 | config.cache_store = :mem_cache_store, elasticache.servers, { expires_in: 1.day } 31 | ``` 32 | ### Configuring a Session Store 33 | 34 | Another use of Dalli in Rails is to support a Rails session store. Dalli ElastiCache can also be used in this case. The usage is very similar - first use Dalli ElastiCache to generate the list of servers, and then pass that result to the Rails configuration. In `config/application.rb` you would write: 35 | 36 | ```ruby 37 | # in config/environments/production.rb 38 | endpoint = "my-cluster-name.abc123.cfg.use1.cache.amazonaws.com:11211" 39 | elasticache = Dalli::ElastiCache.new(endpoint) 40 | 41 | config.session_store = :mem_cache_store, memcache_server: elasticache.servers, pool_size: 10, pool_timeout: 5, expire_after: 1.day 42 | ``` 43 | 44 | ### Dalli Considerations 45 | 46 | Please see [here](https://github.com/petergoldstein/dalli/wiki/Using-Dalli-with-Rails) for more information on configuring Dalli and Rails. 47 | 48 | 49 | Using Dalli ElastiCache with a Dalli Client 50 | ------------ 51 | 52 | To initialize a Dalli Client for all the nodes of a cluster, one simply needs to pass the configuration endpoint and any options for the Dalli Client into the `Dalli::ElastiCache` initializer. Then one can use the methods on the `Dalli::ElastiCache` object to generate an appropriately configured `Dalli::Client`or to get information about the cluster. 53 | 54 | ```ruby 55 | config_endpoint = "aaron-scratch.vfdnac.cfg.use1.cache.amazonaws.com:11211" 56 | 57 | # Options for configuring the Dalli::Client 58 | dalli_options = { 59 | expires_in: 24 * 60 * 60, 60 | namespace: "my_app" 61 | } 62 | 63 | elasticache = Dalli::ElastiCache.new(config_endpoint, dalli_options) 64 | ``` 65 | 66 | Fetch information about the Memcached nodes: 67 | 68 | ```ruby 69 | # Dalli::Client with configuration from the AutoDiscovery endpoint 70 | elasticache.client 71 | # => # 72 | 73 | # Node addresses 74 | elasticache.servers 75 | # => ["aaron-scratch.vfdnac.0001.use1.cache.amazonaws.com:11211", "aaron-scratch.vfdnac.0002.use1.cache.amazonaws.com:11211"] 76 | 77 | # Number of times the cluster configuration has changed 78 | elasticache.version 79 | # => 12 80 | 81 | # Memcached version of the cluster 82 | elasticache.engine_version 83 | # => "1.4.14" 84 | 85 | # Refresh data from the endpoint 86 | elasticache.refresh 87 | 88 | # Refresh and get client with new configuration 89 | elasticache.refresh.client 90 | ``` 91 | 92 | License 93 | ------- 94 | 95 | Copyright (2017-2022) Aaron Suggs, Peter M. Goldstein. See LICENSE for details. 96 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubygems' 4 | require 'bundler/setup' 5 | require 'bundler/gem_tasks' 6 | require 'rspec/core/rake_task' 7 | 8 | RSpec::Core::RakeTask.new(:test) 9 | 10 | task default: :test 11 | -------------------------------------------------------------------------------- /dalli-elasticache.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/dalli/elasticache/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'dalli-elasticache' 7 | s.version = Dalli::ElastiCache::VERSION 8 | s.licenses = ['MIT'] 9 | 10 | s.summary = "Configure Dalli clients with ElastiCache's AutoDiscovery" 11 | s.description = <<-DESC 12 | This gem provides an interface for fetching cluster information from an AWS 13 | ElastiCache AutoDiscovery server and configuring a Dalli client to connect 14 | to all nodes in the cache cluster. 15 | DESC 16 | 17 | s.authors = ['Aaron Suggs', 'Zach Millman', 'Peter M. Goldstein'] 18 | s.email = ['aaron@ktheory.com', 'zach@magoosh.com', 'peter.m.goldstein@gmail.com'] 19 | s.homepage = 'http://github.com/ktheory/dalli-elasticache' 20 | 21 | s.files = Dir.glob('{bin,lib}/**/*') + %w[README.md Rakefile] 22 | s.rdoc_options = ['--charset=UTF-8'] 23 | s.require_paths = ['lib'] 24 | 25 | s.required_ruby_version = '>= 2.6.0' 26 | s.required_rubygems_version = '>= 1.3.5' 27 | 28 | s.add_dependency 'dalli', '>= 2.0.0' 29 | s.metadata['rubygems_mfa_required'] = 'true' 30 | end 31 | -------------------------------------------------------------------------------- /lib/dalli-elasticache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Support default bundler require path 4 | require_relative 'dalli/elasticache' 5 | -------------------------------------------------------------------------------- /lib/dalli/elasticache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dalli' 4 | require 'socket' 5 | require_relative 'elasticache/version' 6 | require_relative 'elasticache/auto_discovery/endpoint' 7 | require_relative 'elasticache/auto_discovery/base_command' 8 | require_relative 'elasticache/auto_discovery/node' 9 | require_relative 'elasticache/auto_discovery/config_response' 10 | require_relative 'elasticache/auto_discovery/config_command' 11 | require_relative 'elasticache/auto_discovery/stats_response' 12 | require_relative 'elasticache/auto_discovery/stats_command' 13 | 14 | module Dalli 15 | ## 16 | # Dalli::Elasticache provides an interface for providing a configuration 17 | # endpoint for a memcached cluster on ElasticCache and retrieving the 18 | # list of addresses (hostname and port) for the individual nodes of that cluster. 19 | # 20 | # This allows the caller to pass that server list to Dalli, which then 21 | # distributes cached items consistently over the nodes. 22 | ## 23 | class ElastiCache 24 | attr_reader :endpoint, :options 25 | 26 | ## 27 | # Creates a new Dalli::ElasticCache instance. 28 | # 29 | # config_endpoint - a String containing the host and (optionally) port of the 30 | # configuration endpoint for the cluster. If not specified the port will 31 | # default to 11211. The host must be either a DNS name or an IPv4 address. IPv6 32 | # addresses are not handled at this time. 33 | # dalli_options - a set of options passed to the Dalli::Client that is returned 34 | # by the client method. Otherwise unused. 35 | ## 36 | def initialize(config_endpoint, dalli_options = {}) 37 | @endpoint = Dalli::Elasticache::AutoDiscovery::Endpoint.new(config_endpoint) 38 | @options = dalli_options 39 | end 40 | 41 | # Dalli::Client configured to connect to the cluster's nodes 42 | def client 43 | Dalli::Client.new(servers, options) 44 | end 45 | 46 | # The number of times the cluster configuration has been changed 47 | # 48 | # Returns an integer 49 | def version 50 | endpoint.config.version 51 | end 52 | 53 | # The cache engine version of the cluster 54 | # 55 | # Returns a string 56 | def engine_version 57 | endpoint.engine_version 58 | end 59 | 60 | # List of cluster server nodes with ip addresses and ports 61 | # Always use host name instead of private elasticache IPs as internal IPs can change after a node is rebooted 62 | def servers 63 | endpoint.config.nodes.map(&:to_s) 64 | end 65 | 66 | # Clear all cached data from the cluster endpoint 67 | def refresh 68 | config_endpoint = "#{endpoint.host}:#{endpoint.port}" 69 | @endpoint = Dalli::Elasticache::AutoDiscovery::Endpoint.new(config_endpoint) 70 | 71 | self 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/dalli/elasticache/auto_discovery/base_command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dalli 4 | module Elasticache 5 | module AutoDiscovery 6 | ## 7 | # Base command class for configuration endpoint 8 | # command. Contains the network logic. 9 | ## 10 | class BaseCommand 11 | attr_reader :host, :port 12 | 13 | def initialize(host, port) 14 | @host = host 15 | @port = port 16 | end 17 | 18 | # Send an ASCII command to the endpoint 19 | # 20 | # Returns the raw response as a String 21 | def send_command 22 | socket = TCPSocket.new(@host, @port) 23 | begin 24 | socket.puts command 25 | response_from_socket(socket) 26 | ensure 27 | socket.close 28 | end 29 | end 30 | 31 | def response_from_socket(socket) 32 | data = +'' 33 | until (line = socket.readline).include?('END') 34 | data << line 35 | end 36 | 37 | data 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/dalli/elasticache/auto_discovery/config_command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dalli 4 | module Elasticache 5 | module AutoDiscovery 6 | ## 7 | # Encapsulates execution of the 'config' command, which is used to 8 | # extract the list of nodes and determine if that list of nodes has changed. 9 | ## 10 | class ConfigCommand < BaseCommand 11 | attr_reader :engine_version 12 | 13 | CONFIG_COMMAND = "config get cluster\r\n" 14 | 15 | # Legacy command for version < 1.4.14 16 | LEGACY_CONFIG_COMMAND = "get AmazonElastiCache:cluster\r\n" 17 | 18 | def initialize(host, port, engine_version) 19 | super(host, port) 20 | @engine_version = engine_version 21 | end 22 | 23 | def response 24 | ConfigResponse.new(send_command) 25 | end 26 | 27 | def command 28 | return LEGACY_CONFIG_COMMAND if legacy_config? 29 | 30 | CONFIG_COMMAND 31 | end 32 | 33 | def legacy_config? 34 | return false unless engine_version 35 | return false if engine_version.casecmp('unknown').zero? 36 | 37 | Gem::Version.new(engine_version) < Gem::Version.new('1.4.14') 38 | rescue ArgumentError 39 | # Just assume false if we can't parse the engine_version 40 | false 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/dalli/elasticache/auto_discovery/config_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dalli 4 | module Elasticache 5 | module AutoDiscovery 6 | # This class wraps the raw ASCII response from an Auto Discovery endpoint 7 | # and provides methods for extracting data from that response. 8 | # 9 | # http://docs.aws.amazon.com/AmazonElastiCache/latest/UserGuide/AutoDiscovery.AddingToYourClientLibrary.html 10 | class ConfigResponse 11 | # The raw response text 12 | attr_reader :text 13 | 14 | # Matches the version line of the response 15 | VERSION_REGEX = /^(\d+)\r?\n/.freeze 16 | 17 | # Matches strings like "my-cluster.001.cache.aws.com|10.154.182.29|11211" 18 | NODE_REGEX = /(([-.a-zA-Z0-9]+)\|(\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b)\|(\d+))/.freeze 19 | NODE_LIST_REGEX = /^(#{NODE_REGEX}\s*)+$/.freeze 20 | 21 | def initialize(response_text) 22 | @text = response_text.to_s 23 | end 24 | 25 | # The number of times the configuration has been changed 26 | # 27 | # Returns an integer 28 | def version 29 | m = VERSION_REGEX.match(@text) 30 | return -1 unless m 31 | 32 | m[1].to_i 33 | end 34 | 35 | # Node hosts, ip addresses, and ports 36 | # 37 | # Returns an Array of Hashes with values for :host, :ip and :port 38 | def nodes 39 | NODE_LIST_REGEX.match(@text).to_s.scan(NODE_REGEX).map do |match| 40 | Node.new(match[1], match[2], match[3].to_i) 41 | end 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/dalli/elasticache/auto_discovery/endpoint.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dalli 4 | module Elasticache 5 | module AutoDiscovery 6 | ## 7 | # This is a representation of the configuration endpoint for 8 | # a memcached cluster. It encapsulates information returned from 9 | # that endpoint. 10 | ## 11 | class Endpoint 12 | # Endpoint configuration 13 | attr_reader :host, :port 14 | 15 | # Matches Strings like "my-host.cache.aws.com:11211" 16 | ENDPOINT_REGEX = /^([-_.a-zA-Z0-9]+)(?::(\d+))?$/.freeze 17 | 18 | def initialize(addr) 19 | @host, @port = parse_endpoint_address(addr) 20 | end 21 | 22 | DEFAULT_PORT = 11_211 23 | def parse_endpoint_address(addr) 24 | m = ENDPOINT_REGEX.match(addr) 25 | raise ArgumentError, "Unable to parse configuration endpoint address - #{addr}" unless m 26 | 27 | [m[1], (m[2] || DEFAULT_PORT).to_i] 28 | end 29 | 30 | # A cached ElastiCache::StatsResponse 31 | def stats 32 | @stats ||= StatsCommand.new(@host, @port).response 33 | end 34 | 35 | # A cached ElastiCache::ConfigResponse 36 | def config 37 | @config ||= ConfigCommand.new(@host, @port, engine_version).response 38 | end 39 | 40 | # The memcached engine version 41 | def engine_version 42 | stats.engine_version 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/dalli/elasticache/auto_discovery/node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dalli 4 | module Elasticache 5 | module AutoDiscovery 6 | ## 7 | # Represents a single memcached node in the 8 | # cluster. 9 | ## 10 | class Node 11 | attr_reader :host, :ip, :port 12 | 13 | def initialize(host, ip, port) 14 | @host = host 15 | @ip = ip 16 | @port = port 17 | end 18 | 19 | def ==(other) 20 | host == other.host && 21 | ip == other.ip && 22 | port == other.port 23 | end 24 | 25 | def eql?(other) 26 | self == other 27 | end 28 | 29 | def hash 30 | [host, ip, port].hash 31 | end 32 | 33 | def to_s 34 | "#{@host}:#{@port}" 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/dalli/elasticache/auto_discovery/stats_command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dalli 4 | module Elasticache 5 | module AutoDiscovery 6 | ## 7 | # Encapsulates execution of the 'stats' command, which is used to 8 | # extract the engine_version 9 | ## 10 | class StatsCommand < BaseCommand 11 | STATS_COMMAND = "stats\r\n" 12 | 13 | def response 14 | StatsResponse.new(send_command) 15 | end 16 | 17 | def command 18 | STATS_COMMAND 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/dalli/elasticache/auto_discovery/stats_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dalli 4 | module Elasticache 5 | module AutoDiscovery 6 | # This class wraps the raw ASCII response from a stats call to an 7 | # Auto Discovery endpoint and provides methods for extracting data 8 | # from that response. 9 | # 10 | # http://docs.aws.amazon.com/AmazonElastiCache/latest/UserGuide/AutoDiscovery.AddingToYourClientLibrary.html 11 | class StatsResponse 12 | # The raw response text 13 | attr_reader :text 14 | 15 | # Matches the version line of the response 16 | VERSION_REGEX = /^STAT version ([0-9.]+|unknown)\s*/i.freeze 17 | 18 | def initialize(response_text) 19 | @text = response_text.to_s 20 | end 21 | 22 | # Extract the engine version stat 23 | # 24 | # Returns a string 25 | def engine_version 26 | m = VERSION_REGEX.match(@text) 27 | return '' unless m && m[1] 28 | 29 | m[1] 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/dalli/elasticache/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dalli 4 | class ElastiCache 5 | VERSION = '1.0.1' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/config_command_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'spec_helper' 4 | 5 | describe 'Dalli::Elasticache::AutoDiscovery::ConfigCommand' do 6 | let(:host) { Faker::Internet.domain_name(subdomain: true) } 7 | let(:port) { rand(1024..16_023) } 8 | let(:command) { Dalli::Elasticache::AutoDiscovery::ConfigCommand.new(host, port, engine_version) } 9 | 10 | let(:socket_response_lines) do 11 | [ 12 | "CONFIG cluster 0 142\r\n", 13 | "12\r\n", 14 | 'mycluster.0001.cache.amazonaws.com|10.112.21.1|11211 ' \ 15 | 'mycluster.0002.cache.amazonaws.com|10.112.21.2|11211 ' \ 16 | "mycluster.0003.cache.amazonaws.com|10.112.21.3|11211\r\n", 17 | "\r\n", 18 | "END\r\n" 19 | ] 20 | end 21 | 22 | let(:expected_nodes) do 23 | [ 24 | Dalli::Elasticache::AutoDiscovery::Node.new('mycluster.0001.cache.amazonaws.com', 25 | '10.112.21.1', 26 | 11_211), 27 | Dalli::Elasticache::AutoDiscovery::Node.new('mycluster.0002.cache.amazonaws.com', 28 | '10.112.21.2', 29 | 11_211), 30 | Dalli::Elasticache::AutoDiscovery::Node.new('mycluster.0003.cache.amazonaws.com', 31 | '10.112.21.3', 32 | 11_211) 33 | ] 34 | end 35 | 36 | let(:mock_socket) { instance_double(TCPSocket) } 37 | 38 | before do 39 | allow(TCPSocket).to receive(:new).with(host, port).and_return(mock_socket) 40 | allow(mock_socket).to receive(:close) 41 | allow(mock_socket).to receive(:puts).with(cmd) 42 | allow(mock_socket).to receive(:readline).and_return(*socket_response_lines) 43 | end 44 | 45 | context 'when the engine_version is 1.4.5' do 46 | let(:engine_version) { '1.4.5' } # This is the only pre-1.4.14 version available on AWS 47 | let(:cmd) { Dalli::Elasticache::AutoDiscovery::ConfigCommand::LEGACY_CONFIG_COMMAND } 48 | 49 | context 'when the socket returns a valid response' do 50 | before do 51 | allow(mock_socket).to receive(:readline).and_return(*socket_response_lines) 52 | end 53 | 54 | it 'sends the legacy command and returns a ConfigResponse with expected values' do 55 | response = command.response 56 | expect(response).to be_a Dalli::Elasticache::AutoDiscovery::ConfigResponse 57 | expect(response.version).to eq(12) 58 | expect(response.nodes).to eq(expected_nodes) 59 | expect(mock_socket).to have_received(:close) 60 | end 61 | end 62 | end 63 | 64 | context 'when the engine_version is greater than or equal to 1.4.14' do 65 | let(:engine_version) { ['1.4.14', '1.5.6', '1.6.10'].sample } 66 | let(:cmd) { Dalli::Elasticache::AutoDiscovery::ConfigCommand::CONFIG_COMMAND } 67 | 68 | context 'when the socket returns a valid response' do 69 | before do 70 | allow(mock_socket).to receive(:readline).and_return(*socket_response_lines) 71 | end 72 | 73 | it 'sends the current command and returns a ConfigResponse with expected values' do 74 | response = command.response 75 | expect(response).to be_a Dalli::Elasticache::AutoDiscovery::ConfigResponse 76 | expect(response.version).to eq(12) 77 | expect(response.nodes).to eq(expected_nodes) 78 | expect(mock_socket).to have_received(:close) 79 | end 80 | end 81 | end 82 | 83 | context 'when the engine_version is UNKNOWN or some other string' do 84 | let(:engine_version) { ['UNKNOWN', SecureRandom.hex(4), nil].sample } 85 | let(:cmd) { Dalli::Elasticache::AutoDiscovery::ConfigCommand::CONFIG_COMMAND } 86 | 87 | context 'when the socket returns a valid response' do 88 | before do 89 | allow(mock_socket).to receive(:readline).and_return(*socket_response_lines) 90 | end 91 | 92 | it 'sends the current command and returns a ConfigResponse with expected values' do 93 | response = command.response 94 | expect(response).to be_a Dalli::Elasticache::AutoDiscovery::ConfigResponse 95 | expect(response.version).to eq(12) 96 | expect(response.nodes).to eq(expected_nodes) 97 | expect(mock_socket).to have_received(:close) 98 | end 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /spec/config_response_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'spec_helper' 4 | 5 | describe 'Dalli::Elasticache::AutoDiscovery::ConfigResponse' do 6 | let :response do 7 | text = "CONFIG cluster 0 141\r\n12\nmycluster.0001.cache.amazonaws.com|10.112.21.1|11211 " \ 8 | 'mycluster.0002.cache.amazonaws.com|10.112.21.2|11211 ' \ 9 | "mycluster.0003.cache.amazonaws.com|10.112.21.3|11211\n\r\n" 10 | Dalli::Elasticache::AutoDiscovery::ConfigResponse.new(text) 11 | end 12 | 13 | describe '#version' do 14 | it 'parses version' do 15 | expect(response.version).to eq 12 16 | end 17 | end 18 | 19 | describe '#nodes' do 20 | it 'parses hosts' do 21 | expect(response.nodes.map(&:host)).to eq [ 22 | 'mycluster.0001.cache.amazonaws.com', 23 | 'mycluster.0002.cache.amazonaws.com', 24 | 'mycluster.0003.cache.amazonaws.com' 25 | ] 26 | end 27 | 28 | it 'parses ip addresses' do 29 | expect(response.nodes.map(&:ip)).to eq ['10.112.21.1', '10.112.21.2', '10.112.21.3'] 30 | end 31 | 32 | it 'parses ports' do 33 | expect(response.nodes.map(&:port)).to eq [11_211, 11_211, 11_211] 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/elasticache_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'spec_helper' 4 | 5 | describe 'Dalli::ElastiCache::Endpoint' do 6 | let(:dalli_options) do 7 | { 8 | expires_in: 24 * 60 * 60, 9 | namespace: 'my_app', 10 | compress: true 11 | } 12 | end 13 | 14 | let(:host) { 'my-cluster.cfg.use1.cache.amazonaws.com' } 15 | let(:port) { 11_211 } 16 | let(:config_endpoint) { "#{host}:#{port}" } 17 | let(:cache) do 18 | Dalli::ElastiCache.new(config_endpoint, dalli_options) 19 | end 20 | 21 | let(:config_text) do 22 | "CONFIG cluster 0 141\r\n12\nmycluster.0001.cache.amazonaws.com|10.112.21.1|11211 " \ 23 | 'mycluster.0002.cache.amazonaws.com|10.112.21.2|11211 ' \ 24 | "mycluster.0003.cache.amazonaws.com|10.112.21.3|11211\n\r\n" 25 | end 26 | let(:response) { Dalli::Elasticache::AutoDiscovery::ConfigResponse.new(config_text) } 27 | 28 | describe '.new' do 29 | it 'builds endpoint' do 30 | expect(cache.endpoint.host).to eq 'my-cluster.cfg.use1.cache.amazonaws.com' 31 | expect(cache.endpoint.port).to eq 11_211 32 | end 33 | 34 | it 'stores Dalli options' do 35 | expect(cache.options[:expires_in]).to eq 24 * 60 * 60 36 | expect(cache.options[:namespace]).to eq 'my_app' 37 | expect(cache.options[:compress]).to be true 38 | end 39 | end 40 | 41 | describe '#client' do 42 | let(:client) { cache.client } 43 | let(:stub_endpoint) { Dalli::Elasticache::AutoDiscovery::Endpoint.new(config_endpoint) } 44 | let(:mock_dalli) { instance_double(Dalli::Client) } 45 | 46 | before do 47 | allow(Dalli::Elasticache::AutoDiscovery::Endpoint).to receive(:new) 48 | .with(config_endpoint).and_return(stub_endpoint) 49 | allow(stub_endpoint).to receive(:config).and_return(response) 50 | allow(Dalli::Client).to receive(:new) 51 | .with(['mycluster.0001.cache.amazonaws.com:11211', 52 | 'mycluster.0002.cache.amazonaws.com:11211', 53 | 'mycluster.0003.cache.amazonaws.com:11211'], 54 | dalli_options).and_return(mock_dalli) 55 | end 56 | 57 | it 'builds with node list and dalli options' do 58 | expect(client).to eq(mock_dalli) 59 | expect(stub_endpoint).to have_received(:config) 60 | expect(Dalli::Client).to have_received(:new) 61 | .with(['mycluster.0001.cache.amazonaws.com:11211', 62 | 'mycluster.0002.cache.amazonaws.com:11211', 63 | 'mycluster.0003.cache.amazonaws.com:11211'], 64 | dalli_options) 65 | end 66 | end 67 | 68 | describe '#servers' do 69 | let(:stub_endpoint) { Dalli::Elasticache::AutoDiscovery::Endpoint.new(config_endpoint) } 70 | 71 | before do 72 | allow(Dalli::Elasticache::AutoDiscovery::Endpoint).to receive(:new) 73 | .with(config_endpoint).and_return(stub_endpoint) 74 | allow(stub_endpoint).to receive(:config).and_return(response) 75 | end 76 | 77 | it 'lists addresses and ports' do 78 | expect(cache.servers).to eq ['mycluster.0001.cache.amazonaws.com:11211', 79 | 'mycluster.0002.cache.amazonaws.com:11211', 80 | 'mycluster.0003.cache.amazonaws.com:11211'] 81 | expect(stub_endpoint).to have_received(:config) 82 | expect(Dalli::Elasticache::AutoDiscovery::Endpoint).to have_received(:new).with(config_endpoint) 83 | end 84 | end 85 | 86 | describe '#version' do 87 | let(:mock_config) { instance_double(Dalli::Elasticache::AutoDiscovery::ConfigResponse) } 88 | let(:version) { rand(1..20) } 89 | 90 | before do 91 | allow(cache.endpoint).to receive(:config).and_return(mock_config) 92 | allow(mock_config).to receive(:version).and_return(version) 93 | end 94 | 95 | it 'delegates the call to the config on the endpoint' do 96 | expect(cache.version).to eq(version) 97 | expect(cache.endpoint).to have_received(:config) 98 | expect(mock_config).to have_received(:version) 99 | end 100 | end 101 | 102 | describe '#engine_version' do 103 | let(:engine_version) { [Gem::Version.new('1.6.13'), Gem::Version.new('1.4.14')].sample } 104 | 105 | before do 106 | allow(cache.endpoint).to receive(:engine_version).and_return(engine_version) 107 | end 108 | 109 | it 'delegates the call to the endpoint' do 110 | expect(cache.engine_version).to eq(engine_version) 111 | expect(cache.endpoint).to have_received(:engine_version) 112 | end 113 | end 114 | 115 | describe '#refresh' do 116 | it 'clears endpoint configuration' do 117 | stale_endpoint = cache.endpoint 118 | expect(cache.refresh.endpoint).not_to eq stale_endpoint 119 | end 120 | 121 | it 'builds endpoint with same configuration' do 122 | stale_endpoint = cache.endpoint 123 | cache.refresh 124 | expect(cache.endpoint.host).to eq(stale_endpoint.host) 125 | expect(cache.endpoint.port).to eq(stale_endpoint.port) 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /spec/endpoint_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'spec_helper' 4 | 5 | describe 'Dalli::Elasticache::AutoDiscovery::Endpoint' do 6 | let(:endpoint) do 7 | Dalli::Elasticache::AutoDiscovery::Endpoint.new(arg_string) 8 | end 9 | 10 | describe '.new' do 11 | context 'when the string includes both host and port' do 12 | let(:arg_string) { 'my-cluster.cfg.use1.cache.amazonaws.com:12345' } 13 | 14 | it 'parses host' do 15 | expect(endpoint.host).to eq 'my-cluster.cfg.use1.cache.amazonaws.com' 16 | end 17 | 18 | it 'parses port' do 19 | expect(endpoint.port).to eq 12_345 20 | end 21 | end 22 | 23 | context 'when the string includes only a host' do 24 | let(:arg_string) { 'example.cfg.use1.cache.amazonaws.com' } 25 | 26 | it 'parses host' do 27 | expect(endpoint.host).to eq 'example.cfg.use1.cache.amazonaws.com' 28 | end 29 | 30 | it 'parses port' do 31 | expect(endpoint.port).to eq 11_211 32 | end 33 | end 34 | 35 | context 'when the string is nil' do 36 | let(:arg_string) { nil } 37 | 38 | it 'raises ArgumentError' do 39 | expect do 40 | endpoint 41 | end.to raise_error ArgumentError, "Unable to parse configuration endpoint address - #{arg_string}" 42 | end 43 | end 44 | 45 | context 'when the string contains disallowed characters in the host' do 46 | let(:arg_string) { 'my-cluster?.cfg.use1.cache.amazonaws.com:12345' } 47 | 48 | it 'raises ArgumentError' do 49 | expect do 50 | endpoint 51 | end.to raise_error ArgumentError, "Unable to parse configuration endpoint address - #{arg_string}" 52 | end 53 | end 54 | 55 | context 'when the string contains disallowed characters in the port' do 56 | let(:arg_string) { 'my-cluster.cfg.use1.cache.amazonaws.com:1234a5' } 57 | 58 | it 'raises ArgumentError' do 59 | expect do 60 | endpoint 61 | end.to raise_error ArgumentError, "Unable to parse configuration endpoint address - #{arg_string}" 62 | end 63 | end 64 | 65 | context 'when the string contains trailing characters' do 66 | let(:arg_string) { 'my-cluster.cfg.use1.cache.amazonaws.com:12345abcd' } 67 | 68 | it 'raises ArgumentError' do 69 | expect do 70 | endpoint 71 | end.to raise_error ArgumentError, "Unable to parse configuration endpoint address - #{arg_string}" 72 | end 73 | end 74 | 75 | context 'when the host in the string includes an underscore' do 76 | let(:arg_string) { 'my_cluster.cfg.use1.cache.amazonaws.com:12345' } 77 | 78 | it 'parses host' do 79 | expect(endpoint.host).to eq 'my_cluster.cfg.use1.cache.amazonaws.com' 80 | end 81 | 82 | it 'parses port' do 83 | expect(endpoint.port).to eq 12_345 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/node_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'spec_helper' 4 | 5 | describe 'Dalli::Elasticache::AutoDiscovery::Node' do 6 | context 'when comparing with equals' do 7 | let(:host1) { Faker::Internet.domain_name(subdomain: true) } 8 | let(:ip1) { Faker::Internet.public_ip_v4_address } 9 | let(:port1) { rand(1024..16_023) } 10 | let(:host2) { Faker::Internet.domain_name(subdomain: true) } 11 | let(:ip2) { Faker::Internet.public_ip_v4_address } 12 | let(:port2) { rand(1024..16_023) } 13 | 14 | let(:node1a) { Dalli::Elasticache::AutoDiscovery::Node.new(host1, ip1, port1) } 15 | let(:node1b) { Dalli::Elasticache::AutoDiscovery::Node.new(host1, ip1, port1) } 16 | 17 | let(:node_with_different_host) { Dalli::Elasticache::AutoDiscovery::Node.new(host2, ip1, port1) } 18 | let(:node_with_different_ip) { Dalli::Elasticache::AutoDiscovery::Node.new(host1, ip2, port1) } 19 | let(:node_with_different_port) { Dalli::Elasticache::AutoDiscovery::Node.new(host1, ip1, port2) } 20 | 21 | it 'is equal to a value with the same values' do 22 | expect(node1a).to eq(node1b) 23 | expect(node1a.eql?(node1b)).to be(true) 24 | end 25 | 26 | it 'is not equal to a value with any differing values' do 27 | expect(node1a).not_to eq(node_with_different_host) 28 | expect(node1a.eql?(node_with_different_host)).to be(false) 29 | expect(node1a).not_to eq(node_with_different_ip) 30 | expect(node1a.eql?(node_with_different_ip)).to be(false) 31 | expect(node1a).not_to eq(node_with_different_port) 32 | expect(node1a.eql?(node_with_different_port)).to be(false) 33 | end 34 | end 35 | 36 | context 'when used as a hash key' do 37 | let(:host1) { Faker::Internet.domain_name(subdomain: true) } 38 | let(:ip1) { Faker::Internet.public_ip_v4_address } 39 | let(:port1) { rand(1024..16_023) } 40 | let(:host2) { Faker::Internet.domain_name(subdomain: true) } 41 | let(:ip2) { Faker::Internet.public_ip_v4_address } 42 | let(:port2) { rand(1024..16_023) } 43 | 44 | let(:node1a) { Dalli::Elasticache::AutoDiscovery::Node.new(host1, ip1, port1) } 45 | let(:node1b) { Dalli::Elasticache::AutoDiscovery::Node.new(host1, ip1, port1) } 46 | 47 | let(:node_with_different_host) { Dalli::Elasticache::AutoDiscovery::Node.new(host2, ip1, port1) } 48 | let(:node_with_different_ip) { Dalli::Elasticache::AutoDiscovery::Node.new(host1, ip2, port1) } 49 | let(:node_with_different_port) { Dalli::Elasticache::AutoDiscovery::Node.new(host1, ip1, port2) } 50 | 51 | let(:test_val) { 'abcd' } 52 | let(:test_hash) do 53 | { node1a => test_val } 54 | end 55 | 56 | it 'computes the same hash key' do 57 | expect(node1a.hash).to eq(node1b.hash) 58 | end 59 | 60 | it 'matches when an equivalent object is used' do 61 | expect(test_hash.key?(node1b)).to be(true) 62 | end 63 | 64 | it 'does not match when an non-equivalent object is used' do 65 | expect(test_hash.key?(node_with_different_host)).to be(false) 66 | expect(test_hash.key?(node_with_different_ip)).to be(false) 67 | expect(test_hash.key?(node_with_different_port)).to be(false) 68 | end 69 | end 70 | 71 | describe '#to_s' do 72 | let(:host) { Faker::Internet.domain_name(subdomain: true) } 73 | let(:ip) { Faker::Internet.public_ip_v4_address } 74 | let(:port) { rand(1024..16_023) } 75 | 76 | let(:node) { Dalli::Elasticache::AutoDiscovery::Node.new(host, ip, port) } 77 | 78 | it 'returns the expected string value' do 79 | expect(node.to_s).to eq("#{host}:#{port}") 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | 5 | require 'dalli/elasticache' 6 | require 'securerandom' 7 | require 'faker' 8 | 9 | RSpec.configure 10 | -------------------------------------------------------------------------------- /spec/stats_command_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'spec_helper' 4 | 5 | describe 'Dalli::Elasticache::AutoDiscovery::StatsCommand' do 6 | let(:host) { 'example.com' } 7 | let(:port) { 12_345 } 8 | let(:command) { Dalli::Elasticache::AutoDiscovery::StatsCommand.new(host, port) } 9 | let(:engine_version) { ['1.4.5', '1.4.14', '1.5.6', '1.6.10'].sample } 10 | let(:cmd) { "stats\r\n" } 11 | 12 | let(:socket_response_lines) do 13 | [ 14 | "STAT pid 1\r\n", 15 | "STAT uptime 68717\r\n", 16 | "STAT time 1398885375\r\n", 17 | "STAT version #{engine_version}\r\n", 18 | "STAT libevent 1.4.13-stable\r\n", 19 | "STAT pointer_size 64\r\n", 20 | "STAT rusage_user 0.136008\r\n", 21 | "STAT rusage_system 0.424026\r\n", 22 | "STAT curr_connections 5\r\n", 23 | "STAT total_connections 1159\r\n", 24 | "STAT connection_structures 6\r\n", 25 | "STAT reserved_fds 5\r\n", 26 | "STAT cmd_get 0\r\n", 27 | "STAT cmd_set 0\r\n", 28 | "STAT cmd_flush 0\r\n", 29 | "STAT cmd_touch 0\r\n", 30 | "STAT cmd_config_get 4582\r\n", 31 | "STAT cmd_config_set 2\r\n", 32 | "STAT get_hits 0\r\n", 33 | "STAT get_misses 0\r\n", 34 | "STAT delete_misses 0\r\n", 35 | "STAT delete_hits 0\r\n", 36 | "STAT incr_misses 0\r\n", 37 | "STAT incr_hits 0\r\n", 38 | "STAT decr_misses 0\r\n", 39 | "STAT decr_hits 0\r\n", 40 | "STAT cas_misses 0\r\n", 41 | "STAT cas_hits 0\r\n", 42 | "STAT cas_badval 0\r\n", 43 | "STAT touch_hits 0\r\n", 44 | "STAT touch_misses 0\r\n", 45 | "STAT auth_cmds 0\r\n", 46 | "STAT auth_errors 0\r\n", 47 | "STAT bytes_read 189356\r\n", 48 | "STAT bytes_written 2906615\r\n", 49 | "STAT limit_maxbytes 209715200\r\n", 50 | "STAT accepting_conns 1\r\n", 51 | "STAT listen_disabled_num 0\r\n", 52 | "STAT threads 1\r\n", 53 | "STAT conn_yields 0\r\n", 54 | "STAT curr_config 1\r\n", 55 | "STAT hash_power_level 16\r\n", 56 | "STAT hash_bytes 524288\r\n", 57 | "STAT hash_is_expanding 0\r\n", 58 | "STAT expired_unfetched 0\r\n", 59 | "STAT evicted_unfetched 0\r\n", 60 | "STAT bytes 0\r\n", 61 | "STAT curr_items 0\r\n", 62 | "STAT total_items 0\r\n", 63 | "STAT evictions 0\r\n", 64 | "STAT reclaimed 0\r\n", 65 | "END\r\n" 66 | ] 67 | end 68 | 69 | let(:mock_socket) { instance_double(TCPSocket) } 70 | 71 | before do 72 | allow(TCPSocket).to receive(:new).with(host, port).and_return(mock_socket) 73 | allow(mock_socket).to receive(:close) 74 | allow(mock_socket).to receive(:puts).with(cmd) 75 | allow(mock_socket).to receive(:readline).and_return(*socket_response_lines) 76 | end 77 | 78 | context 'when the socket returns a valid response' do 79 | before do 80 | allow(mock_socket).to receive(:readline).and_return(*socket_response_lines) 81 | end 82 | 83 | it 'sends the command and parses out the engine version' do 84 | response = command.response 85 | expect(response.engine_version).to eq(engine_version) 86 | expect(mock_socket).to have_received(:close) 87 | expect(mock_socket).to have_received(:puts).with(cmd) 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/stats_response_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'spec_helper' 4 | 5 | describe 'Dalli::Elasticache::AutoDiscovery::StatsResponse' do 6 | let(:response) { Dalli::Elasticache::AutoDiscovery::StatsResponse.new(response_text) } 7 | 8 | describe '#engine_version' do 9 | context 'when the version number is a number' do 10 | let(:engine_version) { ['1.4.14', '1.5.6', '1.6.10'].sample } 11 | 12 | context 'when the response with the version stat only includes the version number' do 13 | let(:response_text) do 14 | "STAT pid 1\r\nSTAT uptime 68717\r\nSTAT time 1398885375\r\n" \ 15 | "STAT version #{engine_version}\r\n" \ 16 | "STAT libevent 1.4.13-stable\r\nSTAT pointer_size 64\r\nSTAT rusage_user 0.136008\r\n" \ 17 | "STAT rusage_system 0.424026\r\nSTAT curr_connections 5\r\nSTAT total_connections 1159\r\n" \ 18 | "STAT connection_structures 6\r\nSTAT reserved_fds 5\r\nSTAT cmd_get 0\r\n" \ 19 | "STAT cmd_set 0\r\nSTAT cmd_flush 0\r\nSTAT cmd_touch 0\r\nSTAT cmd_config_get 4582\r\n" \ 20 | "STAT cmd_config_set 2\r\nSTAT get_hits 0\r\nSTAT get_misses 0\r\nSTAT delete_misses 0\r\n" \ 21 | "STAT delete_hits 0\r\nSTAT incr_misses 0\r\nSTAT incr_hits 0\r\nSTAT decr_misses 0\r\n" \ 22 | "STAT decr_hits 0\r\nSTAT cas_misses 0\r\nSTAT cas_hits 0\r\nSTAT cas_badval 0\r\n" \ 23 | "STAT touch_hits 0\r\nSTAT touch_misses 0\r\nSTAT auth_cmds 0\r\nSTAT auth_errors 0\r\n" \ 24 | "STAT bytes_read 189356\r\nSTAT bytes_written 2906615\r\nSTAT limit_maxbytes 209715200\r\n" \ 25 | "STAT accepting_conns 1\r\nSTAT listen_disabled_num 0\r\nSTAT threads 1\r\n" \ 26 | "STAT conn_yields 0\r\nSTAT curr_config 1\r\nSTAT hash_power_level 16\r\n" \ 27 | "STAT hash_bytes 524288\r\nSTAT hash_is_expanding 0\r\nSTAT expired_unfetched 0\r\n" \ 28 | "STAT evicted_unfetched 0\r\nSTAT bytes 0\r\nSTAT curr_items 0\r\nSTAT total_items 0\r\n" \ 29 | "STAT evictions 0\r\nSTAT reclaimed 0\r\n" 30 | end 31 | 32 | it 'parses out the engine version' do 33 | expect(response.engine_version).to eq engine_version 34 | end 35 | end 36 | 37 | context 'when the response with the version stat includes the version number and trailing text' do 38 | let(:response_text) do 39 | "STAT pid 1\r\nSTAT uptime 68717\r\nSTAT time 1398885375\r\n" \ 40 | "STAT version #{engine_version} #{SecureRandom.hex(5)}\r\n" \ 41 | "STAT libevent 1.4.13-stable\r\nSTAT pointer_size 64\r\nSTAT rusage_user 0.136008\r\n" \ 42 | "STAT rusage_system 0.424026\r\nSTAT curr_connections 5\r\nSTAT total_connections 1159\r\n" \ 43 | "STAT connection_structures 6\r\nSTAT reserved_fds 5\r\nSTAT cmd_get 0\r\n" \ 44 | "STAT cmd_set 0\r\nSTAT cmd_flush 0\r\nSTAT cmd_touch 0\r\nSTAT cmd_config_get 4582\r\n" \ 45 | "STAT cmd_config_set 2\r\nSTAT get_hits 0\r\nSTAT get_misses 0\r\nSTAT delete_misses 0\r\n" \ 46 | "STAT delete_hits 0\r\nSTAT incr_misses 0\r\nSTAT incr_hits 0\r\nSTAT decr_misses 0\r\n" \ 47 | "STAT decr_hits 0\r\nSTAT cas_misses 0\r\nSTAT cas_hits 0\r\nSTAT cas_badval 0\r\n" \ 48 | "STAT touch_hits 0\r\nSTAT touch_misses 0\r\nSTAT auth_cmds 0\r\nSTAT auth_errors 0\r\n" \ 49 | "STAT bytes_read 189356\r\nSTAT bytes_written 2906615\r\nSTAT limit_maxbytes 209715200\r\n" \ 50 | "STAT accepting_conns 1\r\nSTAT listen_disabled_num 0\r\nSTAT threads 1\r\n" \ 51 | "STAT conn_yields 0\r\nSTAT curr_config 1\r\nSTAT hash_power_level 16\r\n" \ 52 | "STAT hash_bytes 524288\r\nSTAT hash_is_expanding 0\r\nSTAT expired_unfetched 0\r\n" \ 53 | "STAT evicted_unfetched 0\r\nSTAT bytes 0\r\nSTAT curr_items 0\r\nSTAT total_items 0\r\n" \ 54 | "STAT evictions 0\r\nSTAT reclaimed 0\r\n" 55 | end 56 | 57 | it 'parses out the engine version' do 58 | expect(response.engine_version).to eq engine_version 59 | end 60 | end 61 | end 62 | 63 | context "when the version number is the string 'unknown'" do 64 | let(:engine_version) { 'UNKNOWN' } 65 | 66 | context 'when the response with the version stat only includes the version number' do 67 | let(:response_text) do 68 | "STAT pid 1\r\nSTAT uptime 68717\r\nSTAT time 1398885375\r\n" \ 69 | "STAT version #{engine_version}\r\n" \ 70 | "STAT libevent 1.4.13-stable\r\nSTAT pointer_size 64\r\nSTAT rusage_user 0.136008\r\n" \ 71 | "STAT rusage_system 0.424026\r\nSTAT curr_connections 5\r\nSTAT total_connections 1159\r\n" \ 72 | "STAT connection_structures 6\r\nSTAT reserved_fds 5\r\nSTAT cmd_get 0\r\n" \ 73 | "STAT cmd_set 0\r\nSTAT cmd_flush 0\r\nSTAT cmd_touch 0\r\nSTAT cmd_config_get 4582\r\n" \ 74 | "STAT cmd_config_set 2\r\nSTAT get_hits 0\r\nSTAT get_misses 0\r\nSTAT delete_misses 0\r\n" \ 75 | "STAT delete_hits 0\r\nSTAT incr_misses 0\r\nSTAT incr_hits 0\r\nSTAT decr_misses 0\r\n" \ 76 | "STAT decr_hits 0\r\nSTAT cas_misses 0\r\nSTAT cas_hits 0\r\nSTAT cas_badval 0\r\n" \ 77 | "STAT touch_hits 0\r\nSTAT touch_misses 0\r\nSTAT auth_cmds 0\r\nSTAT auth_errors 0\r\n" \ 78 | "STAT bytes_read 189356\r\nSTAT bytes_written 2906615\r\nSTAT limit_maxbytes 209715200\r\n" \ 79 | "STAT accepting_conns 1\r\nSTAT listen_disabled_num 0\r\nSTAT threads 1\r\n" \ 80 | "STAT conn_yields 0\r\nSTAT curr_config 1\r\nSTAT hash_power_level 16\r\n" \ 81 | "STAT hash_bytes 524288\r\nSTAT hash_is_expanding 0\r\nSTAT expired_unfetched 0\r\n" \ 82 | "STAT evicted_unfetched 0\r\nSTAT bytes 0\r\nSTAT curr_items 0\r\nSTAT total_items 0\r\n" \ 83 | "STAT evictions 0\r\nSTAT reclaimed 0\r\n" 84 | end 85 | 86 | it "parses out the engine version as 'UNKNOWN'" do 87 | expect(response.engine_version).to eq engine_version 88 | end 89 | end 90 | 91 | context 'when the response with the version stat includes the version number and trailing text' do 92 | let(:response_text) do 93 | "STAT pid 1\r\nSTAT uptime 68717\r\nSTAT time 1398885375\r\n" \ 94 | "STAT version #{engine_version} #{SecureRandom.hex(5)}\r\n" \ 95 | "STAT libevent 1.4.13-stable\r\nSTAT pointer_size 64\r\nSTAT rusage_user 0.136008\r\n" \ 96 | "STAT rusage_system 0.424026\r\nSTAT curr_connections 5\r\nSTAT total_connections 1159\r\n" \ 97 | "STAT connection_structures 6\r\nSTAT reserved_fds 5\r\nSTAT cmd_get 0\r\n" \ 98 | "STAT cmd_set 0\r\nSTAT cmd_flush 0\r\nSTAT cmd_touch 0\r\nSTAT cmd_config_get 4582\r\n" \ 99 | "STAT cmd_config_set 2\r\nSTAT get_hits 0\r\nSTAT get_misses 0\r\nSTAT delete_misses 0\r\n" \ 100 | "STAT delete_hits 0\r\nSTAT incr_misses 0\r\nSTAT incr_hits 0\r\nSTAT decr_misses 0\r\n" \ 101 | "STAT decr_hits 0\r\nSTAT cas_misses 0\r\nSTAT cas_hits 0\r\nSTAT cas_badval 0\r\n" \ 102 | "STAT touch_hits 0\r\nSTAT touch_misses 0\r\nSTAT auth_cmds 0\r\nSTAT auth_errors 0\r\n" \ 103 | "STAT bytes_read 189356\r\nSTAT bytes_written 2906615\r\nSTAT limit_maxbytes 209715200\r\n" \ 104 | "STAT accepting_conns 1\r\nSTAT listen_disabled_num 0\r\nSTAT threads 1\r\n" \ 105 | "STAT conn_yields 0\r\nSTAT curr_config 1\r\nSTAT hash_power_level 16\r\n" \ 106 | "STAT hash_bytes 524288\r\nSTAT hash_is_expanding 0\r\nSTAT expired_unfetched 0\r\n" \ 107 | "STAT evicted_unfetched 0\r\nSTAT bytes 0\r\nSTAT curr_items 0\r\nSTAT total_items 0\r\n" \ 108 | "STAT evictions 0\r\nSTAT reclaimed 0\r\n" 109 | end 110 | 111 | it "parses out the engine version as 'UNKNOWN'" do 112 | expect(response.engine_version).to eq engine_version 113 | end 114 | end 115 | end 116 | end 117 | end 118 | --------------------------------------------------------------------------------