online_migrations 0.26.0 → 0.27.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 +4 -4
- data/CHANGELOG.md +14 -0
- data/docs/0.27-upgrade.md +24 -0
- data/docs/background_data_migrations.md +200 -101
- data/docs/background_schema_migrations.md +2 -2
- data/lib/generators/online_migrations/{background_migration_generator.rb → data_migration_generator.rb} +4 -4
- data/lib/generators/online_migrations/templates/change_background_data_migrations.rb.tt +34 -0
- data/lib/generators/online_migrations/templates/{background_data_migration.rb.tt → data_migration.rb.tt} +8 -9
- data/lib/generators/online_migrations/templates/initializer.rb.tt +19 -25
- data/lib/generators/online_migrations/templates/install_migration.rb.tt +9 -40
- data/lib/generators/online_migrations/upgrade_generator.rb +16 -8
- data/lib/online_migrations/active_record_batch_enumerator.rb +8 -0
- data/lib/online_migrations/background_data_migrations/backfill_column.rb +50 -0
- data/lib/online_migrations/background_data_migrations/config.rb +62 -0
- data/lib/online_migrations/{background_migrations → background_data_migrations}/copy_column.rb +15 -28
- data/lib/online_migrations/{background_migrations → background_data_migrations}/delete_associated_records.rb +9 -5
- data/lib/online_migrations/{background_migrations → background_data_migrations}/delete_orphaned_records.rb +5 -9
- data/lib/online_migrations/background_data_migrations/migration.rb +312 -0
- data/lib/online_migrations/{background_migrations → background_data_migrations}/migration_helpers.rb +72 -61
- data/lib/online_migrations/background_data_migrations/migration_job.rb +158 -0
- data/lib/online_migrations/background_data_migrations/migration_status_validator.rb +65 -0
- data/lib/online_migrations/{background_migrations → background_data_migrations}/perform_action_on_relation.rb +5 -5
- data/lib/online_migrations/{background_migrations → background_data_migrations}/reset_counters.rb +5 -5
- data/lib/online_migrations/background_data_migrations/scheduler.rb +78 -0
- data/lib/online_migrations/background_data_migrations/ticker.rb +62 -0
- data/lib/online_migrations/background_schema_migrations/config.rb +2 -2
- data/lib/online_migrations/background_schema_migrations/migration.rb +51 -123
- data/lib/online_migrations/background_schema_migrations/migration_helpers.rb +25 -46
- data/lib/online_migrations/background_schema_migrations/migration_runner.rb +43 -97
- data/lib/online_migrations/background_schema_migrations/scheduler.rb +2 -2
- data/lib/online_migrations/change_column_type_helpers.rb +3 -2
- data/lib/online_migrations/config.rb +4 -4
- data/lib/online_migrations/data_migration.rb +127 -0
- data/lib/online_migrations/lock_retrier.rb +5 -2
- data/lib/online_migrations/schema_statements.rb +1 -1
- data/lib/online_migrations/shard_aware.rb +44 -0
- data/lib/online_migrations/version.rb +1 -1
- data/lib/online_migrations.rb +18 -11
- metadata +22 -21
- data/lib/online_migrations/background_migration.rb +0 -64
- data/lib/online_migrations/background_migrations/backfill_column.rb +0 -54
- data/lib/online_migrations/background_migrations/background_migration_class_validator.rb +0 -29
- data/lib/online_migrations/background_migrations/config.rb +0 -74
- data/lib/online_migrations/background_migrations/migration.rb +0 -329
- data/lib/online_migrations/background_migrations/migration_job.rb +0 -109
- data/lib/online_migrations/background_migrations/migration_job_runner.rb +0 -66
- data/lib/online_migrations/background_migrations/migration_job_status_validator.rb +0 -29
- data/lib/online_migrations/background_migrations/migration_runner.rb +0 -161
- data/lib/online_migrations/background_migrations/migration_status_validator.rb +0 -48
- data/lib/online_migrations/background_migrations/scheduler.rb +0 -42
@@ -0,0 +1,158 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OnlineMigrations
|
4
|
+
module BackgroundDataMigrations
|
5
|
+
# Sidekiq job responsible for running background data migrations.
|
6
|
+
class MigrationJob
|
7
|
+
include Sidekiq::IterableJob
|
8
|
+
|
9
|
+
sidekiq_retry_in do |count, _exception, jobhash|
|
10
|
+
migration_id = jobhash["args"].fetch(0)
|
11
|
+
migration = Migration.find(migration_id)
|
12
|
+
|
13
|
+
if count + 1 >= migration.max_attempts
|
14
|
+
:kill
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
sidekiq_retries_exhausted do |jobhash, exception|
|
19
|
+
migration_id = jobhash["args"].fetch(0)
|
20
|
+
migration = Migration.find(migration_id)
|
21
|
+
migration.persist_error(exception)
|
22
|
+
|
23
|
+
OnlineMigrations.config.background_data_migrations.error_handler.call(exception, migration)
|
24
|
+
end
|
25
|
+
|
26
|
+
TICKER_INTERVAL = 5 # seconds
|
27
|
+
|
28
|
+
def initialize
|
29
|
+
super
|
30
|
+
|
31
|
+
@migration = nil
|
32
|
+
@data_migration = nil
|
33
|
+
|
34
|
+
@ticker = Ticker.new(TICKER_INTERVAL) do |ticks, duration|
|
35
|
+
# TODO: use 'cursor' accessor from sidekiq in the future.
|
36
|
+
# https://github.com/sidekiq/sidekiq/pull/6606
|
37
|
+
@migration.persist_progress(@_cursor, ticks, duration)
|
38
|
+
@migration.reload
|
39
|
+
end
|
40
|
+
|
41
|
+
@throttle_checked_at = current_time
|
42
|
+
end
|
43
|
+
|
44
|
+
def on_start
|
45
|
+
@migration.start
|
46
|
+
end
|
47
|
+
|
48
|
+
def around_iteration(&block)
|
49
|
+
@migration.on_shard_if_present(&block)
|
50
|
+
end
|
51
|
+
|
52
|
+
def on_resume
|
53
|
+
@data_migration.after_resume
|
54
|
+
end
|
55
|
+
|
56
|
+
def on_stop
|
57
|
+
@ticker.persist
|
58
|
+
@migration.stop
|
59
|
+
end
|
60
|
+
|
61
|
+
def on_complete
|
62
|
+
# Job was manually cancelled.
|
63
|
+
@migration.cancel if cancelled?
|
64
|
+
|
65
|
+
@migration.complete
|
66
|
+
end
|
67
|
+
|
68
|
+
def build_enumerator(migration_id, cursor:)
|
69
|
+
@migration = BackgroundDataMigrations::Migration.find(migration_id)
|
70
|
+
cursor ||= @migration.cursor
|
71
|
+
|
72
|
+
@migration.on_shard_if_present do
|
73
|
+
@data_migration = @migration.data_migration
|
74
|
+
collection_enum = @data_migration.build_enumerator(cursor: cursor)
|
75
|
+
|
76
|
+
if collection_enum
|
77
|
+
if !collection_enum.is_a?(Enumerator)
|
78
|
+
raise ArgumentError, <<~MSG.squish
|
79
|
+
#{@data_migration.class.name}#build_enumerator must return an Enumerator,
|
80
|
+
got #{collection_enum.class.name}.
|
81
|
+
MSG
|
82
|
+
end
|
83
|
+
|
84
|
+
collection_enum
|
85
|
+
else
|
86
|
+
collection = @data_migration.collection
|
87
|
+
|
88
|
+
case collection
|
89
|
+
when ActiveRecord::Relation
|
90
|
+
options = {
|
91
|
+
cursor: cursor,
|
92
|
+
batch_size: @data_migration.class.active_record_enumerator_batch_size || 100,
|
93
|
+
}
|
94
|
+
active_record_records_enumerator(collection, **options)
|
95
|
+
when ActiveRecord::Batches::BatchEnumerator
|
96
|
+
if collection.start || collection.finish
|
97
|
+
raise ArgumentError, <<~MSG.squish
|
98
|
+
#{@data_migration.class.name}#collection does not support
|
99
|
+
a batch enumerator with the "start" or "finish" options.
|
100
|
+
MSG
|
101
|
+
end
|
102
|
+
|
103
|
+
active_record_relations_enumerator(
|
104
|
+
collection.relation,
|
105
|
+
batch_size: collection.batch_size,
|
106
|
+
cursor: cursor,
|
107
|
+
use_ranges: collection.use_ranges
|
108
|
+
)
|
109
|
+
when Array
|
110
|
+
array_enumerator(collection, cursor: cursor)
|
111
|
+
else
|
112
|
+
raise ArgumentError, <<~MSG.squish
|
113
|
+
#{@data_migration.class.name}#collection must be either an ActiveRecord::Relation,
|
114
|
+
ActiveRecord::Batches::BatchEnumerator, or Array.
|
115
|
+
MSG
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def each_iteration(item, _migration_id)
|
122
|
+
if @migration.cancelling? || @migration.pausing? || @migration.paused?
|
123
|
+
# Finish this exact sidekiq job. When the migration is paused
|
124
|
+
# and will be resumed, a new job will be enqueued.
|
125
|
+
finished = true
|
126
|
+
throw :abort, finished
|
127
|
+
elsif should_throttle?
|
128
|
+
ActiveSupport::Notifications.instrument("throttled.background_data_migrations", migration: @migration)
|
129
|
+
finished = false
|
130
|
+
throw :abort, finished
|
131
|
+
else
|
132
|
+
@data_migration.around_process do
|
133
|
+
@migration.data_migration.process(item)
|
134
|
+
|
135
|
+
pause = OnlineMigrations.config.background_data_migrations.iteration_pause
|
136
|
+
sleep(pause) if pause > 0
|
137
|
+
end
|
138
|
+
@ticker.tick
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
private
|
143
|
+
THROTTLE_CHECK_INTERVAL = 5 # seconds
|
144
|
+
private_constant :THROTTLE_CHECK_INTERVAL
|
145
|
+
|
146
|
+
def should_throttle?
|
147
|
+
if current_time - @throttle_checked_at >= THROTTLE_CHECK_INTERVAL
|
148
|
+
@throttle_checked_at = current_time
|
149
|
+
OnlineMigrations.config.throttler.call
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def current_time
|
154
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OnlineMigrations
|
4
|
+
module BackgroundDataMigrations
|
5
|
+
# @private
|
6
|
+
class MigrationStatusValidator < ActiveModel::Validator
|
7
|
+
# Valid status transitions a Migration can make.
|
8
|
+
VALID_STATUS_TRANSITIONS = {
|
9
|
+
# enqueued -> running occurs when the migration starts performing.
|
10
|
+
# enqueued -> paused occurs when the migration is paused before starting.
|
11
|
+
# enqueued -> cancelled occurs when the migration is cancelled before starting.
|
12
|
+
# enqueued -> failed occurs when the migration job fails to be enqueued, or
|
13
|
+
# if the migration is deleted before is starts running.
|
14
|
+
"enqueued" => ["running", "paused", "cancelled", "failed"],
|
15
|
+
# running -> succeeded occurs when the migration completes successfully.
|
16
|
+
# running -> pausing occurs when a user pauses the migration as it's performing.
|
17
|
+
# running -> cancelling occurs when a user cancels the migration as it's performing.
|
18
|
+
# running -> failed occurs when the job raises an exception when running.
|
19
|
+
"running" => [
|
20
|
+
"succeeded",
|
21
|
+
"pausing",
|
22
|
+
"cancelling",
|
23
|
+
"failed",
|
24
|
+
],
|
25
|
+
# pausing -> paused occurs when the migration actually halts performing and
|
26
|
+
# occupies a status of paused.
|
27
|
+
# pausing -> cancelling occurs when the user cancels a migration immediately
|
28
|
+
# after it was paused, such that the migration had not actually halted yet.
|
29
|
+
# pausing -> succeeded occurs when the migration completes immediately after
|
30
|
+
# being paused. This can happen if the migration is on its last iteration
|
31
|
+
# when it is paused, or if the migration is paused after enqueue but has
|
32
|
+
# nothing in its collection to process.
|
33
|
+
# pausing -> failed occurs when the job raises an exception after the
|
34
|
+
# user has paused it.
|
35
|
+
"pausing" => ["paused", "cancelling", "succeeded", "failed"],
|
36
|
+
# paused -> enqueued occurs when the migration is resumed after being paused.
|
37
|
+
# paused -> cancelled when the user cancels the migration after it is paused.
|
38
|
+
"paused" => ["enqueued", "cancelled"],
|
39
|
+
# failed -> enqueued occurs when the migration is retried after encounting an error.
|
40
|
+
"failed" => ["enqueued"],
|
41
|
+
# cancelling -> cancelled occurs when the migration actually halts performing
|
42
|
+
# and occupies a status of cancelled.
|
43
|
+
# cancelling -> succeeded occurs when the migration completes immediately after
|
44
|
+
# being cancelled. See description for pausing -> succeeded.
|
45
|
+
# cancelling -> failed occurs when the job raises an exception after the
|
46
|
+
# user has cancelled it.
|
47
|
+
"cancelling" => ["cancelled", "succeeded", "failed"],
|
48
|
+
}
|
49
|
+
|
50
|
+
def validate(record)
|
51
|
+
return if !record.status_changed?
|
52
|
+
|
53
|
+
previous_status, new_status = record.status_change
|
54
|
+
valid_new_statuses = VALID_STATUS_TRANSITIONS.fetch(previous_status, [])
|
55
|
+
|
56
|
+
if !valid_new_statuses.include?(new_status)
|
57
|
+
record.errors.add(
|
58
|
+
:status,
|
59
|
+
"cannot transition data migration from status '#{previous_status}' to '#{new_status}'"
|
60
|
+
)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -1,9 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module OnlineMigrations
|
4
|
-
module
|
4
|
+
module BackgroundDataMigrations
|
5
5
|
# @private
|
6
|
-
class PerformActionOnRelation <
|
6
|
+
class PerformActionOnRelation < DataMigration
|
7
7
|
attr_reader :model, :conditions, :action, :options
|
8
8
|
|
9
9
|
def initialize(model_name, conditions, action, options = {})
|
@@ -13,11 +13,11 @@ module OnlineMigrations
|
|
13
13
|
@options = options.symbolize_keys
|
14
14
|
end
|
15
15
|
|
16
|
-
def
|
17
|
-
model.unscoped.where(conditions)
|
16
|
+
def collection
|
17
|
+
model.unscoped.where(conditions).in_batches(of: 100)
|
18
18
|
end
|
19
19
|
|
20
|
-
def
|
20
|
+
def process(relation)
|
21
21
|
case action
|
22
22
|
when :update_all
|
23
23
|
updates = options.fetch(:updates)
|
data/lib/online_migrations/{background_migrations → background_data_migrations}/reset_counters.rb
RENAMED
@@ -1,9 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module OnlineMigrations
|
4
|
-
module
|
4
|
+
module BackgroundDataMigrations
|
5
5
|
# @private
|
6
|
-
class ResetCounters <
|
6
|
+
class ResetCounters < DataMigration
|
7
7
|
attr_reader :model, :counters, :touch
|
8
8
|
|
9
9
|
def initialize(model_name, counters, options = {})
|
@@ -12,11 +12,11 @@ module OnlineMigrations
|
|
12
12
|
@touch = options[:touch]
|
13
13
|
end
|
14
14
|
|
15
|
-
def
|
16
|
-
model.unscoped
|
15
|
+
def collection
|
16
|
+
model.unscoped.in_batches(of: 100)
|
17
17
|
end
|
18
18
|
|
19
|
-
def
|
19
|
+
def process(relation)
|
20
20
|
updates = counters.map do |counter_association|
|
21
21
|
has_many_association = has_many_association(counter_association)
|
22
22
|
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OnlineMigrations
|
4
|
+
module BackgroundDataMigrations
|
5
|
+
# Class responsible for scheduling background data migrations.
|
6
|
+
#
|
7
|
+
# Scheduler should be configured to run periodically, for example, via cron.
|
8
|
+
#
|
9
|
+
# @example Run via whenever
|
10
|
+
# # add this to schedule.rb
|
11
|
+
# every 1.minute do
|
12
|
+
# runner "OnlineMigrations.run_background_data_migrations"
|
13
|
+
# end
|
14
|
+
#
|
15
|
+
# @example Specific shard
|
16
|
+
# every 1.minute do
|
17
|
+
# runner "OnlineMigrations.run_background_data_migrations(shard: :shard_two)"
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
# @example Custom concurrency
|
21
|
+
# every 1.minute do
|
22
|
+
# # Allow to run 2 data migrations in parallel.
|
23
|
+
# runner "OnlineMigrations.run_background_data_migrations(concurrency: 2)"
|
24
|
+
# end
|
25
|
+
#
|
26
|
+
class Scheduler
|
27
|
+
def self.run(**options)
|
28
|
+
new.run(**options)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Runs Scheduler
|
32
|
+
def run(shard: nil, concurrency: 1)
|
33
|
+
relation = Migration.queue_order
|
34
|
+
relation = relation.where(shard: shard) if shard
|
35
|
+
|
36
|
+
with_lock do
|
37
|
+
running = relation.running
|
38
|
+
enqueued = relation.enqueued
|
39
|
+
|
40
|
+
# Ensure no more than 'concurrency' migrations are running at the same time.
|
41
|
+
remaining_to_enqueue = concurrency - running.count
|
42
|
+
if remaining_to_enqueue > 0
|
43
|
+
migrations_to_enqueue = enqueued.limit(remaining_to_enqueue)
|
44
|
+
migrations_to_enqueue.each do |migration|
|
45
|
+
enqueue_migration(migration)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
true
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
def with_lock(&block)
|
55
|
+
# Don't lock the whole table if we can lock only a single record (must be always the same).
|
56
|
+
first_record = Migration.queue_order.first
|
57
|
+
if first_record
|
58
|
+
first_record.with_lock(&block)
|
59
|
+
else
|
60
|
+
Migration.transaction do
|
61
|
+
Migration.connection.execute("LOCK #{Migration.table_name} IN ACCESS EXCLUSIVE MODE")
|
62
|
+
yield
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def enqueue_migration(migration)
|
68
|
+
job = OnlineMigrations.config.background_data_migrations.job
|
69
|
+
job_class = job.constantize
|
70
|
+
|
71
|
+
jid = job_class.perform_async(migration.id)
|
72
|
+
if jid
|
73
|
+
migration.update!(status: :running, jid: jid)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OnlineMigrations
|
4
|
+
module BackgroundDataMigrations
|
5
|
+
# This class encapsulates the logic behind updating the tick counter.
|
6
|
+
#
|
7
|
+
# It's initialized with a duration for the throttle, and a block to persist
|
8
|
+
# the number of ticks to increment.
|
9
|
+
#
|
10
|
+
# When +tick+ is called, the block will be called with the increment,
|
11
|
+
# provided the duration since the last update (or initialization) has been
|
12
|
+
# long enough.
|
13
|
+
#
|
14
|
+
# To not lose any increments, +persist+ should be used, which may call the
|
15
|
+
# block with any leftover ticks.
|
16
|
+
#
|
17
|
+
# @private
|
18
|
+
class Ticker
|
19
|
+
# Creates a Ticker that will call the block each time +tick+ is called,
|
20
|
+
# unless the tick is being throttled.
|
21
|
+
#
|
22
|
+
# @param interval [ActiveSupport::Duration, Numeric] Duration
|
23
|
+
# since initialization or last call that will cause a throttle.
|
24
|
+
# @yieldparam ticks [Integer] the increment in ticks to be persisted.
|
25
|
+
#
|
26
|
+
def initialize(interval, &block)
|
27
|
+
@interval = interval
|
28
|
+
@block = block
|
29
|
+
@last_persisted_at = Time.current
|
30
|
+
@ticks_recorded = 0
|
31
|
+
end
|
32
|
+
|
33
|
+
# Increments the tick count by one, and may persist the new value if the
|
34
|
+
# threshold duration has passed since initialization or the tick count was
|
35
|
+
# last persisted.
|
36
|
+
#
|
37
|
+
def tick
|
38
|
+
@ticks_recorded += 1
|
39
|
+
persist if persist?
|
40
|
+
end
|
41
|
+
|
42
|
+
# Persists the tick increments by calling the block passed to the
|
43
|
+
# initializer. This is idempotent in the sense that calling it twice in a
|
44
|
+
# row will call the block at most once (if it had been throttled).
|
45
|
+
#
|
46
|
+
def persist
|
47
|
+
return if @ticks_recorded == 0
|
48
|
+
|
49
|
+
now = Time.current
|
50
|
+
duration = now - @last_persisted_at
|
51
|
+
@last_persisted_at = now
|
52
|
+
@block.call(@ticks_recorded, duration)
|
53
|
+
@ticks_recorded = 0
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
def persist?
|
58
|
+
Time.now - @last_persisted_at >= @interval
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -4,9 +4,9 @@ module OnlineMigrations
|
|
4
4
|
module BackgroundSchemaMigrations
|
5
5
|
# Class representing configuration options for background schema migrations.
|
6
6
|
class Config
|
7
|
-
# Maximum number of run attempts
|
7
|
+
# Maximum number of run attempts.
|
8
8
|
#
|
9
|
-
# When attempts are exhausted, the migration is marked as failed.
|
9
|
+
# When attempts are exhausted, the schema migration is marked as failed.
|
10
10
|
# @return [Integer] defaults to 5
|
11
11
|
#
|
12
12
|
attr_accessor :max_attempts
|
@@ -8,13 +8,15 @@ module OnlineMigrations
|
|
8
8
|
# `enqueue_background_schema_migration` helper inside migrations.
|
9
9
|
#
|
10
10
|
class Migration < ApplicationRecord
|
11
|
+
include ShardAware
|
12
|
+
|
11
13
|
STATUSES = [
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
14
|
+
"enqueued", # The migration has been enqueued by the user.
|
15
|
+
"running", # The migration is being performed by a migration executor.
|
16
|
+
"errored", # The migration raised an error during last run.
|
17
|
+
"failed", # The migration raises an error when running and retry attempts exceeded.
|
18
|
+
"succeeded", # The migration finished without error.
|
19
|
+
"cancelled", # The migration was cancelled by the user.
|
18
20
|
]
|
19
21
|
|
20
22
|
MAX_IDENTIFIER_LENGTH = 63
|
@@ -22,36 +24,12 @@ module OnlineMigrations
|
|
22
24
|
self.table_name = :background_schema_migrations
|
23
25
|
|
24
26
|
scope :queue_order, -> { order(created_at: :asc) }
|
25
|
-
scope :parents, -> { where(parent_id: nil) }
|
26
|
-
scope :runnable, -> { where(composite: false) }
|
27
27
|
scope :active, -> { where(status: [:enqueued, :running, :errored]) }
|
28
|
-
scope :except_succeeded, -> { where.not(status: :succeeded) }
|
29
|
-
|
30
|
-
scope :stuck, -> do
|
31
|
-
runnable.active.where(<<~SQL)
|
32
|
-
updated_at <= NOW() - interval '1 second' * (COALESCE(statement_timeout, 60*60*24) + 60*10)
|
33
|
-
SQL
|
34
|
-
end
|
35
|
-
|
36
|
-
scope :retriable, -> do
|
37
|
-
stuck_sql = connection.unprepared_statement { stuck.to_sql }
|
38
|
-
|
39
|
-
from(Arel.sql(<<~SQL))
|
40
|
-
(
|
41
|
-
(SELECT * FROM background_schema_migrations WHERE NOT composite AND status = 'errored')
|
42
|
-
UNION
|
43
|
-
(#{stuck_sql})
|
44
|
-
) AS #{table_name}
|
45
|
-
SQL
|
46
|
-
end
|
47
28
|
|
48
29
|
alias_attribute :name, :migration_name
|
49
30
|
|
50
31
|
enum :status, STATUSES.index_with(&:to_s)
|
51
32
|
|
52
|
-
belongs_to :parent, class_name: name, optional: true, inverse_of: :children
|
53
|
-
has_many :children, class_name: name, foreign_key: :parent_id, inverse_of: :parent
|
54
|
-
|
55
33
|
validates :table_name, presence: true, length: { maximum: MAX_IDENTIFIER_LENGTH }
|
56
34
|
validates :definition, presence: true
|
57
35
|
validates :migration_name, presence: true, uniqueness: {
|
@@ -65,70 +43,53 @@ module OnlineMigrations
|
|
65
43
|
end,
|
66
44
|
}
|
67
45
|
|
68
|
-
validate :validate_children_statuses, if: -> { composite? && status_changed? }
|
69
|
-
validate :validate_connection_class, if: :connection_class_name?
|
70
46
|
validate :validate_table_exists
|
71
47
|
validates_with MigrationStatusValidator, on: :update
|
72
48
|
|
73
49
|
before_validation :set_defaults
|
74
50
|
|
51
|
+
# Returns whether the migration is completed, which is defined as
|
52
|
+
# having a status of succeeded, failed, or cancelled.
|
53
|
+
#
|
54
|
+
# @return [Boolean] whether the migration is completed.
|
55
|
+
#
|
75
56
|
def completed?
|
76
|
-
succeeded? || failed?
|
57
|
+
succeeded? || failed? || cancelled?
|
77
58
|
end
|
78
59
|
|
79
|
-
#
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
end
|
60
|
+
# Returns whether the migration is active, which is defined as
|
61
|
+
# having a status of enqueued, or running.
|
62
|
+
#
|
63
|
+
# @return [Boolean] whether the migration is active.
|
64
|
+
#
|
65
|
+
def active?
|
66
|
+
enqueued? || running?
|
87
67
|
end
|
68
|
+
|
88
69
|
alias cancel cancelled!
|
89
70
|
|
71
|
+
# Returns whether this migration is pausable.
|
72
|
+
#
|
90
73
|
def pausable?
|
91
74
|
false
|
92
75
|
end
|
93
76
|
|
94
|
-
|
95
|
-
false
|
96
|
-
end
|
97
|
-
|
98
|
-
def can_be_cancelled?
|
99
|
-
!succeeded? && !cancelled?
|
100
|
-
end
|
101
|
-
|
102
|
-
# Returns the progress of the background schema migration.
|
77
|
+
# Dummy method to support the same interface as background data migrations.
|
103
78
|
#
|
104
|
-
# @return [
|
79
|
+
# @return [nil]
|
105
80
|
#
|
106
81
|
def progress
|
107
|
-
if succeeded?
|
108
|
-
100.0
|
109
|
-
elsif composite?
|
110
|
-
progresses = children.map(&:progress)
|
111
|
-
# There should not be composite migrations without children,
|
112
|
-
# but children may be deleted for some reason, so we need to
|
113
|
-
# make a check to avoid 0 division error.
|
114
|
-
if progresses.any?
|
115
|
-
(progresses.sum.to_f / progresses.size).round(2)
|
116
|
-
else
|
117
|
-
0.0
|
118
|
-
end
|
119
|
-
else
|
120
|
-
0.0
|
121
|
-
end
|
122
82
|
end
|
123
83
|
|
124
84
|
# Whether the migration is considered stuck (is running for some configured time).
|
125
85
|
#
|
126
86
|
def stuck?
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
87
|
+
if index_addition?
|
88
|
+
running? && !index_build_in_progress?
|
89
|
+
else
|
90
|
+
stuck_timeout = (statement_timeout || 1.day) + 10.minutes
|
91
|
+
running? && updated_at <= stuck_timeout.seconds.ago
|
92
|
+
end
|
132
93
|
end
|
133
94
|
|
134
95
|
# Mark this migration as ready to be processed again.
|
@@ -136,27 +97,16 @@ module OnlineMigrations
|
|
136
97
|
# This is used to manually retrying failed migrations.
|
137
98
|
#
|
138
99
|
def retry
|
139
|
-
if
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
update!(
|
151
|
-
status: :enqueued,
|
152
|
-
attempts: 0,
|
153
|
-
started_at: nil,
|
154
|
-
finished_at: nil,
|
155
|
-
error_class: nil,
|
156
|
-
error_message: nil,
|
157
|
-
backtrace: nil
|
158
|
-
)
|
159
|
-
end
|
100
|
+
if failed?
|
101
|
+
update!(
|
102
|
+
status: :enqueued,
|
103
|
+
attempts: 0,
|
104
|
+
started_at: nil,
|
105
|
+
finished_at: nil,
|
106
|
+
error_class: nil,
|
107
|
+
error_message: nil,
|
108
|
+
backtrace: nil
|
109
|
+
)
|
160
110
|
|
161
111
|
true
|
162
112
|
else
|
@@ -168,15 +118,6 @@ module OnlineMigrations
|
|
168
118
|
definition.match?(/create (unique )?index/i)
|
169
119
|
end
|
170
120
|
|
171
|
-
# @private
|
172
|
-
def connection_class
|
173
|
-
if connection_class_name && (klass = connection_class_name.safe_constantize)
|
174
|
-
Utils.find_connection_class(klass)
|
175
|
-
else
|
176
|
-
ActiveRecord::Base
|
177
|
-
end
|
178
|
-
end
|
179
|
-
|
180
121
|
# @private
|
181
122
|
def attempts_exceeded?
|
182
123
|
attempts >= max_attempts
|
@@ -184,7 +125,7 @@ module OnlineMigrations
|
|
184
125
|
|
185
126
|
# @private
|
186
127
|
def run
|
187
|
-
|
128
|
+
on_shard_if_present do
|
188
129
|
connection = connection_class.connection
|
189
130
|
|
190
131
|
connection.with_lock_retries do
|
@@ -214,28 +155,11 @@ module OnlineMigrations
|
|
214
155
|
end
|
215
156
|
|
216
157
|
private
|
217
|
-
def validate_children_statuses
|
218
|
-
if composite?
|
219
|
-
if succeeded? && children.except_succeeded.exists?
|
220
|
-
errors.add(:base, "all child migrations must be succeeded")
|
221
|
-
elsif failed? && !children.failed.exists?
|
222
|
-
errors.add(:base, "at least one child migration must be failed")
|
223
|
-
end
|
224
|
-
end
|
225
|
-
end
|
226
|
-
|
227
|
-
def validate_connection_class
|
228
|
-
klass = connection_class_name.safe_constantize
|
229
|
-
if !(klass <= ActiveRecord::Base)
|
230
|
-
errors.add(:connection_class_name, "is not an ActiveRecord::Base child class")
|
231
|
-
end
|
232
|
-
end
|
233
|
-
|
234
158
|
def validate_table_exists
|
235
159
|
# Skip this validation if we have invalid connection class name.
|
236
160
|
return if errors.include?(:connection_class_name)
|
237
161
|
|
238
|
-
|
162
|
+
on_shard_if_present do
|
239
163
|
if !connection_class.connection.table_exists?(table_name)
|
240
164
|
errors.add(:table_name, "'#{table_name}' does not exist")
|
241
165
|
end
|
@@ -248,9 +172,13 @@ module OnlineMigrations
|
|
248
172
|
self.statement_timeout ||= config.statement_timeout
|
249
173
|
end
|
250
174
|
|
251
|
-
def
|
252
|
-
|
253
|
-
|
175
|
+
def index_build_in_progress?
|
176
|
+
indexes_in_progress = connection_class.connection.select_values(<<~SQL)
|
177
|
+
SELECT index_relid::regclass::text
|
178
|
+
FROM pg_stat_progress_create_index
|
179
|
+
SQL
|
180
|
+
|
181
|
+
indexes_in_progress.include?(name)
|
254
182
|
end
|
255
183
|
|
256
184
|
def with_statement_timeout(connection, timeout)
|