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:
Guo Xiang Tan
2018-10-08 15:47:38 +08:00
committed by GitHub
parent 9bbc1ae7b2
commit 40fa96777d
26 changed files with 260 additions and 738 deletions

View File

@@ -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 = [

View File

@@ -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

View File

@@ -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

View File

@@ -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