redis-client-0.22.2/0000755000004100000410000000000014625006647014221 5ustar www-datawww-dataredis-client-0.22.2/redis-client.gemspec0000644000004100000410000000221614625006647020151 0ustar www-datawww-data# frozen_string_literal: true require_relative "lib/redis_client/version" Gem::Specification.new do |spec| spec.name = "redis-client" spec.version = RedisClient::VERSION spec.authors = ["Jean Boussier"] spec.email = ["jean.boussier@gmail.com"] spec.summary = "Simple low-level client for Redis 6+" spec.homepage = "https://github.com/redis-rb/redis-client" spec.license = "MIT" spec.required_ruby_version = ">= 2.6.0" spec.metadata["allowed_push_host"] = "https://rubygems.org" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = spec.homepage spec.metadata["changelog_uri"] = File.join(spec.homepage, "blob/master/CHANGELOG.md") # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. spec.files = Dir.chdir(File.expand_path(__dir__)) do `git ls-files -z`.split("\x0").reject do |f| (f == __FILE__) || f.match(%r{\A(?:(?:bin|hiredis-client|test|spec|features|benchmark)/|\.(?:git|rubocop))}) end end spec.require_paths = ["lib"] spec.add_runtime_dependency "connection_pool" end redis-client-0.22.2/Gemfile.lock0000644000004100000410000000253114625006647016444 0ustar www-datawww-dataPATH remote: . specs: redis-client (0.22.2) connection_pool GEM remote: https://rubygems.org/ specs: ast (2.4.2) benchmark-ips (2.13.0) byebug (11.1.3) connection_pool (2.4.1) hiredis (0.6.3) hiredis (0.6.3-java) json (2.7.1) json (2.7.1-java) minitest (5.23.0) parallel (1.24.0) parser (3.3.0.5) ast (~> 2.4.1) racc racc (1.7.3) racc (1.7.3-java) rainbow (3.1.1) rake (13.2.1) rake-compiler (1.2.7) rake redis (4.6.0) regexp_parser (2.9.0) rexml (3.2.6) rubocop (1.50.2) json (~> 2.3) parallel (~> 1.10) parser (>= 3.2.0.0) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) rubocop-ast (>= 1.28.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) rubocop-ast (1.30.0) parser (>= 3.2.1.0) rubocop-minitest (0.30.0) rubocop (>= 1.39, < 2.0) ruby-progressbar (1.13.0) stackprof (0.2.26) toxiproxy (2.0.2) unicode-display_width (2.5.0) PLATFORMS ruby universal-java-18 x86_64-darwin-20 x86_64-linux DEPENDENCIES benchmark-ips byebug hiredis minitest rake (~> 13.2) rake-compiler redis (~> 4.6) redis-client! rubocop rubocop-minitest stackprof toxiproxy BUNDLED WITH 2.3.13 redis-client-0.22.2/lib/0000755000004100000410000000000014625006647014767 5ustar www-datawww-dataredis-client-0.22.2/lib/redis-client.rb0000644000004100000410000000006614625006647017700 0ustar www-datawww-data# frozen_string_literal: true require "redis_client" redis-client-0.22.2/lib/redis_client/0000755000004100000410000000000014625006647017433 5ustar www-datawww-dataredis-client-0.22.2/lib/redis_client/connection_mixin.rb0000644000004100000410000000370214625006647023325 0ustar www-datawww-data# frozen_string_literal: true class RedisClient module ConnectionMixin def initialize @pending_reads = 0 end def reconnect close connect end def close @pending_reads = 0 nil end def revalidate if @pending_reads > 0 close false else connected? end end def call(command, timeout) @pending_reads += 1 write(command) result = read(connection_timeout(timeout)) @pending_reads -= 1 if result.is_a?(Error) result._set_command(command) result._set_config(config) raise result else result end end def call_pipelined(commands, timeouts, exception: true) first_exception = nil size = commands.size results = Array.new(commands.size) @pending_reads += size write_multi(commands) size.times do |index| timeout = timeouts && timeouts[index] result = read(connection_timeout(timeout)) @pending_reads -= 1 # A multi/exec command can return an array of results. # An error from a multi/exec command is handled in Multi#_coerce!. if result.is_a?(Array) result.each do |res| res._set_config(config) if res.is_a?(Error) end elsif result.is_a?(Error) result._set_command(commands[index]) result._set_config(config) first_exception ||= result end results[index] = result end if first_exception && exception raise first_exception else results end end def connection_timeout(timeout) return timeout unless timeout && timeout > 0 # Can't use the command timeout argument as the connection timeout # otherwise it would be very racy. So we add the regular read_timeout on top # to account for the network delay. timeout + config.read_timeout end end end redis-client-0.22.2/lib/redis_client/pid_cache.rb0000644000004100000410000000127414625006647021663 0ustar www-datawww-data# frozen_string_literal: true class RedisClient module PIDCache if !Process.respond_to?(:fork) # JRuby or TruffleRuby @pid = Process.pid singleton_class.attr_reader(:pid) elsif Process.respond_to?(:_fork) # Ruby 3.1+ class << self attr_reader :pid def update! @pid = Process.pid end end update! module CoreExt def _fork child_pid = super PIDCache.update! if child_pid == 0 child_pid end end Process.singleton_class.prepend(CoreExt) else # Ruby 3.0 or older class << self def pid Process.pid end end end end end redis-client-0.22.2/lib/redis_client/ruby_connection/0000755000004100000410000000000014625006647022633 5ustar www-datawww-dataredis-client-0.22.2/lib/redis_client/ruby_connection/buffered_io.rb0000644000004100000410000001526614625006647025443 0ustar www-datawww-data# frozen_string_literal: true require "io/wait" unless IO.method_defined?(:wait_readable) && IO.method_defined?(:wait_writable) class RedisClient class RubyConnection class BufferedIO EOL = "\r\n".b.freeze EOL_SIZE = EOL.bytesize attr_accessor :read_timeout, :write_timeout if String.method_defined?(:byteindex) # Ruby 3.2+ ENCODING = Encoding::UTF_8 def initialize(io, read_timeout:, write_timeout:, chunk_size: 4096) @io = io @buffer = +"" @offset = 0 @chunk_size = chunk_size @read_timeout = read_timeout @write_timeout = write_timeout @blocking_reads = false end def gets_chomp fill_buffer(false) if @offset >= @buffer.bytesize until eol_index = @buffer.byteindex(EOL, @offset) fill_buffer(false) end line = @buffer.byteslice(@offset, eol_index - @offset) @offset = eol_index + EOL_SIZE line end def read_chomp(bytes) ensure_remaining(bytes + EOL_SIZE) str = @buffer.byteslice(@offset, bytes) @offset += bytes + EOL_SIZE str end private def ensure_line fill_buffer(false) if @offset >= @buffer.bytesize until @buffer.byteindex(EOL, @offset) fill_buffer(false) end end else ENCODING = Encoding::BINARY def initialize(io, read_timeout:, write_timeout:, chunk_size: 4096) @io = io @buffer = "".b @offset = 0 @chunk_size = chunk_size @read_timeout = read_timeout @write_timeout = write_timeout @blocking_reads = false end def gets_chomp fill_buffer(false) if @offset >= @buffer.bytesize until eol_index = @buffer.index(EOL, @offset) fill_buffer(false) end line = @buffer.byteslice(@offset, eol_index - @offset) @offset = eol_index + EOL_SIZE line end def read_chomp(bytes) ensure_remaining(bytes + EOL_SIZE) str = @buffer.byteslice(@offset, bytes) @offset += bytes + EOL_SIZE str.force_encoding(Encoding::UTF_8) end private def ensure_line fill_buffer(false) if @offset >= @buffer.bytesize until @buffer.index(EOL, @offset) fill_buffer(false) end end end def close @io.to_io.close end def closed? @io.to_io.closed? end def eof? @offset >= @buffer.bytesize && @io.eof? end def with_timeout(new_timeout) new_timeout = false if new_timeout == 0 previous_read_timeout = @read_timeout previous_blocking_reads = @blocking_reads if new_timeout @read_timeout = new_timeout else @blocking_reads = true end begin yield ensure @read_timeout = previous_read_timeout @blocking_reads = previous_blocking_reads end end def skip(offset) ensure_remaining(offset) @offset += offset nil end def write(string) total = remaining = string.bytesize loop do case bytes_written = @io.write_nonblock(string, exception: false) when Integer remaining -= bytes_written if remaining > 0 string = string.byteslice(bytes_written..-1) else return total end when :wait_readable @io.to_io.wait_readable(@read_timeout) or raise(ReadTimeoutError, "Waited #{@read_timeout} seconds") when :wait_writable @io.to_io.wait_writable(@write_timeout) or raise(WriteTimeoutError, "Waited #{@write_timeout} seconds") when nil raise Errno::ECONNRESET else raise "Unexpected `write_nonblock` return: #{bytes.inspect}" end end end def getbyte unless byte = @buffer.getbyte(@offset) ensure_remaining(1) byte = @buffer.getbyte(@offset) end @offset += 1 byte end def gets_integer int = 0 offset = @offset while true chr = @buffer.getbyte(offset) if chr if chr == 13 # "\r".ord @offset = offset + 2 break else int = (int * 10) + chr - 48 end offset += 1 else ensure_line return gets_integer end end int end private def ensure_remaining(bytes) needed = bytes - (@buffer.bytesize - @offset) if needed > 0 fill_buffer(true, needed) end end def fill_buffer(strict, size = @chunk_size) remaining = size buffer_size = @buffer.bytesize start = @offset - buffer_size empty_buffer = start >= 0 loop do bytes = if empty_buffer @io.read_nonblock([remaining, @chunk_size].max, @buffer, exception: false) else @io.read_nonblock([remaining, @chunk_size].max, exception: false) end case bytes when :wait_readable # Ref: https://github.com/redis-rb/redis-client/issues/190 # SSLSocket always clear the provided buffer, even when it didn't # read anything. So we need to reset the offset accordingly. if empty_buffer && @buffer.empty? @offset -= buffer_size end unless @io.to_io.wait_readable(@read_timeout) raise ReadTimeoutError, "Waited #{@read_timeout} seconds" unless @blocking_reads end when :wait_writable # Ref: https://github.com/redis-rb/redis-client/issues/190 # SSLSocket always clear the provided buffer, even when it didn't # read anything. So we need to reset the offset accordingly. if empty_buffer && @buffer.empty? @offset -= buffer_size end @io.to_io.wait_writable(@write_timeout) or raise(WriteTimeoutError, "Waited #{@write_timeout} seconds") when nil raise EOFError else if empty_buffer @offset = start empty_buffer = false @buffer.force_encoding(ENCODING) unless @buffer.encoding == ENCODING else @buffer << bytes.force_encoding(ENCODING) end remaining -= bytes.bytesize return if !strict || remaining <= 0 end end end end end end redis-client-0.22.2/lib/redis_client/ruby_connection/resp3.rb0000644000004100000410000001221314625006647024213 0ustar www-datawww-data# frozen_string_literal: true class RedisClient module RESP3 module_function Error = Class.new(RedisClient::Error) UnknownType = Class.new(Error) SyntaxError = Class.new(Error) EOL = "\r\n".b.freeze EOL_SIZE = EOL.bytesize DUMP_TYPES = { String => :dump_string, Symbol => :dump_symbol, Integer => :dump_numeric, Float => :dump_numeric, }.freeze PARSER_TYPES = { '#' => :parse_boolean, '$' => :parse_blob, '+' => :parse_string, '=' => :parse_verbatim_string, '-' => :parse_error, ':' => :parse_integer, '(' => :parse_integer, ',' => :parse_double, '_' => :parse_null, '*' => :parse_array, '%' => :parse_map, '~' => :parse_set, '>' => :parse_array, }.transform_keys(&:ord).freeze INTEGER_RANGE = ((((2**64) / 2) * -1)..(((2**64) / 2) - 1)).freeze def dump(command, buffer = nil) buffer ||= new_buffer command = command.flat_map do |element| case element when Hash element.flatten else element end end dump_array(command, buffer) end def load(io) parse(io) end def new_buffer String.new(encoding: Encoding::BINARY, capacity: 127) end def dump_any(object, buffer) method = DUMP_TYPES.fetch(object.class) do |unexpected_class| if superclass = DUMP_TYPES.keys.find { |t| t > unexpected_class } DUMP_TYPES[superclass] else raise TypeError, "Unsupported command argument type: #{unexpected_class}" end end send(method, object, buffer) end def dump_array(array, buffer) buffer << '*' << array.size.to_s << EOL array.each do |item| dump_any(item, buffer) end buffer end def dump_set(set, buffer) buffer << '~' << set.size.to_s << EOL set.each do |item| dump_any(item, buffer) end buffer end def dump_hash(hash, buffer) buffer << '%' << hash.size.to_s << EOL hash.each_pair do |key, value| dump_any(key, buffer) dump_any(value, buffer) end buffer end def dump_numeric(numeric, buffer) dump_string(numeric.to_s, buffer) end def dump_string(string, buffer) string = string.b unless string.ascii_only? buffer << '$' << string.bytesize.to_s << EOL << string << EOL end if Symbol.method_defined?(:name) def dump_symbol(symbol, buffer) dump_string(symbol.name, buffer) end else def dump_symbol(symbol, buffer) dump_string(symbol.to_s, buffer) end end def parse(io) type = io.getbyte if type == 35 # '#'.ord parse_boolean(io) elsif type == 36 # '$'.ord parse_blob(io) elsif type == 43 # '+'.ord parse_string(io) elsif type == 61 # '='.ord parse_verbatim_string(io) elsif type == 45 # '-'.ord parse_error(io) elsif type == 58 # ':'.ord parse_integer(io) elsif type == 40 # '('.ord parse_integer(io) elsif type == 44 # ','.ord parse_double(io) elsif type == 95 # '_'.ord parse_null(io) elsif type == 42 # '*'.ord parse_array(io) elsif type == 37 # '%'.ord parse_map(io) elsif type == 126 # '~'.ord parse_set(io) elsif type == 62 # '>'.ord parse_array(io) else raise UnknownType, "Unknown sigil type: #{type.chr.inspect}" end end def parse_string(io) str = io.gets_chomp str.force_encoding(Encoding::BINARY) unless str.valid_encoding? str.freeze end def parse_error(io) CommandError.parse(parse_string(io)) end def parse_boolean(io) case value = io.gets_chomp when "t" true when "f" false else raise SyntaxError, "Expected `t` or `f` after `#`, got: #{value}" end end def parse_array(io) parse_sequence(io, io.gets_integer) end def parse_set(io) parse_sequence(io, io.gets_integer) end def parse_map(io) hash = {} io.gets_integer.times do hash[parse(io).freeze] = parse(io) end hash end def parse_push(io) parse_array(io) end def parse_sequence(io, size) return if size < 0 # RESP2 nil array = Array.new(size) size.times do |index| array[index] = parse(io) end array end def parse_integer(io) Integer(io.gets_chomp) end def parse_double(io) case value = io.gets_chomp when "nan" Float::NAN when "inf" Float::INFINITY when "-inf" -Float::INFINITY else Float(value) end end def parse_null(io) io.skip(EOL_SIZE) nil end def parse_blob(io) bytesize = io.gets_integer return if bytesize < 0 # RESP2 nil type str = io.read_chomp(bytesize) str.force_encoding(Encoding::BINARY) unless str.valid_encoding? str end def parse_verbatim_string(io) blob = parse_blob(io) blob.byteslice(4..-1) end end end redis-client-0.22.2/lib/redis_client/sentinel_config.rb0000644000004100000410000001334314625006647023132 0ustar www-datawww-data# frozen_string_literal: true class RedisClient class SentinelConfig include Config::Common SENTINEL_DELAY = 0.25 DEFAULT_RECONNECT_ATTEMPTS = 2 attr_reader :name def initialize( sentinels:, sentinel_password: nil, sentinel_username: nil, role: :master, name: nil, url: nil, **client_config ) unless %i(master replica slave).include?(role.to_sym) raise ArgumentError, "Expected role to be either :master or :replica, got: #{role.inspect}" end if url url_config = URLConfig.new(url) client_config = { username: url_config.username, password: url_config.password, db: url_config.db, }.compact.merge(client_config) name ||= url_config.host end @name = name unless @name raise ArgumentError, "RedisClient::SentinelConfig requires either a name or an url with a host" end @to_list_of_hash = @to_hash = nil @extra_config = { username: sentinel_username, password: sentinel_password, db: nil, } if client_config[:protocol] == 2 @extra_config[:protocol] = client_config[:protocol] @to_list_of_hash = lambda do |may_be_a_list| if may_be_a_list.is_a?(Array) may_be_a_list.map { |l| l.each_slice(2).to_h } else may_be_a_list end end end @sentinels = {}.compare_by_identity @role = role.to_sym @mutex = Mutex.new @config = nil client_config[:reconnect_attempts] ||= DEFAULT_RECONNECT_ATTEMPTS @client_config = client_config || {} super(**client_config) @sentinel_configs = sentinels_to_configs(sentinels) end def sentinels @mutex.synchronize do @sentinel_configs.dup end end def reset @mutex.synchronize do @config = nil end end def host config.host end def port config.port end def path nil end def retry_connecting?(attempt, error) reset unless error.is_a?(TimeoutError) super end def sentinel? true end def check_role!(role) if @role == :master unless role == "master" sleep SENTINEL_DELAY raise FailoverError, "Expected to connect to a master, but the server is a replica" end else unless role == "slave" sleep SENTINEL_DELAY raise FailoverError, "Expected to connect to a replica, but the server is a master" end end end def resolved? @mutex.synchronize do !!@config end end private def sentinels_to_configs(sentinels) sentinels.map do |sentinel| case sentinel when String Config.new(**@client_config, **@extra_config, url: sentinel) else Config.new(**@client_config, **@extra_config, **sentinel) end end end def config @mutex.synchronize do @config ||= if @role == :master resolve_master else resolve_replica end end end def resolve_master each_sentinel do |sentinel_client| host, port = sentinel_client.call("SENTINEL", "get-master-addr-by-name", @name) next unless host && port refresh_sentinels(sentinel_client) return Config.new(host: host, port: Integer(port), **@client_config) end rescue ConnectionError raise ConnectionError, "No sentinels available" else raise ConnectionError, "Couldn't locate a replica for role: #{@name}" end def sentinel_client(sentinel_config) @sentinels[sentinel_config] ||= sentinel_config.new_client end def resolve_replica each_sentinel do |sentinel_client| replicas = sentinel_client.call("SENTINEL", "replicas", @name, &@to_list_of_hash) replicas.reject! do |r| flags = r["flags"].to_s.split(",") flags.include?("s_down") || flags.include?("o_down") end next if replicas.empty? replica = replicas.sample return Config.new(host: replica["ip"], port: Integer(replica["port"]), **@client_config) end rescue ConnectionError raise ConnectionError, "No sentinels available" else raise ConnectionError, "Couldn't locate a replica for role: #{@name}" end def each_sentinel last_error = nil @sentinel_configs.dup.each do |sentinel_config| sentinel_client = sentinel_client(sentinel_config) success = true begin yield sentinel_client rescue RedisClient::Error => error last_error = error success = false sleep SENTINEL_DELAY ensure if success @sentinel_configs.unshift(@sentinel_configs.delete(sentinel_config)) end # Redis Sentinels may be configured to have a lower maxclients setting than # the Redis nodes. Close the connection to the Sentinel node to avoid using # a connection. sentinel_client.close end end raise last_error if last_error end def refresh_sentinels(sentinel_client) sentinel_response = sentinel_client.call("SENTINEL", "sentinels", @name, &@to_list_of_hash) sentinels = sentinel_response.map do |sentinel| { host: sentinel.fetch("ip"), port: Integer(sentinel.fetch("port")) } end new_sentinels = sentinels.select do |sentinel| @sentinel_configs.none? do |sentinel_config| sentinel_config.host == sentinel.fetch(:host) && sentinel_config.port == sentinel.fetch(:port) end end @sentinel_configs.concat sentinels_to_configs(new_sentinels) end end end redis-client-0.22.2/lib/redis_client/pooled.rb0000644000004100000410000000416414625006647021247 0ustar www-datawww-data# frozen_string_literal: true require "connection_pool" class RedisClient class Pooled EMPTY_HASH = {}.freeze include Common def initialize( config, id: config.id, connect_timeout: config.connect_timeout, read_timeout: config.read_timeout, write_timeout: config.write_timeout, **kwargs ) super(config, id: id, connect_timeout: connect_timeout, read_timeout: read_timeout, write_timeout: write_timeout) @pool_kwargs = kwargs @pool = new_pool @mutex = Mutex.new end def with(options = EMPTY_HASH) pool.with(options) do |client| client.connect_timeout = connect_timeout client.read_timeout = read_timeout client.write_timeout = write_timeout yield client end rescue ConnectionPool::TimeoutError => error raise CheckoutTimeoutError, "Couldn't checkout a connection in time: #{error.message}" end alias_method :then, :with def close if @pool @mutex.synchronize do pool = @pool @pool = nil pool&.shutdown(&:close) end end nil end def size pool.size end methods = %w(pipelined multi pubsub call call_v call_once call_once_v blocking_call blocking_call_v) iterable_methods = %w(scan sscan hscan zscan) methods.each do |method| class_eval <<~RUBY, __FILE__, __LINE__ + 1 def #{method}(*args, &block) with { |r| r.#{method}(*args, &block) } end ruby2_keywords :#{method} if respond_to?(:ruby2_keywords, true) RUBY end iterable_methods.each do |method| class_eval <<~RUBY, __FILE__, __LINE__ + 1 def #{method}(*args, &block) unless block_given? return to_enum(__callee__, *args) end with { |r| r.#{method}(*args, &block) } end ruby2_keywords :#{method} if respond_to?(:ruby2_keywords, true) RUBY end private def pool @pool ||= @mutex.synchronize { new_pool } end def new_pool ConnectionPool.new(**@pool_kwargs) { @config.new_client } end end end redis-client-0.22.2/lib/redis_client/config.rb0000644000004100000410000001263014625006647021227 0ustar www-datawww-data# frozen_string_literal: true require "openssl" require "uri" class RedisClient class Config DEFAULT_TIMEOUT = 1.0 DEFAULT_HOST = "localhost" DEFAULT_PORT = 6379 DEFAULT_USERNAME = "default" DEFAULT_DB = 0 module Common attr_reader :db, :password, :id, :ssl, :ssl_params, :command_builder, :inherit_socket, :connect_timeout, :read_timeout, :write_timeout, :driver, :connection_prelude, :protocol, :middlewares_stack, :custom, :circuit_breaker alias_method :ssl?, :ssl def initialize( username: nil, password: nil, db: nil, id: nil, timeout: DEFAULT_TIMEOUT, read_timeout: timeout, write_timeout: timeout, connect_timeout: timeout, ssl: nil, custom: {}, ssl_params: nil, driver: nil, protocol: 3, client_implementation: RedisClient, command_builder: CommandBuilder, inherit_socket: false, reconnect_attempts: false, middlewares: false, circuit_breaker: nil ) @username = username @password = password @db = begin Integer(db || DEFAULT_DB) rescue ArgumentError raise ArgumentError, "db: must be an Integer, got: #{db.inspect}" end @id = id @ssl = ssl || false @ssl_params = ssl_params @connect_timeout = connect_timeout @read_timeout = read_timeout @write_timeout = write_timeout @driver = driver ? RedisClient.driver(driver) : RedisClient.default_driver @custom = custom @client_implementation = client_implementation @protocol = protocol unless protocol == 2 || protocol == 3 raise ArgumentError, "Unknown protocol version #{protocol.inspect}, expected 2 or 3" end @command_builder = command_builder @inherit_socket = inherit_socket reconnect_attempts = Array.new(reconnect_attempts, 0).freeze if reconnect_attempts.is_a?(Integer) @reconnect_attempts = reconnect_attempts @connection_prelude = build_connection_prelude circuit_breaker = CircuitBreaker.new(**circuit_breaker) if circuit_breaker.is_a?(Hash) if @circuit_breaker = circuit_breaker middlewares = [CircuitBreaker::Middleware] + (middlewares || []) end middlewares_stack = Middlewares if middlewares && !middlewares.empty? middlewares_stack = Class.new(Middlewares) middlewares.each do |mod| middlewares_stack.include(mod) end end @middlewares_stack = middlewares_stack end def username @username || DEFAULT_USERNAME end def resolved? true end def sentinel? false end def new_pool(**kwargs) kwargs[:timeout] ||= DEFAULT_TIMEOUT Pooled.new(self, **kwargs) end def new_client(**kwargs) @client_implementation.new(self, **kwargs) end def retry_connecting?(attempt, _error) if @reconnect_attempts if (pause = @reconnect_attempts[attempt]) if pause > 0 sleep(pause) end return true end end false end def ssl_context if ssl @ssl_context ||= @driver.ssl_context(@ssl_params || {}) end end def server_url if path url = "unix://#{path}" if db != 0 url = "#{url}?db=#{db}" end else # add brackets to IPv6 address redis_host = if host.count(":") >= 2 "[#{host}]" else host end url = "redis#{'s' if ssl?}://#{redis_host}:#{port}" if db != 0 url = "#{url}/#{db}" end end url end private def build_connection_prelude prelude = [] if protocol == 3 prelude << if @password ["HELLO", "3", "AUTH", @username || DEFAULT_USERNAME, @password] else ["HELLO", "3"] end elsif @password prelude << if @username && !@username.empty? ["AUTH", @username, @password] else ["AUTH", @password] end end if @db && @db != 0 prelude << ["SELECT", @db.to_s] end # Deep freeze all the strings and commands prelude.map! do |commands| commands = commands.map { |str| str.frozen? ? str : str.dup.freeze } commands.freeze end prelude.freeze end end include Common attr_reader :host, :port, :path def initialize( url: nil, host: nil, port: nil, path: nil, username: nil, password: nil, **kwargs ) if url url_config = URLConfig.new(url) kwargs = { ssl: url_config.ssl?, db: url_config.db, }.compact.merge(kwargs) host ||= url_config.host port ||= url_config.port path ||= url_config.path username ||= url_config.username password ||= url_config.password end super(username: username, password: password, **kwargs) if @path = path @host = nil @port = nil else @host = host || DEFAULT_HOST @port = Integer(port || DEFAULT_PORT) end end end end redis-client-0.22.2/lib/redis_client/url_config.rb0000644000004100000410000000265214625006647022114 0ustar www-datawww-data# frozen_string_literal: true require "uri" class RedisClient class URLConfig attr_reader :url, :uri def initialize(url) @url = url @uri = URI(url) @unix = false @ssl = false case uri.scheme when "redis" # expected when "rediss" @ssl = true when "unix", nil @unix = true else raise ArgumentError, "Unknown URL scheme: #{url.inspect}" end end def ssl? @ssl end def db unless @unix db_path = uri.path&.delete_prefix("/") return Integer(db_path) if db_path && !db_path.empty? end unless uri.query.nil? || uri.query.empty? _, db_query = URI.decode_www_form(uri.query).find do |key, _| key == "db" end return Integer(db_query) if db_query && !db_query.empty? end end def username uri.user if uri.password && !uri.user.empty? end def password if uri.user && !uri.password URI.decode_www_form_component(uri.user) elsif uri.user && uri.password URI.decode_www_form_component(uri.password) end end def host return if uri.host.nil? || uri.host.empty? uri.host.sub(/\A\[(.*)\]\z/, '\1') end def path if @unix File.join(*[uri.host, uri.path].compact) end end def port return unless uri.port Integer(uri.port) end end end redis-client-0.22.2/lib/redis_client/command_builder.rb0000644000004100000410000000356714625006647023117 0ustar www-datawww-data# frozen_string_literal: true class RedisClient module CommandBuilder extend self if Symbol.method_defined?(:name) def generate(args, kwargs = nil) command = args.flat_map do |element| case element when Hash element.flatten else element end end kwargs&.each do |key, value| if value if value == true command << key.name else command << key.name << value end end end command.map! do |element| case element when String element when Symbol element.name when Integer, Float element.to_s else raise TypeError, "Unsupported command argument type: #{element.class}" end end if command.empty? raise ArgumentError, "can't issue an empty redis command" end command end else def generate(args, kwargs = nil) command = args.flat_map do |element| case element when Hash element.flatten else element end end kwargs&.each do |key, value| if value if value == true command << key.to_s else command << key.to_s << value end end end command.map! do |element| case element when String element when Integer, Float, Symbol element.to_s else raise TypeError, "Unsupported command argument type: #{element.class}" end end if command.empty? raise ArgumentError, "can't issue an empty redis command" end command end end end end redis-client-0.22.2/lib/redis_client/ruby_connection.rb0000644000004100000410000001361414625006647023165 0ustar www-datawww-data# frozen_string_literal: true require "socket" require "openssl" require "redis_client/connection_mixin" require "redis_client/ruby_connection/buffered_io" require "redis_client/ruby_connection/resp3" class RedisClient class RubyConnection include ConnectionMixin class << self def ssl_context(ssl_params) params = ssl_params.dup || {} cert = params[:cert] if cert.is_a?(String) cert = File.read(cert) if File.exist?(cert) params[:cert] = OpenSSL::X509::Certificate.new(cert) end key = params[:key] if key.is_a?(String) key = File.read(key) if File.exist?(key) params[:key] = OpenSSL::PKey.read(key) end context = OpenSSL::SSL::SSLContext.new context.set_params(params) if context.verify_mode != OpenSSL::SSL::VERIFY_NONE if context.respond_to?(:verify_hostname) # Missing on JRuby context.verify_hostname end end context end end SUPPORTS_RESOLV_TIMEOUT = Socket.method(:tcp).parameters.any? { |p| p.last == :resolv_timeout } attr_reader :config def initialize(config, connect_timeout:, read_timeout:, write_timeout:) super() @config = config @connect_timeout = connect_timeout @read_timeout = read_timeout @write_timeout = write_timeout connect end def connected? !@io.closed? end def close @io.close super end def read_timeout=(timeout) @read_timeout = timeout @io.read_timeout = timeout if @io end def write_timeout=(timeout) @write_timeout = timeout @io.write_timeout = timeout if @io end def write(command) buffer = RESP3.dump(command) begin @io.write(buffer) rescue SystemCallError, IOError, OpenSSL::SSL::SSLError => error raise ConnectionError.with_config(error.message, config) end end def write_multi(commands) buffer = nil commands.each do |command| buffer = RESP3.dump(command, buffer) end begin @io.write(buffer) rescue SystemCallError, IOError, OpenSSL::SSL::SSLError => error raise ConnectionError.with_config(error.message, config) end end def read(timeout = nil) if timeout.nil? RESP3.load(@io) else @io.with_timeout(timeout) { RESP3.load(@io) } end rescue RedisClient::RESP3::UnknownType => error raise RedisClient::ProtocolError.with_config(error.message, config) rescue SystemCallError, IOError, OpenSSL::SSL::SSLError => error raise ConnectionError.with_config(error.message, config) end def measure_round_trip_delay start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) call(["PING"], @read_timeout) Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start end private def connect socket = if @config.path UNIXSocket.new(@config.path) else sock = if SUPPORTS_RESOLV_TIMEOUT Socket.tcp(@config.host, @config.port, connect_timeout: @connect_timeout, resolv_timeout: @connect_timeout) else Socket.tcp(@config.host, @config.port, connect_timeout: @connect_timeout) end # disables Nagle's Algorithm, prevents multiple round trips with MULTI sock.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) enable_socket_keep_alive(sock) sock end if @config.ssl socket = OpenSSL::SSL::SSLSocket.new(socket, @config.ssl_context) socket.hostname = @config.host loop do case status = socket.connect_nonblock(exception: false) when :wait_readable socket.to_io.wait_readable(@connect_timeout) or raise CannotConnectError.with_config("", config) when :wait_writable socket.to_io.wait_writable(@connect_timeout) or raise CannotConnectError.with_config("", config) when socket break else raise "Unexpected `connect_nonblock` return: #{status.inspect}" end end end @io = BufferedIO.new( socket, read_timeout: @read_timeout, write_timeout: @write_timeout, ) true rescue SystemCallError, OpenSSL::SSL::SSLError, SocketError => error socket&.close raise CannotConnectError, error.message, error.backtrace end KEEP_ALIVE_INTERVAL = 15 # Same as hiredis defaults KEEP_ALIVE_TTL = 120 # Longer than hiredis defaults KEEP_ALIVE_PROBES = (KEEP_ALIVE_TTL / KEEP_ALIVE_INTERVAL) - 1 private_constant :KEEP_ALIVE_INTERVAL private_constant :KEEP_ALIVE_TTL private_constant :KEEP_ALIVE_PROBES if %i[SOL_TCP SOL_SOCKET TCP_KEEPIDLE TCP_KEEPINTVL TCP_KEEPCNT].all? { |c| Socket.const_defined? c } # Linux def enable_socket_keep_alive(socket) socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true) socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, KEEP_ALIVE_INTERVAL) socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, KEEP_ALIVE_INTERVAL) socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, KEEP_ALIVE_PROBES) end elsif %i[IPPROTO_TCP TCP_KEEPINTVL TCP_KEEPCNT].all? { |c| Socket.const_defined? c } # macOS def enable_socket_keep_alive(socket) socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true) socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_KEEPINTVL, KEEP_ALIVE_INTERVAL) socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_KEEPCNT, KEEP_ALIVE_PROBES) end elsif %i[SOL_SOCKET SO_KEEPALIVE].all? { |c| Socket.const_defined? c } # unknown POSIX def enable_socket_keep_alive(socket) socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true) end else # unknown def enable_socket_keep_alive(_socket) end end end end redis-client-0.22.2/lib/redis_client/middlewares.rb0000644000004100000410000000054514625006647022264 0ustar www-datawww-data# frozen_string_literal: true class RedisClient class BasicMiddleware attr_reader :client def initialize(client) @client = client end def connect(_config) yield end def call(command, _config) yield command end alias_method :call_pipelined, :call end class Middlewares < BasicMiddleware end end redis-client-0.22.2/lib/redis_client/circuit_breaker.rb0000644000004100000410000000514014625006647023115 0ustar www-datawww-data# frozen_string_literal: true class RedisClient class CircuitBreaker module Middleware def connect(config) config.circuit_breaker.protect { super } end def call(_command, config) config.circuit_breaker.protect { super } end def call_pipelined(_commands, config) config.circuit_breaker.protect { super } end end OpenCircuitError = Class.new(CannotConnectError) attr_reader :error_timeout, :error_threshold, :error_threshold_timeout, :success_threshold def initialize(error_threshold:, error_timeout:, error_threshold_timeout: error_timeout, success_threshold: 0) @error_threshold = Integer(error_threshold) @error_threshold_timeout = Float(error_threshold_timeout) @error_timeout = Float(error_timeout) @success_threshold = Integer(success_threshold) @errors = [] @successes = 0 @state = :closed @lock = Mutex.new end def protect if @state == :open refresh_state end case @state when :open raise OpenCircuitError, "Too many connection errors happened recently" when :closed begin yield rescue ConnectionError record_error raise end when :half_open begin result = yield record_success result rescue ConnectionError record_error raise end else raise "[BUG] RedisClient::CircuitBreaker unexpected @state (#{@state.inspect}})" end end private def refresh_state now = Process.clock_gettime(Process::CLOCK_MONOTONIC) @lock.synchronize do if @errors.last < (now - @error_timeout) if @success_threshold > 0 @state = :half_open @successes = 0 else @errors.clear @state = :closed end end end end def record_error now = Process.clock_gettime(Process::CLOCK_MONOTONIC) expiry = now - @error_timeout @lock.synchronize do if @state == :closed @errors.reject! { |t| t < expiry } end @errors << now @successes = 0 if @state == :half_open || (@state == :closed && @errors.size >= @error_threshold) @state = :open end end end def record_success return unless @state == :half_open @lock.synchronize do return unless @state == :half_open @successes += 1 if @successes >= @success_threshold @state = :closed end end end end end redis-client-0.22.2/lib/redis_client/version.rb0000644000004100000410000000011214625006647021437 0ustar www-datawww-data# frozen_string_literal: true class RedisClient VERSION = "0.22.2" end redis-client-0.22.2/lib/redis_client/decorator.rb0000644000004100000410000000432514625006647021746 0ustar www-datawww-data# frozen_string_literal: true class RedisClient module Decorator class << self def create(commands_mixin) client_decorator = Class.new(Client) client_decorator.include(commands_mixin) pipeline_decorator = Class.new(Pipeline) pipeline_decorator.include(commands_mixin) client_decorator.const_set(:Pipeline, pipeline_decorator) client_decorator end end module CommandsMixin def initialize(client) @client = client end %i(call call_v call_once call_once_v blocking_call blocking_call_v).each do |method| class_eval(<<~RUBY, __FILE__, __LINE__ + 1) def #{method}(*args, &block) @client.#{method}(*args, &block) end ruby2_keywords :#{method} if respond_to?(:ruby2_keywords, true) RUBY end end class Pipeline include CommandsMixin end class Client include CommandsMixin def initialize(_client) super @_pipeline_class = self.class::Pipeline end def with(*args) @client.with(*args) { |c| yield self.class.new(c) } end ruby2_keywords :with if respond_to?(:ruby2_keywords, true) def pipelined(exception: true) @client.pipelined(exception: exception) { |p| yield @_pipeline_class.new(p) } end def multi(**kwargs) @client.multi(**kwargs) { |p| yield @_pipeline_class.new(p) } end %i(close scan hscan sscan zscan).each do |method| class_eval(<<~RUBY, __FILE__, __LINE__ + 1) def #{method}(*args, &block) @client.#{method}(*args, &block) end ruby2_keywords :#{method} if respond_to?(:ruby2_keywords, true) RUBY end %i(id config size connect_timeout read_timeout write_timeout pubsub).each do |reader| class_eval(<<~RUBY, __FILE__, __LINE__ + 1) def #{reader} @client.#{reader} end RUBY end %i(timeout connect_timeout read_timeout write_timeout).each do |writer| class_eval(<<~RUBY, __FILE__, __LINE__ + 1) def #{writer}=(value) @client.#{writer} = value end RUBY end end end end redis-client-0.22.2/lib/redis_client.rb0000644000004100000410000004250414625006647017765 0ustar www-datawww-data# frozen_string_literal: true require "redis_client/version" require "redis_client/command_builder" require "redis_client/url_config" require "redis_client/config" require "redis_client/pid_cache" require "redis_client/sentinel_config" require "redis_client/middlewares" class RedisClient @driver_definitions = {} @drivers = {} @default_driver = nil class << self def register_driver(name, &block) @driver_definitions[name] = block end def driver(name) return name if name.is_a?(Class) name = name.to_sym unless @driver_definitions.key?(name) raise ArgumentError, "Unknown driver #{name.inspect}, expected one of: `#{@driver_definitions.keys.inspect}`" end @drivers[name] ||= @driver_definitions[name]&.call end def default_driver unless @default_driver @driver_definitions.each_key do |name| if @default_driver = driver(name) break end rescue LoadError end end @default_driver end def default_driver=(name) @default_driver = driver(name) end end register_driver :ruby do require "redis_client/ruby_connection" RubyConnection end module Common attr_reader :config, :id attr_accessor :connect_timeout, :read_timeout, :write_timeout def initialize( config, id: config.id, connect_timeout: config.connect_timeout, read_timeout: config.read_timeout, write_timeout: config.write_timeout ) @config = config @id = id&.to_s @connect_timeout = connect_timeout @read_timeout = read_timeout @write_timeout = write_timeout @command_builder = config.command_builder @pid = PIDCache.pid end def timeout=(timeout) @connect_timeout = @read_timeout = @write_timeout = timeout end end module HasConfig attr_reader :config def _set_config(config) @config = config end def message return super unless config&.resolved? "#{super} (#{config.server_url})" end end class Error < StandardError include HasConfig def self.with_config(message, config = nil) new(message).tap do |error| error._set_config(config) end end end ProtocolError = Class.new(Error) UnsupportedServer = Class.new(Error) ConnectionError = Class.new(Error) CannotConnectError = Class.new(ConnectionError) FailoverError = Class.new(ConnectionError) TimeoutError = Class.new(ConnectionError) ReadTimeoutError = Class.new(TimeoutError) WriteTimeoutError = Class.new(TimeoutError) CheckoutTimeoutError = Class.new(TimeoutError) module HasCommand attr_reader :command def _set_command(command) @command = command end end class CommandError < Error include HasCommand class << self def parse(error_message) code = if error_message.start_with?("ERR Error running script") # On older redis servers script errors are nested. # So we need to parse some more. if (match = error_message.match(/:\s-([A-Z]+) /)) match[1] end end code ||= error_message.split(' ', 2).first klass = ERRORS.fetch(code, self) klass.new(error_message.strip) end end end AuthenticationError = Class.new(CommandError) PermissionError = Class.new(CommandError) WrongTypeError = Class.new(CommandError) OutOfMemoryError = Class.new(CommandError) ReadOnlyError = Class.new(ConnectionError) ReadOnlyError.include(HasCommand) MasterDownError = Class.new(ConnectionError) MasterDownError.include(HasCommand) CommandError::ERRORS = { "WRONGPASS" => AuthenticationError, "NOPERM" => PermissionError, "READONLY" => ReadOnlyError, "MASTERDOWN" => MasterDownError, "WRONGTYPE" => WrongTypeError, "OOM" => OutOfMemoryError, }.freeze class << self def config(**kwargs) Config.new(client_implementation: self, **kwargs) end def sentinel(**kwargs) SentinelConfig.new(client_implementation: self, **kwargs) end def new(arg = nil, **kwargs) if arg.is_a?(Config::Common) super else super(config(**(arg || {}), **kwargs)) end end def register(middleware) Middlewares.include(middleware) end end include Common def initialize(config, **) super @middlewares = config.middlewares_stack.new(self) @raw_connection = nil @disable_reconnection = false end def inspect id_string = " id=#{id}" if id "#<#{self.class.name} #{config.server_url}#{id_string}>" end def server_url config.server_url end def id config.id end def timeout config.read_timeout end def db config.db end def host config.host unless config.path end def port config.port unless config.path end def path config.path end def username config.username end def password config.password end def size 1 end def with(_options = nil) yield self end alias_method :then, :with def timeout=(timeout) super @raw_connection&.read_timeout = timeout @raw_connection&.write_timeout = timeout end def read_timeout=(timeout) super @raw_connection&.read_timeout = timeout end def write_timeout=(timeout) super @raw_connection&.write_timeout = timeout end def pubsub sub = PubSub.new(ensure_connected, @command_builder) @raw_connection = nil sub end def measure_round_trip_delay ensure_connected do |connection| @middlewares.call(["PING"], config) do connection.measure_round_trip_delay end end end def call(*command, **kwargs) command = @command_builder.generate(command, kwargs) result = ensure_connected do |connection| @middlewares.call(command, config) do connection.call(command, nil) end end if block_given? yield result else result end end def call_v(command) command = @command_builder.generate(command) result = ensure_connected do |connection| @middlewares.call(command, config) do connection.call(command, nil) end end if block_given? yield result else result end end def call_once(*command, **kwargs) command = @command_builder.generate(command, kwargs) result = ensure_connected(retryable: false) do |connection| @middlewares.call(command, config) do connection.call(command, nil) end end if block_given? yield result else result end end def call_once_v(command) command = @command_builder.generate(command) result = ensure_connected(retryable: false) do |connection| @middlewares.call(command, config) do connection.call(command, nil) end end if block_given? yield result else result end end def blocking_call(timeout, *command, **kwargs) command = @command_builder.generate(command, kwargs) error = nil result = ensure_connected do |connection| @middlewares.call(command, config) do connection.call(command, timeout) end rescue ReadTimeoutError => error break end if error raise error elsif block_given? yield result else result end end def blocking_call_v(timeout, command) command = @command_builder.generate(command) error = nil result = ensure_connected do |connection| @middlewares.call(command, config) do connection.call(command, timeout) end rescue ReadTimeoutError => error break end if error raise error elsif block_given? yield result else result end end def scan(*args, **kwargs, &block) unless block_given? return to_enum(__callee__, *args, **kwargs) end args = @command_builder.generate(["SCAN", 0] + args, kwargs) scan_list(1, args, &block) end def sscan(key, *args, **kwargs, &block) unless block_given? return to_enum(__callee__, key, *args, **kwargs) end args = @command_builder.generate(["SSCAN", key, 0] + args, kwargs) scan_list(2, args, &block) end def hscan(key, *args, **kwargs, &block) unless block_given? return to_enum(__callee__, key, *args, **kwargs) end args = @command_builder.generate(["HSCAN", key, 0] + args, kwargs) scan_pairs(2, args, &block) end def zscan(key, *args, **kwargs, &block) unless block_given? return to_enum(__callee__, key, *args, **kwargs) end args = @command_builder.generate(["ZSCAN", key, 0] + args, kwargs) scan_pairs(2, args, &block) end def connected? @raw_connection&.revalidate end def close @raw_connection&.close self end def disable_reconnection(&block) ensure_connected(retryable: false, &block) end def pipelined(exception: true) pipeline = Pipeline.new(@command_builder) yield pipeline if pipeline._size == 0 [] else results = ensure_connected(retryable: pipeline._retryable?) do |connection| commands = pipeline._commands @middlewares.call_pipelined(commands, config) do connection.call_pipelined(commands, pipeline._timeouts, exception: exception) end end pipeline._coerce!(results) end end def multi(watch: nil, &block) transaction = nil results = if watch # WATCH is stateful, so we can't reconnect if it's used, the whole transaction # has to be redone. ensure_connected(retryable: false) do |connection| call("WATCH", *watch) begin if transaction = build_transaction(&block) commands = transaction._commands results = @middlewares.call_pipelined(commands, config) do connection.call_pipelined(commands, nil) end.last else call("UNWATCH") [] end rescue call("UNWATCH") if connected? && watch raise end end else transaction = build_transaction(&block) if transaction._empty? [] else ensure_connected(retryable: transaction._retryable?) do |connection| commands = transaction._commands @middlewares.call_pipelined(commands, config) do connection.call_pipelined(commands, nil) end.last end end end if transaction transaction._coerce!(results) else results end end class PubSub def initialize(raw_connection, command_builder) @raw_connection = raw_connection @command_builder = command_builder end def call(*command, **kwargs) raw_connection.write(@command_builder.generate(command, kwargs)) nil end def call_v(command) raw_connection.write(@command_builder.generate(command)) nil end def close @raw_connection&.close @raw_connection = nil # PubSub can't just reconnect self end def next_event(timeout = nil) unless raw_connection raise ConnectionError, "Connection was closed or lost" end raw_connection.read(timeout) rescue ReadTimeoutError nil end private attr_reader :raw_connection end class Multi def initialize(command_builder) @command_builder = command_builder @size = 0 @commands = [] @blocks = nil @retryable = true end def call(*command, **kwargs, &block) command = @command_builder.generate(command, kwargs) (@blocks ||= [])[@commands.size] = block if block_given? @commands << command nil end def call_v(command, &block) command = @command_builder.generate(command) (@blocks ||= [])[@commands.size] = block if block_given? @commands << command nil end def call_once(*command, **kwargs, &block) command = @command_builder.generate(command, kwargs) @retryable = false (@blocks ||= [])[@commands.size] = block if block_given? @commands << command nil end def call_once_v(command, &block) command = @command_builder.generate(command) @retryable = false (@blocks ||= [])[@commands.size] = block if block_given? @commands << command nil end def _commands @commands end def _blocks @blocks end def _size @commands.size end def _empty? @commands.size <= 2 end def _timeouts nil end def _retryable? @retryable end def _coerce!(results) results&.each_with_index do |result, index| if result.is_a?(CommandError) result._set_command(@commands[index + 1]) raise result end if @blocks && block = @blocks[index + 1] results[index] = block.call(result) end end results end end class Pipeline < Multi def initialize(_command_builder) super @timeouts = nil end def blocking_call(timeout, *command, **kwargs, &block) command = @command_builder.generate(command, kwargs) @timeouts ||= [] @timeouts[@commands.size] = timeout (@blocks ||= [])[@commands.size] = block if block_given? @commands << command nil end def blocking_call_v(timeout, command, &block) command = @command_builder.generate(command) @timeouts ||= [] @timeouts[@commands.size] = timeout (@blocks ||= [])[@commands.size] = block if block_given? @commands << command nil end def _timeouts @timeouts end def _empty? @commands.empty? end def _coerce!(results) return results unless results @blocks&.each_with_index do |block, index| if block results[index] = block.call(results[index]) end end results end end private def build_transaction transaction = Multi.new(@command_builder) transaction.call("MULTI") yield transaction transaction.call("EXEC") transaction end def scan_list(cursor_index, command, &block) cursor = 0 while cursor != "0" command[cursor_index] = cursor cursor, elements = call(*command) elements.each(&block) end nil end def scan_pairs(cursor_index, command) cursor = 0 while cursor != "0" command[cursor_index] = cursor cursor, elements = call(*command) index = 0 size = elements.size while index < size yield elements[index], elements[index + 1] index += 2 end end nil end def ensure_connected(retryable: true) close if !config.inherit_socket && @pid != PIDCache.pid if @disable_reconnection if block_given? yield @raw_connection else @raw_connection end elsif retryable tries = 0 connection = nil preferred_error = nil begin connection = raw_connection if block_given? yield connection else connection end rescue ConnectionError, ProtocolError => error preferred_error ||= error preferred_error = error unless error.is_a?(CircuitBreaker::OpenCircuitError) close if !@disable_reconnection && config.retry_connecting?(tries, error) tries += 1 retry else raise preferred_error end end else previous_disable_reconnection = @disable_reconnection connection = ensure_connected begin @disable_reconnection = true yield connection rescue ConnectionError, ProtocolError close raise ensure @disable_reconnection = previous_disable_reconnection end end end def raw_connection if @raw_connection.nil? || !@raw_connection.revalidate connect end @raw_connection end def connect @pid = PIDCache.pid if @raw_connection @middlewares.connect(config) do @raw_connection.reconnect end else @raw_connection = @middlewares.connect(config) do config.driver.new( config, connect_timeout: connect_timeout, read_timeout: read_timeout, write_timeout: write_timeout, ) end end prelude = config.connection_prelude.dup if id prelude << ["CLIENT", "SETNAME", id] end # The connection prelude is deliberately not sent to Middlewares if config.sentinel? prelude << ["ROLE"] role, = @middlewares.call_pipelined(prelude, config) do @raw_connection.call_pipelined(prelude, nil).last end config.check_role!(role) else unless prelude.empty? @middlewares.call_pipelined(prelude, config) do @raw_connection.call_pipelined(prelude, nil) end end end rescue FailoverError, CannotConnectError => error error._set_config(config) raise error rescue ConnectionError => error connect_error = CannotConnectError.with_config(error.message, config) connect_error.set_backtrace(error.backtrace) raise connect_error rescue CommandError => error if error.message.match?(/ERR unknown command ['`]HELLO['`]/) raise UnsupportedServer, "redis-client requires Redis 6+ with HELLO command available (#{config.server_url})" else raise end end end require "redis_client/pooled" require "redis_client/circuit_breaker" RedisClient.default_driver redis-client-0.22.2/Rakefile0000644000004100000410000000742114625006647015672 0ustar www-datawww-data# frozen_string_literal: true require "rake/extensiontask" require "rake/testtask" require 'rubocop/rake_task' RuboCop::RakeTask.new require "rake/clean" CLOBBER.include "pkg" require "bundler/gem_helper" Bundler::GemHelper.install_tasks(name: "redis-client") Bundler::GemHelper.install_tasks(dir: "hiredis-client", name: "hiredis-client") gemspec = Gem::Specification.load("redis-client.gemspec") Rake::ExtensionTask.new do |ext| ext.name = "hiredis_connection" ext.ext_dir = "hiredis-client/ext/redis_client/hiredis" ext.lib_dir = "hiredis-client/lib/redis_client" ext.gem_spec = gemspec CLEAN.add("#{ext.ext_dir}/vendor/*.{a,o}") end namespace :test do Rake::TestTask.new(:ruby) do |t| t.libs << "test" t.libs << "lib" t.test_files = FileList["test/**/*_test.rb"].exclude("test/sentinel/*_test.rb") t.options = '-v' if ENV['CI'] || ENV['VERBOSE'] end Rake::TestTask.new(:sentinel) do |t| t.libs << "test/sentinel" t.libs << "test" t.libs << "lib" t.test_files = FileList["test/sentinel/*_test.rb"] t.options = '-v' if ENV['CI'] || ENV['VERBOSE'] end Rake::TestTask.new(:hiredis) do |t| t.libs << "test/hiredis" t.libs << "test" t.libs << "hiredis-client/lib" t.libs << "lib" t.test_files = FileList["test/**/*_test.rb"].exclude("test/sentinel/*_test.rb") t.options = '-v' if ENV['CI'] || ENV['VERBOSE'] end end hiredis_supported = RUBY_ENGINE == "ruby" && !RUBY_PLATFORM.match?(/mswin/) if hiredis_supported task test: %i[test:ruby test:hiredis test:sentinel] else task test: %i[test:ruby test:sentinel] end namespace :hiredis do task :download do version = "1.0.2" archive_path = "tmp/hiredis-#{version}.tar.gz" url = "https://github.com/redis/hiredis/archive/refs/tags/v#{version}.tar.gz" system("curl", "-L", url, out: archive_path) or raise "Downloading of #{url} failed" system("rm", "-rf", "hiredis-client/ext/redis_client/hiredis/vendor/") system("mkdir", "-p", "hiredis-client/ext/redis_client/hiredis/vendor/") system( "tar", "xvzf", archive_path, "-C", "hiredis-client/ext/redis_client/hiredis/vendor", "--strip-components", "1", ) system("rm", "-rf", "hiredis-client/ext/redis_client/hiredis/vendor/examples") end end benchmark_suites = %w(single pipelined drivers) benchmark_modes = %i[ruby yjit hiredis] namespace :benchmark do benchmark_suites.each do |suite| benchmark_modes.each do |mode| next if suite == "drivers" && mode == :hiredis name = "#{suite}_#{mode}" desc name task name do output_path = "benchmark/#{name}.md" sh "rm", "-f", output_path File.open(output_path, "w+") do |output| output.puts("ruby: `#{RUBY_DESCRIPTION}`\n\n") output.puts("redis-server: `#{`redis-server -v`.strip}`\n\n") output.puts output.flush env = {} args = [] args << "--yjit" if mode == :yjit env["DRIVER"] = mode == :hiredis ? "hiredis" : "ruby" system(env, RbConfig.ruby, *args, "benchmark/#{suite}.rb", out: output) end skipping = false output = File.readlines(output_path).reject do |line| if skipping if line == "Comparison:\n" skipping = false true else skipping end else skipping = true if line.start_with?("Warming up ---") skipping end end File.write(output_path, output.join) end end end task all: benchmark_suites.flat_map { |s| benchmark_modes.flat_map { |m| "#{s}_#{m}" } } end if hiredis_supported task default: %i[compile test rubocop] task ci: %i[compile test:ruby test:hiredis] else task default: %i[test rubocop] task ci: %i[test:ruby] end redis-client-0.22.2/LICENSE.md0000644000004100000410000000206214625006647015625 0ustar www-datawww-dataThe MIT License (MIT) Copyright (c) 2022 Shopify Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. redis-client-0.22.2/Gemfile0000644000004100000410000000063314625006647015516 0ustar www-datawww-data# frozen_string_literal: true source "https://rubygems.org" # Specify your gem's dependencies in redis-client.gemspec gemspec name: "redis-client" gem "minitest" gem "rake", "~> 13.2" gem "rake-compiler" gem "rubocop" gem "rubocop-minitest" gem "toxiproxy" group :benchmark do gem "benchmark-ips" gem "hiredis" gem "redis", "~> 4.6" gem "stackprof", platform: :mri end gem "byebug", platform: :mri redis-client-0.22.2/README.md0000644000004100000410000004627414625006647015515 0ustar www-datawww-data# RedisClient `redis-client` is a simple, low-level, client for Redis 6+. Contrary to the `redis` gem, `redis-client` doesn't try to map all Redis commands to Ruby constructs, it merely is a thin wrapper on top of the RESP3 protocol. ## Installation Add this line to your application's Gemfile: ```ruby gem 'redis-client' ``` And then execute: $ bundle install Or install it yourself as: $ gem install redis-client ## Usage To use `RedisClient` you first define a connection configuration, from which you can create a connection pool: ```ruby redis_config = RedisClient.config(host: "10.0.1.1", port: 6380, db: 15) redis = redis_config.new_pool(timeout: 0.5, size: Integer(ENV.fetch("RAILS_MAX_THREADS", 5))) redis.call("PING") # => "PONG" ``` If you are issuing multiple commands in a raw, but can't pipeline them, it's best to use `#with` to avoid going through the connection checkout several times: ```ruby redis.with do |r| r.call("SET", "mykey", "hello world") # => "OK" r.call("GET", "mykey") # => "hello world" end ``` If you are working in a single-threaded environment, or wish to use your own connection pooling mechanism, you can obtain a raw client with `#new_client` ```ruby redis_config = RedisClient.config(host: "10.0.1.1", port: 6380, db: 15) redis = redis_config.new_client redis.call("PING") # => "PONG" ``` NOTE: Raw `RedisClient` instances must not be shared between threads. Make sure to read the section on [thread safety](#thread-safety). For simple use cases where only a single connection is needed, you can use the `RedisClient.new` shortcut: ```ruby redis = RedisClient.new redis.call("GET", "mykey") ``` ### Configuration - `url`: A Redis connection URL, e.g. `redis://example.com:6379/5` - a `rediss://` scheme enables SSL, and the path is interpreted as a database number. To connect to UNIX domain sockets, the `url` can also just be a path, and the database specified as query parameter: `/run/redis/foo.sock?db=5`, or optionally have a `unix://` scheme: `unix:///run/redis/foo.sock?db=5` Note that all other configurations take precedence, e.g. `RedisClient.config(url: "redis://localhost:3000", port: 6380)` will connect on port `6380`. - `host`: The server hostname or IP address. Defaults to `"localhost"`. - `port`: The server port. Defaults to `6379`. - `path`: The path to a UNIX socket, if set `url`, `host` and `port` are ignored. - `ssl`: Whether to connect using SSL or not. - `ssl_params`: A configuration Hash passed to [`OpenSSL::SSL::SSLContext#set_params`](https://www.rubydoc.info/stdlib/openssl/OpenSSL%2FSSL%2FSSLContext:set_params), notable options include: - `cert`: The path to the client certificate (e.g. `client.crt`). - `key`: The path to the client key (e.g. `client.key`). - `ca_file`: The certificate authority to use, useful for self-signed certificates (e.g. `ca.crt`), - `db`: The database to select after connecting, defaults to `0`. - `id` ID for the client connection, assigns name to current connection by sending `CLIENT SETNAME`. - `username` Username to authenticate against server, defaults to `"default"`. - `password` Password to authenticate against server. - `timeout`: The general timeout in seconds, default to `1.0`. - `connect_timeout`: The connection timeout, takes precedence over the general timeout when connecting to the server. - `read_timeout`: The read timeout, takes precedence over the general timeout when reading responses from the server. - `write_timeout`: The write timeout, takes precedence over the general timeout when sending commands to the server. - `reconnect_attempts`: Specify how many times the client should retry to send queries. Defaults to `0`. Makes sure to read the [reconnection section](#reconnection) before enabling it. - `circuit_breaker`: A Hash with circuit breaker configuration. Defaults to `nil`. See the [circuit breaker section](#circuit-breaker) for details. - `protocol:` The version of the RESP protocol to use. Default to `3`. - `custom`: A user-owned value ignored by `redis-client` but available as `Config#custom`. This can be used to hold middleware configurations and other user-specific metadata. ### Sentinel support The client is able to perform automatic failover by using [Redis Sentinel](https://redis.io/docs/manual/sentinel/). To connect using Sentinel, use: ```ruby redis_config = RedisClient.sentinel( name: "mymaster", sentinels: [ { host: "127.0.0.1", port: 26380 }, { host: "127.0.0.1", port: 26381 }, ], role: :master, ) ``` or: ```ruby redis_config = RedisClient.sentinel( name: "mymaster", sentinels: [ "redis://127.0.0.1:26380", "redis://127.0.0.1:26381", ], role: :master, ) ``` * The name identifies a group of Redis instances composed of a master and one or more replicas (`mymaster` in the example). * It is possible to optionally provide a role. The allowed roles are `:master` and `:replica`. When the role is `:replica`, the client will try to connect to a random replica of the specified master. If a role is not specified, the client will connect to the master. * When using the Sentinel support you need to specify a list of sentinels to connect to. The list does not need to enumerate all your Sentinel instances, but a few so that if one is down the client will try the next one. The client is able to remember the last Sentinel that was able to reply correctly and will use it for the next requests. To [authenticate](https://redis.io/docs/management/sentinel/#configuring-sentinel-instances-with-authentication) Sentinel itself, you can specify the `sentinel_username` and `sentinel_password` options per instance. Exclude the `sentinel_username` option if you're using password-only authentication. ```ruby SENTINELS = [{ host: '127.0.0.1', port: 26380}, { host: '127.0.0.1', port: 26381}] redis_config = RedisClient.sentinel(name: 'mymaster', sentinel_username: 'appuser', sentinel_password: 'mysecret', sentinels: SENTINELS, role: :master) ``` If you specify a username and/or password at the top level for your main Redis instance, Sentinel *will not* using thouse credentials ```ruby # Use 'mysecret' to authenticate against the mymaster instance, but skip authentication for the sentinels: SENTINELS = [{ host: '127.0.0.1', port: 26380 }, { host: '127.0.0.1', port: 26381 }] redis_config = RedisClient.sentinel(name: 'mymaster', sentinels: SENTINELS, role: :master, password: 'mysecret') ``` So you have to provide Sentinel credential and Redis explicitly even they are the same ```ruby # Use 'mysecret' to authenticate against the mymaster instance and sentinel SENTINELS = [{ host: '127.0.0.1', port: 26380 }, { host: '127.0.0.1', port: 26381 }] redis_config = RedisClient.sentinel(name: 'mymaster', sentinels: SENTINELS, role: :master, password: 'mysecret', sentinel_password: 'mysecret') ``` Also the `name`, `password`, `username` and `db` for Redis instance can be passed as an url: ```ruby redis_config = RedisClient.sentinel(url: "redis://appuser:mysecret@mymaster/10", sentinels: SENTINELS, role: :master) ``` ### Type support Only a select few Ruby types are supported as arguments beside strings. Integer and Float are supported: ```ruby redis.call("SET", "mykey", 42) redis.call("SET", "mykey", 1.23) ``` is equivalent to: ```ruby redis.call("SET", "mykey", 42.to_s) redis.call("SET", "mykey", 1.23.to_s) ``` Arrays are flattened as arguments: ```ruby redis.call("LPUSH", "list", [1, 2, 3], 4) ``` is equivalent to: ```ruby redis.call("LPUSH", "list", "1", "2", "3", "4") ``` Hashes are flattened as well: ```ruby redis.call("HMSET", "hash", { "foo" => "1", "bar" => "2" }) ``` is equivalent to: ```ruby redis.call("HMSET", "hash", "foo", "1", "bar", "2") ``` Any other type requires the caller to explicitly cast the argument as a string. Keywords arguments are treated as Redis command flags: ```ruby redis.call("SET", "mykey", "value", nx: true, ex: 60) redis.call("SET", "mykey", "value", nx: false, ex: nil) ``` is equivalent to: ```ruby redis.call("SET", "mykey", "value", "nx", "ex", "60") redis.call("SET", "mykey", "value") ``` If flags are built dynamically, you'll have to explicitly pass them as keyword arguments with `**`: ```ruby flags = {} flags[:nx] = true if something? redis.call("SET", "mykey", "value", **flags) ``` **Important Note**: because of the keyword argument semantic change between Ruby 2 and Ruby 3, unclosed hash literals with string keys may be interpreted differently: ```ruby redis.call("HMSET", "hash", "foo" => "bar") ``` On Ruby 2 `"foo" => "bar"` will be passed as a positional argument, but on Ruby 3 it will be interpreted as keyword arguments. To avoid such problem, make sure to enclose hash literals: ```ruby redis.call("HMSET", "hash", { "foo" => "bar" }) ``` ### Commands return values Contrary to the `redis` gem, `redis-client` doesn't do any type casting on the return value of commands. If you wish to cast the return value, you can pass a block to the `#call` family of methods: ```ruby redis.call("INCR", "counter") # => 1 redis.call("GET", "counter") # => "1" redis.call("GET", "counter", &:to_i) # => 1 redis.call("EXISTS", "counter") # => 1 redis.call("EXISTS", "counter") { |c| c > 0 } # => true ``` ### `*_v` methods In some it's more convenient to pass commands as arrays, for that `_v` versions of `call` methods are available. ```ruby redis.call_v(["MGET"] + keys) redis.blocking_call_v(1, ["MGET"] + keys) redis.call_once_v(1, ["MGET"] + keys) ``` ### Blocking commands For blocking commands such as `BRPOP`, a custom timeout duration can be passed as first argument of the `#blocking_call` method: ``` redis.blocking_call(timeout, "BRPOP", "key", 0) ``` If `timeout` is reached, `#blocking_call` raises `RedisClient::ReadTimeoutError` and doesn't retry regardless of the `reconnect_attempts` configuration. `timeout` is expressed in seconds, you can pass `false` or `0` to mean no timeout. ### Scan commands For easier use of the [`SCAN` family of commands](https://redis.io/commands/scan), `#scan`, `#sscan`, `#hscan` and `#zscan` methods are provided ```ruby redis.scan("MATCH", "pattern:*") do |key| ... end ``` ```ruby redis.sscan("myset", "MATCH", "pattern:*") do |key| ... end ``` For `HSCAN` and `ZSCAN`, pairs are yielded ```ruby redis.hscan("myhash", "MATCH", "pattern:*") do |key, value| ... end ``` ```ruby redis.zscan("myzset") do |element, score| ... end ``` In all cases the `cursor` parameter must be omitted and starts at `0`. ### Pipelining When multiple commands are executed sequentially, but are not dependent, the calls can be pipelined. This means that the client doesn't wait for reply of the first command before sending the next command. The advantage is that multiple commands are sent at once, resulting in faster overall execution. The client can be instructed to pipeline commands by using the `#pipelined method`. After the block is executed, the client sends all commands to Redis and gathers their replies. These replies are returned by the `#pipelined` method. ```ruby redis.pipelined do |pipeline| pipeline.call("SET", "foo", "bar") # => nil pipeline.call("INCR", "baz") # => nil end # => ["OK", 1] ``` #### Exception management The `exception` flag in the `#pipelined` method of `RedisClient` is a feature that modifies the pipeline execution behavior. When set to `false`, it doesn't raise an exception when a command error occurs. Instead, it allows the pipeline to execute all commands, and any failed command will be available in the returned array. (Defaults to `true`) ```ruby results = redis.pipelined(exception: false) do |pipeline| pipeline.call("SET", "foo", "bar") # => nil pipeline.call("DOESNOTEXIST", 12) # => nil pipeline.call("INCR", "baz") # => nil end # results => ["OK", #, 2] results.each do |result| if result.is_a?(RedisClient::CommandError) # Do something with the failed result end end ``` ### Transactions You can use [`MULTI/EXEC` to run a number of commands in an atomic fashion](https://redis.io/topics/transactions). This is similar to executing a pipeline, but the commands are preceded by a call to `MULTI`, and followed by a call to `EXEC`. Like the regular pipeline, the replies to the commands are returned by the `#multi` method. ```ruby redis.multi do |transaction| transaction.call("SET", "foo", "bar") # => nil transaction.call("INCR", "baz") # => nil end # => ["OK", 1] ``` For optimistic locking, the watched keys can be passed to the `#multi` method: ```ruby redis.multi(watch: ["title"]) do |transaction| title = redis.call("GET", "title") transaction.call("SET", "title", title.upcase) end # => ["OK"] / nil ``` If the transaction wasn't successful, `#multi` will return `nil`. Note that transactions using optimistic locking aren't automatically retried upon connection errors. ### Publish / Subscribe Pub/Sub related commands must be called on a dedicated `PubSub` object: ```ruby redis = RedisClient.new pubsub = redis.pubsub pubsub.call("SUBSCRIBE", "channel-1", "channel-2") loop do if message = pubsub.next_event(timeout) message # => ["subscribe", "channel-1", 1] else # no new message was received in the allocated timeout end end ``` *Note*: pubsub connections are stateful, as such they won't ever reconnect automatically. The caller is responsible for reconnecting if the connection is lost and to resubscribe to all channels. ## Production ### Instrumentation and Middlewares `redis-client` offers a public middleware API to aid in monitoring and library extension. Middleware can be registered either globally or on a given configuration instance. ```ruby module MyGlobalRedisInstrumentation def connect(redis_config) MyMonitoringService.instrument("redis.connect") { super } end def call(command, redis_config) MyMonitoringService.instrument("redis.query") { super } end def call_pipelined(commands, redis_config) MyMonitoringService.instrument("redis.pipeline") { super } end end RedisClient.register(MyGlobalRedisInstrumentation) ``` Note that `RedisClient.register` is global and apply to all `RedisClient` instances. To add middlewares to only a single client, you can provide them when creating the config: ```ruby redis_config = RedisClient.config(middlewares: [AnotherRedisInstrumentation]) redis_config.new_client ``` If middlewares need a client-specific configuration, `Config#custom` can be used ```ruby module MyGlobalRedisInstrumentation def connect(redis_config) MyMonitoringService.instrument("redis.connect", tags: redis_config.custom[:tags]) { super } end def call(command, redis_config) MyMonitoringService.instrument("redis.query", tags: redis_config.custom[:tags]) { super } end def call_pipelined(commands, redis_config) MyMonitoringService.instrument("redis.pipeline", tags: redis_config.custom[:tags]) { super } end end RedisClient.register(MyGlobalRedisInstrumentation) redis_config = RedisClient.config(custom: { tags: { "environment": Rails.env }}) ``` ### Timeouts The client allows you to configure connect, read, and write timeouts. Passing a single `timeout` option will set all three values: ```ruby RedisClient.config(timeout: 1).new ``` But you can use specific values for each of them: ```ruby RedisClient.config( connect_timeout: 0.2, read_timeout: 1.0, write_timeout: 0.5, ).new ``` All timeout values are specified in seconds. ### Reconnection `redis-client` support automatic reconnection after network errors via the `reconnect_attempts:` configuration option. It can be set as a number of retries: ```ruby redis_config = RedisClient.config(reconnect_attempts: 1) ``` **Important Note**: Retrying may cause commands to be issued more than once to the server, so in the case of non-idempotent commands such as `LPUSH` or `INCR`, it may cause consistency issues. To selectively disable automatic retries, you can use the `#call_once` method: ```ruby redis_config = RedisClient.config(reconnect_attempts: 3) redis = redis_config.new_client redis.call("GET", "counter") # Will be retried up to 3 times. redis.call_once("INCR", "counter") # Won't be retried. ``` **Note**: automatic reconnection doesn't apply to pubsub clients as their connection is stateful. ### Exponential backoff Alternatively, `reconnect_attempts` accepts a list of sleep durations for implementing exponential backoff: ```ruby redis_config = RedisClient.config(reconnect_attempts: [0, 0.05, 0.1]) ``` This configuration is generally used when the Redis server is expected to failover or recover relatively quickly and that it's not really possible to continue without issuing the command. When the Redis server is used as an ephemeral cache, circuit breakers are generally preferred. ### Circuit Breaker When Redis is used as a cache and a connection error happens, you may not want to retry as it might take longer than to recompute the value. Instead it's likely preferable to mark the server as unavailable and let it recover for a while. [Circuit breakers are a pattern that does exactly that](https://en.wikipedia.org/wiki/Circuit_breaker_design_pattern). Configuration options: - `error_threshold`. The amount of errors to encounter within `error_threshold_timeout` amount of time before opening the circuit, that is to start rejecting requests instantly. - `error_threshold_timeout`. The amount of time in seconds that `error_threshold` errors must occur to open the circuit. Defaults to `error_timeout` seconds if not set. - `error_timeout`. The amount of time in seconds until trying to query the resource again. - `success_threshold`. The amount of successes on the circuit until closing it again, that is to start accepting all requests to the circuit. ```ruby RedisClient.config( circuit_breaker: { # Stop querying the server after 3 errors happened in a 2 seconds window error_threshold: 3, error_threshold_timeout: 2, # Try querying again after 1 second error_timeout: 1, # Stay in half-open state until 3 queries succeeded. success_threshold: 3, } ) ``` ### Drivers `redis-client` ships with a pure Ruby socket implementation. For increased performance, you can enable the `hiredis` binding by adding `hiredis-client` to your Gemfile: ```ruby gem "hiredis-client" ``` The hiredis binding is only available on Linux, macOS and other POSIX platforms. You can install the gem on other platforms, but it won't have any effect. The default driver can be set through `RedisClient.default_driver=`: ## Notable differences with the `redis` gem ### Thread Safety Contrary to the `redis` gem, `redis-client` doesn't protect against concurrent access. To use `redis-client` in concurrent environments, you MUST use a connection pool, or have one client per Thread or Fiber. ## Development After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment. To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/redis-rb/redis-client. redis-client-0.22.2/CHANGELOG.md0000644000004100000410000001520414625006647016034 0ustar www-datawww-data# Unreleased # 0.22.2 - Fix the sentinel client to properly extend timeout for blocking commands. - Fix IPv6 support in `RedisClient::Config#server_url`. # 0.22.1 - Fix `ProtocolError: Unknown sigil type` errors when using SSL connection. See #190. # 0.22.0 - Made various performance optimizations to the Ruby driver. See #184. - Always assume UTF-8 encoding instead of relying on `Encoding.default_external`. - Add `exception` flag in `pipelined` allowing failed commands to be returned in the result array when set to `false`. See #187. # 0.21.1 - Handle unresolved Sentinel master/replica error when displaying server URL in exceptions. See #182. # 0.21.0 - Include redis server URL in most error messages. See #178. - Close Redis Sentinel connection after resolving role. See #176. # 0.20.0 - Accept `unix://` schemes as well as simple paths in the `url:` config parameter. #170. - Make basic usage Ractor compatible. # 0.19.1 - Fixed a bug in `hiredis-client` that could cause a crash if interrupted by `Timeout.timeout` or other `Thread#raise` based mecanism. - Fixed a GC bug that could cause crashes in `hiredis-client`. # 0.19.0 - Revalidate connection in `RedisClient#connected?` - Eagerly fail if `db:` isn't an Integer. #151. # 0.18.0 - Expose more connection details such as `host`, `db`, etc on `RedisClient`. # 0.17.1 - Add support for `NaN` in RESP3 protocol doubles. This was initially missing from the spec and added about a year ago. # 0.17.0 - Adds `sentinel_username` and `sentinel_password` options for `RedisClient#sentinel` # 0.16.0 - Add `RedisClient#disable_reconnection`. - Reverted the special discard of connection. A regular `close(2)` should be enough. # 0.15.0 - Discard sockets rather than explictly close them when a fork is detected. #126. - Allow to configure sentinel client via url. #117. - Fix sentinel to preverse the auth/password when refreshing the sentinel list. #107. # 0.14.1 - Include the timeout value in TimeoutError messages. - Fix connection keep-alive on FreeBSD. #102. # 0.14.0 - Implement Sentinels list automatic refresh. - hiredis binding now implement GC compaction and write barriers. - hiredis binding now properly release the GVL around `connect(2)`. - hiredis the client memory is now re-used on reconnection when possible to reduce allocation churn. # 0.13.0 - Enable TCP keepalive on redis sockets. It sends a keep alive probe every 15 seconds for 2 minutes. #94. # 0.12.2 - Cache calls to `Process.pid` on Ruby 3.1+. #91. # 0.12.1 - Improve compatibility with `uri 0.12.0` (default in Ruby 3.2.0). # 0.12.0 - hiredis: fix a compilation issue on macOS and Ruby 3.2.0. See: #79 - Close connection on MASTERDOWN errors. Similar to READONLY. - Add a `circuit_breaker` configuration option for cache servers and other disposable Redis servers. See #55 / #70 # 0.11.2 - Close connection on READONLY errors. Fix: #64 - Handle Redis 6+ servers with a missing HELLO command. See: #67 - Validate `url` parameters a bit more strictly. Fix #61 # 0.11.1 - hiredis: Workaround a compilation bug with Xcode 14.0. Fix: #58 - Accept `URI` instances as `uri` parameter. # 0.11.0 - hiredis: do not eagerly close the connection on read timeout, let the caller decide if a timeout is final. - Add `Config#custom` to store configuration metadata. It can be used for per server middleware configuration. # 0.10.0 - Added instance scoped middlewares. See: #53 - Allow subclasses of accepted types as command arguments. Fix: #51 - Improve hiredis driver error messages. # 0.9.0 - Automatically reconnect if the process was forked. # 0.8.1 - Make the client resilient to `Timeout.timeout` or `Thread#kill` use (it still is very much discouraged to use either). Use of async interrupts could cause responses to be interleaved. - hiredis: handle commands returning a top-level `false` (no command does this today, but some extensions might). - Workaround a bug in Ruby 2.6 causing a crash if the `debug` gem is enabled when `redis-client` is being required. Fix: #48 # 0.8.0 - Add a `connect` interface to the instrumentation API. # 0.7.4 - Properly parse script errors on pre 7.0 redis server. # 0.7.3 - Fix a bug in `url` parsing conflicting with the `path` option. # 0.7.2 - Raise a distinct `RedisClient::OutOfMemoryError`, for Redis `OOM` errors. - Fix the instrumentation API to be called even for authentication commands. - Fix `url:` configuration to accept a trailing slash. # 0.7.1 - Fix `#pubsub` being called when reconnection is disabled (redis-rb compatibility fix). # 0.7.0 - Sentinel config now accept a list of URLs: `RedisClient.sentinel(sentinels: %w(redis://example.com:7000 redis://example.com:7001 ..))` # 0.6.2 - Fix sentinel to not connected to s_down or o_down replicas. # 0.6.1 - Fix `REDIS_REPLY_SET` parsing in `hiredis`. # 0.6.0 - Added `protocol: 2` options to talk with Redis 5 and older servers. - Added `_v` versions of `call` methods to make it easier to pass commands as arrays without splating. - Fix calling `blocking_call` with a block in a pipeline. - `blocking_call` now raise `ReadTimeoutError` if the command didn't complete in time. - Fix `blocking_call` to not respect `retry_attempts` on timeout. - Stop parsing RESP3 sets as Ruby Set instances. - Fix `SystemStackError` when parsing very large hashes. Fix: #30 - `hiredis` now more properly release the GVL when doing IOs. # 0.5.1 - Fix a regression in the `scan` familly of methods, they would raise with `ArgumentError: can't issue an empty redis command`. Fix: #24 # 0.5.0 - Fix handling of connection URLs with empty passwords (`redis://:pass@example.com`). - Handle URLs with IPv6 hosts. - Add `RedisClient::Config#server_url` as a quick way to identify which server the client is pointing to. - Add `CommandError#command` to expose the command that caused the error. - Raise a more explicit error when connecting to older redises without RESP3 support (5.0 and older). - Properly reject empty commands early. # 0.4.0 - The `hiredis` driver have been moved to the `hiredis-client` gem. # 0.3.0 - `hiredis` is now the default driver when available. - Add `RedisClient.default_driver=`. - `#call` now takes an optional block to cast the return value. - Treat `#call` keyword arguments as Redis flags. - Fix `RedisClient#multi` returning some errors as values instead of raising them. # 0.2.1 - Use a more robust way to detect the current compiler. # 0.2.0 - Added `RedisClient.register` as a public instrumentation API. - Fix `read_timeout=` and `write_timeout=` to apply even when the client or pool is already connected. - Properly convert DNS resolution errors into `RedisClient::ConnectionError`. Previously it would raise `SocketError` # 0.1.0 - Initial Release