mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
FEATURE: Post deployment migrations. (#6406)
This moves us away from the delayed drops pattern which was problematic on two counts. First, it uses a hardcoded "delay for" duration which may be too short for certain deployment strategies. Second, delayed drop doesn't ensure that it only runs after the latest application code has been deployed. If the migration runs and the application code fails to deploy, running the migration after "delay for" has been met will cause the application to blow up. The new strategy allows post deployment migrations to be skipped if the env `SKIP_POST_DEPLOYMENT_MIGRATIONS` is provided. ``` SKIP_POST_DEPLOYMENT_MIGRATIONS=1 rake db:migrate -> deploy app servers SKIP_POST_DEPLOYMENT_MIGRATIONS=0 rake db:migrate ``` To aid with the generation of a post deployment migration, a generator has been added. Simply run `rails generate post_migration`.
This commit is contained in:
@@ -2,51 +2,6 @@ module Migration
|
||||
class BaseDropper
|
||||
FUNCTION_SCHEMA_NAME = "discourse_functions".freeze
|
||||
|
||||
def initialize(after_migration, delay, on_drop, after_drop)
|
||||
@after_migration = after_migration
|
||||
@on_drop = on_drop
|
||||
@after_drop = after_drop
|
||||
|
||||
# in production we need some extra delay to allow for slow migrations
|
||||
@delay = delay || (Rails.env.production? ? 3600 : 0)
|
||||
end
|
||||
|
||||
def delayed_drop
|
||||
if droppable?
|
||||
@on_drop&.call
|
||||
execute_drop!
|
||||
@after_drop&.call
|
||||
|
||||
Discourse.reset_active_record_cache
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def droppable?
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def execute_drop!
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def previous_migration_done
|
||||
<<~SQL
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM schema_migration_details
|
||||
WHERE name = :after_migration AND
|
||||
(created_at <= (current_timestamp AT TIME ZONE 'UTC' - INTERVAL :delay) OR
|
||||
(SELECT created_at
|
||||
FROM schema_migration_details
|
||||
ORDER BY id ASC
|
||||
LIMIT 1) > (current_timestamp AT TIME ZONE 'UTC' - INTERVAL '10 minutes')
|
||||
)
|
||||
)
|
||||
SQL
|
||||
end
|
||||
|
||||
def self.create_readonly_function(table_name, column_name = nil)
|
||||
DB.exec <<~SQL
|
||||
CREATE SCHEMA IF NOT EXISTS #{FUNCTION_SCHEMA_NAME};
|
||||
@@ -64,15 +19,6 @@ module Migration
|
||||
$rcr$ LANGUAGE plpgsql;
|
||||
SQL
|
||||
end
|
||||
private_class_method :create_readonly_function
|
||||
|
||||
def self.validate_table_name(table_name)
|
||||
raise ArgumentError.new("Invalid table name passed: #{table_name}") if table_name =~ /[^a-z0-9_]/i
|
||||
end
|
||||
|
||||
def self.validate_column_name(column_name)
|
||||
raise ArgumentError.new("Invalid column name passed to drop #{column_name}") if column_name =~ /[^a-z0-9_]/i
|
||||
end
|
||||
|
||||
def self.readonly_function_name(table_name, column_name = nil)
|
||||
function_name = [
|
||||
|
||||
@@ -1,69 +1,35 @@
|
||||
require_dependency 'migration/base_dropper'
|
||||
|
||||
module Migration
|
||||
class ColumnDropper < BaseDropper
|
||||
def self.drop(table:, after_migration:, columns:, delay: nil, on_drop: nil, after_drop: nil)
|
||||
validate_table_name(table)
|
||||
columns.each { |column| validate_column_name(column) }
|
||||
|
||||
ColumnDropper.new(
|
||||
table, columns, after_migration, delay, on_drop, after_drop
|
||||
).delayed_drop
|
||||
end
|
||||
|
||||
class ColumnDropper
|
||||
def self.mark_readonly(table_name, column_name)
|
||||
create_readonly_function(table_name, column_name)
|
||||
BaseDropper.create_readonly_function(table_name, column_name)
|
||||
|
||||
DB.exec <<~SQL
|
||||
CREATE TRIGGER #{readonly_trigger_name(table_name, column_name)}
|
||||
CREATE TRIGGER #{BaseDropper.readonly_trigger_name(table_name, column_name)}
|
||||
BEFORE INSERT OR UPDATE OF #{column_name}
|
||||
ON #{table_name}
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.#{column_name} IS NOT NULL)
|
||||
EXECUTE PROCEDURE #{readonly_function_name(table_name, column_name)};
|
||||
EXECUTE PROCEDURE #{BaseDropper.readonly_function_name(table_name, column_name)};
|
||||
SQL
|
||||
end
|
||||
|
||||
private
|
||||
def self.execute_drop(table, columns)
|
||||
table = table.to_s
|
||||
|
||||
def initialize(table, columns, after_migration, delay, on_drop, after_drop)
|
||||
super(after_migration, delay, on_drop, after_drop)
|
||||
columns.each do |column|
|
||||
column = column.to_s
|
||||
|
||||
@table = table
|
||||
@columns = columns
|
||||
end
|
||||
|
||||
def droppable?
|
||||
builder = DB.build(<<~SQL)
|
||||
SELECT 1
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
/*where*/
|
||||
LIMIT 1
|
||||
SQL
|
||||
|
||||
builder
|
||||
.where("table_schema = 'public'")
|
||||
.where("table_name = :table")
|
||||
.where("column_name IN (:columns)")
|
||||
.where(previous_migration_done)
|
||||
.exec(table: @table,
|
||||
columns: @columns,
|
||||
delay: "#{@delay} seconds",
|
||||
after_migration: @after_migration) > 0
|
||||
end
|
||||
|
||||
def execute_drop!
|
||||
@columns.each do |column|
|
||||
DB.exec <<~SQL
|
||||
DROP TRIGGER IF EXISTS #{BaseDropper.readonly_trigger_name(@table, column)} ON #{@table};
|
||||
DROP FUNCTION IF EXISTS #{BaseDropper.readonly_function_name(@table, column)} CASCADE;
|
||||
DROP FUNCTION IF EXISTS #{BaseDropper.readonly_function_name(table, column)} CASCADE;
|
||||
-- Backward compatibility for old functions created in the public
|
||||
-- schema
|
||||
DROP FUNCTION IF EXISTS #{BaseDropper.old_readonly_function_name(@table, column)} CASCADE;
|
||||
DROP FUNCTION IF EXISTS #{BaseDropper.old_readonly_function_name(table, column)} CASCADE;
|
||||
SQL
|
||||
|
||||
# safe cause it is protected on method entry, can not be passed in params
|
||||
DB.exec("ALTER TABLE #{@table} DROP COLUMN IF EXISTS #{column}")
|
||||
DB.exec("ALTER TABLE #{table} DROP COLUMN IF EXISTS #{column}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,13 +16,33 @@ class Migration::SafeMigrate
|
||||
end
|
||||
|
||||
def migrate(direction)
|
||||
if direction == :up && version && version > UNSAFE_VERSION && @@enable_safe != false
|
||||
if direction == :up &&
|
||||
version && version > UNSAFE_VERSION &&
|
||||
@@enable_safe != false &&
|
||||
!is_post_deploy_migration?
|
||||
|
||||
Migration::SafeMigrate.enable!
|
||||
end
|
||||
|
||||
super
|
||||
ensure
|
||||
Migration::SafeMigrate.disable!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def is_post_deploy_migration?
|
||||
method =
|
||||
if self.respond_to?(:up)
|
||||
:up
|
||||
elsif self.respond_to?(:change)
|
||||
:change
|
||||
end
|
||||
|
||||
self.method(method).source_location.first.include?(
|
||||
Discourse::DB_POST_MIGRATE_PATH
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
module NiceErrors
|
||||
@@ -90,7 +110,7 @@ class Migration::SafeMigrate
|
||||
-------------------------------------------------------------------------------------
|
||||
An attempt was made to drop or rename a table in a migration
|
||||
SQL used was: '#{sql}'
|
||||
Please use the deferred pattern using Migration::TableDropper in db/seeds to drop
|
||||
Please generate a post deployment migration using `rails g post_migration` to drop
|
||||
or rename the table.
|
||||
|
||||
This protection is in place to protect us against dropping tables that are currently
|
||||
@@ -103,7 +123,8 @@ class Migration::SafeMigrate
|
||||
-------------------------------------------------------------------------------------
|
||||
An attempt was made to drop or rename a column in a migration
|
||||
SQL used was: '#{sql}'
|
||||
Please use the deferred pattern using Migration::ColumnDropper in db/seeds to drop
|
||||
|
||||
Please generate a post deployment migration using `rails g post_migration` to drop
|
||||
or rename columns.
|
||||
|
||||
Note, to minimize disruption use self.ignored_columns = ["column name"] on your
|
||||
|
||||
@@ -1,84 +1,21 @@
|
||||
require_dependency 'migration/base_dropper'
|
||||
|
||||
module Migration
|
||||
class Migration::TableDropper < BaseDropper
|
||||
def self.delayed_drop(table_name:, after_migration:, delay: nil, on_drop: nil, after_drop: nil)
|
||||
validate_table_name(table_name)
|
||||
|
||||
TableDropper.new(
|
||||
table_name, nil, after_migration, delay, on_drop, after_drop
|
||||
).delayed_drop
|
||||
end
|
||||
|
||||
def self.delayed_rename(old_name:, new_name:, after_migration:, delay: nil, on_drop: nil, after_drop: nil)
|
||||
validate_table_name(old_name)
|
||||
validate_table_name(new_name)
|
||||
|
||||
TableDropper.new(
|
||||
old_name, new_name, after_migration, delay, on_drop, after_drop
|
||||
).delayed_drop
|
||||
end
|
||||
|
||||
class Migration::TableDropper
|
||||
def self.read_only_table(table_name)
|
||||
create_readonly_function(table_name)
|
||||
BaseDropper.create_readonly_function(table_name)
|
||||
|
||||
DB.exec <<~SQL
|
||||
CREATE TRIGGER #{readonly_trigger_name(table_name)}
|
||||
CREATE TRIGGER #{BaseDropper.readonly_trigger_name(table_name)}
|
||||
BEFORE INSERT OR UPDATE OR DELETE OR TRUNCATE
|
||||
ON #{table_name}
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE PROCEDURE #{readonly_function_name(table_name)};
|
||||
EXECUTE PROCEDURE #{BaseDropper.readonly_function_name(table_name)};
|
||||
SQL
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def initialize(old_name, new_name, after_migration, delay, on_drop, after_drop)
|
||||
super(after_migration, delay, on_drop, after_drop)
|
||||
|
||||
@old_name = old_name
|
||||
@new_name = new_name
|
||||
end
|
||||
|
||||
def droppable?
|
||||
builder = DB.build(<<~SQL)
|
||||
SELECT 1
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
/*where*/
|
||||
LIMIT 1
|
||||
SQL
|
||||
|
||||
builder.where(table_exists(":new_name")) if @new_name.present?
|
||||
|
||||
builder.where("table_schema = 'public'")
|
||||
.where(table_exists(":old_name"))
|
||||
.where(previous_migration_done)
|
||||
.exec(old_name: @old_name,
|
||||
new_name: @new_name,
|
||||
delay: "#{@delay} seconds",
|
||||
after_migration: @after_migration) > 0
|
||||
end
|
||||
|
||||
def table_exists(table_name_placeholder)
|
||||
<<~SQL
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE table_schema = 'public' AND
|
||||
table_name = #{table_name_placeholder}
|
||||
)
|
||||
SQL
|
||||
end
|
||||
|
||||
def execute_drop!
|
||||
DB.exec("DROP TABLE IF EXISTS #{@old_name}")
|
||||
|
||||
DB.exec <<~SQL
|
||||
DROP FUNCTION IF EXISTS #{BaseDropper.readonly_function_name(@old_name)} CASCADE;
|
||||
-- Backward compatibility for old functions created in the public
|
||||
-- schema
|
||||
DROP FUNCTION IF EXISTS #{BaseDropper.old_readonly_function_name(@old_name)} CASCADE;
|
||||
SQL
|
||||
def self.execute_drop(table_name)
|
||||
DB.exec("DROP TABLE IF EXISTS #{table_name}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user