lib/semian/redis.rb



# frozen_string_literal: true

require "semian/adapter"
require "redis"

if Redis::VERSION >= "5"
  gem "redis", ">= 5.0.3"
  require "semian/redis/v5"
  return
end

class Redis
  Redis::BaseConnectionError.include(::Semian::AdapterError)
  ::Errno::EINVAL.include(::Semian::AdapterError)

  class SemianError < Redis::BaseConnectionError
    def initialize(semian_identifier, *args)
      super(*args)
      @semian_identifier = semian_identifier
    end
  end

  class OutOfMemoryError < Redis::CommandError
    include ::Semian::AdapterError
  end

  class ConnectionError < Redis::BaseConnectionError
    # A Connection Reset is a fast failure and we don't want to track these errors in
    # semian
    def marks_semian_circuits?
      message != "Connection lost (ECONNRESET)"
    end
  end

  ResourceBusyError = Class.new(SemianError)
  CircuitOpenError = Class.new(SemianError)
  ResolveError = Class.new(SemianError)

  alias_method :_original_initialize, :initialize

  def initialize(*args, &block)
    _original_initialize(*args, &block)

    # This reference is necessary because during a `pipelined` block the client
    # is replaced by an instance of `Redis::Pipeline` and there is no way to
    # access the original client which references the Semian resource.
    @original_client = _client
  end

  def semian_resource
    @original_client.semian_resource
  end

  def semian_identifier
    semian_resource.name
  end

  # Compatibility with old versions of the Redis gem
  unless instance_methods.include?(:_client)
    def _client
      @client
    end
  end
end

module Semian
  module RedisV4
    include Semian::Adapter

    ResourceBusyError = ::Redis::ResourceBusyError
    CircuitOpenError = ::Redis::CircuitOpenError
    ResolveError = ::Redis::ResolveError

    class << self
      # The naked methods are exposed as `raw_query` and `raw_connect` for instrumentation purpose
      def included(base)
        base.send(:alias_method, :raw_io, :io)
        base.send(:remove_method, :io)

        base.send(:alias_method, :raw_connect, :connect)
        base.send(:remove_method, :connect)
      end
    end

    def semian_identifier
      @semian_identifier ||= begin
        name = semian_options && semian_options[:name]
        name ||= "#{location}/#{db}"
        :"redis_#{name}"
      end
    end

    def io(&block)
      acquire_semian_resource(adapter: :redis, scope: :query) do
        reply = raw_io(&block)
        raise_if_out_of_memory(reply)
        reply
      end
    end

    def connect
      acquire_semian_resource(adapter: :redis, scope: :connection) do
        raw_connect
      rescue SocketError, RuntimeError => e
        raise ResolveError, semian_identifier if dns_resolve_failure?(e.cause || e)

        raise
      end
    end

    def with_resource_timeout(temp_timeout)
      timeout = options[:timeout]
      connect_timeout = options[:connect_timeout]
      read_timeout = options[:read_timeout]
      write_timeout = options[:write_timeout]

      begin
        connection.timeout = temp_timeout if connected?
        options[:timeout] = Float(temp_timeout)
        options[:connect_timeout] = Float(temp_timeout)
        options[:read_timeout] = Float(temp_timeout)
        options[:write_timeout] = Float(temp_timeout)
        yield
      ensure
        options[:timeout] = timeout
        options[:connect_timeout] = connect_timeout
        options[:read_timeout] = read_timeout
        options[:write_timeout] = write_timeout
        connection.timeout = self.timeout if connected?
      end
    end

    private

    def resource_exceptions
      [
        ::Redis::BaseConnectionError,
        ::Errno::EINVAL, # Hiredis bug: https://github.com/redis/hiredis-rb/issues/21
        ::Redis::OutOfMemoryError,
      ]
    end

    def raw_semian_options
      return options[:semian] if options.key?(:semian)
      return options["semian"] if options.key?("semian")
    end

    def raise_if_out_of_memory(reply)
      return unless reply.is_a?(::Redis::CommandError)
      return unless reply.message =~ /OOM command not allowed when used memory > 'maxmemory'/

      raise ::Redis::OutOfMemoryError, reply.message
    end

    def dns_resolve_failure?(e)
      e.to_s.match?(/(can't resolve)|(name or service not known)|(nodename nor servname provided, or not known)|(failure in name resolution)/i) # rubocop:disable Layout/LineLength
    end
  end
end

::Redis::Client.include(Semian::RedisV4)