# frozen_string_literal: true

module Gitlab
  module Database
    module Reindexing
      class ConcurrentReindex
        include Gitlab::Utils::StrongMemoize

        ReindexError = Class.new(StandardError)

        PG_IDENTIFIER_LENGTH = 63
        TEMPORARY_INDEX_PREFIX = 'tmp_reindex_'
        REPLACED_INDEX_PREFIX = 'old_reindex_'
        STATEMENT_TIMEOUT = 9.hours

        # When dropping an index, we acquire a SHARE UPDATE EXCLUSIVE lock,
        # which only conflicts with DDL and vacuum. We therefore execute this with a rather
        # high lock timeout and a long pause in between retries. This is an alternative to
        # setting a high statement timeout, which would lead to a long running query with effects
        # on e.g. vacuum.
        REMOVE_INDEX_RETRY_CONFIG = [[1.minute, 9.minutes]] * 30

        attr_reader :index, :logger

        def initialize(index, logger: Gitlab::AppLogger)
          @index = index
          @logger = logger
        end

        def perform
          raise ReindexError, 'UNIQUE indexes are currently not supported' if index.unique?
          raise ReindexError, 'partitioned indexes are currently not supported' if index.partitioned?
          raise ReindexError, 'indexes serving an exclusion constraint are currently not supported' if index.exclusion?
          raise ReindexError, 'index is a left-over temporary index from a previous reindexing run' if index.name.start_with?(TEMPORARY_INDEX_PREFIX, REPLACED_INDEX_PREFIX)

          logger.info "Starting reindex of #{index}"

          with_rebuilt_index do |replacement_index|
            swap_index(replacement_index)
          end
        end

        private

        def with_rebuilt_index
          if Gitlab::Database::PostgresIndex.find_by(schema: index.schema, name: replacement_index_name)
            logger.debug("dropping dangling index from previous run (if it exists): #{replacement_index_name}")
            remove_index(index.schema, replacement_index_name)
          end

          create_replacement_index_statement = index.definition
            .sub(/CREATE INDEX #{index.name}/, "CREATE INDEX CONCURRENTLY #{replacement_index_name}")

          logger.info("creating replacement index #{replacement_index_name}")
          logger.debug("replacement index definition: #{create_replacement_index_statement}")

          set_statement_timeout do
            connection.execute(create_replacement_index_statement)
          end

          replacement_index = Gitlab::Database::PostgresIndex.find_by(schema: index.schema, name: replacement_index_name)

          unless replacement_index.valid_index?
            message = 'replacement index was created as INVALID'
            logger.error("#{message}, cleaning up")
            raise ReindexError, "failed to reindex #{index}: #{message}"
          end

          # Some expression indexes (aka functional indexes)
          # require additional statistics. The existing statistics
          # are tightly bound to the original index. We have to
          # rebuild statistics for the new index before dropping
          # the original one.
          rebuild_statistics if index.expression?

          yield replacement_index
        ensure
          begin
            remove_index(index.schema, replacement_index_name)
          rescue StandardError => e
            logger.error(e)
          end
        end

        def swap_index(replacement_index)
          logger.info("swapping replacement index #{replacement_index} with #{index}")

          with_lock_retries do
            rename_index(index.schema, index.name, replaced_index_name)
            rename_index(replacement_index.schema, replacement_index.name, index.name)
            rename_index(index.schema, replaced_index_name, replacement_index.name)
          end
        end

        def rename_index(schema, old_index_name, new_index_name)
          connection.execute(<<~SQL)
            ALTER INDEX #{quote_table_name(schema)}.#{quote_table_name(old_index_name)}
            RENAME TO #{quote_table_name(new_index_name)}
          SQL
        end

        def remove_index(schema, name)
          logger.info("Removing index #{schema}.#{name}")

          retries = Gitlab::Database::WithLockRetriesOutsideTransaction.new(
            timing_configuration: REMOVE_INDEX_RETRY_CONFIG,
            klass: self.class,
            logger: logger
          )

          retries.run(raise_on_exhaustion: false) do
            connection.execute(<<~SQL)
              DROP INDEX CONCURRENTLY
              IF EXISTS #{quote_table_name(schema)}.#{quote_table_name(name)}
            SQL
          end
        end

        def rebuild_statistics
          logger.info("rebuilding table statistics for #{index.schema}.#{index.tablename}")

          connection.execute(<<~SQL)
            ANALYZE #{quote_table_name(index.schema)}.#{quote_table_name(index.tablename)}
          SQL
        end

        def replacement_index_name
          @replacement_index_name ||= "#{TEMPORARY_INDEX_PREFIX}#{index.indexrelid}"
        end

        def replaced_index_name
          @replaced_index_name ||= "#{REPLACED_INDEX_PREFIX}#{index.indexrelid}"
        end

        def with_lock_retries(&block)
          arguments = { klass: self.class, logger: logger }
          Gitlab::Database::WithLockRetries.new(**arguments).run(raise_on_exhaustion: true, &block)
        end

        def set_statement_timeout
          execute("SET statement_timeout TO '%ds'" % STATEMENT_TIMEOUT)
          yield
        ensure
          execute('RESET statement_timeout')
        end

        delegate :execute, :quote_table_name, to: :connection
        def connection
          @connection ||= ActiveRecord::Base.connection
        end
      end
    end
  end
end
