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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/test.yml +112 -0
  3. data/.gitignore +10 -0
  4. data/.rubocop.yml +113 -0
  5. data/.yardopts +1 -0
  6. data/BACKGROUND_MIGRATIONS.md +288 -0
  7. data/CHANGELOG.md +5 -0
  8. data/Gemfile +27 -0
  9. data/Gemfile.lock +108 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +1067 -0
  12. data/Rakefile +23 -0
  13. data/gemfiles/activerecord_42.gemfile +6 -0
  14. data/gemfiles/activerecord_50.gemfile +5 -0
  15. data/gemfiles/activerecord_51.gemfile +5 -0
  16. data/gemfiles/activerecord_52.gemfile +5 -0
  17. data/gemfiles/activerecord_60.gemfile +5 -0
  18. data/gemfiles/activerecord_61.gemfile +5 -0
  19. data/gemfiles/activerecord_70.gemfile +5 -0
  20. data/gemfiles/activerecord_head.gemfile +5 -0
  21. data/lib/generators/online_migrations/background_migration_generator.rb +29 -0
  22. data/lib/generators/online_migrations/install_generator.rb +34 -0
  23. data/lib/generators/online_migrations/templates/background_migration.rb.tt +22 -0
  24. data/lib/generators/online_migrations/templates/initializer.rb.tt +94 -0
  25. data/lib/generators/online_migrations/templates/migration.rb.tt +46 -0
  26. data/lib/online_migrations/background_migration.rb +64 -0
  27. data/lib/online_migrations/background_migrations/advisory_lock.rb +62 -0
  28. data/lib/online_migrations/background_migrations/backfill_column.rb +52 -0
  29. data/lib/online_migrations/background_migrations/background_migration_class_validator.rb +36 -0
  30. data/lib/online_migrations/background_migrations/config.rb +98 -0
  31. data/lib/online_migrations/background_migrations/copy_column.rb +90 -0
  32. data/lib/online_migrations/background_migrations/migration.rb +210 -0
  33. data/lib/online_migrations/background_migrations/migration_helpers.rb +238 -0
  34. data/lib/online_migrations/background_migrations/migration_job.rb +92 -0
  35. data/lib/online_migrations/background_migrations/migration_job_runner.rb +63 -0
  36. data/lib/online_migrations/background_migrations/migration_job_status_validator.rb +27 -0
  37. data/lib/online_migrations/background_migrations/migration_runner.rb +97 -0
  38. data/lib/online_migrations/background_migrations/migration_status_validator.rb +45 -0
  39. data/lib/online_migrations/background_migrations/scheduler.rb +49 -0
  40. data/lib/online_migrations/batch_iterator.rb +87 -0
  41. data/lib/online_migrations/change_column_type_helpers.rb +587 -0
  42. data/lib/online_migrations/command_checker.rb +590 -0
  43. data/lib/online_migrations/command_recorder.rb +137 -0
  44. data/lib/online_migrations/config.rb +198 -0
  45. data/lib/online_migrations/copy_trigger.rb +91 -0
  46. data/lib/online_migrations/database_tasks.rb +19 -0
  47. data/lib/online_migrations/error_messages.rb +388 -0
  48. data/lib/online_migrations/foreign_key_definition.rb +17 -0
  49. data/lib/online_migrations/foreign_keys_collector.rb +33 -0
  50. data/lib/online_migrations/indexes_collector.rb +48 -0
  51. data/lib/online_migrations/lock_retrier.rb +250 -0
  52. data/lib/online_migrations/migration.rb +63 -0
  53. data/lib/online_migrations/migrator.rb +23 -0
  54. data/lib/online_migrations/schema_cache.rb +96 -0
  55. data/lib/online_migrations/schema_statements.rb +1042 -0
  56. data/lib/online_migrations/utils.rb +140 -0
  57. data/lib/online_migrations/version.rb +5 -0
  58. data/lib/online_migrations.rb +74 -0
  59. data/online_migrations.gemspec +28 -0
  60. 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