summaryrefslogtreecommitdiff
path: root/lib/gitlab/database/reindexing/concurrent_reindex.rb
blob: a6fe7d61a4fedd8b4e55d3124dbee52e05a648fd (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# 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 = 6.hours

        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 => 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}")

          set_statement_timeout 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