Skip to content

Commit 9f81c76

Browse files
committed
Add non-atomic migration mode with automatic down rollback
Support `atomic false` for migrations that should not be wrapped in a transaction. This avoids holding database locks for the full duration of large migrations. When atomic is false: - A `down` method is required (raises ArgumentError if missing) - If `up` or `verify!` fails, `down` is called automatically - If `down` itself fails, warns and re-raises the original error - `batch`/`find_each` throttle between batches by default - In atomic mode, throttling is disabled (it only prolongs the lock)
1 parent d0f240f commit 9f81c76

4 files changed

Lines changed: 227 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
11
## [Unreleased]
22

3+
- Add `atomic false` mode for non-atomic migrations that don't hold a transaction open
4+
5+
```ruby
6+
class BackfillDefaultUsername < DataCustoms::Migration
7+
atomic false
8+
9+
def up
10+
batch(User.where(username: nil)) do |rel|
11+
rel.update_all(username: "guest")
12+
end
13+
end
14+
15+
def verify!
16+
raise "Failed" if User.exists?(username: nil)
17+
end
18+
19+
def down
20+
User.where(username: "guest").update_all(username: nil)
21+
end
22+
end
23+
```
24+
25+
- Requires a `down` method (raises `ArgumentError` if missing)
26+
- Automatically calls `down` if `up` or `verify!` fails
27+
- If `down` itself fails, warns and re-raises the original error
28+
- Disable throttling by default in atomic mode (`batch`/`find_each` only throttle in non-atomic mode)
29+
330
## [0.2.0] - 2026-03-04
431

532
- Add `progress.report` for tracking migration progress with a visual bar that updates in place on TTY terminals

README.md

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -101,48 +101,49 @@ AddDefaultUsername.run("anonymous")
101101

102102
#### Dealing with large datasets
103103

104-
The migration code runs inside a transaction, so be careful when dealing with
105-
large datasets, as [it will block the database][blocking] for the duration of
106-
the transaction.
104+
By default, the migration code runs inside a transaction. For large datasets,
105+
this [blocks the database][blocking] for the duration of the migration. To
106+
avoid this, use `atomic false` to opt out of the transaction.
107107

108-
Data Customs provides a few helpers to make working in batches easier and
109-
automatically throttles the migration to avoid overwhelming the database.
108+
Non-atomic migrations require a `down` method that reverts the changes made by
109+
`up`. Since there is no transaction to roll back, `down` is your rollback
110+
strategy: if `up` or `verify!` fails, `down` is called automatically. It should
111+
be idempotent, as the gem does not track which records were changed.
110112

111113
```ruby
112-
class AddDefaultUsername < DataCustoms::Migration
114+
class BackfillDefaultUsername < DataCustoms::Migration
115+
atomic false
116+
113117
def up
114-
batch(User.where.missing(:username)) do |relation|
115-
relation.update_all(username: "guest")
118+
batch(User.where(username: nil)) do |rel|
119+
rel.update_all(username: "guest")
116120
end
117121
end
118-
end
119-
```
120122

121-
Or, if you need access to each individual record:
123+
def verify!
124+
raise "Failed" if User.exists?(username: nil)
125+
end
122126

123-
```ruby
124-
class AddDefaultUsername < DataCustoms::Migration
125-
def up
126-
find_each(User.where.missing(:username)) do |record|
127-
# do something with record
128-
end
127+
def down
128+
User.where(username: "guest").update_all(username: nil)
129129
end
130130
end
131131
```
132132

133-
For both methods, you can configure the batch size and the pause between batches:
133+
The `batch` and `find_each` helpers process records using
134+
[`in_batches`][in_batches], with a short pause between each batch so other
135+
queries can run in between. Use `find_each` when you need access to individual
136+
records instead of relations.
137+
138+
Both methods accept `batch_size` and `throttle_seconds` options:
134139

135140
```ruby
136-
class LongMigration < DataCustoms::Migration
137-
def up
138-
batch(records, batch_size: 500, throttle_seconds: 0.1) do |relation|
139-
# ...
140-
end
141+
batch(records, batch_size: 500, throttle_seconds: 0.1) do |relation|
142+
# ...
143+
end
141144

142-
find_each(records, batch_size: 500, throttle_seconds: 0.1) do |record|
143-
# ...
144-
end
145-
end
145+
find_each(records, batch_size: 500, throttle_seconds: 0.1) do |record|
146+
# ...
146147
end
147148
```
148149

@@ -274,3 +275,4 @@ We love open source software! See [our other projects][community]. We are
274275
<!-- END /templates/footer.md -->
275276

276277
[blocking]: https://github.com/ankane/strong_migrations?tab=readme-ov-file#backfilling-data
278+
[in_batches]: https://api.rubyonrails.org/classes/ActiveRecord/Batches.html#method-i-in_batches

lib/data_customs/migration.rb

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ class Migration
55
DEFAULT_BATCH_SIZE = 1000
66
DEFAULT_THROTTLE = 0.01
77

8+
def self.atomic(value)
9+
@atomic = value
10+
end
11+
12+
def self.atomic?
13+
@atomic != false
14+
end
15+
816
def self.progress(**options)
917
@progress_options = options
1018
end
@@ -14,6 +22,8 @@ def self.progress_options
1422
end
1523

1624
def self.run(*args, **kwargs)
25+
ensure_rollback_strategy!
26+
1727
with_transaction(*args, **kwargs) do |migration|
1828
migration.run
1929
end
@@ -22,9 +32,29 @@ def self.run(*args, **kwargs)
2232
raise e
2333
end
2434

35+
def self.ensure_rollback_strategy!
36+
return if atomic? || method_defined?(:down)
37+
38+
raise ArgumentError, "down method is required when running a non-atomic migration"
39+
end
40+
2541
def self.with_transaction(*args, **kwargs)
26-
ActiveRecord::Base.transaction do
27-
yield new(*args, **kwargs)
42+
if atomic?
43+
ActiveRecord::Base.transaction do
44+
yield new(*args, **kwargs)
45+
end
46+
else
47+
migration = new(*args, **kwargs)
48+
begin
49+
yield migration
50+
rescue => e
51+
begin
52+
migration.down
53+
rescue => down_error
54+
warn "🛃 down failed: #{down_error.message}"
55+
end
56+
raise e
57+
end
2858
end
2959
end
3060

@@ -50,7 +80,11 @@ def with_progress_reporter(&block)
5080

5181
def progress = @_progress
5282

53-
def batch(scope, batch_size: DEFAULT_BATCH_SIZE, throttle_seconds: DEFAULT_THROTTLE)
83+
def default_throttle
84+
self.class.atomic? ? 0 : DEFAULT_THROTTLE
85+
end
86+
87+
def batch(scope, batch_size: DEFAULT_BATCH_SIZE, throttle_seconds: default_throttle)
5488
scope.in_batches(of: batch_size) do |relation|
5589
yield relation
5690
sleep(throttle_seconds) if throttle_seconds.positive?

spec/data_customs/migration_spec.rb

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,114 @@ def verify! = nil
215215
end
216216
end
217217

218+
describe "non-atomic migration" do
219+
it "does not wrap in a transaction (changes persist on failure)" do
220+
migration = Class.new(DataCustoms::Migration) do
221+
atomic false
222+
223+
def up
224+
TestUser.create!(name: "persisted")
225+
raise "Boom"
226+
end
227+
228+
def verify! = nil
229+
def down = nil
230+
end
231+
232+
expect { migration.run }.to raise_error("Boom")
233+
expect(TestUser.exists?(name: "persisted")).to be true
234+
end
235+
236+
it "calls down when up fails" do
237+
migration = Class.new(DataCustoms::Migration) do
238+
atomic false
239+
240+
def up
241+
TestUser.create!(name: "to_revert")
242+
raise "Boom"
243+
end
244+
245+
def verify! = nil
246+
247+
def down
248+
TestUser.where(name: "to_revert").delete_all
249+
end
250+
end
251+
252+
expect { migration.run }.to raise_error("Boom")
253+
expect(TestUser.exists?(name: "to_revert")).to be false
254+
end
255+
256+
it "calls down when verify! fails" do
257+
migration = Class.new(DataCustoms::Migration) do
258+
atomic false
259+
260+
def up
261+
TestUser.create!(name: "to_revert")
262+
end
263+
264+
def verify!
265+
raise "Verification failed"
266+
end
267+
268+
def down
269+
TestUser.where(name: "to_revert").delete_all
270+
end
271+
end
272+
273+
expect { migration.run }.to raise_error("Verification failed")
274+
expect(TestUser.exists?(name: "to_revert")).to be false
275+
end
276+
277+
it "warns and re-raises the original error if down also fails" do
278+
migration = Class.new(DataCustoms::Migration) do
279+
atomic false
280+
281+
def up = raise "Original error"
282+
def verify! = nil
283+
def down = raise "Down error"
284+
end
285+
286+
expect { migration.run }
287+
.to raise_error("Original error")
288+
.and output(/down failed: Down error/).to_stderr
289+
end
290+
291+
it "succeeds without calling down" do
292+
migration = Class.new(DataCustoms::Migration) do
293+
atomic false
294+
295+
def up
296+
TestUser.create!(name: "kept")
297+
end
298+
299+
def verify!
300+
raise "Missing!" unless TestUser.exists?(name: "kept")
301+
end
302+
303+
def down
304+
raise "down should not be called"
305+
end
306+
end
307+
308+
expect { migration.run }.to(
309+
change { TestUser.count }.by(1)
310+
.and(output("🛃 Data migration ran successfully!\n").to_stdout)
311+
)
312+
end
313+
314+
it "raises ArgumentError if down is not defined" do
315+
migration = Class.new(DataCustoms::Migration) do
316+
atomic false
317+
318+
def up = nil
319+
def verify! = nil
320+
end
321+
322+
expect { migration.run }.to raise_error(ArgumentError, /down method is required/)
323+
end
324+
end
325+
218326
describe "helpers" do
219327
it "batches records" do
220328
3.times { |i| TestUser.create!(name: "User #{i}") }
@@ -232,20 +340,44 @@ def verify!
232340
raise "Wrong batches #{@batch_sizes}" if @batch_sizes != [2, 1]
233341
end
234342
end
343+
expect_any_instance_of(Kernel).not_to receive(:sleep)
344+
345+
expect { migration.run }.to output("🛃 Data migration ran successfully!\n").to_stdout
346+
end
347+
348+
it "throttles between batches in non-atomic mode" do
349+
3.times { |i| TestUser.create!(name: "User #{i}") }
350+
351+
migration = Class.new(DataCustoms::Migration) do
352+
atomic false
353+
354+
def initialize = @batch_sizes = []
355+
356+
def up
357+
batch(TestUser.all, batch_size: 2) do |relation|
358+
@batch_sizes << relation.size
359+
end
360+
end
361+
362+
def verify!
363+
raise "Wrong batches #{@batch_sizes}" if @batch_sizes != [2, 1]
364+
end
365+
366+
def down = nil
367+
end
235368
expect_any_instance_of(Kernel).to receive(:sleep).exactly(2).times
236369

237370
expect { migration.run }.to output("🛃 Data migration ran successfully!\n").to_stdout
238371
end
239372

240373
it "finds each record" do
241-
allow_any_instance_of(Kernel).to receive(:sleep)
242374
3.times { |i| TestUser.create!(name: "User #{i}") }
243375

244376
migration = Class.new(DataCustoms::Migration) do
245377
def initialize = @users = []
246378

247379
def up
248-
find_each(TestUser.all, throttle_seconds: -1) do |user|
380+
find_each(TestUser.all) do |user|
249381
@users << user.name
250382
end
251383
end

0 commit comments

Comments
 (0)