lib/semian/mysql2.rb



# frozen_string_literal: true

require "semian/adapter"
require "mysql2"

module Mysql2
  Mysql2::Error.include(::Semian::AdapterError)

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

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

module Semian
  module Mysql2
    include Semian::Adapter

    CONNECTION_ERROR = Regexp.union(
      /Can't connect to (?:MySQL )?server on/i,
      /Lost connection to (?:MySQL )?server/i,
      /MySQL server has gone away/i,
      /Too many connections/i,
      /closed MySQL connection/i,
      /Timeout waiting for a response/i,
      /No matching servers with free connections/i,
      /Max connect timeout reached while reaching hostgroup/i,
    )

    ResourceBusyError = ::Mysql2::ResourceBusyError
    CircuitOpenError = ::Mysql2::CircuitOpenError
    PingFailure = Class.new(::Mysql2::Error)

    DEFAULT_HOST = "localhost"
    DEFAULT_PORT = 3306

    QUERY_ALLOWLIST = %r{\A(?:/\*.*?\*/)?\s*(ROLLBACK|COMMIT|RELEASE\s+SAVEPOINT)}i

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

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

        base.send(:alias_method, :raw_ping, :ping)
        base.send(:remove_method, :ping)
      end
    end

    def semian_identifier
      @semian_identifier ||= begin
        name = semian_options && semian_options[:name]
        unless name
          host = query_options[:host] || DEFAULT_HOST
          port = query_options[:port] || DEFAULT_PORT
          name = "#{host}:#{port}"
        end
        :"mysql_#{name}"
      end
    end

    def ping
      return false if closed?

      result = nil
      acquire_semian_resource(adapter: :mysql, scope: :ping) do
        result = raw_ping
        raise PingFailure, result.to_s unless result
      end
      result
    rescue ResourceBusyError, CircuitOpenError, PingFailure
      false
    end

    def query(*args)
      if query_whitelisted?(*args)
        raw_query(*args)
      else
        acquire_semian_resource(adapter: :mysql, scope: :query) { raw_query(*args) }
      end
    end

    # TODO: write_timeout and connect_timeout can't be configured currently
    # dynamically, await https://github.com/brianmario/mysql2/pull/955
    def with_resource_timeout(temp_timeout)
      prev_read_timeout = @read_timeout

      begin
        # C-ext reads this directly, writer method will configure
        # properly on the client but based on my read--this is good enough
        # until we get https://github.com/brianmario/mysql2/pull/955 in
        @read_timeout = temp_timeout
        yield
      ensure
        @read_timeout = prev_read_timeout
      end
    end

    private

    EXCEPTIONS = [].freeze
    def resource_exceptions
      EXCEPTIONS
    end

    def query_whitelisted?(sql, *)
      QUERY_ALLOWLIST =~ sql
    rescue ArgumentError
      # The above regexp match can fail if the input SQL string contains binary
      # data that is not recognized as a valid encoding, in which case we just
      # return false.
      return false unless sql.valid_encoding?

      raise
    end

    def connect(*args)
      acquire_semian_resource(adapter: :mysql, scope: :connection) { raw_connect(*args) }
    end

    def acquire_semian_resource(**)
      super
    rescue ::Mysql2::Error => error
      if error.is_a?(PingFailure) || (!error.is_a?(::Mysql2::SemianError) && error.message.match?(CONNECTION_ERROR))
        semian_resource.mark_failed(error)
        error.semian_identifier = semian_identifier
      end
      raise
    end

    def raw_semian_options
      return query_options[:semian] if query_options.key?(:semian)

      query_options["semian"] if query_options.key?("semian")
    end
  end
end

::Mysql2::Client.include(Semian::Mysql2)