online_migrations 0.17.1 → 0.19.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e9822bc892c205f69f4dba067e5b3b096c1a2950e36a40800411aa30e23cdf95
4
- data.tar.gz: 3756a3c7f274370411372ce08ffd37fde5ef7e930b0c98c57d1185e853e400af
3
+ metadata.gz: 57661cb5e096da3eaed31d4dbe5cda862f9f75271b59c28045412642acaf786b
4
+ data.tar.gz: 50ed46eaa89a4ee95ecd8b249b0fce616047006c91339dbc1069e572a96f0454
5
5
  SHA512:
6
- metadata.gz: bce2d08b3126bfe68cfe85535f6d0efcdbdeea37843bdab281eb8330a5279a60df7dc63b904aa58d26c7a6c6f617103896a1478d4197127511632c151f0ed335
7
- data.tar.gz: ffd7307d6e042188199991abb61efa93d53e72f8f1653349863167f6e8cd7df4d90fafe2113c76ac032bb9b32274b5f512ccda36840289c99b10ef3bab80df9a
6
+ metadata.gz: be91a43493896dbf0787cf9045c0ac187920a55946b98b0c653b8a50604031aee1bba3b59591fd4c22f7996af05254c71001e098289ded180eec9cdd8ae47d43
7
+ data.tar.gz: 840c35a008b4b949d1d686222d0722f98aa579fb31ccb14ffe3f6bd80147100bbcb323a9bdba657f230efbd3b2a97f799385e21f43218d8dbf19ab9a484d1147
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## master (unreleased)
2
2
 
3
+ ## 0.19.0 (2024-05-21)
4
+
5
+ - Add ability to cancel background migrations
6
+
7
+ ## 0.18.0 (2024-05-07)
8
+
9
+ - Fix setting `started_at`/`finished_at` for parents of sharded background schema migrations
10
+ - Improve retrying of failed sharded background migrations
11
+ - Fix a bug when retried background data migration can not start
12
+ - Do not run multiple background schema migrations on the same table at the same time
13
+
3
14
  ## 0.17.1 (2024-04-28)
4
15
 
5
16
  - Fix raising in development when using sharding and background index creation/removal was not enqueued
@@ -237,6 +237,7 @@ Background Migrations can be in various states during its execution:
237
237
  Note: In normal circumstances, this should not be used since background migrations should be run and finished by the scheduler.
238
238
  * **failed**: A migration raises an exception when running.
239
239
  * **succeeded**: A migration finished without error.
240
+ * **cancelled**: A migration was cancelled by the user.
240
241
 
241
242
  To get the progress (assuming `#count` method on background migration class was defined):
242
243
 
@@ -247,6 +248,26 @@ migration.progress # value from 0 to 100.0
247
248
 
248
249
  **Note**: It will be easier to work with background migrations through some kind of Web UI, but until it is implemented, we can work with them only manually.
249
250
 
251
+ ## Retrying a failed migration
252
+
253
+ To retry a failed migration, run:
254
+
255
+ ```ruby
256
+ migration = OnlineMigrations::BackgroundMigrations::Migration.find(id)
257
+ migration.retry # => `true` if scheduled to be retried, `false` - if not
258
+ ```
259
+
260
+ The migration will be retried on the next Scheduler run.
261
+
262
+ ## Cancelling a migration
263
+
264
+ To cancel an existing migration from future performing, run:
265
+
266
+ ```ruby
267
+ migration = OnlineMigrations::BackgroundMigrations::Migration.find(id)
268
+ migration.cancel
269
+ ```
270
+
250
271
  ## Configuring
251
272
 
252
273
  There are a few configurable options for the Background Migrations. Custom configurations should be placed in a `online_migrations.rb` initializer.
@@ -66,6 +66,26 @@ end
66
66
 
67
67
  You shouldn't depend on the schema until the background schema migration is finished. If having the schema migrated is a requirement, then the `ensure_background_schema_migration_succeeded` helper can be used to guarantee that the migration succeeded and the schema change applied.
68
68
 
69
+ ## Retrying a failed migration
70
+
71
+ To retry a failed migration, run:
72
+
73
+ ```ruby
74
+ migration = OnlineMigrations::BackgroundSchemaMigrations::Migration.find(id)
75
+ migration.retry # => `true` if scheduled to be retried, `false` - if not
76
+ ```
77
+
78
+ The migration will be retried on the next Scheduler run.
79
+
80
+ ## Cancelling a migration
81
+
82
+ To cancel an existing migration from future performing, run:
83
+
84
+ ```ruby
85
+ migration = OnlineMigrations::BackgroundSchemaMigrations::Migration.find(id)
86
+ migration.cancel
87
+ ```
88
+
69
89
  ## Instrumentation
70
90
 
71
91
  Background schema migrations use the [ActiveSupport::Notifications](http://api.rubyonrails.org/classes/ActiveSupport/Notifications.html) API.
@@ -110,6 +130,7 @@ Background Schema Migrations can be in various states during its execution:
110
130
  * **running**: A migration is being performed by a migration executor.
111
131
  * **failed**: A migration raises an exception when running.
112
132
  * **succeeded**: A migration finished without error.
133
+ * **cancelled**: A migration was cancelled by the user.
113
134
 
114
135
  ## Configuring
115
136
 
@@ -15,6 +15,7 @@ module OnlineMigrations
15
15
  :finishing, # The migration is being manually finishing inline by the user.
16
16
  :failed, # The migration raises an exception when running.
17
17
  :succeeded, # The migration finished without error.
18
+ :cancelled, # The migration was cancelled by the user.
18
19
  ]
19
20
 
20
21
  self.table_name = :background_migrations
@@ -38,9 +39,9 @@ module OnlineMigrations
38
39
  enum status: STATUSES.index_with(&:to_s)
39
40
  end
40
41
 
41
- belongs_to :parent, class_name: name, optional: true
42
- has_many :children, class_name: name, foreign_key: :parent_id, dependent: :delete_all
43
- has_many :migration_jobs, dependent: :delete_all
42
+ belongs_to :parent, class_name: name, optional: true, inverse_of: :children
43
+ has_many :children, class_name: name, foreign_key: :parent_id, dependent: :delete_all, inverse_of: :parent
44
+ has_many :migration_jobs, dependent: :delete_all, inverse_of: :migration
44
45
 
45
46
  validates :migration_name, :batch_column_name, presence: true
46
47
 
@@ -98,12 +99,19 @@ module OnlineMigrations
98
99
  end
99
100
  end
100
101
 
101
- def last_job
102
- migration_jobs.order(:max_value).last
102
+ # Overwrite enum's generated method to correctly work for composite migrations.
103
+ def cancelled!
104
+ return super if !composite?
105
+
106
+ transaction do
107
+ super
108
+ children.each { |child| child.cancelled! if !child.succeeded? }
109
+ end
103
110
  end
111
+ alias cancel cancelled!
104
112
 
105
- def last_completed_job
106
- migration_jobs.completed.order(:finished_at).last
113
+ def last_job
114
+ migration_jobs.order(:max_value).last
107
115
  end
108
116
 
109
117
  # Returns the progress of the background migration.
@@ -115,10 +123,13 @@ module OnlineMigrations
115
123
  def progress
116
124
  if succeeded?
117
125
  100.0
126
+ elsif enqueued?
127
+ 0.0
118
128
  elsif composite?
119
129
  rows_counts = children.to_a.pluck(:rows_count)
120
130
  if rows_counts.none?(nil)
121
131
  total_rows_count = rows_counts.sum
132
+ return 100.0 if total_rows_count == 0
122
133
 
123
134
  progresses = children.map do |child|
124
135
  child.progress * child.rows_count / total_rows_count # weighted progress
@@ -126,11 +137,15 @@ module OnlineMigrations
126
137
 
127
138
  progresses.sum.round(2)
128
139
  end
129
- elsif rows_count && rows_count > 0
130
- jobs_rows_count = migration_jobs.succeeded.sum(:batch_size)
131
- # The last migration job may need to process the amount of rows
132
- # less than the batch size, so we can get a value > 1.0.
133
- ([jobs_rows_count.to_f / rows_count, 1.0].min * 100).round(2)
140
+ elsif rows_count
141
+ if rows_count > 0
142
+ jobs_rows_count = migration_jobs.succeeded.sum(:batch_size)
143
+ # The last migration job may need to process the amount of rows
144
+ # less than the batch size, so we can get a value > 1.0.
145
+ ([jobs_rows_count.to_f / rows_count, 1.0].min * 100).round(2)
146
+ else
147
+ 0.0
148
+ end
134
149
  end
135
150
  end
136
151
 
@@ -154,31 +169,34 @@ module OnlineMigrations
154
169
  # @return [Boolean]
155
170
  #
156
171
  def interval_elapsed?
157
- last_active_job = migration_jobs.active.order(:updated_at).last
172
+ last_job = migration_jobs.order(:updated_at).last
173
+ return true if last_job.nil?
158
174
 
159
- if last_active_job && !last_active_job.stuck?
160
- false
161
- elsif batch_pause > 0 && (job = last_completed_job)
162
- job.finished_at + batch_pause <= Time.current
163
- else
164
- true
165
- end
175
+ last_job.enqueued? || (last_job.updated_at + batch_pause <= Time.current)
166
176
  end
167
177
 
168
- # Manually retry failed jobs.
178
+ # Mark this migration as ready to be processed again.
169
179
  #
170
180
  # This method marks failed jobs as ready to be processed again, and
171
181
  # they will be picked up on the next Scheduler run.
172
182
  #
173
- def retry_failed_jobs
174
- iterator = BatchIterator.new(migration_jobs.failed)
175
- iterator.each_batch(of: 100) do |batch|
176
- transaction do
183
+ def retry
184
+ if composite? && failed?
185
+ children.failed.each(&:retry)
186
+ running!
187
+ true
188
+ elsif failed?
189
+ iterator = BatchIterator.new(migration_jobs.failed)
190
+ iterator.each_batch(of: 100) do |batch|
177
191
  batch.each(&:retry)
178
- enqueued!
179
192
  end
193
+ running!
194
+ true
195
+ else
196
+ false
180
197
  end
181
198
  end
199
+ alias retry_failed_jobs retry
182
200
 
183
201
  # Returns the time this migration started running.
184
202
  def started_at
@@ -8,6 +8,7 @@ module OnlineMigrations
8
8
  :running,
9
9
  :failed,
10
10
  :succeeded,
11
+ :cancelled,
11
12
  ]
12
13
 
13
14
  self.table_name = :background_migration_jobs
@@ -47,7 +48,7 @@ module OnlineMigrations
47
48
  delegate :migration_name, :migration_class, :migration_object, :migration_relation, :batch_column_name,
48
49
  :arguments, :batch_pause, to: :migration
49
50
 
50
- belongs_to :migration
51
+ belongs_to :migration, inverse_of: :migration_jobs
51
52
 
52
53
  validates :min_value, :max_value, presence: true, numericality: { greater_than: 0 }
53
54
  validate :values_in_migration_range, if: :min_value?
@@ -69,15 +70,23 @@ module OnlineMigrations
69
70
  # This is used when retrying failed jobs.
70
71
  #
71
72
  def retry
72
- update!(
73
- status: self.class.statuses[:enqueued],
74
- attempts: 0,
75
- started_at: nil,
76
- finished_at: nil,
77
- error_class: nil,
78
- error_message: nil,
79
- backtrace: nil
80
- )
73
+ if failed?
74
+ transaction do
75
+ update!(
76
+ status: self.class.statuses[:enqueued],
77
+ attempts: 0,
78
+ started_at: nil,
79
+ finished_at: nil,
80
+ error_class: nil,
81
+ error_message: nil,
82
+ backtrace: nil
83
+ )
84
+ migration.running! if migration.failed?
85
+ end
86
+ true
87
+ else
88
+ false
89
+ end
81
90
  end
82
91
 
83
92
  private
@@ -5,9 +5,9 @@ module OnlineMigrations
5
5
  # @private
6
6
  class MigrationJobStatusValidator < ActiveModel::Validator
7
7
  VALID_STATUS_TRANSITIONS = {
8
- "enqueued" => ["running"],
9
- "running" => ["succeeded", "failed"],
10
- "failed" => ["enqueued", "running"],
8
+ "enqueued" => ["running", "cancelled"],
9
+ "running" => ["succeeded", "failed", "cancelled"],
10
+ "failed" => ["enqueued", "running", "cancelled"],
11
11
  }
12
12
 
13
13
  def validate(record)
@@ -13,6 +13,7 @@ module OnlineMigrations
13
13
  # Runs one background migration job.
14
14
  def run_migration_job
15
15
  raise "Should not be called on a composite (with sharding) migration" if migration.composite?
16
+ return if migration.cancelled?
16
17
 
17
18
  mark_as_running if migration.enqueued?
18
19
  migration_payload = notifications_payload(migration)
@@ -56,7 +57,7 @@ module OnlineMigrations
56
57
  raise "This method is not intended for use in production environments"
57
58
  end
58
59
 
59
- return if migration.completed?
60
+ return if migration.completed? || migration.cancelled?
60
61
 
61
62
  mark_as_running
62
63
 
@@ -77,7 +78,7 @@ module OnlineMigrations
77
78
  # Keep running until the migration is finished.
78
79
  #
79
80
  def finish
80
- return if migration.completed?
81
+ return if migration.completed? || migration.cancelled?
81
82
 
82
83
  if migration.composite?
83
84
  migration.children.each do |child_migration|
@@ -7,7 +7,7 @@ module OnlineMigrations
7
7
  VALID_STATUS_TRANSITIONS = {
8
8
  # enqueued -> running occurs when the migration starts performing.
9
9
  # enqueued -> paused occurs when the migration is paused before starting.
10
- "enqueued" => ["running", "paused"],
10
+ "enqueued" => ["running", "paused", "cancelled"],
11
11
  # running -> paused occurs when a user pauses the migration as
12
12
  # it's performing.
13
13
  # running -> finishing occurs when a user manually finishes the migration.
@@ -18,14 +18,16 @@ module OnlineMigrations
18
18
  "finishing",
19
19
  "succeeded",
20
20
  "failed",
21
+ "cancelled",
21
22
  ],
22
23
  # finishing -> succeeded occurs when the migration completes successfully.
23
24
  # finishing -> failed occurs when the migration raises an exception when running.
24
- "finishing" => ["succeeded", "failed"],
25
+ "finishing" => ["succeeded", "failed", "cancelled"],
25
26
  # paused -> running occurs when the migration is resumed after being paused.
26
- "paused" => ["running"],
27
+ "paused" => ["running", "cancelled"],
27
28
  # failed -> enqueued occurs when the failed migration jobs are retried after being failed.
28
- "failed" => ["enqueued"],
29
+ # failed -> running occurs when the failed migration is retried.
30
+ "failed" => ["enqueued", "running", "cancelled"],
29
31
  }
30
32
 
31
33
  def validate(record)
@@ -13,6 +13,7 @@ module OnlineMigrations
13
13
  :running, # The migration is being performed by a migration executor.
14
14
  :failed, # The migration raises an exception when running.
15
15
  :succeeded, # The migration finished without error.
16
+ :cancelled, # The migration was cancelled by the user.
16
17
  ]
17
18
 
18
19
  MAX_IDENTIFIER_LENGTH = 63
@@ -55,8 +56,8 @@ module OnlineMigrations
55
56
  enum status: STATUSES.index_with(&:to_s)
56
57
  end
57
58
 
58
- belongs_to :parent, class_name: name, optional: true
59
- has_many :children, class_name: name, foreign_key: :parent_id
59
+ belongs_to :parent, class_name: name, optional: true, inverse_of: :children
60
+ has_many :children, class_name: name, foreign_key: :parent_id, inverse_of: :parent
60
61
 
61
62
  validates :table_name, presence: true, length: { maximum: MAX_IDENTIFIER_LENGTH }
62
63
  validates :definition, presence: true
@@ -82,6 +83,17 @@ module OnlineMigrations
82
83
  succeeded? || failed?
83
84
  end
84
85
 
86
+ # Overwrite enum's generated method to correctly work for composite migrations.
87
+ def cancelled!
88
+ return super if !composite?
89
+
90
+ transaction do
91
+ super
92
+ children.each { |child| child.cancelled! if !child.succeeded? }
93
+ end
94
+ end
95
+ alias cancel cancelled!
96
+
85
97
  # Returns the progress of the background schema migration.
86
98
  #
87
99
  # @return [Float] value in range from 0.0 to 100.0
@@ -109,8 +121,13 @@ module OnlineMigrations
109
121
  # This is used to manually retrying failed migrations.
110
122
  #
111
123
  def retry
112
- if composite?
124
+ if composite? && failed?
113
125
  children.failed.each(&:retry)
126
+ update!(
127
+ status: self.class.statuses[:running],
128
+ finished_at: nil
129
+ )
130
+ true
114
131
  elsif failed?
115
132
  update!(
116
133
  status: self.class.statuses[:enqueued],
@@ -121,6 +138,9 @@ module OnlineMigrations
121
138
  error_message: nil,
122
139
  backtrace: nil
123
140
  )
141
+ true
142
+ else
143
+ false
124
144
  end
125
145
  end
126
146
 
@@ -11,6 +11,8 @@ module OnlineMigrations
11
11
  end
12
12
 
13
13
  def run
14
+ return if migration.cancelled?
15
+
14
16
  mark_as_running if migration.enqueued? || migration.failed?
15
17
 
16
18
  if migration.composite?
@@ -27,7 +29,14 @@ module OnlineMigrations
27
29
  def mark_as_running
28
30
  Migration.transaction do
29
31
  migration.running!
30
- migration.parent.running! if migration.parent
32
+
33
+ if (parent = migration.parent)
34
+ if parent.started_at
35
+ parent.update!(status: :running, finished_at: nil)
36
+ else
37
+ parent.update!(status: :running, started_at: Time.current, finished_at: nil)
38
+ end
39
+ end
31
40
  end
32
41
  end
33
42
 
@@ -90,10 +99,10 @@ module OnlineMigrations
90
99
  parent.with_lock do
91
100
  children = parent.children.select(:status)
92
101
  if children.all?(&:succeeded?)
93
- parent.succeeded!
102
+ parent.update!(status: :succeeded, finished_at: Time.current)
94
103
  completed = true
95
104
  elsif children.any?(&:failed?)
96
- parent.failed!
105
+ parent.update!(status: :failed, finished_at: Time.current)
97
106
  completed = true
98
107
  end
99
108
  end
@@ -6,13 +6,13 @@ module OnlineMigrations
6
6
  class MigrationStatusValidator < ActiveModel::Validator
7
7
  VALID_STATUS_TRANSITIONS = {
8
8
  # enqueued -> running occurs when the migration starts performing.
9
- "enqueued" => ["running"],
9
+ "enqueued" => ["running", "cancelled"],
10
10
  # running -> succeeded occurs when the migration completes successfully.
11
11
  # running -> failed occurs when the migration raises an exception when running and retry attempts exceeded.
12
- "running" => ["succeeded", "failed"],
12
+ "running" => ["succeeded", "failed", "cancelled"],
13
13
  # failed -> enqueued occurs when the failed migration is enqueued to be retried.
14
14
  # failed -> running occurs when the failed migration is retried.
15
- "failed" => ["enqueued", "running"],
15
+ "failed" => ["enqueued", "running", "cancelled"],
16
16
  }
17
17
 
18
18
  def validate(record)
@@ -3,7 +3,8 @@
3
3
  module OnlineMigrations
4
4
  module BackgroundSchemaMigrations
5
5
  # Class responsible for scheduling background schema migrations.
6
- # It selects a single migration and runs it if there is no currently running migration.
6
+ # It selects a single migration and runs it if there is no currently
7
+ # running migration on the same table.
7
8
  #
8
9
  # Scheduler should be configured to run periodically, for example, via cron.
9
10
  # @example Run via whenever
@@ -19,12 +20,25 @@ module OnlineMigrations
19
20
 
20
21
  # Runs Scheduler
21
22
  def run
22
- migration = Migration.runnable.enqueued.queue_order.first || Migration.retriable.queue_order.first
23
+ migration = find_migration
23
24
  if migration
24
25
  runner = MigrationRunner.new(migration)
25
26
  runner.run
26
27
  end
27
28
  end
29
+
30
+ private
31
+ def find_migration
32
+ active_migrations = Migration.running.select(:table_name, :shard).to_a
33
+ runnable_migrations = Migration.runnable.enqueued.queue_order.to_a + Migration.retriable.queue_order.to_a
34
+
35
+ runnable_migrations.find do |runnable_migration|
36
+ active_migrations.none? do |active_migration|
37
+ active_migration.shard == runnable_migration.shard &&
38
+ active_migration.table_name == runnable_migration.table_name
39
+ end
40
+ end
41
+ end
28
42
  end
29
43
  end
30
44
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OnlineMigrations
4
- VERSION = "0.17.1"
4
+ VERSION = "0.19.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: online_migrations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.1
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - fatkodima
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-04-28 00:00:00.000000000 Z
11
+ date: 2024-05-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -113,7 +113,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
113
113
  - !ruby/object:Gem::Version
114
114
  version: '0'
115
115
  requirements: []
116
- rubygems_version: 3.5.4
116
+ rubygems_version: 3.4.19
117
117
  signing_key:
118
118
  specification_version: 4
119
119
  summary: Catch unsafe PostgreSQL migrations in development and run them easier in