Skip to content

Commit 74a6fbd

Browse files
authored
Merge pull request #2 from ProductPlan/feat/touch-in-own-transaction
feat: do touches in separate transaction
2 parents 2a4b28d + 460f3c8 commit 74a6fbd

File tree

4 files changed

+61
-10
lines changed

4 files changed

+61
-10
lines changed

.rubocop.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ Metrics/AbcSize:
2525
Metrics/MethodLength:
2626
Enabled: false
2727

28+
Metrics/ModuleLength:
29+
Enabled: false
30+
2831
Layout/LineLength:
2932
Enabled: false
3033

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ class Pet < ActiveRecord::Base
6565
end
6666
```
6767
### Deadlock Prevention
68-
`batch_touching` will sort the consolidated SQL updates by model name. The predictable order for updates should help mitigate potential database deadlocking.
68+
`batch_touching` will sort the consolidated SQL updates by model name, and then commit touches in their own transaction. The separate transaction in a predictable order for updates should help mitigate potential database deadlocking.
6969

7070
For example, if two transactions happen to touch records in the following order, there is a potential for a deadlock:
7171

lib/activerecord/batch_touching.rb

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,17 @@ module BatchTouchingAbstractAdapter
2222
# Person.first.touch
2323
# end
2424
def transaction(requires_new: nil, isolation: nil, joinable: true, &block)
25-
super(requires_new: requires_new, isolation: isolation, joinable: joinable) do
25+
result = super(requires_new: requires_new, isolation: isolation, joinable: joinable) do
2626
BatchTouching.start(requires_new: requires_new, &block)
2727
end
28+
29+
if BatchTouching.should_apply_touches?
30+
super() do
31+
BatchTouching.apply_touches
32+
end
33+
end
34+
35+
result
2836
end
2937
end
3038

@@ -83,12 +91,12 @@ def disabled?
8391
def start(requires_new:)
8492
states.push State.new
8593
yield.tap do
86-
apply_touches if states.length == 1
94+
gather_touches if states.length == 1
8795
end
8896
ensure
8997
merge_transactions unless $! && requires_new
9098

91-
# Decrement nesting even if +apply_touches+ raised an error. To ensure the stack of States
99+
# Decrement nesting even if +gather_touches+ raised an error. To ensure the stack of States
92100
# is empty after the top-level transaction exits.
93101
states.pop
94102
end
@@ -99,8 +107,34 @@ def batch_touching?
99107
states.present? && !disabled?
100108
end
101109

110+
def should_apply_touches?
111+
batched_touches.present?
112+
end
113+
114+
def apply_touches
115+
batched_touches&.each do |(klass, columns), records|
116+
records.reject!(&:destroyed?)
117+
touch_records klass, columns, records, batched_touches_time
118+
end
119+
120+
reset!
121+
end
122+
102123
private
103124

125+
def reset!
126+
Thread.current[:batch_touching_batch] = nil
127+
Thread.current[:batch_touching_time] = nil
128+
end
129+
130+
def batched_touches
131+
Thread.current[:batch_touching_batch]
132+
end
133+
134+
def batched_touches_time
135+
Thread.current[:batch_touching_time]
136+
end
137+
104138
def states
105139
Thread.current[:batch_touching_states] ||= []
106140
end
@@ -115,8 +149,8 @@ def merge_transactions
115149
states[-2].merge!(current_state) if states.length > 1
116150
end
117151

118-
# Apply the touches that were batched. We're in a transaction already so there's no need to open one.
119-
def apply_touches
152+
# Gather all the touches that were batched.
153+
def gather_touches
120154
current_time = ActiveRecord::Base.current_time_from_proper_timezone
121155
already_touched = Set.new
122156
all_states = State.new
@@ -131,10 +165,10 @@ def apply_touches
131165

132166
# Sort by class name. Having a consistent order can help mitigate deadlocks.
133167
sorted_records = all_states.records.keys.sort_by { |k| k.first.name }.to_h { |k| [k, all_states.records[k]] }
134-
sorted_records.each do |(klass, columns), records|
135-
records.reject!(&:destroyed?)
136-
touch_records klass, columns, records, current_time
137-
end
168+
169+
# Save results to a thread so that we can reference these later in their own transaction.
170+
Thread.current[:batch_touching_batch] = sorted_records
171+
Thread.current[:batch_touching_time] = current_time
138172
end
139173

140174
# Perform DB update to touch records

spec/activerecord/batch_touching_spec.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,20 @@ def touch_owner
358358
end
359359
end
360360

361+
it "performs expected touches across multiple transactions" do
362+
expect_updates [{ "pets" => { ids: pet1 } }, { "owners" => { ids: owner } }] do
363+
ActiveRecord::Base.transaction do
364+
pet1.touch
365+
end
366+
end
367+
368+
expect_updates [{ "pets" => { ids: pet2 } }, { "owners" => { ids: owner } }] do
369+
ActiveRecord::Base.transaction do
370+
pet2.touch
371+
end
372+
end
373+
end
374+
361375
context "with dependent deletes" do
362376
let(:post) { Post.create }
363377
let(:user) { User.create }

0 commit comments

Comments
 (0)