Use FileUpdateChecker for Migration::CheckPending

Migration::CheckPending is a rack middleware normally used in
development to raise an exception if there pending migrations.

This commit replaces the existing caching, which avoided checking all
migrations if the newest migration (by version number) hadn't changed.
Instead, we now use FileUpdateChecker, which has two advantages: it can
detect new migrations which aren't the highest version, and it is
faster.
This commit is contained in:
John Hawthorn 2019-10-03 21:49:41 -07:00
parent e684f5b090
commit 4705ba82db
3 changed files with 97 additions and 33 deletions

View File

@ -3,6 +3,7 @@
require "benchmark" require "benchmark"
require "set" require "set"
require "zlib" require "zlib"
require "active_support/core_ext/enumerable"
require "active_support/core_ext/module/attribute_accessors" require "active_support/core_ext/module/attribute_accessors"
require "active_support/actionable_error" require "active_support/actionable_error"
@ -553,21 +554,37 @@ module ActiveRecord
# This class is used to verify that all migrations have been run before # This class is used to verify that all migrations have been run before
# loading a web page if <tt>config.active_record.migration_error</tt> is set to :page_load # loading a web page if <tt>config.active_record.migration_error</tt> is set to :page_load
class CheckPending class CheckPending
def initialize(app) def initialize(app, file_watcher: ActiveSupport::FileUpdateChecker)
@app = app @app = app
@last_check = 0 @needs_check = true
@mutex = Mutex.new
@file_watcher = file_watcher
end end
def call(env) def call(env)
mtime = ActiveRecord::Base.connection.migration_context.last_migration.mtime.to_i @mutex.synchronize do
if @last_check < mtime @watcher ||= build_watcher do
ActiveRecord::Migration.check_pending!(connection) @needs_check = true
@last_check = mtime ActiveRecord::Migration.check_pending!(connection)
@needs_check = false
end
if @needs_check
@watcher.execute
else
@watcher.execute_if_updated
end
end end
@app.call(env) @app.call(env)
end end
private private
def build_watcher(&block)
paths = Array(connection.migration_context.migrations_paths)
@file_watcher.new([], paths.index_with(["rb"]), &block)
end
def connection def connection
ActiveRecord::Base.connection ActiveRecord::Base.connection
end end
@ -993,10 +1010,6 @@ module ActiveRecord
File.basename(filename) File.basename(filename)
end end
def mtime
File.mtime filename
end
delegate :migrate, :announce, :write, :disable_ddl_transaction, to: :migration delegate :migrate, :announce, :write, :disable_ddl_transaction, to: :migration
private private
@ -1010,16 +1023,6 @@ module ActiveRecord
end end
end end
class NullMigration < MigrationProxy #:nodoc:
def initialize
super(nil, 0, nil, nil)
end
def mtime
0
end
end
class MigrationContext #:nodoc: class MigrationContext #:nodoc:
attr_reader :migrations_paths, :schema_migration attr_reader :migrations_paths, :schema_migration
@ -1098,10 +1101,6 @@ module ActiveRecord
migrations.any? migrations.any?
end end
def last_migration #:nodoc:
migrations.last || NullMigration.new
end
def migrations def migrations
migrations = migration_files.map do |file| migrations = migration_files.map do |file|
version, name, scope = parse_migration_filename(file) version, name, scope = parse_migration_filename(file)

View File

@ -82,10 +82,11 @@ module ActiveRecord
ActiveSupport.on_load(:active_record) { LogSubscriber.backtrace_cleaner = ::Rails.backtrace_cleaner } ActiveSupport.on_load(:active_record) { LogSubscriber.backtrace_cleaner = ::Rails.backtrace_cleaner }
end end
initializer "active_record.migration_error" do initializer "active_record.migration_error" do |app|
if config.active_record.delete(:migration_error) == :page_load if config.active_record.delete(:migration_error) == :page_load
config.app_middleware.insert_after ::ActionDispatch::Callbacks, config.app_middleware.insert_after ::ActionDispatch::Callbacks,
ActiveRecord::Migration::CheckPending ActiveRecord::Migration::CheckPending,
file_watcher: app.config.file_watcher
end end
end end

View File

@ -7,34 +7,98 @@ module ActiveRecord
if current_adapter?(:SQLite3Adapter) && !in_memory_db? if current_adapter?(:SQLite3Adapter) && !in_memory_db?
class PendingMigrationsTest < ActiveRecord::TestCase class PendingMigrationsTest < ActiveRecord::TestCase
setup do setup do
@migration_dir = Dir.mktmpdir("activerecord-migrations-")
file = ActiveRecord::Base.connection.raw_connection.filename file = ActiveRecord::Base.connection.raw_connection.filename
@conn = ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:", migrations_paths: MIGRATIONS_ROOT + "/valid" @conn = ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:", migrations_paths: @migration_dir
source_db = SQLite3::Database.new file source_db = SQLite3::Database.new file
dest_db = ActiveRecord::Base.connection.raw_connection dest_db = ActiveRecord::Base.connection.raw_connection
backup = SQLite3::Backup.new(dest_db, "main", source_db, "main") backup = SQLite3::Backup.new(dest_db, "main", source_db, "main")
backup.step(-1) backup.step(-1)
backup.finish backup.finish
ActiveRecord::Base.connection.drop_table "schema_migrations", if_exists: true
@app = Minitest::Mock.new
end end
teardown do teardown do
@conn.release_connection if @conn @conn.release_connection if @conn
ActiveRecord::Base.establish_connection :arunit ActiveRecord::Base.establish_connection :arunit
FileUtils.rm_rf(@migration_dir)
end
def run_migrations
migrator = Base.connection.migration_context
capture(:stdout) { migrator.migrate }
end
def create_migration(number, name)
filename = "#{number}_#{name.underscore}.rb"
File.write(File.join(@migration_dir, filename), <<~RUBY)
class #{name.classify} < ActiveRecord::Migration::Current
end
RUBY
end end
def test_errors_if_pending def test_errors_if_pending
ActiveRecord::Base.connection.drop_table "schema_migrations", if_exists: true create_migration "01", "create_foo"
assert_raises ActiveRecord::PendingMigrationError do assert_raises ActiveRecord::PendingMigrationError do
CheckPending.new(Proc.new { }).call({}) CheckPending.new(@app).call({})
end
# Continues failing
assert_raises ActiveRecord::PendingMigrationError do
CheckPending.new(@app).call({})
end end
end end
def test_checks_if_supported def test_checks_if_supported
ActiveRecord::SchemaMigration.create_table run_migrations
migrator = Base.connection.migration_context
capture(:stdout) { migrator.migrate }
assert_nil CheckPending.new(Proc.new { }).call({}) check_pending = CheckPending.new(@app)
@app.expect :call, nil, [{}]
check_pending.call({})
@app.verify
# With cached result
@app.expect :call, nil, [{}]
check_pending.call({})
@app.verify
end
def test_okay_with_no_migrations
check_pending = CheckPending.new(@app)
@app.expect :call, nil, [{}]
check_pending.call({})
@app.verify
# With cached result
@app.expect :call, nil, [{}]
check_pending.call({})
@app.verify
end
# Regression test for https://github.com/rails/rails/pull/29759
def test_understands_migrations_created_out_of_order
# With a prior file before even initialization
create_migration "05", "create_bar"
run_migrations
check_pending = CheckPending.new(@app)
@app.expect :call, nil, [{}]
check_pending.call({})
@app.verify
# It understands the new migration created at 01
create_migration "01", "create_foo"
assert_raises ActiveRecord::PendingMigrationError do
check_pending.call({})
end
end end
end end
end end