online_migrations 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/test.yml +112 -0
- data/.gitignore +10 -0
- data/.rubocop.yml +113 -0
- data/.yardopts +1 -0
- data/BACKGROUND_MIGRATIONS.md +288 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +27 -0
- data/Gemfile.lock +108 -0
- data/LICENSE.txt +21 -0
- data/README.md +1067 -0
- data/Rakefile +23 -0
- data/gemfiles/activerecord_42.gemfile +6 -0
- data/gemfiles/activerecord_50.gemfile +5 -0
- data/gemfiles/activerecord_51.gemfile +5 -0
- data/gemfiles/activerecord_52.gemfile +5 -0
- data/gemfiles/activerecord_60.gemfile +5 -0
- data/gemfiles/activerecord_61.gemfile +5 -0
- data/gemfiles/activerecord_70.gemfile +5 -0
- data/gemfiles/activerecord_head.gemfile +5 -0
- data/lib/generators/online_migrations/background_migration_generator.rb +29 -0
- data/lib/generators/online_migrations/install_generator.rb +34 -0
- data/lib/generators/online_migrations/templates/background_migration.rb.tt +22 -0
- data/lib/generators/online_migrations/templates/initializer.rb.tt +94 -0
- data/lib/generators/online_migrations/templates/migration.rb.tt +46 -0
- data/lib/online_migrations/background_migration.rb +64 -0
- data/lib/online_migrations/background_migrations/advisory_lock.rb +62 -0
- data/lib/online_migrations/background_migrations/backfill_column.rb +52 -0
- data/lib/online_migrations/background_migrations/background_migration_class_validator.rb +36 -0
- data/lib/online_migrations/background_migrations/config.rb +98 -0
- data/lib/online_migrations/background_migrations/copy_column.rb +90 -0
- data/lib/online_migrations/background_migrations/migration.rb +210 -0
- data/lib/online_migrations/background_migrations/migration_helpers.rb +238 -0
- data/lib/online_migrations/background_migrations/migration_job.rb +92 -0
- data/lib/online_migrations/background_migrations/migration_job_runner.rb +63 -0
- data/lib/online_migrations/background_migrations/migration_job_status_validator.rb +27 -0
- data/lib/online_migrations/background_migrations/migration_runner.rb +97 -0
- data/lib/online_migrations/background_migrations/migration_status_validator.rb +45 -0
- data/lib/online_migrations/background_migrations/scheduler.rb +49 -0
- data/lib/online_migrations/batch_iterator.rb +87 -0
- data/lib/online_migrations/change_column_type_helpers.rb +587 -0
- data/lib/online_migrations/command_checker.rb +590 -0
- data/lib/online_migrations/command_recorder.rb +137 -0
- data/lib/online_migrations/config.rb +198 -0
- data/lib/online_migrations/copy_trigger.rb +91 -0
- data/lib/online_migrations/database_tasks.rb +19 -0
- data/lib/online_migrations/error_messages.rb +388 -0
- data/lib/online_migrations/foreign_key_definition.rb +17 -0
- data/lib/online_migrations/foreign_keys_collector.rb +33 -0
- data/lib/online_migrations/indexes_collector.rb +48 -0
- data/lib/online_migrations/lock_retrier.rb +250 -0
- data/lib/online_migrations/migration.rb +63 -0
- data/lib/online_migrations/migrator.rb +23 -0
- data/lib/online_migrations/schema_cache.rb +96 -0
- data/lib/online_migrations/schema_statements.rb +1042 -0
- data/lib/online_migrations/utils.rb +140 -0
- data/lib/online_migrations/version.rb +5 -0
- data/lib/online_migrations.rb +74 -0
- data/online_migrations.gemspec +28 -0
- metadata +119 -0
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OnlineMigrations
|
4
|
+
module BackgroundMigrations
|
5
|
+
# @private
|
6
|
+
class MigrationJobStatusValidator < ActiveModel::Validator
|
7
|
+
VALID_STATUS_TRANSITIONS = {
|
8
|
+
"enqueued" => ["running"],
|
9
|
+
"running" => ["succeeded", "failed"],
|
10
|
+
"failed" => ["enqueued", "running"],
|
11
|
+
}
|
12
|
+
|
13
|
+
def validate(record)
|
14
|
+
return unless (previous_status, new_status = record.status_change)
|
15
|
+
|
16
|
+
valid_new_statuses = VALID_STATUS_TRANSITIONS.fetch(previous_status, [])
|
17
|
+
|
18
|
+
unless valid_new_statuses.include?(new_status)
|
19
|
+
record.errors.add(
|
20
|
+
:status,
|
21
|
+
"cannot transition background migration job from status #{previous_status} to #{new_status}"
|
22
|
+
)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OnlineMigrations
|
4
|
+
module BackgroundMigrations
|
5
|
+
# Runs single background migration.
|
6
|
+
class MigrationRunner
|
7
|
+
attr_reader :migration
|
8
|
+
|
9
|
+
def initialize(migration)
|
10
|
+
@migration = migration
|
11
|
+
end
|
12
|
+
|
13
|
+
# Runs one background migration job.
|
14
|
+
def run_migration_job
|
15
|
+
migration.running! if migration.enqueued?
|
16
|
+
migration_payload = { background_migration: migration }
|
17
|
+
|
18
|
+
if !migration.migration_jobs.exists?
|
19
|
+
ActiveSupport::Notifications.instrument("started.background_migrations", migration_payload)
|
20
|
+
end
|
21
|
+
|
22
|
+
if should_throttle?
|
23
|
+
ActiveSupport::Notifications.instrument("throttled.background_migrations", migration_payload)
|
24
|
+
return
|
25
|
+
end
|
26
|
+
|
27
|
+
next_migration_job = find_or_create_next_migration_job
|
28
|
+
|
29
|
+
if next_migration_job
|
30
|
+
job_runner = MigrationJobRunner.new(next_migration_job)
|
31
|
+
job_runner.run
|
32
|
+
elsif !migration.migration_jobs.active.exists?
|
33
|
+
if migration.migration_jobs.failed.exists?
|
34
|
+
migration.failed!
|
35
|
+
else
|
36
|
+
migration.succeeded!
|
37
|
+
end
|
38
|
+
|
39
|
+
ActiveSupport::Notifications.instrument("completed.background_migrations", migration_payload)
|
40
|
+
end
|
41
|
+
|
42
|
+
next_migration_job
|
43
|
+
end
|
44
|
+
|
45
|
+
# Runs the background migration until completion.
|
46
|
+
#
|
47
|
+
# @note This method should not be used in production environments
|
48
|
+
#
|
49
|
+
def run_all_migration_jobs
|
50
|
+
raise "This method is not intended for use in production environments" if !Utils.developer_env?
|
51
|
+
return if migration.completed?
|
52
|
+
|
53
|
+
migration.running!
|
54
|
+
|
55
|
+
while migration.running?
|
56
|
+
run_migration_job
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Finishes the background migration.
|
61
|
+
#
|
62
|
+
# Keep running until the migration is finished.
|
63
|
+
#
|
64
|
+
def finish
|
65
|
+
return if migration.completed?
|
66
|
+
|
67
|
+
# Mark is as finishing to avoid being picked up
|
68
|
+
# by the background migrations scheduler.
|
69
|
+
migration.finishing!
|
70
|
+
|
71
|
+
while migration.finishing?
|
72
|
+
run_migration_job
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
def should_throttle?
|
78
|
+
::OnlineMigrations.config.background_migrations.throttler.call
|
79
|
+
end
|
80
|
+
|
81
|
+
def find_or_create_next_migration_job
|
82
|
+
if (min_value, max_value = migration.next_batch_range)
|
83
|
+
create_migration_job!(min_value, max_value)
|
84
|
+
else
|
85
|
+
migration.migration_jobs.retriable.first
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def create_migration_job!(min_value, max_value)
|
90
|
+
migration.migration_jobs.create!(
|
91
|
+
min_value: min_value,
|
92
|
+
max_value: max_value
|
93
|
+
)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OnlineMigrations
|
4
|
+
module BackgroundMigrations
|
5
|
+
# @private
|
6
|
+
class MigrationStatusValidator < ActiveModel::Validator
|
7
|
+
VALID_STATUS_TRANSITIONS = {
|
8
|
+
# enqueued -> running occurs when the migration starts performing.
|
9
|
+
# enqueued -> paused occurs when the migration is paused before starting.
|
10
|
+
"enqueued" => ["running", "paused"],
|
11
|
+
# running -> paused occurs when a user pauses the migration as
|
12
|
+
# it's performing.
|
13
|
+
# running -> finishing occurs when a user manually finishes the migration.
|
14
|
+
# running -> succeeded occurs when the migration completes successfully.
|
15
|
+
# running -> failed occurs when the migration raises an exception when running.
|
16
|
+
"running" => [
|
17
|
+
"paused",
|
18
|
+
"finishing",
|
19
|
+
"succeeded",
|
20
|
+
"failed",
|
21
|
+
],
|
22
|
+
# finishing -> succeeded occurs when the migration completes successfully.
|
23
|
+
# finishing -> failed occurs when the migration raises an exception when running.
|
24
|
+
"finishing" => ["succeeded", "failed"],
|
25
|
+
# paused -> running occurs when the migration is resumed after being paused.
|
26
|
+
"paused" => ["running"],
|
27
|
+
# failed -> enqueued occurs when the failed migration jobs are retried after being failed.
|
28
|
+
"failed" => ["enqueued"],
|
29
|
+
}
|
30
|
+
|
31
|
+
def validate(record)
|
32
|
+
return unless (previous_status, new_status = record.status_change)
|
33
|
+
|
34
|
+
valid_new_statuses = VALID_STATUS_TRANSITIONS.fetch(previous_status, [])
|
35
|
+
|
36
|
+
unless valid_new_statuses.include?(new_status)
|
37
|
+
record.errors.add(
|
38
|
+
:status,
|
39
|
+
"cannot transition background migration from status #{previous_status} to #{new_status}"
|
40
|
+
)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OnlineMigrations
|
4
|
+
module BackgroundMigrations
|
5
|
+
# Class responsible for scheduling background migrations.
|
6
|
+
# It selects runnable background migrations and runs them one step (one batch) at a time.
|
7
|
+
# A migration is considered runnable if it is not completed and time the interval between
|
8
|
+
# successive runs has passed.
|
9
|
+
# Scheduler ensures (via advisory locks) that at most one background migration at a time is running per database.
|
10
|
+
#
|
11
|
+
# Scheduler should be run via some kind of periodical means, for example, cron.
|
12
|
+
# @example Run via whenever
|
13
|
+
# # add this to schedule.rb
|
14
|
+
# every 1.minute do
|
15
|
+
# runner "OnlineMigrations::BackgroundMigrations::Scheduler.run"
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
class Scheduler
|
19
|
+
def self.run
|
20
|
+
new.run
|
21
|
+
end
|
22
|
+
|
23
|
+
# Runs Scheduler
|
24
|
+
def run
|
25
|
+
active_migrations = Migration.active.queue_order
|
26
|
+
runnable_migrations = active_migrations.select(&:interval_elapsed?)
|
27
|
+
|
28
|
+
runnable_migrations.each do |migration|
|
29
|
+
connection = migration.migration_relation.connection
|
30
|
+
|
31
|
+
with_exclusive_lock(connection) do
|
32
|
+
run_migration_job(migration)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
def with_exclusive_lock(connection, &block)
|
39
|
+
lock = AdvisoryLock.new(name: "online_migrations_scheduler", connection: connection)
|
40
|
+
lock.with_lock(&block)
|
41
|
+
end
|
42
|
+
|
43
|
+
def run_migration_job(migration)
|
44
|
+
runner = MigrationRunner.new(migration)
|
45
|
+
runner.run_migration_job
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OnlineMigrations
|
4
|
+
# @private
|
5
|
+
class BatchIterator
|
6
|
+
attr_reader :relation
|
7
|
+
|
8
|
+
def initialize(relation)
|
9
|
+
if !relation.is_a?(ActiveRecord::Relation)
|
10
|
+
raise ArgumentError, "relation is not an ActiveRecord::Relation"
|
11
|
+
end
|
12
|
+
|
13
|
+
@relation = relation
|
14
|
+
end
|
15
|
+
|
16
|
+
def each_batch(of: 1000, column: relation.primary_key, start: nil, finish: nil, order: :asc)
|
17
|
+
unless [:asc, :desc].include?(order)
|
18
|
+
raise ArgumentError, ":order must be :asc or :desc, got #{order.inspect}"
|
19
|
+
end
|
20
|
+
|
21
|
+
relation = apply_limits(self.relation, column, start, finish, order)
|
22
|
+
|
23
|
+
base_relation = relation.except(:select)
|
24
|
+
.select(column)
|
25
|
+
.reorder(column => order)
|
26
|
+
|
27
|
+
start_row = base_relation.uncached { base_relation.first }
|
28
|
+
|
29
|
+
return unless start_row
|
30
|
+
|
31
|
+
start_id = start_row[column]
|
32
|
+
arel_table = relation.arel_table
|
33
|
+
|
34
|
+
0.step do |index|
|
35
|
+
if order == :asc
|
36
|
+
start_cond = arel_table[column].gteq(start_id)
|
37
|
+
else
|
38
|
+
start_cond = arel_table[column].lteq(start_id)
|
39
|
+
end
|
40
|
+
|
41
|
+
stop_row = base_relation.uncached do
|
42
|
+
base_relation
|
43
|
+
.where(start_cond)
|
44
|
+
.offset(of)
|
45
|
+
.first
|
46
|
+
end
|
47
|
+
|
48
|
+
batch_relation = relation.where(start_cond)
|
49
|
+
|
50
|
+
if stop_row
|
51
|
+
stop_id = stop_row[column]
|
52
|
+
start_id = stop_id
|
53
|
+
|
54
|
+
if order == :asc
|
55
|
+
stop_cond = arel_table[column].lt(stop_id)
|
56
|
+
else
|
57
|
+
stop_cond = arel_table[column].gt(stop_id)
|
58
|
+
end
|
59
|
+
|
60
|
+
batch_relation = batch_relation.where(stop_cond)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Any ORDER BYs are useless for this relation and can lead to less
|
64
|
+
# efficient UPDATE queries, hence we get rid of it.
|
65
|
+
batch_relation = batch_relation.except(:order)
|
66
|
+
|
67
|
+
# Retaining the results in the query cache would undermine the point of batching.
|
68
|
+
batch_relation.uncached { yield batch_relation, index }
|
69
|
+
|
70
|
+
break unless stop_row
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
def apply_limits(relation, column, start, finish, order)
|
76
|
+
if start
|
77
|
+
relation = relation.where(relation.arel_table[column].public_send((order == :asc ? :gteq : :lteq), start))
|
78
|
+
end
|
79
|
+
|
80
|
+
if finish
|
81
|
+
relation = relation.where(relation.arel_table[column].public_send((order == :asc ? :lteq : :gteq), finish))
|
82
|
+
end
|
83
|
+
|
84
|
+
relation
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|