discourse/lib/backup_restore/database_restorer.rb
Gerhard Schlager 07ff21d045
FIX: Restoring backup could fail due to missing discourse_functions (#29332)
Database dumps sometimes reference functions in the `discourse_functions` schema. It's possible that some of these functions have been dropped in a newer version of Discourse. In that case, restoring an older backup will fail with a `ERROR:  function discourse_functions.something_something() does not exist` error. The restore functionality contains a workaround for that problem, but it didn't work with functions created in plugin migrations.

This commit adds support for temporarily creating missing `discourse_functions` from plugins. And it adds a simple check if the DB migration file even contains the required `DROPPED_TABLES` or `DROPPED_COLUMNS` constant. We don't need to create an instance of the DB migration class unless one of those constants is used. This makes the restore slightly faster and works around a problem with migrations that execute without `up` or `down` methods (e.g. `BackfillChatChannelAndThreadLastMessageIdsPostMigrate`).
2024-10-22 16:13:01 +02:00

228 lines
7.2 KiB
Ruby

# frozen_string_literal: true
module BackupRestore
DatabaseRestoreError = Class.new(RuntimeError)
class DatabaseRestorer
delegate :log, to: :@logger, private: true
MAIN_SCHEMA = "public"
BACKUP_SCHEMA = "backup"
DROP_BACKUP_SCHEMA_AFTER_DAYS = 7
def initialize(logger, current_db)
@logger = logger
@db_was_changed = false
@current_db = current_db
end
def restore(db_dump_path)
BackupRestore.move_tables_between_schemas(MAIN_SCHEMA, BACKUP_SCHEMA)
@db_dump_path = db_dump_path
@db_was_changed = true
create_missing_discourse_functions
restore_dump
migrate_database
reconnect_database
BackupMetadata.update_last_restore_date
end
def rollback
log "Trying to rollback..."
if @db_was_changed && BackupRestore.can_rollback?
log "Rolling back..."
BackupRestore.move_tables_between_schemas(BACKUP_SCHEMA, MAIN_SCHEMA)
else
log "There was no need to rollback"
end
end
def clean_up
drop_created_discourse_functions
end
def self.drop_backup_schema
ActiveRecord::Base.connection.drop_schema(BACKUP_SCHEMA) if backup_schema_dropable?
end
def self.all_migration_files
Dir[Rails.root.join(Migration::SafeMigrate.post_migration_path, "**/*.rb")] +
Dir[Rails.root.join("db/migrate/*.rb")] +
Dir[Rails.root.join("plugins/**", Migration::SafeMigrate.post_migration_path, "**/*.rb")] +
Dir[Rails.root.join("plugins/**", "db/migrate/*.rb")]
end
protected
def restore_dump
log "Restoring dump file... (this may take a while)"
logs = Queue.new
last_line = nil
psql_running = true
log_thread =
Thread.new do
RailsMultisite::ConnectionManagement.establish_connection(db: @current_db)
while psql_running || !logs.empty?
message = logs.pop.strip
log(message) if message.present?
end
end
IO.popen(restore_dump_command) do |pipe|
begin
while line = pipe.readline
logs << line
last_line = line
end
rescue EOFError
# finished reading...
ensure
psql_running = false
end
end
logs << ""
log_thread.join
if Process.last_status&.exitstatus != 0
raise DatabaseRestoreError.new("psql failed: #{last_line}")
end
end
# Removes unwanted SQL added by certain versions of pg_dump and modifies
# the dump so that it works on the current version of PostgreSQL.
def sed_command
unwanted_sql = [
"DROP SCHEMA", # Discourse <= v1.5
"CREATE SCHEMA", # PostgreSQL 11+
"COMMENT ON SCHEMA", # PostgreSQL 11+
"SET default_table_access_method", # PostgreSQL 12
].join("|")
command = "sed -E '/^(#{unwanted_sql})/d' #{@db_dump_path}"
if BackupRestore.postgresql_major_version < 11
command = "#{command} | sed -E 's/^(CREATE TRIGGER.+EXECUTE) FUNCTION/\\1 PROCEDURE/'"
end
command
end
def restore_dump_command
"#{sed_command} | #{self.class.psql_command} 2>&1"
end
def self.psql_command
db_conf = BackupRestore.database_configuration
password_argument = "PGPASSWORD='#{db_conf.password}'" if db_conf.password.present?
host_argument = "--host=#{db_conf.host}" if db_conf.host.present?
port_argument = "--port=#{db_conf.port}" if db_conf.port.present?
username_argument = "--username=#{db_conf.username}" if db_conf.username.present?
[
password_argument, # pass the password to psql (if any)
"psql", # the psql command
"--dbname='#{db_conf.database}'", # connect to database *dbname*
"--single-transaction", # all or nothing (also runs COPY commands faster)
"--variable=ON_ERROR_STOP=1", # stop on first error
host_argument, # the hostname to connect to (if any)
port_argument, # the port to connect to (if any)
username_argument, # the username to connect as (if any)
].compact.join(" ")
end
def migrate_database
log "Migrating the database..."
log Discourse::Utils.execute_command(
{
"SKIP_POST_DEPLOYMENT_MIGRATIONS" => "0",
"SKIP_OPTIMIZE_ICONS" => "1",
"DISABLE_TRANSLATION_OVERRIDES" => "1",
},
"rake",
"db:migrate",
failure_message: "Failed to migrate database.",
chdir: Rails.root,
)
end
def reconnect_database
log "Reconnecting to the database..."
RailsMultisite::ConnectionManagement.reload if RailsMultisite::ConnectionManagement.instance
RailsMultisite::ConnectionManagement.establish_connection(db: @current_db)
end
def create_missing_discourse_functions
log "Creating missing functions in the discourse_functions schema..."
@created_functions_for_table_columns = []
all_readonly_table_columns = []
DatabaseRestorer.all_migration_files.each do |path|
file_content = File.read(path)
next if file_content.exclude?("DROPPED_TABLES") && file_content.exclude?("DROPPED_COLUMNS")
require path
class_name = File.basename(path, ".rb").sub(/\A\d+_/, "").camelize
migration_class = class_name.constantize
if migration_class.const_defined?(:DROPPED_TABLES)
migration_class::DROPPED_TABLES.each do |table_name|
all_readonly_table_columns << [table_name]
end
end
if migration_class.const_defined?(:DROPPED_COLUMNS)
migration_class::DROPPED_COLUMNS.each do |table_name, column_names|
column_names.each do |column_name|
all_readonly_table_columns << [table_name, column_name]
end
end
end
end
existing_function_names =
Migration::BaseDropper.existing_discourse_function_names.map { |name| "#{name}()" }
all_readonly_table_columns.each do |table_name, column_name|
function_name =
Migration::BaseDropper.readonly_function_name(table_name, column_name, with_schema: false)
if !existing_function_names.include?(function_name)
Migration::BaseDropper.create_readonly_function(table_name, column_name)
@created_functions_for_table_columns << [table_name, column_name]
end
end
end
def drop_created_discourse_functions
return if @created_functions_for_table_columns.blank?
log "Dropping functions from the discourse_functions schema..."
@created_functions_for_table_columns.each do |table_name, column_name|
Migration::BaseDropper.drop_readonly_function(table_name, column_name)
end
rescue => ex
log "Something went wrong while dropping functions from the discourse_functions schema", ex
end
def self.backup_schema_dropable?
return false unless ActiveRecord::Base.connection.schema_exists?(BACKUP_SCHEMA)
if last_restore_date = BackupMetadata.last_restore_date
return last_restore_date + DROP_BACKUP_SCHEMA_AFTER_DAYS.days < Time.zone.now
end
BackupMetadata.update_last_restore_date
false
end
private_class_method :backup_schema_dropable?
end
end