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,388 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnlineMigrations
4
+ # @private
5
+ module ErrorMessages
6
+ ERROR_MESSAGES = {
7
+ short_primary_key_type:
8
+ "Using short integer types for primary keys is dangerous due to the risk of running
9
+ out of IDs on inserts. Better to use one of 'bigint', 'bigserial' or 'uuid'.",
10
+
11
+ create_table:
12
+ "The `:force` option will destroy existing table. If this is intended, drop the existing table first.
13
+ Otherwise, remove the `:force` option.",
14
+
15
+ change_table:
16
+ "Online Migrations does not support inspecting what happens inside a
17
+ change_table block, so cannot help you here. Make really sure that what
18
+ you're doing is safe before proceeding, then wrap it in a safety_assured { ... } block.",
19
+
20
+ rename_table:
21
+ "Renaming a table that's in use will cause errors in your application.
22
+ migration_helpers provides a safer approach to do this:
23
+
24
+ 1. Instruct Rails that you are going to rename a table:
25
+
26
+ OnlineMigrations.config.table_renames = {
27
+ <%= table_name.to_s.inspect %> => <%= new_name.to_s.inspect %>
28
+ }
29
+
30
+ 2. Deploy
31
+ 3. Tell the database that you are going to rename a table. This will not actually rename any tables,
32
+ nor any data/indexes/foreign keys copying will be made, so will be very fast.
33
+ It will use a VIEW to work with both table names simultaneously:
34
+
35
+ class Initialize<%= migration_name %> < <%= migration_parent %>
36
+ def change
37
+ initialize_table_rename <%= table_name.inspect %>, <%= new_name.inspect %>
38
+ end
39
+ end
40
+
41
+ 4. Replace usages of the old table with a new table in the codebase
42
+ 5. Remove the table rename config from step 1
43
+ 6. Deploy
44
+ 7. Remove the VIEW created on step 3:
45
+
46
+ class Finalize<%= migration_name %> < <%= migration_parent %>
47
+ def change
48
+ finalize_table_rename <%= table_name.inspect %>, <%= new_name.inspect %>
49
+ end
50
+ end
51
+
52
+ 8. Deploy",
53
+
54
+ add_column_with_default:
55
+ "Adding a column with a non-null default blocks reads and writes while the entire table is rewritten.
56
+
57
+ A safer approach is to:
58
+ 1. add the column without a default value
59
+ 2. change the column default
60
+ 3. backfill existing rows with the new value
61
+ <% if not_null %>
62
+ 4. add the NOT NULL constraint
63
+ <% end %>
64
+
65
+ <% unless volatile_default %>
66
+ add_column_with_default takes care of all this steps:
67
+
68
+ class <%= migration_name %> < <%= migration_parent %>
69
+ disable_ddl_transaction!
70
+
71
+ def change
72
+ <%= code %>
73
+ end
74
+ end
75
+ <% end %>",
76
+
77
+ add_column_json:
78
+ "There's no equality operator for the json column type, which can cause errors for
79
+ existing SELECT DISTINCT queries in your application. Use jsonb instead.
80
+
81
+ class <%= migration_name %> < <%= migration_parent %>
82
+ def change
83
+ <%= code %>
84
+ end
85
+ end",
86
+
87
+ rename_column:
88
+ "Renaming a column that's in use will cause errors in your application.
89
+ migration_helpers provides a safer approach to do this:
90
+
91
+ 1. Instruct Rails that you are going to rename a column:
92
+
93
+ OnlineMigrations.config.column_renames = {
94
+ <%= table_name.to_s.inspect %> => {
95
+ <%= column_name.to_s.inspect %> => <%= new_column.to_s.inspect %>
96
+ }
97
+ }
98
+ <% unless partial_writes %>
99
+ NOTE: You also need to temporarily enable partial writes until the process of column rename is fully done.
100
+ # config/application.rb
101
+ config.active_record.<%= partial_writes_setting %> = true
102
+ <% end %>
103
+
104
+ 2. Deploy
105
+ 3. Tell the database that you are going to rename a column. This will not actually rename any columns,
106
+ nor any data/indexes/foreign keys copying will be made, so will be instantaneous.
107
+ It will use a combination of a VIEW and column aliasing to work with both column names simultaneously:
108
+
109
+ class Initialize<%= migration_name %> < <%= migration_parent %>
110
+ def change
111
+ initialize_column_rename <%= table_name.inspect %>, <%= column_name.inspect %>, <%= new_column.inspect %>
112
+ end
113
+ end
114
+
115
+ 4. Replace usages of the old column with a new column in the codebase
116
+ 5. Deploy
117
+ 6. Remove the column rename config from step 1
118
+ 7. Remove the VIEW created in step 3:
119
+
120
+ class Finalize<%= migration_name %> < <%= migration_parent %>
121
+ def change
122
+ finalize_column_rename <%= table_name.inspect %>, <%= column_name.inspect %>, <%= new_column.inspect %>
123
+ end
124
+ end
125
+
126
+ 8. Deploy",
127
+
128
+ change_column_with_not_null:
129
+ "Changing the type is safe, but setting NOT NULL is not.",
130
+
131
+ change_column:
132
+ "Changing the type of an existing column blocks reads and writes while the entire table is rewritten.
133
+ A safer approach can be accomplished in several steps:
134
+
135
+ 1. Create a new column and keep column's data in sync:
136
+
137
+ class Initialize<%= migration_name %> < <%= migration_parent %>
138
+ def change
139
+ <%= initialize_change_code %>
140
+ end
141
+ end
142
+
143
+ 2. Backfill data from the old column to the new column:
144
+
145
+ class Backfill<%= migration_name %> < <%= migration_parent %>
146
+ disable_ddl_transaction!
147
+
148
+ def up
149
+ <%= backfill_code %>
150
+ end
151
+
152
+ def down
153
+ # no op
154
+ end
155
+ end
156
+
157
+ 3. Copy indexes, foreign keys, check constraints, NOT NULL constraint, swap new column in place:
158
+
159
+ class Finalize<%= migration_name %> < <%= migration_parent %>
160
+ disable_ddl_transaction!
161
+
162
+ def change
163
+ <%= finalize_code %>
164
+ end
165
+ end
166
+
167
+ 4. Deploy
168
+ 5. Finally, if everything is working as expected, remove copy trigger and old column:
169
+
170
+ class Cleanup<%= migration_name %> < <%= migration_parent %>
171
+ def up
172
+ <%= cleanup_code %>
173
+ end
174
+
175
+ def down
176
+ <%= cleanup_down_code %>
177
+ end
178
+ end
179
+
180
+ 6. Deploy",
181
+
182
+ change_column_null:
183
+ "Setting NOT NULL on an existing column blocks reads and writes while every row is checked.
184
+ A safer approach is to add a NOT NULL check constraint and validate it in a separate transaction.
185
+ add_not_null_constraint and validate_not_null_constraint take care of that.
186
+
187
+ class <%= migration_name %> < <%= migration_parent %>
188
+ disable_ddl_transaction!
189
+
190
+ def change
191
+ <%= add_constraint_code %>
192
+ <% if backfill_code %>
193
+ <%= backfill_code %>
194
+ <% end %>
195
+ <%= validate_constraint_code %>
196
+ <% if remove_constraint_code %>
197
+ <%= remove_constraint_code %>
198
+ <%= change_column_null_code %>
199
+ <% end %>
200
+ end
201
+ end",
202
+
203
+ remove_column:
204
+ "<% if indexes.any? %>
205
+ Removing a column will automatically remove all of the indexes that involved the removed column.
206
+ But the indexes would be removed non-concurrently, so you need to safely remove the indexes first:
207
+
208
+ class <%= migration_name %>RemoveIndexes < <%= migration_parent %>
209
+ disable_ddl_transaction!
210
+
211
+ def change
212
+ <% indexes.each do |index| %>
213
+ remove_index <%= table_name %>, name: <%= index %>, algorithm: :concurrently
214
+ <% end %>
215
+ end
216
+ end
217
+ <% else %>
218
+ ActiveRecord caches database columns at runtime, so if you drop a column, it can cause exceptions until your app reboots.
219
+ A safer approach is to:
220
+
221
+ 1. Ignore the column(s):
222
+
223
+ class <%= model %> < <%= model_parent %>
224
+ self.ignored_columns = <%= columns %>
225
+ end
226
+
227
+ 2. Deploy
228
+ 3. Wrap column removing in a safety_assured { ... } block
229
+
230
+ class <%= migration_name %> < <%= migration_parent %>
231
+ def change
232
+ safety_assured { <%= command %> }
233
+ end
234
+ end
235
+
236
+ 4. Remove columns ignoring
237
+ 5. Deploy
238
+ <% end %>",
239
+
240
+ add_timestamps_with_default:
241
+ "Adding timestamps columns with non-null defaults blocks reads and writes while the entire table is rewritten.
242
+
243
+ A safer approach is to, for both timestamps columns:
244
+ 1. add the column without a default value
245
+ 2. change the column default
246
+ 3. backfill existing rows with the new value
247
+ <% if not_null %>
248
+ 4. add the NOT NULL constraint
249
+ <% end %>
250
+
251
+ <% unless volatile_default %>
252
+ add_column_with_default takes care of all this steps:
253
+
254
+ class <%= migration_name %> < <%= migration_parent %>
255
+ disable_ddl_transaction!
256
+
257
+ def change
258
+ <%= code %>
259
+ end
260
+ end
261
+ <% end %>",
262
+
263
+ add_reference:
264
+ "<% if bad_foreign_key %>
265
+ Adding a foreign key blocks writes on both tables.
266
+ <% end %>
267
+ <% if bad_index %>
268
+ Adding an index non-concurrently blocks writes.
269
+ <% end %>
270
+ Instead, use add_reference_concurrently helper. It will create a reference and take care of safely adding <% if bad_foreign_key %>a foreign key<% end %><% if bad_index && bad_foreign_key %> and <% end %><% if bad_index %>index<% end %>.
271
+
272
+ class <%= migration_name %> < <%= migration_parent %>
273
+ disable_ddl_transaction!
274
+
275
+ def change
276
+ <%= code %>
277
+ end
278
+ end",
279
+
280
+ add_hash_index:
281
+ "Hash index operations are not WAL-logged, so hash indexes might need to be rebuilt with REINDEX
282
+ after a database crash if there were unwritten changes. Also, changes to hash indexes are not replicated
283
+ over streaming or file-based replication after the initial base backup, so they give wrong answers
284
+ to queries that subsequently use them. For these reasons, hash index use is discouraged.
285
+ Use B-tree indexes instead.",
286
+
287
+ add_index:
288
+ "Adding an index non-concurrently blocks writes. Instead, use:
289
+
290
+ class <%= migration_name %> < <%= migration_parent %>
291
+ disable_ddl_transaction!
292
+
293
+ def change
294
+ <%= command %>
295
+ end
296
+ end",
297
+
298
+ remove_index:
299
+ "Removing an index non-concurrently blocks writes. Instead, use:
300
+
301
+ class <%= migration_name %> < <%= migration_parent %>
302
+ disable_ddl_transaction!
303
+
304
+ def change
305
+ <%= command %>
306
+ end
307
+ end",
308
+
309
+ add_foreign_key:
310
+ "Adding a foreign key blocks writes on both tables. Add the foreign key without validating existing rows,
311
+ and then validate them in a separate transaction.
312
+
313
+ class <%= migration_name %> < <%= migration_parent %>
314
+ disable_ddl_transaction!
315
+
316
+ def change
317
+ <%= add_code %>
318
+ <%= validate_code %>
319
+ end
320
+ end",
321
+
322
+ validate_foreign_key:
323
+ "Validating a foreign key while holding heavy locks on tables is dangerous.
324
+ Use disable_ddl_transaction! or a separate migration.",
325
+
326
+ add_check_constraint:
327
+ "Adding a check constraint blocks reads and writes while every row is checked.
328
+ A safer approach is to add the check constraint without validating existing rows,
329
+ and then validating them in a separate transaction.
330
+
331
+ class <%= migration_name %> < <%= migration_parent %>
332
+ disable_ddl_transaction!
333
+
334
+ def change
335
+ <%= add_code %>
336
+ <%= validate_code %>
337
+ end
338
+ end",
339
+
340
+ validate_constraint:
341
+ "Validating a constraint while holding heavy locks on tables is dangerous.
342
+ Use disable_ddl_transaction! or a separate migration.",
343
+
344
+ add_not_null_constraint:
345
+ "Adding a NOT NULL constraint blocks reads and writes while every row is checked.
346
+ A safer approach is to add the NOT NULL check constraint without validating existing rows,
347
+ and then validating them in a separate migration.
348
+
349
+ class <%= migration_name %> < <%= migration_parent %>
350
+ def change
351
+ <%= add_code %>
352
+ end
353
+ end
354
+
355
+ class <%= migration_name %>Validate < <%= migration_parent %>
356
+ def change
357
+ <%= validate_code %>
358
+ end
359
+ end",
360
+
361
+ add_text_limit_constraint:
362
+ "Adding a limit on the text column blocks reads and writes while every row is checked.
363
+ A safer approach is to add the limit check constraint without validating existing rows,
364
+ and then validating them in a separate migration.
365
+
366
+ class <%= migration_name %> < <%= migration_parent %>
367
+ def change
368
+ <%= add_code %>
369
+ end
370
+ end
371
+
372
+ class <%= migration_name %>Validate < <%= migration_parent %>
373
+ def change
374
+ <%= validate_code %>
375
+ end
376
+ end",
377
+
378
+ execute:
379
+ "Online Migrations does not support inspecting what happens inside an
380
+ execute call, so cannot help you here. Make really sure that what
381
+ you're doing is safe before proceeding, then wrap it in a safety_assured { ... } block.",
382
+
383
+ multiple_foreign_keys:
384
+ "Adding multiple foreign keys in a single migration blocks writes on all involved tables until migration is completed.
385
+ Avoid adding foreign key more than once per migration file, unless the source and target tables are identical.",
386
+ }
387
+ end
388
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnlineMigrations
4
+ # @private
5
+ module ForeignKeyDefinition
6
+ if Utils.ar_version <= 4.2
7
+ def defined_for?(to_table: nil, **options)
8
+ (to_table.nil? || to_table.to_s == self.to_table) &&
9
+ options.all? { |k, v| self.options[k].to_s == v.to_s }
10
+ end
11
+ elsif Utils.ar_version <= 5.1
12
+ def defined_for?(*args, **options)
13
+ super(*args, **options.except(:validate))
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnlineMigrations
4
+ # @private
5
+ class ForeignKeysCollector
6
+ attr_reader :referenced_tables
7
+
8
+ def initialize
9
+ @referenced_tables = Set.new
10
+ end
11
+
12
+ def collect(&table_definition)
13
+ table_definition.call(self)
14
+ end
15
+
16
+ def foreign_key(to_table, **_options)
17
+ @referenced_tables << to_table.to_s
18
+ end
19
+
20
+ def references(*ref_names, **options)
21
+ if options[:foreign_key]
22
+ ref_names.each do |ref_name|
23
+ @referenced_tables << Utils.foreign_table_name(ref_name, options)
24
+ end
25
+ end
26
+ end
27
+ alias belongs_to references
28
+
29
+ def method_missing(*)
30
+ # we only care about foreign keys related methods
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnlineMigrations
4
+ # @private
5
+ class IndexesCollector
6
+ IndexDefinition = Struct.new(:using)
7
+
8
+ COLUMN_TYPES = [:bigint, :binary, :boolean, :date, :datetime, :decimal,
9
+ :float, :integer, :json, :string, :text, :time, :timestamp, :virtual]
10
+
11
+ attr_reader :indexes
12
+
13
+ def initialize
14
+ @indexes = []
15
+ end
16
+
17
+ def collect(&table_definition)
18
+ table_definition.call(self)
19
+ end
20
+
21
+ def index(_column_name, **options)
22
+ @indexes << IndexDefinition.new(options[:using].to_s)
23
+ end
24
+
25
+ def references(*_ref_names, **options)
26
+ index = options.fetch(:index) { Utils.ar_version >= 5.0 }
27
+
28
+ if index
29
+ using = index.is_a?(Hash) ? index[:using].to_s : nil
30
+ @indexes << IndexDefinition.new(using)
31
+ end
32
+ end
33
+ alias belongs_to references
34
+
35
+ def method_missing(method_name, *_args, **options)
36
+ # Check for type-based methods, where we can also specify an index:
37
+ # t.string :email, index: true
38
+ if COLUMN_TYPES.include?(method_name)
39
+ index = options.fetch(:index, false)
40
+
41
+ if index
42
+ using = index.is_a?(Hash) ? index[:using].to_s : nil
43
+ @indexes << IndexDefinition.new(using)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end