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.
- checksums.yaml +7 -0
- data/.github/workflows/test.yml +112 -0
- data/.gitignore +10 -0
- data/.rubocop.yml +113 -0
- data/.yardopts +1 -0
- data/BACKGROUND_MIGRATIONS.md +288 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +27 -0
- data/Gemfile.lock +108 -0
- data/LICENSE.txt +21 -0
- data/README.md +1067 -0
- data/Rakefile +23 -0
- data/gemfiles/activerecord_42.gemfile +6 -0
- data/gemfiles/activerecord_50.gemfile +5 -0
- data/gemfiles/activerecord_51.gemfile +5 -0
- data/gemfiles/activerecord_52.gemfile +5 -0
- data/gemfiles/activerecord_60.gemfile +5 -0
- data/gemfiles/activerecord_61.gemfile +5 -0
- data/gemfiles/activerecord_70.gemfile +5 -0
- data/gemfiles/activerecord_head.gemfile +5 -0
- data/lib/generators/online_migrations/background_migration_generator.rb +29 -0
- data/lib/generators/online_migrations/install_generator.rb +34 -0
- data/lib/generators/online_migrations/templates/background_migration.rb.tt +22 -0
- data/lib/generators/online_migrations/templates/initializer.rb.tt +94 -0
- data/lib/generators/online_migrations/templates/migration.rb.tt +46 -0
- data/lib/online_migrations/background_migration.rb +64 -0
- data/lib/online_migrations/background_migrations/advisory_lock.rb +62 -0
- data/lib/online_migrations/background_migrations/backfill_column.rb +52 -0
- data/lib/online_migrations/background_migrations/background_migration_class_validator.rb +36 -0
- data/lib/online_migrations/background_migrations/config.rb +98 -0
- data/lib/online_migrations/background_migrations/copy_column.rb +90 -0
- data/lib/online_migrations/background_migrations/migration.rb +210 -0
- data/lib/online_migrations/background_migrations/migration_helpers.rb +238 -0
- data/lib/online_migrations/background_migrations/migration_job.rb +92 -0
- data/lib/online_migrations/background_migrations/migration_job_runner.rb +63 -0
- data/lib/online_migrations/background_migrations/migration_job_status_validator.rb +27 -0
- data/lib/online_migrations/background_migrations/migration_runner.rb +97 -0
- data/lib/online_migrations/background_migrations/migration_status_validator.rb +45 -0
- data/lib/online_migrations/background_migrations/scheduler.rb +49 -0
- data/lib/online_migrations/batch_iterator.rb +87 -0
- data/lib/online_migrations/change_column_type_helpers.rb +587 -0
- data/lib/online_migrations/command_checker.rb +590 -0
- data/lib/online_migrations/command_recorder.rb +137 -0
- data/lib/online_migrations/config.rb +198 -0
- data/lib/online_migrations/copy_trigger.rb +91 -0
- data/lib/online_migrations/database_tasks.rb +19 -0
- data/lib/online_migrations/error_messages.rb +388 -0
- data/lib/online_migrations/foreign_key_definition.rb +17 -0
- data/lib/online_migrations/foreign_keys_collector.rb +33 -0
- data/lib/online_migrations/indexes_collector.rb +48 -0
- data/lib/online_migrations/lock_retrier.rb +250 -0
- data/lib/online_migrations/migration.rb +63 -0
- data/lib/online_migrations/migrator.rb +23 -0
- data/lib/online_migrations/schema_cache.rb +96 -0
- data/lib/online_migrations/schema_statements.rb +1042 -0
- data/lib/online_migrations/utils.rb +140 -0
- data/lib/online_migrations/version.rb +5 -0
- data/lib/online_migrations.rb +74 -0
- data/online_migrations.gemspec +28 -0
- metadata +119 -0
@@ -0,0 +1,250 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OnlineMigrations
|
4
|
+
# This class provides a way to automatically retry code that relies on acquiring
|
5
|
+
# a database lock in a way designed to minimize impact on a busy production database.
|
6
|
+
#
|
7
|
+
# This class defines an interface for child classes to implement to configure
|
8
|
+
# timing configurations and the maximum number of attempts.
|
9
|
+
#
|
10
|
+
# There are two predefined implementations (see OnlineMigrations::ConstantLockRetrier and OnlineMigrations::ExponentialLockRetrier).
|
11
|
+
# It is easy to provide more sophisticated implementations.
|
12
|
+
#
|
13
|
+
# @example Custom LockRetrier implementation
|
14
|
+
# module OnlineMigrations
|
15
|
+
# class SophisticatedLockRetrier < LockRetrier
|
16
|
+
# TIMINGS = [
|
17
|
+
# [0.1.seconds, 0.05.seconds], # first - lock timeout, second - delay time
|
18
|
+
# [0.1.seconds, 0.05.seconds],
|
19
|
+
# [0.2.seconds, 0.05.seconds],
|
20
|
+
# [0.3.seconds, 0.10.seconds],
|
21
|
+
# [1.second, 5.seconds],
|
22
|
+
# [1.second, 1.minute],
|
23
|
+
# [0.1.seconds, 0.05.seconds],
|
24
|
+
# [0.2.seconds, 0.15.seconds],
|
25
|
+
# [0.5.seconds, 2.seconds],
|
26
|
+
# [0.5.seconds, 2.seconds],
|
27
|
+
# [3.seconds, 3.minutes],
|
28
|
+
# [0.1.seconds, 0.05.seconds],
|
29
|
+
# [0.5.seconds, 2.seconds],
|
30
|
+
# [5.seconds, 2.minutes],
|
31
|
+
# [7.seconds, 5.minutes],
|
32
|
+
# [0.5.seconds, 2.seconds],
|
33
|
+
# ]
|
34
|
+
#
|
35
|
+
# def attempts
|
36
|
+
# TIMINGS.size
|
37
|
+
# end
|
38
|
+
#
|
39
|
+
# def lock_timeout(attempt)
|
40
|
+
# TIMINGS[attempt - 1][0]
|
41
|
+
# end
|
42
|
+
#
|
43
|
+
# def delay(attempt)
|
44
|
+
# TIMINGS[attempt - 1][1]
|
45
|
+
# end
|
46
|
+
# end
|
47
|
+
#
|
48
|
+
class LockRetrier
|
49
|
+
# Database connection on which retries are run
|
50
|
+
#
|
51
|
+
attr_accessor :connection
|
52
|
+
|
53
|
+
# Returns the number of retrying attempts
|
54
|
+
#
|
55
|
+
def attempts
|
56
|
+
raise NotImplementedError
|
57
|
+
end
|
58
|
+
|
59
|
+
# Returns database lock timeout value (in seconds) for specified attempt number
|
60
|
+
#
|
61
|
+
# @param _attempt [Integer] attempt number
|
62
|
+
#
|
63
|
+
def lock_timeout(_attempt)
|
64
|
+
raise NotImplementedError
|
65
|
+
end
|
66
|
+
|
67
|
+
# Returns sleep time after unsuccessful lock attempt (in seconds)
|
68
|
+
#
|
69
|
+
# @param _attempt [Integer] attempt number
|
70
|
+
#
|
71
|
+
def delay(_attempt)
|
72
|
+
raise NotImplementedError
|
73
|
+
end
|
74
|
+
|
75
|
+
# Executes the block with a retry mechanism that alters the `lock_timeout`
|
76
|
+
# and sleep time between attempts.
|
77
|
+
#
|
78
|
+
# @return [void]
|
79
|
+
#
|
80
|
+
# @example
|
81
|
+
# retrier.with_lock_retries do
|
82
|
+
# add_column(:users, :name, :string)
|
83
|
+
# end
|
84
|
+
#
|
85
|
+
def with_lock_retries(&block)
|
86
|
+
return yield if lock_retries_disabled?
|
87
|
+
|
88
|
+
current_attempt = 0
|
89
|
+
|
90
|
+
begin
|
91
|
+
current_attempt += 1
|
92
|
+
|
93
|
+
current_lock_timeout = lock_timeout(current_attempt)
|
94
|
+
if current_lock_timeout
|
95
|
+
with_lock_timeout(current_lock_timeout.in_milliseconds, &block)
|
96
|
+
else
|
97
|
+
yield
|
98
|
+
end
|
99
|
+
# ActiveRecord::LockWaitTimeout can be used for ActiveRecord 5.2+
|
100
|
+
rescue ActiveRecord::StatementInvalid => e
|
101
|
+
if lock_timeout_error?(e) && current_attempt <= attempts
|
102
|
+
current_delay = delay(current_attempt)
|
103
|
+
Utils.say("Lock timeout. Retrying in #{current_delay} seconds...")
|
104
|
+
sleep(current_delay)
|
105
|
+
retry
|
106
|
+
end
|
107
|
+
raise
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
private
|
112
|
+
def lock_retries_disabled?
|
113
|
+
Utils.to_bool(ENV["DISABLE_LOCK_RETRIES"])
|
114
|
+
end
|
115
|
+
|
116
|
+
def with_lock_timeout(value)
|
117
|
+
value = value.ceil.to_i
|
118
|
+
prev_value = connection.select_value("SHOW lock_timeout")
|
119
|
+
connection.execute("SET lock_timeout TO #{connection.quote("#{value}ms")}")
|
120
|
+
|
121
|
+
yield
|
122
|
+
ensure
|
123
|
+
connection.execute("SET lock_timeout TO #{connection.quote(prev_value)}")
|
124
|
+
end
|
125
|
+
|
126
|
+
def lock_timeout_error?(error)
|
127
|
+
error.message.include?("canceling statement due to lock timeout")
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# `LockRetrier` implementation that has a constant delay between tries
|
132
|
+
# and lock timeout for each try
|
133
|
+
#
|
134
|
+
# @example
|
135
|
+
# # This will attempt 5 retries with 2 seconds between each unsuccessful try
|
136
|
+
# # and 50ms set as lock timeout for each try:
|
137
|
+
# config.retrier = OnlineMigrations::ConstantLockRetrier.new(attempts: 5, delay: 2.seconds, lock_timeout: 0.05.seconds)
|
138
|
+
#
|
139
|
+
class ConstantLockRetrier < LockRetrier
|
140
|
+
# LockRetrier API implementation
|
141
|
+
#
|
142
|
+
# @return [Integer] Number of retrying attempts
|
143
|
+
# @see LockRetrier#attempts
|
144
|
+
#
|
145
|
+
attr_reader :attempts
|
146
|
+
|
147
|
+
# Create a new ConstantLockRetrier instance
|
148
|
+
#
|
149
|
+
# @param attempts [Integer] Maximum number of attempts
|
150
|
+
# @param delay [Numeric] Sleep time after unsuccessful lock attempt (in seconds)
|
151
|
+
# @param lock_timeout [Numeric] Database lock timeout value (in seconds)
|
152
|
+
#
|
153
|
+
def initialize(attempts:, delay:, lock_timeout:)
|
154
|
+
super()
|
155
|
+
@attempts = attempts
|
156
|
+
@delay = delay
|
157
|
+
@lock_timeout = lock_timeout
|
158
|
+
end
|
159
|
+
|
160
|
+
# LockRetrier API implementation
|
161
|
+
#
|
162
|
+
# @return [Numeric] Database lock timeout value (in seconds)
|
163
|
+
# @see LockRetrier#lock_timeout
|
164
|
+
#
|
165
|
+
def lock_timeout(_attempt)
|
166
|
+
@lock_timeout
|
167
|
+
end
|
168
|
+
|
169
|
+
# LockRetrier API implementation
|
170
|
+
#
|
171
|
+
# @return [Numeric] Sleep time after unsuccessful lock attempt (in seconds)
|
172
|
+
# @see LockRetrier#delay
|
173
|
+
#
|
174
|
+
def delay(_attempt)
|
175
|
+
@delay
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
# `LockRetrier` implementation that uses exponential delay with jitter between tries
|
180
|
+
# and constant lock timeout for each try
|
181
|
+
#
|
182
|
+
# @example
|
183
|
+
# # This will attempt 30 retries starting with delay of 10ms between each unsuccessful try, increasing exponentially
|
184
|
+
# # up to the maximum delay of 1 minute and 50ms set as lock timeout for each try:
|
185
|
+
#
|
186
|
+
# config.retrier = OnlineMigrations::ConstantLockRetrier.new(attempts: 30,
|
187
|
+
# base_delay: 0.01.seconds, max_delay: 1.minute, lock_timeout: 0.05.seconds)
|
188
|
+
#
|
189
|
+
class ExponentialLockRetrier < LockRetrier
|
190
|
+
# LockRetrier API implementation
|
191
|
+
#
|
192
|
+
# @return [Integer] Number of retrying attempts
|
193
|
+
# @see LockRetrier#attempts
|
194
|
+
#
|
195
|
+
attr_reader :attempts
|
196
|
+
|
197
|
+
# Create a new ExponentialLockRetrier instance
|
198
|
+
#
|
199
|
+
# @param attempts [Integer] Maximum number of attempts
|
200
|
+
# @param base_delay [Numeric] Base sleep time to calculate total sleep time after unsuccessful lock attempt (in seconds)
|
201
|
+
# @param max_delay [Numeric] Maximum sleep time after unsuccessful lock attempt (in seconds)
|
202
|
+
# @param lock_timeout [Numeric] Database lock timeout value (in seconds)
|
203
|
+
#
|
204
|
+
def initialize(attempts:, base_delay:, max_delay:, lock_timeout:)
|
205
|
+
super()
|
206
|
+
@attempts = attempts
|
207
|
+
@base_delay = base_delay
|
208
|
+
@max_delay = max_delay
|
209
|
+
@lock_timeout = lock_timeout
|
210
|
+
end
|
211
|
+
|
212
|
+
# LockRetrier API implementation
|
213
|
+
#
|
214
|
+
# @return [Numeric] Database lock timeout value (in seconds)
|
215
|
+
# @see LockRetrier#lock_timeout
|
216
|
+
#
|
217
|
+
def lock_timeout(_attempt)
|
218
|
+
@lock_timeout
|
219
|
+
end
|
220
|
+
|
221
|
+
# LockRetrier API implementation
|
222
|
+
#
|
223
|
+
# @return [Numeric] Sleep time after unsuccessful lock attempt (in seconds)
|
224
|
+
# @see LockRetrier#delay
|
225
|
+
# @see https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
|
226
|
+
#
|
227
|
+
def delay(attempt)
|
228
|
+
(rand * [@max_delay, @base_delay * 2**(attempt - 1)].min).ceil
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
# @private
|
233
|
+
class NullLockRetrier < LockRetrier
|
234
|
+
def attempts(*)
|
235
|
+
0
|
236
|
+
end
|
237
|
+
|
238
|
+
def lock_timeout(*)
|
239
|
+
0
|
240
|
+
end
|
241
|
+
|
242
|
+
def delay(*)
|
243
|
+
0
|
244
|
+
end
|
245
|
+
|
246
|
+
def with_lock_retries
|
247
|
+
yield
|
248
|
+
end
|
249
|
+
end
|
250
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OnlineMigrations
|
4
|
+
module Migration
|
5
|
+
# @private
|
6
|
+
def migrate(direction)
|
7
|
+
OnlineMigrations.current_migration = self
|
8
|
+
command_checker.direction = direction
|
9
|
+
super
|
10
|
+
end
|
11
|
+
|
12
|
+
# @private
|
13
|
+
def method_missing(method, *args, &block)
|
14
|
+
if is_a?(ActiveRecord::Schema)
|
15
|
+
super
|
16
|
+
elsif command_checker.check(method, *args, &block)
|
17
|
+
if !in_transaction?
|
18
|
+
if method == :with_lock_retries
|
19
|
+
connection.with_lock_retries(*args, &block)
|
20
|
+
else
|
21
|
+
connection.with_lock_retries { super }
|
22
|
+
end
|
23
|
+
else
|
24
|
+
super
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true)
|
29
|
+
|
30
|
+
# Mark a command in the migration as safe, despite using a method that might otherwise be dangerous.
|
31
|
+
#
|
32
|
+
# @example
|
33
|
+
# safety_assured { remove_column(:users, :some_column) }
|
34
|
+
#
|
35
|
+
def safety_assured(&block)
|
36
|
+
command_checker.safety_assured(&block)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Stop running migrations.
|
40
|
+
#
|
41
|
+
# It is intended for use in custom checks.
|
42
|
+
#
|
43
|
+
# @example
|
44
|
+
# OnlineMigrations.config.add_check do |method, args|
|
45
|
+
# if method == :add_column && args[0].to_s == "users"
|
46
|
+
# stop!("No more columns on the users table")
|
47
|
+
# end
|
48
|
+
# end
|
49
|
+
#
|
50
|
+
def stop!(message, header: "Custom check")
|
51
|
+
raise OnlineMigrations::UnsafeMigration, "⚠️ [online_migrations] #{header} ⚠️\n\n#{message}\n\n"
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
def command_checker
|
56
|
+
@command_checker ||= CommandChecker.new(self)
|
57
|
+
end
|
58
|
+
|
59
|
+
def in_transaction?
|
60
|
+
connection.open_transactions > 0
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OnlineMigrations
|
4
|
+
# @private
|
5
|
+
module Migrator
|
6
|
+
def ddl_transaction(migration_or_proxy)
|
7
|
+
migration =
|
8
|
+
if migration_or_proxy.is_a?(ActiveRecord::MigrationProxy)
|
9
|
+
migration_or_proxy.send(:migration)
|
10
|
+
else
|
11
|
+
migration_or_proxy
|
12
|
+
end
|
13
|
+
|
14
|
+
if use_transaction?(migration)
|
15
|
+
migration.connection.with_lock_retries do
|
16
|
+
super
|
17
|
+
end
|
18
|
+
else
|
19
|
+
super
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OnlineMigrations
|
4
|
+
# @private
|
5
|
+
module SchemaCache
|
6
|
+
def primary_keys(table_name)
|
7
|
+
if renamed_tables.key?(table_name)
|
8
|
+
super(renamed_tables[table_name])
|
9
|
+
elsif renamed_columns.key?(table_name)
|
10
|
+
super(column_rename_table(table_name))
|
11
|
+
else
|
12
|
+
super
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def columns(table_name)
|
17
|
+
if renamed_tables.key?(table_name)
|
18
|
+
super(renamed_tables[table_name])
|
19
|
+
elsif renamed_columns.key?(table_name)
|
20
|
+
columns = super(column_rename_table(table_name))
|
21
|
+
|
22
|
+
old_column_name, new_column_name = renamed_columns[table_name].first.to_a
|
23
|
+
|
24
|
+
old_column = columns.find { |column| column.name == old_column_name }
|
25
|
+
new_column = old_column.dup
|
26
|
+
|
27
|
+
# ActiveRecord defines only reader for :name
|
28
|
+
new_column.instance_variable_set(:@name, new_column_name)
|
29
|
+
|
30
|
+
# Correspond to the ActiveRecord freezing of each column
|
31
|
+
columns << new_column.freeze
|
32
|
+
else
|
33
|
+
super.reject { |column| column.name.end_with?("_for_type_change") }
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def indexes(table_name)
|
38
|
+
# Available only in Active Record 6.0+
|
39
|
+
return if !defined?(super)
|
40
|
+
|
41
|
+
if renamed_tables.key?(table_name)
|
42
|
+
super(renamed_tables[table_name])
|
43
|
+
elsif renamed_columns.key?(table_name)
|
44
|
+
super(column_rename_table(table_name))
|
45
|
+
else
|
46
|
+
super
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def clear!
|
51
|
+
super
|
52
|
+
clear_renames_cache!
|
53
|
+
end
|
54
|
+
|
55
|
+
def clear_data_source_cache!(name)
|
56
|
+
if renamed_tables.key?(name)
|
57
|
+
super(renamed_tables[name])
|
58
|
+
end
|
59
|
+
|
60
|
+
if renamed_columns.key?(name)
|
61
|
+
super(column_rename_table(name))
|
62
|
+
end
|
63
|
+
|
64
|
+
super(name)
|
65
|
+
clear_renames_cache!
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
def renamed_tables
|
70
|
+
@renamed_tables ||= begin
|
71
|
+
table_renames = OnlineMigrations.config.table_renames
|
72
|
+
table_renames.select do |old_name, _|
|
73
|
+
connection.views.include?(old_name)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def renamed_columns
|
79
|
+
@renamed_columns ||= begin
|
80
|
+
column_renames = OnlineMigrations.config.column_renames
|
81
|
+
column_renames.select do |table_name, _|
|
82
|
+
connection.views.include?(table_name)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def column_rename_table(table_name)
|
88
|
+
"#{table_name}_column_rename"
|
89
|
+
end
|
90
|
+
|
91
|
+
def clear_renames_cache!
|
92
|
+
@renamed_columns = nil
|
93
|
+
@renamed_tables = nil
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|