zero_downtime_migrations 0.0.0 → 0.0.1
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 +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +137 -12
- data/lib/zero_downtime_migrations/data.rb +8 -0
- data/lib/zero_downtime_migrations/dsl.rb +37 -0
- data/lib/zero_downtime_migrations/migration.rb +66 -17
- data/lib/zero_downtime_migrations/relation.rb +12 -0
- data/lib/zero_downtime_migrations/validation/add_column.rb +93 -0
- data/lib/zero_downtime_migrations/validation/add_index.rb +59 -0
- data/lib/zero_downtime_migrations/validation/ddl_migration.rb +22 -0
- data/lib/zero_downtime_migrations/validation/mixed_migration.rb +23 -0
- data/lib/zero_downtime_migrations/validation.rb +7 -1
- data/lib/zero_downtime_migrations.rb +13 -5
- data/spec/internal/app/models/post.rb +2 -0
- data/spec/internal/app/models/user.rb +2 -0
- data/spec/internal/db/migrate/20161012223255_safe_add_index_with_env.rb +2 -2
- data/spec/internal/db/migrate/20161012223256_safe_add_index_with_dsl.rb +7 -0
- data/spec/internal/db/migrate/{20161012223256_add_index_concurrently.rb → 20161012223257_add_index_concurrently.rb} +0 -0
- data/spec/zero_downtime_migrations/relation_spec.rb +53 -0
- data/spec/zero_downtime_migrations/{add_column_spec.rb → validation/add_column_spec.rb} +1 -1
- data/spec/zero_downtime_migrations/{add_index_spec.rb → validation/add_index_spec.rb} +1 -1
- data/spec/zero_downtime_migrations/validation/ddl_migration_spec.rb +68 -0
- data/spec/zero_downtime_migrations/validation/mixed_migration_spec.rb +78 -0
- data/spec/zero_downtime_migrations/validation_spec.rb +2 -2
- data/zero_downtime_migrations.gemspec +1 -1
- metadata +27 -10
- data/lib/zero_downtime_migrations/add_column.rb +0 -91
- data/lib/zero_downtime_migrations/add_index.rb +0 -57
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5c8dfec1a4d2d84e2a9260829418235053eeec0c
|
4
|
+
data.tar.gz: 0568a51dd7990eb896354ed67cfe984a3bcc8f13
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1b0b3503a2749957e5f310bd5c38bcfa3243a3fd2d077f793ca17fff631c384a65f6dc75d247588f52912bb43ee36d2bbbd6d173f33912f34abc357f9e0aa4f6
|
7
|
+
data.tar.gz: 1d2d7bc71333d19ba28b1c6600a99d23cf2094078f9f45dd9df826ce9306b0284b9e9236eae920b3b1224d33cddeb7f8ce020c81567d77448df1d36e62966b3c
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -1,15 +1,15 @@
|
|
1
|
-
# zero_downtime_migrations
|
1
|
+
#  zero_downtime_migrations
|
2
2
|
|
3
3
|
Zero downtime migrations with ActiveRecord and PostgreSQL. Catch problematic migrations at development/test time!
|
4
4
|
|
5
|
-
Heavily
|
5
|
+
Heavily inspired by these similar projects:
|
6
6
|
|
7
7
|
* https://github.com/ankane/strong_migrations
|
8
8
|
* https://github.com/foobarfighter/safe-migrations
|
9
9
|
|
10
10
|
## Installation
|
11
11
|
|
12
|
-
Simply add the gem to the project `Gemfile`. Ensure that it's only added to the `development` and `test
|
12
|
+
Simply add the gem to the project `Gemfile`. Ensure that it's only **added to the `development` and `test`** groups.
|
13
13
|
|
14
14
|
```ruby
|
15
15
|
gem "zero_downtime_migrations", only: %i(development test)
|
@@ -17,13 +17,17 @@ gem "zero_downtime_migrations", only: %i(development test)
|
|
17
17
|
|
18
18
|
## Usage
|
19
19
|
|
20
|
-
This gem will automatically raise exceptions when potential database locking migrations are detected
|
20
|
+
This gem will automatically **raise exceptions when potential database locking migrations are detected**.
|
21
|
+
|
22
|
+
It checks for common things like:
|
21
23
|
|
22
24
|
* Adding a column with a default
|
23
25
|
* Adding a non-concurrent index
|
24
|
-
* Mixing data changes with schema migrations
|
26
|
+
* Mixing data changes with index or schema migrations
|
27
|
+
* Performing data or schema migrations with the DDL transaction disabled
|
28
|
+
* Using `each` instead of `find_each` to loop thru `ActiveRecord` objects
|
25
29
|
|
26
|
-
These exceptions display clear instructions of how to perform the same operation the "zero downtime way".
|
30
|
+
These exceptions display very clear instructions of how to perform the same operation the "zero downtime way".
|
27
31
|
|
28
32
|
## Disabling exceptions
|
29
33
|
|
@@ -39,16 +43,29 @@ class AddPublishedToPosts < ActiveRecord::Migration[5.0]
|
|
39
43
|
end
|
40
44
|
```
|
41
45
|
|
42
|
-
We can also
|
46
|
+
We can also mark an entire migration as safe by using the `safety_assured` helper method.
|
47
|
+
|
48
|
+
```ruby
|
49
|
+
class AddPublishedToPosts < ActiveRecord::Migration[5.0]
|
50
|
+
safety_assured
|
51
|
+
|
52
|
+
def change
|
53
|
+
add_column :posts, :published, :boolean
|
54
|
+
Post.where("created_at >= ?", 1.day.ago).update_all(published: true)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
```
|
58
|
+
|
59
|
+
Enforcements can be globally disabled by setting `ENV["SAFETY_ASSURED"]` when running migrations.
|
43
60
|
|
44
61
|
```bash
|
45
62
|
SAFETY_ASSURED=1 bundle exec rake db:migrate --trace
|
46
63
|
```
|
47
64
|
|
48
|
-
These enforcements are automatically disabled by default for the following scenarios
|
65
|
+
These enforcements are **automatically disabled by default for the following scenarios**:
|
49
66
|
|
50
|
-
*
|
51
|
-
*
|
67
|
+
* The database schema is being loaded with `rake db:schema:load` instead of `db:migrate`
|
68
|
+
* The current migration is a reverse (down) migration
|
52
69
|
|
53
70
|
## Validations
|
54
71
|
|
@@ -117,14 +134,122 @@ class IndexUsersOnEmail < ActiveRecord::Migration[5.0]
|
|
117
134
|
end
|
118
135
|
```
|
119
136
|
|
137
|
+
### Mixing data/index/schema migrations
|
138
|
+
|
139
|
+
#### Bad
|
140
|
+
|
141
|
+
```ruby
|
142
|
+
class AddPublishedToPosts < ActiveRecord::Migration[5.0]
|
143
|
+
def change
|
144
|
+
add_column :posts, :published, :boolean
|
145
|
+
Post.update_all(published: true)
|
146
|
+
add_index :posts, :published
|
147
|
+
end
|
148
|
+
end
|
149
|
+
```
|
150
|
+
|
151
|
+
#### Good
|
152
|
+
|
153
|
+
```ruby
|
154
|
+
class AddPublishedToPosts < ActiveRecord::Migration[5.0]
|
155
|
+
def change
|
156
|
+
add_column :posts, :published, :boolean
|
157
|
+
end
|
158
|
+
end
|
159
|
+
```
|
160
|
+
|
161
|
+
```ruby
|
162
|
+
class BackportPublishedOnPosts < ActiveRecord::Migration[5.0]
|
163
|
+
def change
|
164
|
+
Post.update_all(published: true)
|
165
|
+
end
|
166
|
+
end
|
167
|
+
```
|
168
|
+
|
169
|
+
```ruby
|
170
|
+
class IndexPublishedOnPosts < ActiveRecord::Migration[5.0]
|
171
|
+
disable_ddl_transaction!
|
172
|
+
|
173
|
+
def change
|
174
|
+
add_index :posts, :published, algorithm: :concurrently
|
175
|
+
end
|
176
|
+
end
|
177
|
+
```
|
178
|
+
|
179
|
+
### Disabling the DDL transaction
|
180
|
+
|
181
|
+
#### Bad
|
182
|
+
|
183
|
+
```ruby
|
184
|
+
class AddPublishedToPosts < ActiveRecord::Migration[5.0]
|
185
|
+
disable_ddl_transaction!
|
186
|
+
|
187
|
+
def change
|
188
|
+
add_column :posts, :published, :boolean
|
189
|
+
end
|
190
|
+
end
|
191
|
+
```
|
192
|
+
|
193
|
+
```ruby
|
194
|
+
class UpdatePublishedOnPosts < ActiveRecord::Migration[5.0]
|
195
|
+
disable_ddl_transaction!
|
196
|
+
|
197
|
+
def change
|
198
|
+
Post.update_all(published: true)
|
199
|
+
end
|
200
|
+
end
|
201
|
+
```
|
202
|
+
|
203
|
+
#### Good
|
204
|
+
|
205
|
+
```ruby
|
206
|
+
class AddPublishedToPosts < ActiveRecord::Migration[5.0]
|
207
|
+
def change
|
208
|
+
add_column :posts, :published, :boolean
|
209
|
+
end
|
210
|
+
end
|
211
|
+
```
|
212
|
+
|
213
|
+
```ruby
|
214
|
+
class UpdatePublishedOnPosts < ActiveRecord::Migration[5.0]
|
215
|
+
def change
|
216
|
+
Post.update_all(published: true)
|
217
|
+
end
|
218
|
+
end
|
219
|
+
```
|
220
|
+
|
221
|
+
### Looping thru `ActiveRecord::Base` objects
|
222
|
+
|
223
|
+
#### Bad
|
224
|
+
|
225
|
+
```ruby
|
226
|
+
class BackportPublishedDefaultOnPosts < ActiveRecord::Migration[5.0]
|
227
|
+
def change
|
228
|
+
Post.all.each do |post|
|
229
|
+
post.update_attribute(published: true)
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
233
|
+
```
|
234
|
+
|
235
|
+
#### Good
|
236
|
+
|
237
|
+
```ruby
|
238
|
+
class BackportPublishedDefaultOnPosts < ActiveRecord::Migration[5.0]
|
239
|
+
def change
|
240
|
+
Post.all.find_each do |post|
|
241
|
+
post.update_attribute(published: true)
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
245
|
+
```
|
246
|
+
|
120
247
|
### TODO
|
121
248
|
|
122
249
|
* Changing a column type
|
123
250
|
* Removing a column
|
124
251
|
* Renaming a column
|
125
252
|
* Renaming a table
|
126
|
-
* Mixing data changes with schema or index migrations
|
127
|
-
* Performing schema changes with the DDL transaction disabled
|
128
253
|
|
129
254
|
## Testing
|
130
255
|
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module ZeroDowntimeMigrations
|
2
|
+
module DSL
|
3
|
+
attr_accessor :data, :ddl, :index, :migrating, :safe
|
4
|
+
|
5
|
+
def data?
|
6
|
+
!!@data
|
7
|
+
end
|
8
|
+
|
9
|
+
def ddl?
|
10
|
+
!!@ddl
|
11
|
+
end
|
12
|
+
|
13
|
+
def index?
|
14
|
+
!!@index
|
15
|
+
end
|
16
|
+
|
17
|
+
def migrating?
|
18
|
+
!!@migrating
|
19
|
+
end
|
20
|
+
|
21
|
+
def mixed?
|
22
|
+
[data?, ddl?, index?].select(&:itself).size > 1
|
23
|
+
end
|
24
|
+
|
25
|
+
def safe?
|
26
|
+
!!@safe || ENV["SAFETY_ASSURED"].presence
|
27
|
+
end
|
28
|
+
|
29
|
+
def safety_assured
|
30
|
+
Migration.safe = true
|
31
|
+
end
|
32
|
+
|
33
|
+
def unsafe?
|
34
|
+
!safe?
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -1,31 +1,80 @@
|
|
1
1
|
module ZeroDowntimeMigrations
|
2
2
|
module Migration
|
3
|
+
extend DSL
|
4
|
+
|
5
|
+
def self.prepended(mod)
|
6
|
+
mod.singleton_class.prepend(DSL)
|
7
|
+
end
|
8
|
+
|
3
9
|
def ddl_disabled?
|
4
10
|
!!disable_ddl_transaction
|
5
11
|
end
|
6
12
|
|
13
|
+
def define(*)
|
14
|
+
Migration.migrating = true
|
15
|
+
Migration.safe = true
|
16
|
+
super
|
17
|
+
end
|
18
|
+
|
7
19
|
def migrate(direction)
|
8
20
|
@direction = direction
|
9
|
-
|
21
|
+
|
22
|
+
Migration.data = false
|
23
|
+
Migration.ddl = false
|
24
|
+
Migration.index = false
|
25
|
+
Migration.migrating = true
|
26
|
+
Migration.safe ||= reverse_migration? || rollup_migration?
|
27
|
+
|
28
|
+
super.tap do
|
29
|
+
validate(:ddl_migration)
|
30
|
+
validate(:mixed_migration)
|
31
|
+
Migration.migrating = false
|
32
|
+
Migration.safe = false
|
33
|
+
end
|
10
34
|
end
|
11
35
|
|
12
36
|
private
|
13
37
|
|
14
|
-
def
|
15
|
-
|
38
|
+
def ddl_method?(method)
|
39
|
+
%i(
|
40
|
+
add_belongs_to
|
41
|
+
add_column
|
42
|
+
add_foreign_key
|
43
|
+
add_reference
|
44
|
+
add_timestamps
|
45
|
+
change_column
|
46
|
+
change_column_default
|
47
|
+
change_column_null
|
48
|
+
change_table
|
49
|
+
create_join_table
|
50
|
+
create_table
|
51
|
+
drop_join_table
|
52
|
+
drop_table
|
53
|
+
remove_belongs_to
|
54
|
+
remove_column
|
55
|
+
remove_columns
|
56
|
+
remove_foreign_key
|
57
|
+
remove_index
|
58
|
+
remove_index!
|
59
|
+
remove_reference
|
60
|
+
remove_timestamps
|
61
|
+
rename_column
|
62
|
+
rename_column_indexes
|
63
|
+
rename_index
|
64
|
+
rename_table
|
65
|
+
rename_table_indexes
|
66
|
+
).include?(method)
|
16
67
|
end
|
17
68
|
|
18
|
-
def
|
19
|
-
|
20
|
-
validator = "#{namespace}::#{method.to_s.classify}".safe_constantize
|
21
|
-
validator.new(self, args).validate! if validator
|
22
|
-
end
|
23
|
-
|
24
|
-
super
|
69
|
+
def index_method?(method)
|
70
|
+
%i(add_index).include?(method)
|
25
71
|
end
|
26
72
|
|
27
|
-
def
|
28
|
-
|
73
|
+
def method_missing(method, *args)
|
74
|
+
Migration.ddl = true if ddl_method?(method)
|
75
|
+
Migration.index = true if index_method?(method)
|
76
|
+
validate(method, *args)
|
77
|
+
super
|
29
78
|
end
|
30
79
|
|
31
80
|
def reverse_migration?
|
@@ -37,15 +86,15 @@ module ZeroDowntimeMigrations
|
|
37
86
|
end
|
38
87
|
|
39
88
|
def safety_assured
|
40
|
-
safe =
|
41
|
-
|
89
|
+
safe = Migration.safe
|
90
|
+
Migration.safe = true
|
42
91
|
yield
|
43
92
|
ensure
|
44
|
-
|
93
|
+
Migration.safe = safe
|
45
94
|
end
|
46
95
|
|
47
|
-
def
|
48
|
-
|
96
|
+
def validate(type, *args)
|
97
|
+
Validation.validate!(type, self, *args) if Migration.unsafe?
|
49
98
|
end
|
50
99
|
end
|
51
100
|
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module ZeroDowntimeMigrations
|
2
|
+
module Relation
|
3
|
+
prepend Data
|
4
|
+
|
5
|
+
def each(*)
|
6
|
+
return super unless Migration.migrating? && Migration.unsafe?
|
7
|
+
error = "Using ActiveRecord::Relation#each is unsafe!"
|
8
|
+
correction = "Instead, let's use the find_each method to query in batches."
|
9
|
+
raise UnsafeMigrationError.new(error, correction)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
module ZeroDowntimeMigrations
|
2
|
+
class Validation
|
3
|
+
class AddColumn < Validation
|
4
|
+
def validate!
|
5
|
+
return if options[:default].nil? # only nil is safe
|
6
|
+
message = "Adding a column with a default is unsafe!"
|
7
|
+
error!(message, correction)
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def correction
|
13
|
+
<<-MESSAGE.strip_heredoc
|
14
|
+
This action can potentially lock your database table!
|
15
|
+
|
16
|
+
Instead, let's first add the column without a default.
|
17
|
+
|
18
|
+
class Add#{column_title}To#{table_title} < ActiveRecord::Migration
|
19
|
+
def change
|
20
|
+
add_column :#{table}, :#{column}, :#{column_type}
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
Then set the new column default in a separate migration. Note that
|
25
|
+
this does not update any existing data.
|
26
|
+
|
27
|
+
class AddDefault#{column_title}To#{table_title} < ActiveRecord::Migration
|
28
|
+
def change
|
29
|
+
change_column_default :#{table}, :#{column}, #{column_default}
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
If necessary then backport the default value for existing data in batches.
|
34
|
+
This should be done in its own migration as well.
|
35
|
+
|
36
|
+
class BackportDefault#{column_title}To#{table_title} < ActiveRecord::Migration
|
37
|
+
def change
|
38
|
+
#{table_model}.select(:id).find_in_batches.with_index do |records, index|
|
39
|
+
puts "Processing batch \#{index + 1}\\r"
|
40
|
+
#{table_model}.where(id: records).update_all(#{column}: #{column_default})
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
Note that in some cases it may not even be necessary to backport a default value.
|
46
|
+
|
47
|
+
class #{table_model} < ActiveRecord::Base
|
48
|
+
def #{column}
|
49
|
+
self["#{column}"] ||= #{column_default}
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
If you're 100% positive that this migration is already safe, then wrap the
|
54
|
+
call to `add_column` in a `safety_assured` block.
|
55
|
+
|
56
|
+
class Add#{column_title}To#{table_title} < ActiveRecord::Migration
|
57
|
+
def change
|
58
|
+
safety_assured { add_column :#{table}, :#{column}, :#{column_type}, default: #{column_default} }
|
59
|
+
end
|
60
|
+
end
|
61
|
+
MESSAGE
|
62
|
+
end
|
63
|
+
|
64
|
+
def column
|
65
|
+
args[1]
|
66
|
+
end
|
67
|
+
|
68
|
+
def column_default
|
69
|
+
options[:default].inspect
|
70
|
+
end
|
71
|
+
|
72
|
+
def column_title
|
73
|
+
column.to_s.camelize
|
74
|
+
end
|
75
|
+
|
76
|
+
def column_type
|
77
|
+
args[2]
|
78
|
+
end
|
79
|
+
|
80
|
+
def table
|
81
|
+
args[0]
|
82
|
+
end
|
83
|
+
|
84
|
+
def table_model
|
85
|
+
table_title.singularize
|
86
|
+
end
|
87
|
+
|
88
|
+
def table_title
|
89
|
+
table.to_s.camelize
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
module ZeroDowntimeMigrations
|
2
|
+
class Validation
|
3
|
+
class AddIndex < Validation
|
4
|
+
def validate!
|
5
|
+
return if concurrent? && migration.ddl_disabled?
|
6
|
+
message = "Adding a non-concurrent index is unsafe!"
|
7
|
+
error!(message, correction)
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def correction
|
13
|
+
<<-MESSAGE.strip_heredoc
|
14
|
+
This action can potentially lock your database table!
|
15
|
+
|
16
|
+
Instead, let's add the index concurrently in its own migration with
|
17
|
+
the DDL transaction disabled.
|
18
|
+
|
19
|
+
class Index#{table_title}On#{column_title} < ActiveRecord::Migration
|
20
|
+
disable_ddl_transaction!
|
21
|
+
|
22
|
+
def change
|
23
|
+
add_index :#{table}, #{column.inspect}, algorithm: :concurrently
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
If you're 100% positive that this migration is already safe, then wrap the
|
28
|
+
call to `add_index` in a `safety_assured` block.
|
29
|
+
|
30
|
+
class Index#{table_title}On#{column_title} < ActiveRecord::Migration
|
31
|
+
def change
|
32
|
+
safety_assured { add_index :#{table}, #{column.inspect} }
|
33
|
+
end
|
34
|
+
end
|
35
|
+
MESSAGE
|
36
|
+
end
|
37
|
+
|
38
|
+
def concurrent?
|
39
|
+
options[:algorithm] == :concurrently
|
40
|
+
end
|
41
|
+
|
42
|
+
def column
|
43
|
+
args[1]
|
44
|
+
end
|
45
|
+
|
46
|
+
def column_title
|
47
|
+
Array(column).map(&:to_s).join("_and_").camelize
|
48
|
+
end
|
49
|
+
|
50
|
+
def table
|
51
|
+
args[0]
|
52
|
+
end
|
53
|
+
|
54
|
+
def table_title
|
55
|
+
table.to_s.camelize
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module ZeroDowntimeMigrations
|
2
|
+
class Validation
|
3
|
+
class DdlMigration < Validation
|
4
|
+
def validate!
|
5
|
+
return unless migration.ddl_disabled? && !Migration.index?
|
6
|
+
message = "Disabling the DDL transaction is unsafe!"
|
7
|
+
error!(message, correction)
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def correction
|
13
|
+
<<-MESSAGE.strip_heredoc
|
14
|
+
The DDL transaction should only be disabled for migrations that add indexes.
|
15
|
+
|
16
|
+
Any other data or schema changes must live in their own migration files with
|
17
|
+
the DDL transaction enabled just in case they need to be rolled back.
|
18
|
+
MESSAGE
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module ZeroDowntimeMigrations
|
2
|
+
class Validation
|
3
|
+
class MixedMigration < Validation
|
4
|
+
def validate!
|
5
|
+
return unless Migration.mixed?
|
6
|
+
message = "Mixing data/index/schema changes in the same migration is unsafe!"
|
7
|
+
error!(message, correction)
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def correction
|
13
|
+
<<-MESSAGE.strip_heredoc
|
14
|
+
Instead, let's split apart these types of migrations into separate files.
|
15
|
+
|
16
|
+
* Introduce schema changes with methods like `create_table` or `add_column` in one file.
|
17
|
+
* Update data with methods like `update_all` or `save` in another file.
|
18
|
+
* Add indexes concurrently within their own file as well.
|
19
|
+
MESSAGE
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -1,8 +1,14 @@
|
|
1
1
|
module ZeroDowntimeMigrations
|
2
2
|
class Validation
|
3
|
+
def self.validate!(type, migration, *args)
|
4
|
+
validator = type.to_s.classify
|
5
|
+
validator = const_get(validator) if const_defined?(validator)
|
6
|
+
validator.new(migration, *args).validate! if validator
|
7
|
+
end
|
8
|
+
|
3
9
|
attr_reader :migration, :args
|
4
10
|
|
5
|
-
def initialize(migration, args)
|
11
|
+
def initialize(migration, *args)
|
6
12
|
@migration = migration
|
7
13
|
@args = args
|
8
14
|
end
|
@@ -1,19 +1,27 @@
|
|
1
1
|
require "active_record"
|
2
|
-
require "pathname"
|
3
2
|
|
3
|
+
require_relative "zero_downtime_migrations/data"
|
4
|
+
require_relative "zero_downtime_migrations/dsl"
|
4
5
|
require_relative "zero_downtime_migrations/migration"
|
6
|
+
require_relative "zero_downtime_migrations/relation"
|
5
7
|
require_relative "zero_downtime_migrations/validation"
|
8
|
+
require_relative "zero_downtime_migrations/validation/add_column"
|
9
|
+
require_relative "zero_downtime_migrations/validation/add_index"
|
10
|
+
require_relative "zero_downtime_migrations/validation/ddl_migration"
|
11
|
+
require_relative "zero_downtime_migrations/validation/mixed_migration"
|
6
12
|
require_relative "zero_downtime_migrations/unsafe_migration_error"
|
7
13
|
|
8
|
-
|
9
|
-
require_relative "zero_downtime_migrations/add_index"
|
10
|
-
|
14
|
+
ActiveRecord::Base.send(:prepend, ZeroDowntimeMigrations::Data)
|
11
15
|
ActiveRecord::Migration.send(:prepend, ZeroDowntimeMigrations::Migration)
|
16
|
+
ActiveRecord::Relation.send(:prepend, ZeroDowntimeMigrations::Relation)
|
17
|
+
ActiveRecord::Schema.send(:prepend, ZeroDowntimeMigrations::Migration)
|
12
18
|
|
13
19
|
module ZeroDowntimeMigrations
|
20
|
+
GEMSPEC = name.underscore.concat(".gemspec")
|
21
|
+
|
14
22
|
class << self
|
15
23
|
def gemspec
|
16
|
-
@gemspec ||= Gem::Specification.load(root.join(
|
24
|
+
@gemspec ||= Gem::Specification.load(root.join(GEMSPEC).to_s)
|
17
25
|
end
|
18
26
|
|
19
27
|
def root
|
File without changes
|
@@ -0,0 +1,53 @@
|
|
1
|
+
RSpec.describe ZeroDowntimeMigrations::Relation do
|
2
|
+
let(:error) { ZeroDowntimeMigrations::UnsafeMigrationError }
|
3
|
+
|
4
|
+
context "with data migrations using each" do
|
5
|
+
let(:migration) do
|
6
|
+
Class.new(ActiveRecord::Migration[5.0]) do
|
7
|
+
def change
|
8
|
+
User.all.each
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
it "raises an unsafe migration error" do
|
14
|
+
expect { migration.migrate(:up) }.to raise_error(error)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
context "with data migrations using each within safety_assured" do
|
19
|
+
let(:migration) do
|
20
|
+
Class.new(ActiveRecord::Migration[5.0]) do
|
21
|
+
def change
|
22
|
+
safety_assured do
|
23
|
+
User.all.each
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
it "does not raise an unsafe migration error" do
|
30
|
+
expect { migration.migrate(:up) }.not_to raise_error(error)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
context "with data migrations using find_each" do
|
35
|
+
let(:migration) do
|
36
|
+
Class.new(ActiveRecord::Migration[5.0]) do
|
37
|
+
def change
|
38
|
+
User.all.find_each
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
it "does not raise an unsafe migration error" do
|
44
|
+
expect { migration.migrate(:up) }.not_to raise_error(error)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
context "outside of a migration" do
|
49
|
+
it "does not raise an unsafe migration error" do
|
50
|
+
expect { User.all.each }.not_to raise_error(error)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
RSpec.describe ZeroDowntimeMigrations::Validation::DdlMigration do
|
2
|
+
let(:error) { ZeroDowntimeMigrations::UnsafeMigrationError }
|
3
|
+
|
4
|
+
context "with a migration that adds a column with ddl disabled" do
|
5
|
+
let(:migration) do
|
6
|
+
Class.new(ActiveRecord::Migration[5.0]) do
|
7
|
+
disable_ddl_transaction!
|
8
|
+
|
9
|
+
def change
|
10
|
+
add_column :users, :active, :boolean
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
it "raises an unsafe migration error" do
|
16
|
+
expect { migration.migrate(:up) }.to raise_error(error)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
context "with a migration that queries data with ddl disabled" do
|
21
|
+
let(:migration) do
|
22
|
+
Class.new(ActiveRecord::Migration[5.0]) do
|
23
|
+
disable_ddl_transaction!
|
24
|
+
|
25
|
+
def change
|
26
|
+
User.find_in_batches
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
it "raises an unsafe migration error" do
|
32
|
+
expect { migration.migrate(:up) }.to raise_error(error)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
context "with a migration that updates data with ddl disabled" do
|
37
|
+
let(:migration) do
|
38
|
+
Class.new(ActiveRecord::Migration[5.0]) do
|
39
|
+
disable_ddl_transaction!
|
40
|
+
|
41
|
+
def change
|
42
|
+
add_column :users, :active, :boolean
|
43
|
+
User.update_all(active: true)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
it "raises an unsafe migration error" do
|
49
|
+
expect { migration.migrate(:up) }.to raise_error(error)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
context "with a migration that creates data with ddl disabled" do
|
54
|
+
let(:migration) do
|
55
|
+
Class.new(ActiveRecord::Migration[5.0]) do
|
56
|
+
disable_ddl_transaction!
|
57
|
+
|
58
|
+
def change
|
59
|
+
User.new(email: "test").save!
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
it "raises an unsafe migration error" do
|
65
|
+
expect { migration.migrate(:up) }.to raise_error(error)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
RSpec.describe ZeroDowntimeMigrations::Validation::MixedMigration do
|
2
|
+
let(:error) { ZeroDowntimeMigrations::UnsafeMigrationError }
|
3
|
+
|
4
|
+
context "with a migration that adds a column and index" do
|
5
|
+
let(:migration) do
|
6
|
+
Class.new(ActiveRecord::Migration[5.0]) do
|
7
|
+
def change
|
8
|
+
add_column :users, :active, :boolean
|
9
|
+
add_index :users, :active
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
it "raises an unsafe migration error" do
|
15
|
+
expect { migration.migrate(:up) }.to raise_error(error)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
context "with a migration that adds a column and queries data" do
|
20
|
+
let(:migration) do
|
21
|
+
Class.new(ActiveRecord::Migration[5.0]) do
|
22
|
+
def change
|
23
|
+
add_column :users, :active, :boolean
|
24
|
+
User.find_in_batches
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
it "raises an unsafe migration error" do
|
30
|
+
expect { migration.migrate(:up) }.to raise_error(error)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
context "with a migration that adds a column and updates data" do
|
35
|
+
let(:migration) do
|
36
|
+
Class.new(ActiveRecord::Migration[5.0]) do
|
37
|
+
def change
|
38
|
+
add_column :users, :active, :boolean
|
39
|
+
User.update_all(active: true)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
it "raises an unsafe migration error" do
|
45
|
+
expect { migration.migrate(:up) }.to raise_error(error)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
context "with a migration that adds an index and updates data" do
|
50
|
+
let(:migration) do
|
51
|
+
Class.new(ActiveRecord::Migration[5.0]) do
|
52
|
+
def change
|
53
|
+
User.where(email: nil).delete_all
|
54
|
+
add_index :users, :created_at
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
it "raises an unsafe migration error" do
|
60
|
+
expect { migration.migrate(:up) }.to raise_error(error)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
context "with a migration that adds a column and creates data" do
|
65
|
+
let(:migration) do
|
66
|
+
Class.new(ActiveRecord::Migration[5.0]) do
|
67
|
+
def change
|
68
|
+
add_column :users, :active, :boolean
|
69
|
+
User.new(email: "test").save!
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
it "raises an unsafe migration error" do
|
75
|
+
expect { migration.migrate(:up) }.to raise_error(error)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -1,8 +1,8 @@
|
|
1
1
|
RSpec.describe ZeroDowntimeMigrations::Validation do
|
2
|
-
subject { described_class.new(migration, args) }
|
2
|
+
subject { described_class.new(migration, *args) }
|
3
3
|
|
4
4
|
let(:migration) { double("migration") }
|
5
|
-
let(:args) {
|
5
|
+
let(:args) { [] }
|
6
6
|
|
7
7
|
describe "#args" do
|
8
8
|
it "returns the initialized args" do
|
@@ -11,7 +11,7 @@ Gem::Specification.new do |s|
|
|
11
11
|
s.required_ruby_version = ">= 2.0.0"
|
12
12
|
s.summary = "Zero downtime migrations with ActiveRecord and PostgreSQL"
|
13
13
|
s.test_files = `git ls-files -- spec/*`.split("\n")
|
14
|
-
s.version = "0.0.
|
14
|
+
s.version = "0.0.1"
|
15
15
|
|
16
16
|
s.add_dependency "activerecord"
|
17
17
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: zero_downtime_migrations
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- LendingHome
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2016-10-
|
11
|
+
date: 2016-10-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -44,22 +44,33 @@ files:
|
|
44
44
|
- bin/test
|
45
45
|
- docker-compose.yml
|
46
46
|
- lib/zero_downtime_migrations.rb
|
47
|
-
- lib/zero_downtime_migrations/
|
48
|
-
- lib/zero_downtime_migrations/
|
47
|
+
- lib/zero_downtime_migrations/data.rb
|
48
|
+
- lib/zero_downtime_migrations/dsl.rb
|
49
49
|
- lib/zero_downtime_migrations/migration.rb
|
50
|
+
- lib/zero_downtime_migrations/relation.rb
|
50
51
|
- lib/zero_downtime_migrations/unsafe_migration_error.rb
|
51
52
|
- lib/zero_downtime_migrations/validation.rb
|
53
|
+
- lib/zero_downtime_migrations/validation/add_column.rb
|
54
|
+
- lib/zero_downtime_migrations/validation/add_index.rb
|
55
|
+
- lib/zero_downtime_migrations/validation/ddl_migration.rb
|
56
|
+
- lib/zero_downtime_migrations/validation/mixed_migration.rb
|
57
|
+
- spec/internal/app/models/post.rb
|
58
|
+
- spec/internal/app/models/user.rb
|
52
59
|
- spec/internal/config/database.yml
|
53
60
|
- spec/internal/db/migrate/20161012223252_rollup_migrations.rb
|
54
61
|
- spec/internal/db/migrate/20161012223253_safe_add_column_with_default.rb
|
55
62
|
- spec/internal/db/migrate/20161012223254_safe_add_index.rb
|
56
63
|
- spec/internal/db/migrate/20161012223255_safe_add_index_with_env.rb
|
57
|
-
- spec/internal/db/migrate/
|
64
|
+
- spec/internal/db/migrate/20161012223256_safe_add_index_with_dsl.rb
|
65
|
+
- spec/internal/db/migrate/20161012223257_add_index_concurrently.rb
|
58
66
|
- spec/internal/db/schema.rb
|
59
67
|
- spec/internal/log/.gitignore
|
60
68
|
- spec/spec_helper.rb
|
61
|
-
- spec/zero_downtime_migrations/
|
62
|
-
- spec/zero_downtime_migrations/
|
69
|
+
- spec/zero_downtime_migrations/relation_spec.rb
|
70
|
+
- spec/zero_downtime_migrations/validation/add_column_spec.rb
|
71
|
+
- spec/zero_downtime_migrations/validation/add_index_spec.rb
|
72
|
+
- spec/zero_downtime_migrations/validation/ddl_migration_spec.rb
|
73
|
+
- spec/zero_downtime_migrations/validation/mixed_migration_spec.rb
|
63
74
|
- spec/zero_downtime_migrations/validation_spec.rb
|
64
75
|
- spec/zero_downtime_migrations_spec.rb
|
65
76
|
- zero_downtime_migrations.gemspec
|
@@ -93,16 +104,22 @@ signing_key:
|
|
93
104
|
specification_version: 4
|
94
105
|
summary: Zero downtime migrations with ActiveRecord and PostgreSQL
|
95
106
|
test_files:
|
107
|
+
- spec/internal/app/models/post.rb
|
108
|
+
- spec/internal/app/models/user.rb
|
96
109
|
- spec/internal/config/database.yml
|
97
110
|
- spec/internal/db/migrate/20161012223252_rollup_migrations.rb
|
98
111
|
- spec/internal/db/migrate/20161012223253_safe_add_column_with_default.rb
|
99
112
|
- spec/internal/db/migrate/20161012223254_safe_add_index.rb
|
100
113
|
- spec/internal/db/migrate/20161012223255_safe_add_index_with_env.rb
|
101
|
-
- spec/internal/db/migrate/
|
114
|
+
- spec/internal/db/migrate/20161012223256_safe_add_index_with_dsl.rb
|
115
|
+
- spec/internal/db/migrate/20161012223257_add_index_concurrently.rb
|
102
116
|
- spec/internal/db/schema.rb
|
103
117
|
- spec/internal/log/.gitignore
|
104
118
|
- spec/spec_helper.rb
|
105
|
-
- spec/zero_downtime_migrations/
|
106
|
-
- spec/zero_downtime_migrations/
|
119
|
+
- spec/zero_downtime_migrations/relation_spec.rb
|
120
|
+
- spec/zero_downtime_migrations/validation/add_column_spec.rb
|
121
|
+
- spec/zero_downtime_migrations/validation/add_index_spec.rb
|
122
|
+
- spec/zero_downtime_migrations/validation/ddl_migration_spec.rb
|
123
|
+
- spec/zero_downtime_migrations/validation/mixed_migration_spec.rb
|
107
124
|
- spec/zero_downtime_migrations/validation_spec.rb
|
108
125
|
- spec/zero_downtime_migrations_spec.rb
|
@@ -1,91 +0,0 @@
|
|
1
|
-
module ZeroDowntimeMigrations
|
2
|
-
class AddColumn < Validation
|
3
|
-
def validate!
|
4
|
-
return if options[:default].nil? # only nil is safe
|
5
|
-
message = "Adding a column with a default is unsafe!"
|
6
|
-
error!(message, correction)
|
7
|
-
end
|
8
|
-
|
9
|
-
private
|
10
|
-
|
11
|
-
def correction
|
12
|
-
<<-MESSAGE.strip_heredoc
|
13
|
-
This action can potentially lock your database table!
|
14
|
-
|
15
|
-
Instead, let's first add the column without a default.
|
16
|
-
|
17
|
-
class Add#{column_title}To#{table_title} < ActiveRecord::Migration
|
18
|
-
def change
|
19
|
-
add_column :#{table}, :#{column}, :#{column_type}
|
20
|
-
end
|
21
|
-
end
|
22
|
-
|
23
|
-
Then set the new column default in a separate migration. Note that
|
24
|
-
this does not update any existing data.
|
25
|
-
|
26
|
-
class AddDefault#{column_title}To#{table_title} < ActiveRecord::Migration
|
27
|
-
def change
|
28
|
-
change_column_default :#{table}, :#{column}, #{column_default}
|
29
|
-
end
|
30
|
-
end
|
31
|
-
|
32
|
-
If necessary then backport the default value for existing data in batches.
|
33
|
-
This should be done in its own migration as well.
|
34
|
-
|
35
|
-
class BackportDefault#{column_title}To#{table_title} < ActiveRecord::Migration
|
36
|
-
def change
|
37
|
-
#{table_model}.select(:id).find_in_batches.with_index do |records, index|
|
38
|
-
puts "Processing batch \#{index + 1}\\r"
|
39
|
-
#{table_model}.where(id: records).update_all(#{column}: #{column_default})
|
40
|
-
end
|
41
|
-
end
|
42
|
-
end
|
43
|
-
|
44
|
-
Note that in some cases it may not even be necessary to backport a default value.
|
45
|
-
|
46
|
-
class #{table_model} < ActiveRecord::Base
|
47
|
-
def #{column}
|
48
|
-
self["#{column}"] ||= #{column_default}
|
49
|
-
end
|
50
|
-
end
|
51
|
-
|
52
|
-
If you're 100% positive that this migration is already safe, then wrap the
|
53
|
-
call to `add_column` in a `safety_assured` block.
|
54
|
-
|
55
|
-
class Add#{column_title}To#{table_title} < ActiveRecord::Migration
|
56
|
-
def change
|
57
|
-
safety_assured { add_column :#{table}, :#{column}, :#{column_type}, default: #{column_default} }
|
58
|
-
end
|
59
|
-
end
|
60
|
-
MESSAGE
|
61
|
-
end
|
62
|
-
|
63
|
-
def column
|
64
|
-
args[1]
|
65
|
-
end
|
66
|
-
|
67
|
-
def column_default
|
68
|
-
options[:default].inspect
|
69
|
-
end
|
70
|
-
|
71
|
-
def column_title
|
72
|
-
column.to_s.camelize
|
73
|
-
end
|
74
|
-
|
75
|
-
def column_type
|
76
|
-
args[2]
|
77
|
-
end
|
78
|
-
|
79
|
-
def table
|
80
|
-
args[0]
|
81
|
-
end
|
82
|
-
|
83
|
-
def table_model
|
84
|
-
table_title.singularize
|
85
|
-
end
|
86
|
-
|
87
|
-
def table_title
|
88
|
-
table.to_s.camelize
|
89
|
-
end
|
90
|
-
end
|
91
|
-
end
|
@@ -1,57 +0,0 @@
|
|
1
|
-
module ZeroDowntimeMigrations
|
2
|
-
class AddIndex < Validation
|
3
|
-
def validate!
|
4
|
-
return if concurrent? && migration.ddl_disabled?
|
5
|
-
message = "Adding a non-concurrent index is unsafe!"
|
6
|
-
error!(message, correction)
|
7
|
-
end
|
8
|
-
|
9
|
-
private
|
10
|
-
|
11
|
-
def correction
|
12
|
-
<<-MESSAGE.strip_heredoc
|
13
|
-
This action can potentially lock your database table!
|
14
|
-
|
15
|
-
Instead, let's add the index concurrently in its own migration with
|
16
|
-
the DDL transaction disabled.
|
17
|
-
|
18
|
-
class Index#{table_title}On#{column_title} < ActiveRecord::Migration
|
19
|
-
disable_ddl_transaction!
|
20
|
-
|
21
|
-
def change
|
22
|
-
add_index :#{table}, #{column.inspect}, algorithm: :concurrently
|
23
|
-
end
|
24
|
-
end
|
25
|
-
|
26
|
-
If you're 100% positive that this migration is already safe, then wrap the
|
27
|
-
call to `add_index` in a `safety_assured` block.
|
28
|
-
|
29
|
-
class Index#{table_title}On#{column_title} < ActiveRecord::Migration
|
30
|
-
def change
|
31
|
-
safety_assured { add_index :#{table}, #{column.inspect} }
|
32
|
-
end
|
33
|
-
end
|
34
|
-
MESSAGE
|
35
|
-
end
|
36
|
-
|
37
|
-
def concurrent?
|
38
|
-
options[:algorithm] == :concurrently
|
39
|
-
end
|
40
|
-
|
41
|
-
def column
|
42
|
-
args[1]
|
43
|
-
end
|
44
|
-
|
45
|
-
def column_title
|
46
|
-
Array(column).map(&:to_s).join("_and_").camelize
|
47
|
-
end
|
48
|
-
|
49
|
-
def table
|
50
|
-
args[0]
|
51
|
-
end
|
52
|
-
|
53
|
-
def table_title
|
54
|
-
table.to_s.camelize
|
55
|
-
end
|
56
|
-
end
|
57
|
-
end
|