online_migrations 0.17.1 → 0.19.0

Sign up to get free protection for your applications and to get access to all the features.
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