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,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