154 lines
5.7 KiB
Ruby
154 lines
5.7 KiB
Ruby
# 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
|