strong_migrations 0.6.8 → 0.7.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +1 -1
- data/README.md +169 -96
- data/lib/strong_migrations.rb +33 -18
- data/lib/strong_migrations/checker.rb +64 -21
- data/lib/strong_migrations/railtie.rb +0 -4
- data/lib/strong_migrations/version.rb +1 -1
- data/lib/tasks/strong_migrations.rake +0 -6
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2ef314694846eec868e7e01b74fcfc9a29d361e01e3bc9102914a0bd3ba0facc
|
4
|
+
data.tar.gz: b27a43c73b0c394bd30579db15a412ade64d53fcc72a5d9a9b8fc35695d3aedd
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 793918328d63451ef6a6ec3b90b3d2a4082ef2c57e018fa0f84e223defe506c3722f7e2168ef97854fcade562dc115943d9655954fdd9a319a13dca6ec50648b
|
7
|
+
data.tar.gz: c353256d7854ea1240bfc1e3f6e0ce7b8c76b72404f728c37695f923c4189777463481d57abc55efdce7a0326621a6a61ca9fcb98638e2f2aa279496d126bd20
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,13 @@
|
|
1
|
+
## 0.7.0 (2020-07-22)
|
2
|
+
|
3
|
+
- Added `check_down` option
|
4
|
+
- Added check for `change_column` with `null: false`
|
5
|
+
- Added check for `validate_foreign_key`
|
6
|
+
- Improved error messages
|
7
|
+
- Made auto analyze less verbose in Postgres
|
8
|
+
- Decreasing the length limit of a `varchar` column or adding a limit is not safe in Postgres
|
9
|
+
- Removed safety checks for `db` rake tasks (Rails 5+ handles this)
|
10
|
+
|
1
11
|
## 0.6.8 (2020-05-13)
|
2
12
|
|
3
13
|
- `change_column_null` on a column with a `NOT NULL` constraint is safe in Postgres 12+
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
@@ -25,6 +25,36 @@ bundle install
|
|
25
25
|
rails generate strong_migrations:install
|
26
26
|
```
|
27
27
|
|
28
|
+
Strong Migrations sets a long statement timeout for migrations so you can set a [short statement timeout](#app-timeouts) for your application.
|
29
|
+
|
30
|
+
## How It Works
|
31
|
+
|
32
|
+
When you run a migration that’s potentially dangerous, you’ll see an error message like:
|
33
|
+
|
34
|
+
```txt
|
35
|
+
=== Dangerous operation detected #strong_migrations ===
|
36
|
+
|
37
|
+
Active Record caches attributes, which causes problems
|
38
|
+
when removing columns. Be sure to ignore the column:
|
39
|
+
|
40
|
+
class User < ApplicationRecord
|
41
|
+
self.ignored_columns = ["name"]
|
42
|
+
end
|
43
|
+
|
44
|
+
Deploy the code, then wrap this step in a safety_assured { ... } block.
|
45
|
+
|
46
|
+
class RemoveColumn < ActiveRecord::Migration[6.0]
|
47
|
+
def change
|
48
|
+
safety_assured { remove_column :users, :name, :string }
|
49
|
+
end
|
50
|
+
end
|
51
|
+
```
|
52
|
+
|
53
|
+
An operation is classified as dangerous if it either:
|
54
|
+
|
55
|
+
- Blocks reads or writes for more than a few seconds (after a lock is acquired)
|
56
|
+
- Has a good chance of causing application errors
|
57
|
+
|
28
58
|
## Checks
|
29
59
|
|
30
60
|
Potentially dangerous operations:
|
@@ -32,11 +62,11 @@ Potentially dangerous operations:
|
|
32
62
|
- [removing a column](#removing-a-column)
|
33
63
|
- [adding a column with a default value](#adding-a-column-with-a-default-value)
|
34
64
|
- [backfilling data](#backfilling-data)
|
35
|
-
- [changing the type of a column](#
|
36
|
-
- [renaming a column](#renaming-
|
65
|
+
- [changing the type of a column](#changing-the-type-of-a-column)
|
66
|
+
- [renaming a column](#renaming-a-column)
|
37
67
|
- [renaming a table](#renaming-a-table)
|
38
68
|
- [creating a table with the force option](#creating-a-table-with-the-force-option)
|
39
|
-
- [
|
69
|
+
- [setting NOT NULL on an existing column](#setting-not-null-on-an-existing-column)
|
40
70
|
- [executing SQL directly](#executing-SQL-directly)
|
41
71
|
|
42
72
|
Postgres-specific checks:
|
@@ -45,7 +75,6 @@ Postgres-specific checks:
|
|
45
75
|
- [adding a reference](#adding-a-reference)
|
46
76
|
- [adding a foreign key](#adding-a-foreign-key)
|
47
77
|
- [adding a json column](#adding-a-json-column)
|
48
|
-
- [setting NOT NULL on an existing column](#setting-not-null-on-an-existing-column)
|
49
78
|
|
50
79
|
Best practices:
|
51
80
|
|
@@ -57,7 +86,7 @@ You can also add [custom checks](#custom-checks) or [disable specific checks](#d
|
|
57
86
|
|
58
87
|
#### Bad
|
59
88
|
|
60
|
-
|
89
|
+
Active Record caches database columns at runtime, so if you drop a column, it can cause exceptions until your app reboots.
|
61
90
|
|
62
91
|
```ruby
|
63
92
|
class RemoveSomeColumnFromUsers < ActiveRecord::Migration[6.0]
|
@@ -69,7 +98,7 @@ end
|
|
69
98
|
|
70
99
|
#### Good
|
71
100
|
|
72
|
-
1. Tell
|
101
|
+
1. Tell Active Record to ignore the column from its cache
|
73
102
|
|
74
103
|
```ruby
|
75
104
|
class User < ApplicationRecord
|
@@ -92,11 +121,9 @@ end
|
|
92
121
|
|
93
122
|
### Adding a column with a default value
|
94
123
|
|
95
|
-
Note: This operation is safe in Postgres 11+, MySQL 8.0.12+, and MariaDB 10.3.2+.
|
96
|
-
|
97
124
|
#### Bad
|
98
125
|
|
99
|
-
|
126
|
+
In earlier versions of Postgres, MySQL, and MariaDB, adding a column with a default value to an existing table causes the entire table to be rewritten. During this time, reads and writes are blocked in Postgres, and writes are blocked in MySQL and MariaDB.
|
100
127
|
|
101
128
|
```ruby
|
102
129
|
class AddSomeColumnToUsers < ActiveRecord::Migration[6.0]
|
@@ -106,6 +133,8 @@ class AddSomeColumnToUsers < ActiveRecord::Migration[6.0]
|
|
106
133
|
end
|
107
134
|
```
|
108
135
|
|
136
|
+
In Postgres 11+, MySQL 8.0.12+, and MariaDB 10.3.2+, this no longer requires a table rewrite and is safe.
|
137
|
+
|
109
138
|
#### Good
|
110
139
|
|
111
140
|
Instead, add the column without a default value, then change the default.
|
@@ -129,7 +158,7 @@ See the next section for how to backfill.
|
|
129
158
|
|
130
159
|
#### Bad
|
131
160
|
|
132
|
-
|
161
|
+
Active Record creates a transaction around each migration, and backfilling in the same transaction that alters a table keeps the table locked for the [duration of the backfill](https://wework.github.io/data/2015/11/05/add-columns-with-default-values-to-large-tables-in-rails-postgres/).
|
133
162
|
|
134
163
|
```ruby
|
135
164
|
class AddSomeColumnToUsers < ActiveRecord::Migration[6.0]
|
@@ -159,19 +188,11 @@ class BackfillSomeColumn < ActiveRecord::Migration[6.0]
|
|
159
188
|
end
|
160
189
|
```
|
161
190
|
|
162
|
-
###
|
191
|
+
### Changing the type of a column
|
163
192
|
|
164
193
|
#### Bad
|
165
194
|
|
166
|
-
|
167
|
-
class RenameSomeColumn < ActiveRecord::Migration[6.0]
|
168
|
-
def change
|
169
|
-
rename_column :users, :some_column, :new_name
|
170
|
-
end
|
171
|
-
end
|
172
|
-
```
|
173
|
-
|
174
|
-
or
|
195
|
+
Changing the type of a column causes the entire table to be rewritten. During this time, reads and writes are blocked in Postgres, and writes are blocked in MySQL and MariaDB.
|
175
196
|
|
176
197
|
```ruby
|
177
198
|
class ChangeSomeColumnType < ActiveRecord::Migration[6.0]
|
@@ -181,17 +202,44 @@ class ChangeSomeColumnType < ActiveRecord::Migration[6.0]
|
|
181
202
|
end
|
182
203
|
```
|
183
204
|
|
184
|
-
A few changes are safe in Postgres:
|
205
|
+
A few changes don’t require a table rewrite (and are safe) in Postgres:
|
185
206
|
|
186
|
-
-
|
207
|
+
- Increasing the length limit of a `varchar` column (or removing the limit)
|
208
|
+
- Changing a `varchar` column to a `text` column
|
209
|
+
- Changing a `text` column to a `varchar` column with no length limit
|
187
210
|
- Increasing the precision of a `decimal` or `numeric` column
|
188
211
|
- Making a `decimal` or `numeric` column unconstrained
|
189
212
|
- Changing between `timestamp` and `timestamptz` columns when session time zone is UTC in Postgres 12+
|
190
213
|
|
191
214
|
And a few in MySQL and MariaDB:
|
192
215
|
|
193
|
-
- Increasing the length of a `varchar` column from under 255 up to 255
|
194
|
-
- Increasing the length of a `varchar` column over 255
|
216
|
+
- Increasing the length limit of a `varchar` column from under 255 up to 255
|
217
|
+
- Increasing the length limit of a `varchar` column from over 255 to the max limit
|
218
|
+
|
219
|
+
#### Good
|
220
|
+
|
221
|
+
A safer approach is to:
|
222
|
+
|
223
|
+
1. Create a new column
|
224
|
+
2. Write to both columns
|
225
|
+
3. Backfill data from the old column to the new column
|
226
|
+
4. Move reads from the old column to the new column
|
227
|
+
5. Stop writing to the old column
|
228
|
+
6. Drop the old column
|
229
|
+
|
230
|
+
### Renaming a column
|
231
|
+
|
232
|
+
#### Bad
|
233
|
+
|
234
|
+
Renaming a column that’s in use will cause errors in your application.
|
235
|
+
|
236
|
+
```ruby
|
237
|
+
class RenameSomeColumn < ActiveRecord::Migration[6.0]
|
238
|
+
def change
|
239
|
+
rename_column :users, :some_column, :new_name
|
240
|
+
end
|
241
|
+
end
|
242
|
+
```
|
195
243
|
|
196
244
|
#### Good
|
197
245
|
|
@@ -208,6 +256,8 @@ A safer approach is to:
|
|
208
256
|
|
209
257
|
#### Bad
|
210
258
|
|
259
|
+
Renaming a table that’s in use will cause errors in your application.
|
260
|
+
|
211
261
|
```ruby
|
212
262
|
class RenameUsersToCustomers < ActiveRecord::Migration[6.0]
|
213
263
|
def change
|
@@ -259,33 +309,55 @@ end
|
|
259
309
|
|
260
310
|
If you intend to drop an existing table, run `drop_table` first.
|
261
311
|
|
262
|
-
###
|
312
|
+
### Setting NOT NULL on an existing column
|
263
313
|
|
264
314
|
#### Bad
|
265
315
|
|
266
|
-
|
316
|
+
Setting `NOT NULL` on an existing column blocks reads and writes while the every row is checked.
|
267
317
|
|
268
318
|
```ruby
|
269
|
-
class
|
319
|
+
class SetSomeColumnNotNull < ActiveRecord::Migration[6.0]
|
270
320
|
def change
|
271
|
-
change_column_null :users, :some_column, false
|
321
|
+
change_column_null :users, :some_column, false
|
272
322
|
end
|
273
323
|
end
|
274
324
|
```
|
275
325
|
|
276
|
-
#### Good
|
326
|
+
#### Good - Postgres
|
327
|
+
|
328
|
+
Instead, add a check constraint:
|
329
|
+
|
330
|
+
```ruby
|
331
|
+
class SetSomeColumnNotNull < ActiveRecord::Migration[6.0]
|
332
|
+
def change
|
333
|
+
safety_assured do
|
334
|
+
execute 'ALTER TABLE "users" ADD CONSTRAINT "users_some_column_null" CHECK ("some_column" IS NOT NULL) NOT VALID'
|
335
|
+
end
|
336
|
+
end
|
337
|
+
end
|
338
|
+
```
|
277
339
|
|
278
|
-
|
340
|
+
Then validate it in a separate migration. A `NOT NULL` check constraint is [functionally equivalent](https://medium.com/doctolib/adding-a-not-null-constraint-on-pg-faster-with-minimal-locking-38b2c00c4d1c) to setting `NOT NULL` on the column, but it won’t show up in `schema.rb`. In Postgres 12+, once the check constraint is validated, you can safely set `NOT NULL` on the column and drop the check constraint.
|
279
341
|
|
280
342
|
```ruby
|
281
|
-
class
|
343
|
+
class ValidateSomeColumnNotNull < ActiveRecord::Migration[6.0]
|
282
344
|
def change
|
345
|
+
safety_assured do
|
346
|
+
execute 'ALTER TABLE "users" VALIDATE CONSTRAINT "users_some_column_null"'
|
347
|
+
end
|
348
|
+
|
349
|
+
# in Postgres 12+, you can then safely set NOT NULL on the column
|
283
350
|
change_column_null :users, :some_column, false
|
351
|
+
safety_assured do
|
352
|
+
execute 'ALTER TABLE "users" DROP CONSTRAINT "users_some_column_null"'
|
353
|
+
end
|
284
354
|
end
|
285
355
|
end
|
286
356
|
```
|
287
357
|
|
288
|
-
|
358
|
+
#### Good - MySQL and MariaDB
|
359
|
+
|
360
|
+
[Let us know](https://github.com/ankane/strong_migrations/issues/new) if you have a safe way to do this.
|
289
361
|
|
290
362
|
### Executing SQL directly
|
291
363
|
|
@@ -303,7 +375,7 @@ end
|
|
303
375
|
|
304
376
|
#### Bad
|
305
377
|
|
306
|
-
In Postgres, adding an index non-concurrently
|
378
|
+
In Postgres, adding an index non-concurrently blocks writes.
|
307
379
|
|
308
380
|
```ruby
|
309
381
|
class AddSomeIndexToUsers < ActiveRecord::Migration[6.0]
|
@@ -339,7 +411,7 @@ rails g index table column
|
|
339
411
|
|
340
412
|
#### Bad
|
341
413
|
|
342
|
-
Rails adds an index non-concurrently to references by default, which
|
414
|
+
Rails adds an index non-concurrently to references by default, which blocks writes in Postgres.
|
343
415
|
|
344
416
|
```ruby
|
345
417
|
class AddReferenceToUsers < ActiveRecord::Migration[6.0]
|
@@ -367,7 +439,7 @@ end
|
|
367
439
|
|
368
440
|
#### Bad
|
369
441
|
|
370
|
-
In Postgres,
|
442
|
+
In Postgres, adding a foreign key blocks writes on both tables.
|
371
443
|
|
372
444
|
```ruby
|
373
445
|
class AddForeignKeyOnUsers < ActiveRecord::Migration[6.0]
|
@@ -389,7 +461,7 @@ end
|
|
389
461
|
|
390
462
|
#### Good
|
391
463
|
|
392
|
-
|
464
|
+
Add the foreign key without validating existing rows, then validate them in a separate migration.
|
393
465
|
|
394
466
|
For Rails 5.2+, use:
|
395
467
|
|
@@ -401,7 +473,7 @@ class AddForeignKeyOnUsers < ActiveRecord::Migration[6.0]
|
|
401
473
|
end
|
402
474
|
```
|
403
475
|
|
404
|
-
Then
|
476
|
+
Then:
|
405
477
|
|
406
478
|
```ruby
|
407
479
|
class ValidateForeignKeyOnUsers < ActiveRecord::Migration[6.0]
|
@@ -423,7 +495,7 @@ class AddForeignKeyOnUsers < ActiveRecord::Migration[5.1]
|
|
423
495
|
end
|
424
496
|
```
|
425
497
|
|
426
|
-
Then
|
498
|
+
Then:
|
427
499
|
|
428
500
|
```ruby
|
429
501
|
class ValidateForeignKeyOnUsers < ActiveRecord::Migration[5.1]
|
@@ -439,7 +511,7 @@ end
|
|
439
511
|
|
440
512
|
#### Bad
|
441
513
|
|
442
|
-
In Postgres, there’s no equality operator for the `json` column type, which can cause errors for existing `SELECT DISTINCT` queries.
|
514
|
+
In Postgres, there’s no equality operator for the `json` column type, which can cause errors for existing `SELECT DISTINCT` queries in your application.
|
443
515
|
|
444
516
|
```ruby
|
445
517
|
class AddPropertiesToUsers < ActiveRecord::Migration[6.0]
|
@@ -461,54 +533,6 @@ class AddPropertiesToUsers < ActiveRecord::Migration[6.0]
|
|
461
533
|
end
|
462
534
|
```
|
463
535
|
|
464
|
-
### Setting NOT NULL on an existing column
|
465
|
-
|
466
|
-
#### Bad
|
467
|
-
|
468
|
-
In Postgres, setting `NOT NULL` on an existing column requires an `AccessExclusiveLock`, which is expensive on large tables.
|
469
|
-
|
470
|
-
```ruby
|
471
|
-
class SetSomeColumnNotNull < ActiveRecord::Migration[6.0]
|
472
|
-
def change
|
473
|
-
change_column_null :users, :some_column, false
|
474
|
-
end
|
475
|
-
end
|
476
|
-
```
|
477
|
-
|
478
|
-
#### Good
|
479
|
-
|
480
|
-
Instead, add a constraint:
|
481
|
-
|
482
|
-
```ruby
|
483
|
-
class SetSomeColumnNotNull < ActiveRecord::Migration[6.0]
|
484
|
-
def change
|
485
|
-
safety_assured do
|
486
|
-
execute 'ALTER TABLE "users" ADD CONSTRAINT "users_some_column_null" CHECK ("some_column" IS NOT NULL) NOT VALID'
|
487
|
-
end
|
488
|
-
end
|
489
|
-
end
|
490
|
-
```
|
491
|
-
|
492
|
-
Then validate it in a separate migration.
|
493
|
-
|
494
|
-
```ruby
|
495
|
-
class ValidateSomeColumnNotNull < ActiveRecord::Migration[6.0]
|
496
|
-
def change
|
497
|
-
safety_assured do
|
498
|
-
execute 'ALTER TABLE "users" VALIDATE CONSTRAINT "users_some_column_null"'
|
499
|
-
end
|
500
|
-
|
501
|
-
# in Postgres 12+, you can safely turn this into a traditional column constraint
|
502
|
-
change_column_null :users, :some_column, false
|
503
|
-
safety_assured do
|
504
|
-
execute 'ALTER TABLE "users" DROP CONSTRAINT "users_some_column_null"'
|
505
|
-
end
|
506
|
-
end
|
507
|
-
end
|
508
|
-
```
|
509
|
-
|
510
|
-
Note: This is not 100% the same as `NOT NULL` column constraint before Postgres 12. Here’s a [good explanation](https://medium.com/doctolib/adding-a-not-null-constraint-on-pg-faster-with-minimal-locking-38b2c00c4d1c).
|
511
|
-
|
512
536
|
### Keeping non-unique indexes to three columns or less
|
513
537
|
|
514
538
|
#### Bad
|
@@ -587,6 +611,14 @@ StrongMigrations.disable_check(:add_index)
|
|
587
611
|
|
588
612
|
Check the [source code](https://github.com/ankane/strong_migrations/blob/master/lib/strong_migrations.rb) for the list of keys.
|
589
613
|
|
614
|
+
## Down Migrations / Rollbacks [unreleased]
|
615
|
+
|
616
|
+
By default, checks are disabled when migrating down. Enable them with:
|
617
|
+
|
618
|
+
```ruby
|
619
|
+
StrongMigrations.check_down = true
|
620
|
+
```
|
621
|
+
|
590
622
|
## Custom Messages
|
591
623
|
|
592
624
|
To customize specific messages, create an initializer with:
|
@@ -597,7 +629,7 @@ StrongMigrations.error_messages[:add_column_default] = "Your custom instructions
|
|
597
629
|
|
598
630
|
Check the [source code](https://github.com/ankane/strong_migrations/blob/master/lib/strong_migrations.rb) for the list of keys.
|
599
631
|
|
600
|
-
## Timeouts
|
632
|
+
## Migration Timeouts
|
601
633
|
|
602
634
|
It’s extremely important to set a short lock timeout for migrations. This way, if a migration can’t acquire a lock in a timely manner, other statements won’t be stuck behind it. We also recommend setting a long statement timeout so migrations can run for a while.
|
603
635
|
|
@@ -617,6 +649,49 @@ ALTER ROLE myuser SET statement_timeout = '1h';
|
|
617
649
|
|
618
650
|
Note: If you use PgBouncer in transaction mode, you must set timeouts on the database user.
|
619
651
|
|
652
|
+
## App Timeouts
|
653
|
+
|
654
|
+
We recommend adding timeouts to `config/database.yml` to prevent connections from hanging and individual queries from taking up too many resources in controllers, jobs, the Rails console, and other places.
|
655
|
+
|
656
|
+
For Postgres:
|
657
|
+
|
658
|
+
```yml
|
659
|
+
production:
|
660
|
+
connect_timeout: 5
|
661
|
+
variables:
|
662
|
+
statement_timeout: 15s
|
663
|
+
lock_timeout: 10s
|
664
|
+
```
|
665
|
+
|
666
|
+
Note: If you use PgBouncer in transaction mode, you must set the statement and lock timeouts on the database user as shown above.
|
667
|
+
|
668
|
+
For MySQL:
|
669
|
+
|
670
|
+
```yml
|
671
|
+
production:
|
672
|
+
connect_timeout: 5
|
673
|
+
read_timeout: 5
|
674
|
+
write_timeout: 5
|
675
|
+
variables:
|
676
|
+
max_execution_time: 15000 # ms
|
677
|
+
lock_wait_timeout: 10 # sec
|
678
|
+
|
679
|
+
```
|
680
|
+
|
681
|
+
For MariaDB:
|
682
|
+
|
683
|
+
```yml
|
684
|
+
production:
|
685
|
+
connect_timeout: 5
|
686
|
+
read_timeout: 5
|
687
|
+
write_timeout: 5
|
688
|
+
variables:
|
689
|
+
max_statement_time: 15 # sec
|
690
|
+
lock_wait_timeout: 10 # sec
|
691
|
+
```
|
692
|
+
|
693
|
+
For HTTP connections, Redis, and other services, check out [this guide](https://github.com/ankane/the-ultimate-guide-to-ruby-timeouts).
|
694
|
+
|
620
695
|
## Existing Migrations
|
621
696
|
|
622
697
|
To mark migrations as safe that were created before installing this gem, create an initializer with:
|
@@ -664,22 +739,20 @@ Columns can flip order in `db/schema.rb` when you have multiple developers. One
|
|
664
739
|
task "db:schema:dump": "strong_migrations:alphabetize_columns"
|
665
740
|
```
|
666
741
|
|
667
|
-
## Dangerous Tasks
|
668
|
-
|
669
|
-
For safety, dangerous database tasks are disabled in production - `db:drop`, `db:reset`, `db:schema:load`, and `db:structure:load`. To get around this, use:
|
670
|
-
|
671
|
-
```sh
|
672
|
-
SAFETY_ASSURED=1 rails db:drop
|
673
|
-
```
|
674
|
-
|
675
742
|
## Permissions
|
676
743
|
|
677
744
|
We recommend using a [separate database user](https://ankane.org/postgres-users) for migrations when possible so you don’t need to grant your app user permission to alter tables.
|
678
745
|
|
746
|
+
## Smaller Projects
|
747
|
+
|
748
|
+
You probably don’t need this gem for smaller projects, as operations that are unsafe at scale can be perfectly safe on smaller, low-traffic tables.
|
749
|
+
|
679
750
|
## Additional Reading
|
680
751
|
|
681
752
|
- [Rails Migrations with No Downtime](https://pedro.herokuapp.com/past/2011/7/13/rails_migrations_with_no_downtime/)
|
682
753
|
- [PostgreSQL at Scale: Database Schema Changes Without Downtime](https://medium.com/braintree-product-technology/postgresql-at-scale-database-schema-changes-without-downtime-20d3749ed680)
|
754
|
+
- [An Overview of DDL Algorithms in MySQL](https://mydbops.wordpress.com/2020/03/04/an-overview-of-ddl-algorithms-in-mysql-covers-mysql-8/)
|
755
|
+
- [MariaDB InnoDB Online DDL Overview](https://mariadb.com/kb/en/innodb-online-ddl-overview/)
|
683
756
|
|
684
757
|
## Credits
|
685
758
|
|
data/lib/strong_migrations.rb
CHANGED
@@ -17,7 +17,7 @@ module StrongMigrations
|
|
17
17
|
class << self
|
18
18
|
attr_accessor :auto_analyze, :start_after, :checks, :error_messages,
|
19
19
|
:target_postgresql_version, :target_mysql_version, :target_mariadb_version,
|
20
|
-
:enabled_checks, :lock_timeout, :statement_timeout
|
20
|
+
:enabled_checks, :lock_timeout, :statement_timeout, :check_down
|
21
21
|
attr_writer :lock_timeout_limit
|
22
22
|
end
|
23
23
|
self.auto_analyze = false
|
@@ -25,7 +25,7 @@ module StrongMigrations
|
|
25
25
|
self.checks = []
|
26
26
|
self.error_messages = {
|
27
27
|
add_column_default:
|
28
|
-
"Adding a column with a non-null default
|
28
|
+
"Adding a column with a non-null default blocks %{rewrite_blocks} while the entire table is rewritten.
|
29
29
|
Instead, add the column without a default value, then change the default.
|
30
30
|
|
31
31
|
class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
|
@@ -50,12 +50,18 @@ class Backfill%{migration_name} < ActiveRecord::Migration%{migration_suffix}
|
|
50
50
|
end",
|
51
51
|
|
52
52
|
add_column_json:
|
53
|
-
"There's no equality operator for the json column type, which can
|
54
|
-
|
53
|
+
"There's no equality operator for the json column type, which can cause errors for
|
54
|
+
existing SELECT DISTINCT queries in your application. Use jsonb instead.
|
55
|
+
|
56
|
+
class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
|
57
|
+
def change
|
58
|
+
%{command}
|
59
|
+
end
|
60
|
+
end",
|
55
61
|
|
56
62
|
change_column:
|
57
|
-
"Changing the type of an existing column
|
58
|
-
|
63
|
+
"Changing the type of an existing column blocks %{rewrite_blocks}
|
64
|
+
while the entire table is rewritten. A safer approach is to:
|
59
65
|
|
60
66
|
1. Create a new column
|
61
67
|
2. Write to both columns
|
@@ -64,7 +70,10 @@ table and indexes to be rewritten. A safer approach is to:
|
|
64
70
|
5. Stop writing to the old column
|
65
71
|
6. Drop the old column",
|
66
72
|
|
67
|
-
|
73
|
+
change_column_with_not_null:
|
74
|
+
"Changing the type is safe, but setting NOT NULL is not.",
|
75
|
+
|
76
|
+
remove_column: "Active Record caches attributes, which causes problems
|
68
77
|
when removing columns. Be sure to ignore the column%{column_suffix}:
|
69
78
|
|
70
79
|
class %{model} < %{base_model}
|
@@ -80,7 +89,8 @@ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
|
|
80
89
|
end",
|
81
90
|
|
82
91
|
rename_column:
|
83
|
-
"Renaming a column
|
92
|
+
"Renaming a column that's in use will cause errors
|
93
|
+
in your application. A safer approach is to:
|
84
94
|
|
85
95
|
1. Create a new column
|
86
96
|
2. Write to both columns
|
@@ -90,7 +100,8 @@ end",
|
|
90
100
|
6. Drop the old column",
|
91
101
|
|
92
102
|
rename_table:
|
93
|
-
"Renaming a table
|
103
|
+
"Renaming a table that's in use will cause errors
|
104
|
+
in your application. A safer approach is to:
|
94
105
|
|
95
106
|
1. Create a new table. Don't forget to recreate indexes from the old table
|
96
107
|
2. Write to both tables
|
@@ -111,7 +122,7 @@ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
|
|
111
122
|
end",
|
112
123
|
|
113
124
|
add_index:
|
114
|
-
"Adding an index non-concurrently
|
125
|
+
"Adding an index non-concurrently blocks writes. Instead, use:
|
115
126
|
|
116
127
|
class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
|
117
128
|
disable_ddl_transaction!
|
@@ -122,7 +133,7 @@ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
|
|
122
133
|
end",
|
123
134
|
|
124
135
|
remove_index:
|
125
|
-
"Removing an index non-concurrently
|
136
|
+
"Removing an index non-concurrently blocks writes. Instead, use:
|
126
137
|
|
127
138
|
class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
|
128
139
|
disable_ddl_transaction!
|
@@ -165,9 +176,8 @@ class Backfill%{migration_name} < ActiveRecord::Migration%{migration_suffix}
|
|
165
176
|
end",
|
166
177
|
|
167
178
|
change_column_null_postgresql:
|
168
|
-
"Setting NOT NULL on
|
169
|
-
|
170
|
-
validate it in a separate migration with a more agreeable RowShareLock.
|
179
|
+
"Setting NOT NULL on an existing column blocks reads and writes while every row is checked.
|
180
|
+
Instead, add a check constraint and validate it in a separate migration.
|
171
181
|
|
172
182
|
class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
|
173
183
|
def change
|
@@ -185,9 +195,9 @@ end",
|
|
185
195
|
"Setting NOT NULL on an existing column is not safe with your database engine.",
|
186
196
|
|
187
197
|
add_foreign_key:
|
188
|
-
"
|
189
|
-
|
190
|
-
|
198
|
+
"Adding a foreign key blocks writes on both tables. Instead,
|
199
|
+
add the foreign key without validating existing rows,
|
200
|
+
then validate them in a separate migration.
|
191
201
|
|
192
202
|
class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
|
193
203
|
def change
|
@@ -199,9 +209,14 @@ class Validate%{migration_name} < ActiveRecord::Migration%{migration_suffix}
|
|
199
209
|
def change
|
200
210
|
%{validate_foreign_key_code}
|
201
211
|
end
|
202
|
-
end"
|
212
|
+
end",
|
213
|
+
|
214
|
+
validate_foreign_key:
|
215
|
+
"Validating a foreign key while writes are blocked is dangerous.
|
216
|
+
Use disable_ddl_transaction! or a separate migration."
|
203
217
|
}
|
204
218
|
self.enabled_checks = (error_messages.keys - [:remove_index]).map { |k| [k, {}] }.to_h
|
219
|
+
self.check_down = false
|
205
220
|
|
206
221
|
# private
|
207
222
|
def self.developer_env?
|
@@ -96,11 +96,13 @@ Then add the NOT NULL constraint in separate migrations."
|
|
96
96
|
change_command: command_str("change_column_default", [table, column, default]),
|
97
97
|
remove_command: command_str("remove_column", [table, column]),
|
98
98
|
code: backfill_code(table, column, default),
|
99
|
-
append: append
|
99
|
+
append: append,
|
100
|
+
rewrite_blocks: rewrite_blocks
|
100
101
|
end
|
101
102
|
|
102
103
|
if type.to_s == "json" && postgresql?
|
103
|
-
raise_error :add_column_json
|
104
|
+
raise_error :add_column_json,
|
105
|
+
command: command_str("add_column", [table, column, :jsonb, options])
|
104
106
|
end
|
105
107
|
when :change_column
|
106
108
|
table, column, type, options = args
|
@@ -109,15 +111,24 @@ Then add the NOT NULL constraint in separate migrations."
|
|
109
111
|
safe = false
|
110
112
|
existing_column = connection.columns(table).find { |c| c.name.to_s == column.to_s }
|
111
113
|
if existing_column
|
112
|
-
|
114
|
+
existing_type = existing_column.sql_type.split("(").first
|
113
115
|
if postgresql?
|
114
116
|
case type.to_s
|
115
|
-
when "string"
|
116
|
-
# safe to
|
117
|
-
safe
|
117
|
+
when "string"
|
118
|
+
# safe to increase limit or remove it
|
119
|
+
# not safe to decrease limit or add a limit
|
120
|
+
case existing_type
|
121
|
+
when "character varying"
|
122
|
+
safe = !options[:limit] || (existing_column.limit && options[:limit] >= existing_column.limit)
|
123
|
+
when "text"
|
124
|
+
safe = !options[:limit]
|
125
|
+
end
|
126
|
+
when "text"
|
127
|
+
# safe to change varchar to text (and text to text)
|
128
|
+
safe = ["character varying", "text"].include?(existing_type)
|
118
129
|
when "numeric", "decimal"
|
119
130
|
# numeric and decimal are equivalent and can be used interchangably
|
120
|
-
safe = ["numeric", "decimal"].include?(
|
131
|
+
safe = ["numeric", "decimal"].include?(existing_type) &&
|
121
132
|
(
|
122
133
|
(
|
123
134
|
# unconstrained
|
@@ -130,7 +141,7 @@ Then add the NOT NULL constraint in separate migrations."
|
|
130
141
|
)
|
131
142
|
)
|
132
143
|
when "datetime", "timestamp", "timestamptz"
|
133
|
-
safe = ["timestamp without time zone", "timestamp with time zone"].include?(
|
144
|
+
safe = ["timestamp without time zone", "timestamp with time zone"].include?(existing_type) &&
|
134
145
|
postgresql_version >= Gem::Version.new("12") &&
|
135
146
|
connection.select_all("SHOW timezone").first["TimeZone"] == "UTC"
|
136
147
|
end
|
@@ -142,13 +153,19 @@ Then add the NOT NULL constraint in separate migrations."
|
|
142
153
|
# increased limit, but doesn't change number of length bytes
|
143
154
|
# 1-255 = 1 byte, 256-65532 = 2 bytes, 65533+ = too big for varchar
|
144
155
|
limit = options[:limit] || 255
|
145
|
-
safe = ["varchar"].include?(
|
156
|
+
safe = ["varchar"].include?(existing_type) &&
|
146
157
|
limit >= existing_column.limit &&
|
147
158
|
(limit <= 255 || existing_column.limit > 255)
|
148
159
|
end
|
149
160
|
end
|
150
161
|
end
|
151
|
-
|
162
|
+
|
163
|
+
# unsafe to set NOT NULL for safe types
|
164
|
+
if safe && existing_column.null && options[:null] == false
|
165
|
+
raise_error :change_column_with_not_null
|
166
|
+
end
|
167
|
+
|
168
|
+
raise_error :change_column, rewrite_blocks: rewrite_blocks unless safe
|
152
169
|
when :create_table
|
153
170
|
table, options = args
|
154
171
|
options ||= {}
|
@@ -176,7 +193,7 @@ Then add the NOT NULL constraint in separate migrations."
|
|
176
193
|
end
|
177
194
|
|
178
195
|
if options.delete(:foreign_key)
|
179
|
-
headline = "Adding a
|
196
|
+
headline = "Adding a foreign key blocks writes on both tables."
|
180
197
|
append = "
|
181
198
|
|
182
199
|
Then add the foreign key in separate migrations."
|
@@ -247,6 +264,10 @@ Then add the foreign key in separate migrations."
|
|
247
264
|
validate_foreign_key_code: command_str("validate_foreign_key", [from_table, to_table])
|
248
265
|
end
|
249
266
|
end
|
267
|
+
when :validate_foreign_key
|
268
|
+
if postgresql? && writes_blocked?
|
269
|
+
raise_error :validate_foreign_key
|
270
|
+
end
|
250
271
|
end
|
251
272
|
|
252
273
|
StrongMigrations.checks.each do |check|
|
@@ -259,8 +280,7 @@ Then add the foreign key in separate migrations."
|
|
259
280
|
# outdated statistics + a new index can hurt performance of existing queries
|
260
281
|
if StrongMigrations.auto_analyze && direction == :up && method == :add_index
|
261
282
|
if postgresql?
|
262
|
-
|
263
|
-
connection.execute "ANALYZE VERBOSE #{connection.quote_table_name(args[0].to_s)}"
|
283
|
+
connection.execute "ANALYZE #{connection.quote_table_name(args[0].to_s)}"
|
264
284
|
elsif mariadb? || mysql?
|
265
285
|
connection.execute "ANALYZE TABLE #{connection.quote_table_name(args[0].to_s)}"
|
266
286
|
end
|
@@ -269,6 +289,8 @@ Then add the foreign key in separate migrations."
|
|
269
289
|
result
|
270
290
|
end
|
271
291
|
|
292
|
+
private
|
293
|
+
|
272
294
|
def set_timeouts
|
273
295
|
if !@timeouts_set
|
274
296
|
if StrongMigrations.statement_timeout
|
@@ -303,8 +325,6 @@ Then add the foreign key in separate migrations."
|
|
303
325
|
end
|
304
326
|
end
|
305
327
|
|
306
|
-
private
|
307
|
-
|
308
328
|
def connection
|
309
329
|
@migration.connection
|
310
330
|
end
|
@@ -314,7 +334,8 @@ Then add the foreign key in separate migrations."
|
|
314
334
|
end
|
315
335
|
|
316
336
|
def safe?
|
317
|
-
@safe || ENV["SAFETY_ASSURED"] || @migration.is_a?(ActiveRecord::Schema) ||
|
337
|
+
@safe || ENV["SAFETY_ASSURED"] || @migration.is_a?(ActiveRecord::Schema) ||
|
338
|
+
(direction == :down && !StrongMigrations.check_down) || version_safe?
|
318
339
|
end
|
319
340
|
|
320
341
|
def version_safe?
|
@@ -419,11 +440,16 @@ Then add the foreign key in separate migrations."
|
|
419
440
|
end
|
420
441
|
|
421
442
|
def constraints(table_name)
|
422
|
-
query =
|
423
|
-
SELECT
|
424
|
-
|
425
|
-
|
426
|
-
|
443
|
+
query = <<~SQL
|
444
|
+
SELECT
|
445
|
+
conname AS name,
|
446
|
+
pg_get_constraintdef(oid) AS def
|
447
|
+
FROM
|
448
|
+
pg_constraint
|
449
|
+
WHERE
|
450
|
+
contype = 'c' AND
|
451
|
+
convalidated AND
|
452
|
+
conrelid = #{connection.quote(connection.quote_table_name(table_name))}::regclass
|
427
453
|
SQL
|
428
454
|
connection.select_all(query.squish).to_a
|
429
455
|
end
|
@@ -472,6 +498,23 @@ Then add the foreign key in separate migrations."
|
|
472
498
|
"#{command} #{str_args.join(", ")}"
|
473
499
|
end
|
474
500
|
|
501
|
+
def writes_blocked?
|
502
|
+
query = <<~SQL
|
503
|
+
SELECT
|
504
|
+
relation::regclass::text
|
505
|
+
FROM
|
506
|
+
pg_locks
|
507
|
+
WHERE
|
508
|
+
mode IN ('ShareRowExclusiveLock', 'AccessExclusiveLock') AND
|
509
|
+
pid = pg_backend_pid()
|
510
|
+
SQL
|
511
|
+
connection.select_all(query.squish).any?
|
512
|
+
end
|
513
|
+
|
514
|
+
def rewrite_blocks
|
515
|
+
mysql? || mariadb? ? "writes" : "reads and writes"
|
516
|
+
end
|
517
|
+
|
475
518
|
def backfill_code(table, column, default)
|
476
519
|
model = table.to_s.classify
|
477
520
|
"#{model}.unscoped.in_batches do |relation| \n relation.update_all #{column}: #{default.inspect}\n sleep(0.01)\n end"
|
@@ -5,10 +5,6 @@ module StrongMigrations
|
|
5
5
|
class Railtie < Rails::Railtie
|
6
6
|
rake_tasks do
|
7
7
|
load "tasks/strong_migrations.rake"
|
8
|
-
|
9
|
-
["db:drop", "db:reset", "db:schema:load", "db:structure:load"].each do |t|
|
10
|
-
Rake::Task[t].enhance ["strong_migrations:safety_assured"]
|
11
|
-
end
|
12
8
|
end
|
13
9
|
end
|
14
10
|
end
|
@@ -1,10 +1,4 @@
|
|
1
|
-
# https://nithinbekal.com/posts/safe-rake-tasks
|
2
|
-
|
3
1
|
namespace :strong_migrations do
|
4
|
-
task safety_assured: :environment do
|
5
|
-
raise "Set SAFETY_ASSURED=1 to run this task in production" if Rails.env.production? && !ENV["SAFETY_ASSURED"]
|
6
|
-
end
|
7
|
-
|
8
2
|
# https://www.pgrs.net/2008/03/13/alphabetize-schema-rb-columns/
|
9
3
|
task :alphabetize_columns do
|
10
4
|
$stderr.puts "Dumping schema"
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: strong_migrations
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.7.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Andrew Kane
|
@@ -10,7 +10,7 @@ authors:
|
|
10
10
|
autorequire:
|
11
11
|
bindir: bin
|
12
12
|
cert_chain: []
|
13
|
-
date: 2020-
|
13
|
+
date: 2020-07-23 00:00:00.000000000 Z
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|
16
16
|
name: activerecord
|