online_migrations 0.34.0 → 0.36.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: b7f1dd28a4540742f77c2a33d7336f4f6f2b12a4f267a1cc556975f7b7122ba4
4
- data.tar.gz: db448dca841dc39418cd92e5d86108ee0b59b69b72eeda391b0a81a30aa7ce71
3
+ metadata.gz: 6740adaa6d20273e8d148e8e0788a6e27c6c6701187fe60e006ff3ba8e2129d2
4
+ data.tar.gz: 445a3ef72888dd358cc5d917ca31de7499df3368797c9c73324704e0e2d8307a
5
5
  SHA512:
6
- metadata.gz: b423580e44eefc2f1ff71b2ebdcd15e4ff449f869231aaebe38c4598dc364d817eb3b484a82b7a0cc493727f94d956610ced75ac409092227c2e5cc727935eee
7
- data.tar.gz: 3287e23ec07a09a1026790ab4747427eafbc70f675fd2cbf7382f302a86f748c3fde45f5a7546b3e66848cd1e12eb86f073ff3e5461471bb35ba79f5b0a160ec
6
+ metadata.gz: 338cf3a26b64efb827b5691addc67987048e0522f467d89c5ff4a18494f6b0e3cbeb93513e419a01b04be643d9f7e7a36c1aaf1b0a9d7f5c8158ecff1eb315e7
7
+ data.tar.gz: 4a6b9282a87124e037b3f101a5856d4a8756e86f9fb5b8bf3a4e42e7e2790e0edbd78b7263b8e9c653bedc25700d8a3fb8ade671e2f99e1e74dbfd49780c20eb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  ## master (unreleased)
2
2
 
3
+ ## 0.36.0 (2026-07-20)
4
+
5
+ - Serialize to JSON `cursor` value for background data migrations
6
+
7
+ Note: Run `bin/rails generate online_migrations:upgrade` if using background migrations.
8
+
9
+ - Do not consider running background migrations as stuck
10
+ - Do not automatically retry stuck background migrations
11
+
12
+ ## 0.35.0 (2026-07-01)
13
+
14
+ - Drop support for Ruby < 3.3
15
+ - Defer `tick_total` calculation to the background data migration starting time instead of enqueueing time
16
+ - Make "replacing an index" check independent of the live database state
17
+
3
18
  ## 0.34.0 (2026-05-29)
4
19
 
5
20
  - Drop support for Rails < 7.2
data/README.md CHANGED
@@ -16,7 +16,7 @@ See [comparison to `strong_migrations`](#comparison-to-strong_migrations)
16
16
 
17
17
  ## Requirements
18
18
 
19
- - Ruby 3.1+
19
+ - Ruby 3.3+
20
20
  - Rails 7.2+
21
21
  - PostgreSQL 12+
22
22
 
@@ -342,6 +342,8 @@ during writes works automatically). For most column type changes, this does not
342
342
 
343
343
  ```ruby
344
344
  class CleanupChangeFilesSizeType < ActiveRecord::Migration[8.0]
345
+ disable_ddl_transaction!
346
+
345
347
  def up
346
348
  cleanup_column_type_change :files, :size
347
349
  end
@@ -0,0 +1,9 @@
1
+ class BackgroundDataMigrationsConvertCursor < <%= migration_parent %>
2
+ def change
3
+ up_only do
4
+ OnlineMigrations::DataMigrations::Migration.find_each do |migration|
5
+ OnlineMigrations::DataMigrations::Migration.where(id: migration.id).update_all(cursor: migration.cursor)
6
+ end
7
+ end
8
+ end
9
+ end
@@ -48,7 +48,7 @@ module OnlineMigrations
48
48
  end
49
49
 
50
50
  iteration_pause_column = connection.columns(:background_data_migrations).find { |c| c.name == "iteration_pause" }
51
- if iteration_pause_column && iteration_pause_column.default
51
+ if iteration_pause_column&.default
52
52
  migrations << "background_data_migrations_remove_iteration_pause_default"
53
53
  end
54
54
 
@@ -57,6 +57,8 @@ module OnlineMigrations
57
57
  migrations << "background_migrations_change_status_default"
58
58
  end
59
59
 
60
+ migrations << "background_data_migrations_convert_cursor"
61
+
60
62
  migrations
61
63
  end
62
64
 
@@ -52,6 +52,8 @@ module OnlineMigrations
52
52
 
53
53
  enum :status, STATUSES.index_with(&:to_s)
54
54
 
55
+ serialize :cursor, coder: JSON
56
+
55
57
  validates :migration_name, presence: true
56
58
  validates :arguments, uniqueness: { scope: [:migration_name, :shard] }
57
59
 
@@ -66,6 +68,13 @@ module OnlineMigrations
66
68
  migration_name.sub(/^(::)?#{namespace}::/, "")
67
69
  end
68
70
 
71
+ # TODO: delete in some future version
72
+ def cursor
73
+ self[:cursor]
74
+ rescue JSON::ParserError
75
+ cursor_before_type_cast
76
+ end
77
+
69
78
  def migration_name=(class_name)
70
79
  class_name = class_name.name if class_name.is_a?(Class)
71
80
  write_attribute(:migration_name, self.class.normalize_migration_name(class_name))
@@ -111,19 +120,25 @@ module OnlineMigrations
111
120
  end
112
121
 
113
122
  # Returns whether a migration is stuck, which is defined as having a status of
114
- # running, cancelling or pausing, and not having been updated in the last 5 minutes.
123
+ # cancelling or pausing, and not having been updated in the last 5 minutes.
115
124
  #
116
125
  # @return [Boolean] whether the migration is stuck.
117
126
  #
118
127
  def stuck?
119
128
  stuck_timeout = OnlineMigrations.config.background_data_migrations.stuck_timeout
120
- (running? || cancelling? || pausing?) && updated_at <= stuck_timeout.ago
129
+ (cancelling? || pausing?) && updated_at <= stuck_timeout.ago
121
130
  end
122
131
 
123
132
  # @private
124
133
  def start
125
134
  if enqueued?
126
- update!(status: :running, started_at: Time.current)
135
+ # Defer `tick_total` calculation to the migration starting time
136
+ # instead of the enqueueing time to avoid failed deploys.
137
+ self.tick_total ||= safely_calculate_tick_total
138
+ self.status = :running
139
+ self.started_at = Time.current
140
+ save!
141
+
127
142
  data_migration.after_start
128
143
  true
129
144
  else
@@ -309,11 +324,20 @@ module OnlineMigrations
309
324
  def set_defaults
310
325
  config = ::OnlineMigrations.config.background_data_migrations
311
326
  self.max_attempts ||= config.max_attempts
312
- self.tick_total ||= on_shard_if_present do
327
+ self.iteration_pause ||= config.iteration_pause
328
+ end
329
+
330
+ def safely_calculate_tick_total
331
+ on_shard_if_present do
313
332
  data_migration.count
314
333
  end
334
+ rescue ActiveRecord::QueryCanceled => e
335
+ OnlineMigrations.config.background_data_migrations.error_handler.call(e, self)
315
336
 
316
- self.iteration_pause ||= config.iteration_pause
337
+ # `tick_total` is not required and is used only for progress tracking.
338
+ # Probably the `count` method was implemented in a non-efficient way.
339
+ # Better to not track progress than have a failing migration.
340
+ nil
317
341
  end
318
342
 
319
343
  def instrument_status_change
@@ -72,6 +72,7 @@ module OnlineMigrations
72
72
  # Job was manually cancelled.
73
73
  @migration.cancel if cancelled?
74
74
 
75
+ @migration.update!(jid: nil)
75
76
  @migration.complete
76
77
  end
77
78
 
@@ -13,14 +13,12 @@ module OnlineMigrations
13
13
  # enqueued -> failed occurs when the migration job fails to be enqueued, or
14
14
  # if the migration is deleted before is starts running.
15
15
  "enqueued" => ["running", "paused", "cancelled", "failed"],
16
- # running -> enqueued occurs when the migration is stuck and rescheduled by the scheduler.
17
16
  # running -> succeeded occurs when the migration completes successfully.
18
17
  # running -> pausing occurs when a user pauses the migration as it's performing.
19
18
  # running -> cancelling occurs when a user cancels the migration as it's performing.
20
19
  # running -> errored occurs when the migration raised an error during the last run.
21
20
  # running -> failed occurs when the migration raises an error when running and retry attempts exceeded.
22
21
  "running" => [
23
- "enqueued",
24
22
  "succeeded",
25
23
  "pausing",
26
24
  "cancelling",
@@ -36,11 +36,10 @@ module OnlineMigrations
36
36
  migrations_to_enqueue = []
37
37
 
38
38
  with_lock do
39
- stuck_migrations, active_migrations = relation.running.partition(&:stuck?)
40
- runnable_migrations = migrations_with_existing_classes(relation.pending) + stuck_migrations
39
+ runnable_migrations = migrations_with_existing_classes(relation.pending)
41
40
 
42
41
  # Ensure no more than 'concurrency' migrations are running at the same time.
43
- remaining_to_enqueue = concurrency - active_migrations.count
42
+ remaining_to_enqueue = concurrency - relation.running.count
44
43
  if remaining_to_enqueue > 0
45
44
  runnable_migrations.take(remaining_to_enqueue).each do |migration|
46
45
  migration.update!(status: :enqueued)
@@ -23,6 +23,7 @@ module OnlineMigrations
23
23
  MAX_IDENTIFIER_LENGTH = 63
24
24
 
25
25
  self.table_name = :background_schema_migrations
26
+ self.ignored_columns += ["parent_id", "composite"]
26
27
 
27
28
  scope :queue_order, -> { order(created_at: :asc) }
28
29
  scope :active, -> { where(status: [:pending, :running, :errored]) }
@@ -80,15 +81,10 @@ module OnlineMigrations
80
81
  def progress
81
82
  end
82
83
 
83
- # Whether the migration is considered stuck (is running for some configured time).
84
+ # Whether the migration is considered stuck.
84
85
  #
85
86
  def stuck?
86
- if index_addition?
87
- running? && !index_build_in_progress?
88
- else
89
- stuck_timeout = (statement_timeout || 1.day) + 10.minutes
90
- running? && updated_at <= stuck_timeout.seconds.ago
91
- end
87
+ index_addition? && running? && !index_build_in_progress?
92
88
  end
93
89
 
94
90
  # Mark this migration as ready to be processed again.
@@ -44,9 +44,8 @@ module OnlineMigrations
44
44
  # Background schema migrations could take a while to run. It is possible, that the process
45
45
  # never reaches this (or the rescue below) line of code. E.g., when it is force quitted
46
46
  # (SIGKILL etc.) and so the migration will end up in the "running" state and the query is
47
- # still executing (or already finished) in the database. This migration can either be safely
48
- # manually retried or will be picked up in the future by scheduler when it decides that
49
- # this migration is stuck.
47
+ # still executing (or already finished) in the database. This migration can be safely
48
+ # manually retried.
50
49
 
51
50
  migration.update!(status: :succeeded, finished_at: Time.current)
52
51
 
@@ -35,8 +35,8 @@ module OnlineMigrations
35
35
 
36
36
  private
37
37
  def find_migration(**options)
38
- stuck_migrations, active_migrations = Migration.running.partition(&:stuck?)
39
- runnable_migrations = (Migration.pending + Migration.errored + stuck_migrations).sort_by(&:created_at)
38
+ active_migrations = Migration.running.to_a
39
+ runnable_migrations = (Migration.pending + Migration.errored).sort_by(&:created_at)
40
40
 
41
41
  if options.key?(:shard)
42
42
  runnable_migrations = runnable_migrations.select { |migration| migration.shard.to_s == options[:shard].to_s }
@@ -212,7 +212,14 @@ module OnlineMigrations
212
212
  conversions = column_names.map do |column_name|
213
213
  tmp_column = __change_type_column(column_name)
214
214
 
215
- old_value = Arel::Table.new(table_name)[column_name]
215
+ old_value = begin
216
+ # Delete after supporting only ActiveRecord >= 8.2
217
+ Arel::Table.new(table_name)[column_name]
218
+ rescue ArgumentError
219
+ # https://github.com/rails/rails/commit/b1650993b02497ae7d0d8b984d40bc036e62c681
220
+ Arel::Table.new(name: table_name)[column_name]
221
+ end
222
+
216
223
  if (type_cast_function = type_cast_functions.with_indifferent_access[column_name])
217
224
  old_value =
218
225
  case type_cast_function
@@ -393,10 +400,8 @@ module OnlineMigrations
393
400
  def __options_from_column(column, options)
394
401
  result = {}
395
402
  options.each do |option|
396
- if column.respond_to?(option)
397
- value = column.public_send(option)
398
- result[option] = value if !value.nil?
399
- end
403
+ value = column.public_send(option)
404
+ result[option] = value if !value.nil?
400
405
  end
401
406
  result
402
407
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "erb"
4
- require "set"
5
4
 
6
5
  module OnlineMigrations
7
6
  # @private
@@ -46,7 +45,7 @@ module OnlineMigrations
46
45
 
47
46
  true
48
47
  end
49
- ruby2_keywords(:check) if respond_to?(:ruby2_keywords, true)
48
+ ruby2_keywords(:check)
50
49
 
51
50
  def version_safe?
52
51
  version && version <= OnlineMigrations.config.start_after
@@ -574,13 +573,21 @@ module OnlineMigrations
574
573
  Array(index.columns).map(&:to_s) == Array(options[:column]).map(&:to_s)
575
574
  end
576
575
 
577
- if index_def
578
- existing_options = [:name, :columns, :unique, :where, :type, :using, :opclasses].filter_map do |option|
579
- [option, index_def.public_send(option)] if index_def.respond_to?(option)
580
- end.to_h
576
+ # IndexDefinition expects "columns", but "remove_index" API uses "column".
577
+ options[:columns] = options[:column]
581
578
 
582
- @removed_indexes << IndexDefinition.new(table: table_name, **existing_options)
583
- end
579
+ # Fall back to the declared options when the index isn't present in the database,
580
+ # so an index remove → add is still recorded and caught.
581
+ existing_options =
582
+ [:name, :columns, :unique, :where, :type, :using, :opclasses].index_with do |option|
583
+ if index_def
584
+ index_def.public_send(option)
585
+ else
586
+ options[option]
587
+ end
588
+ end.compact
589
+
590
+ @removed_indexes << IndexDefinition.new(table: table_name, **existing_options)
584
591
  end
585
592
 
586
593
  def add_foreign_key(from_table, to_table, **options)
@@ -844,8 +851,8 @@ module OnlineMigrations
844
851
 
845
852
  def index_include_column?(index, column)
846
853
  index.columns.include?(column) ||
847
- (index.include && index.include.include?(column)) ||
848
- (index.where && index.where.include?(column))
854
+ index.include&.include?(column) ||
855
+ index.where&.include?(column)
849
856
  end
850
857
 
851
858
  def run_custom_checks(method, args)
@@ -48,7 +48,7 @@ module OnlineMigrations
48
48
  record(:"#{method}", args, &block) # record(:create_table, args, &block)
49
49
  end # end
50
50
  RUBY
51
- ruby2_keywords(method) if respond_to?(:ruby2_keywords, true)
51
+ ruby2_keywords(method)
52
52
  end
53
53
 
54
54
  private
@@ -3,10 +3,11 @@
3
3
  module OnlineMigrations
4
4
  # @private
5
5
  class IndexDefinition
6
- attr_reader :table, :columns, :unique, :opclasses, :where, :type, :using
6
+ attr_reader :table, :name, :columns, :unique, :opclasses, :where, :type, :using
7
7
 
8
8
  def initialize(**options)
9
- @table = options[:table]
9
+ @table = options[:table]&.to_s
10
+ @name = options[:name]&.to_s
10
11
  @columns = Array(options[:columns]).map(&:to_s)
11
12
  @unique = options[:unique]
12
13
  @opclasses = options[:opclass] || {}
@@ -20,7 +21,7 @@ module OnlineMigrations
20
21
  # For ActiveRecord::ConnectionAdapters::IndexDefinition is for expression indexes,
21
22
  # `columns` is a string
22
23
  table == other.table &&
23
- columns.intersect?(Array(other.columns))
24
+ ((name && name == other.name) || columns.intersect?(Array(other.columns)))
24
25
  end
25
26
 
26
27
  # @param other [OnlineMigrations::IndexDefinition, ActiveRecord::ConnectionAdapters::IndexDefinition]
@@ -28,7 +29,7 @@ module OnlineMigrations
28
29
  return false if type != other.type
29
30
  return false if using != other.using
30
31
  return false if where != other.where
31
- return false if other.respond_to?(:opclasses) && opclasses != other.opclasses
32
+ return false if opclasses != other.opclasses
32
33
 
33
34
  if unique && !other.unique
34
35
  false
@@ -29,7 +29,7 @@ module OnlineMigrations
29
29
  end
30
30
  end
31
31
  end
32
- ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true)
32
+ ruby2_keywords(:method_missing)
33
33
 
34
34
  # @private
35
35
  def revert(*args)
@@ -111,7 +111,7 @@ module OnlineMigrations
111
111
 
112
112
  relation.update_all(updates)
113
113
 
114
- progress.call(relation) if progress
114
+ progress.call(relation) if progress.present?
115
115
 
116
116
  sleep(pause_ms * 0.001) if pause_ms > 0
117
117
  end
@@ -635,7 +635,7 @@ module OnlineMigrations
635
635
  # @see https://api.rubyonrails.org/v8.1/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_column
636
636
  #
637
637
  def add_column(table_name, column_name, type, **options)
638
- if column_exists?(table_name, column_name, type, **options)
638
+ if column_exists?(table_name, column_name)
639
639
  Utils.say("Column was not added because it already exists (this may be due to an aborted migration " \
640
640
  "or similar) table_name: #{table_name}, column_name: #{column_name}")
641
641
  else
@@ -145,7 +145,7 @@ module OnlineMigrations
145
145
 
146
146
  def run_background_migrations_inline?
147
147
  run_inline = OnlineMigrations.config.run_background_migrations_inline
148
- run_inline && run_inline.call
148
+ run_inline.call if run_inline.present?
149
149
  end
150
150
  end
151
151
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OnlineMigrations
4
- VERSION = "0.34.0"
4
+ VERSION = "0.36.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: online_migrations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.34.0
4
+ version: 0.36.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - fatkodima
@@ -41,6 +41,7 @@ files:
41
41
  - lib/generators/online_migrations/templates/add_sharding_to_online_migrations.rb.tt
42
42
  - lib/generators/online_migrations/templates/add_timestamps_to_background_migrations.rb.tt
43
43
  - lib/generators/online_migrations/templates/background_data_migrations_add_iteration_pause.rb.tt
44
+ - lib/generators/online_migrations/templates/background_data_migrations_convert_cursor.rb.tt
44
45
  - lib/generators/online_migrations/templates/background_data_migrations_remove_iteration_pause_default.rb.tt
45
46
  - lib/generators/online_migrations/templates/background_migrations_change_status_default.rb.tt
46
47
  - lib/generators/online_migrations/templates/background_schema_migrations_change_unique_index.rb.tt
@@ -108,14 +109,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
108
109
  requirements:
109
110
  - - ">="
110
111
  - !ruby/object:Gem::Version
111
- version: '3.1'
112
+ version: '3.3'
112
113
  required_rubygems_version: !ruby/object:Gem::Requirement
113
114
  requirements:
114
115
  - - ">="
115
116
  - !ruby/object:Gem::Version
116
117
  version: '0'
117
118
  requirements: []
118
- rubygems_version: 4.0.10
119
+ rubygems_version: 4.0.14
119
120
  specification_version: 4
120
121
  summary: Catch unsafe PostgreSQL migrations in development and run them easier in
121
122
  production