I have already written about how you should be aware about race conditions when saving a computed value, because you could save a wrong value.
Here I want to share with you an exercise to help you (and me 😅) get more sensitivity about when an implementation can save a wrong value thanks to race conditions.
I will show you some excercises with different ways/methods to calculate the balance and for each method you will have to think if the implementation can save the wrong value due to race conditions or not.
I am presenting my answer for each problem as guide, but this kind of problems are hard for me also. So, if you see an error please tell me in the comments.
Imagine that you have an Account
 record that has many entries
, and you want to update the balance
 each time an Entry
 is created. The balance
 is the sum of the amount
 of each entry
.
class Account < ActiveRecord::Base
has_many :entries
def create_entry(amount:)
# we are going to change what happen here
end
end
class Entry < ActiveRecord::Base
belongs_to :account
end
But the problem is that the account will need to create many entries concurrently, maybe on different background jobs or different requests.
To simulate concurrency you can use a code like this:
threads = 4.times.map do |i|
Thread.new do
Account.first.create_entry(amount: 100)
end
end
threads.each(&:join)
Account.first.balance
Or you can use the tool described in the post: Examples to explore possible race conditions when caching custom computed values in Rails.
class Account < ExampleRecord
has_many :entries
def create_entry(amount:)
entry = entries.create(amount: amount)
update_balance_with(entry)
end
def update_balance_with(entry)
balance = self.balance + entry.amount
update!(balance: balance)
end
end
class Entry < ExampleRecord
belongs_to :account, touch: true
end
It can save the wrong value due to race conditions. You can see this example.
Answer
class Account < ExampleRecord
has_many :entries
def create_entry(amount:)
entry = entries.create(amount: amount)
update_balance
end
def update_balance
balance = entries.balance
update!(balance: balance)
end
end
class Entry < ExampleRecord
belongs_to :account, touch: true
def self.balance
sum(&:amount)
end
end
It can save the wrong value due to race conditions. You can see this example.
Answer
class Account < ExampleRecord
has_many :entries
def create_entry(amount:)
entry = entries.create(amount: amount)
update_balance
end
def update_balance
balance = entries.balance
update!(balance: balance)
end
end
class Entry < ExampleRecord
belongs_to :account, touch: true
def self.balance
sum(:amount)
end
end
It can save the wrong value due to race conditions. You can see this example.
Answer
class Account < ExampleRecord
has_many :entries
after_touch :update_balance
def create_entry(amount:)
entries.create(amount: amount)
end
def update_balance
balance = entries.balance
update!(balance: balance)
end
end
class Entry < ExampleRecord
belongs_to :account, touch: true
def self.balance
sum(:amount)
end
end
As far as I understand, if you are using Postgres with the default isolation level Read Committed, you can compute and save the value on an
Apparently Answer
after_touch
.
after_touch
works something like this:
account
).update_balance
).PostgreSQL uses by default the Read Committed isolation level.
In this mode (as far as I understand), if within a transaction there is an UPDATE
of a row and in another concurrent transaction an UPDATE
of the same row is attempted, PostgreSQL will wait for the first transaction to be committed before continuing with the second one.
This makes the operations we perform on after_touch
to be isolated and avoids the problem of saving a wrong error due to another transaction registering another entry for the same account concurrently.
You can test more, with this example.
class Account < ExampleRecord
has_many :entries
def create_entry(amount:)
entries.create(amount: amount)
update_balance
end
def update_balance
with_lock do
balance = entries.balance
update!(balance: balance)
end
end
end
class Entry < ExampleRecord
belongs_to :account, touch: true
def self.balance
sum(:amount)
end
end
In the example the calculation of the balance and the saving will be in a lock. So, at least this part will always be right.
Answer
But as we are dividing the process in two steps:
… it could happen that the first time that the balance is saved will be with a result different than 100, because more entries have been created before that moment.
You can check this run as an example of this problem:
Run with ThreadsTransaction (run 1) [2] Record created: 77 [0] Record created: 76 [3] Record created: 75 [1] Record created: 78 [2] Balance calculated: 400 [2] Balance saved: 400 [1] Balance calculated: 400 [1] Balance saved: 400 [3] Balance calculated: 400 [3] Balance saved: 400 [0] Balance calculated: 400 [0] Balance saved: 400 Output: 400
class Account < ExampleRecord
has_many :entries
after_touch :update_balance
def create_entry(amount:)
entries.create(amount: amount)
end
def update_balance
with_lock do
balance = entries.balance
update!(balance: balance)
end
end
end
class Entry < ExampleRecord
belongs_to :account, touch: true
def self.balance
sum(:amount)
end
end
Taking in account what I said in the exercises 03 and 04, the after touch wraps the call to “update_balance” in the same transaction of the entry creation. So, maybe the lock is not necessary.
You can test more, with this example.
Answer
class Account < ExampleRecord
has_many :entries
def create_entry(amount:)
with_lock do
entries.create(amount: amount)
update_balance
end
end
def update_balance
balance = entries.balance
update!(balance: balance)
end
end
class Entry < ExampleRecord
belongs_to :account, touch: true
def self.balance
sum(:amount)
end
end
In this example we are wrapping each entry creation process on a lock. It, should always produce the right balance.
You can test more, with this example.
Answer
class Account < ExampleRecord
has_many :entries
def create_entry(amount:)
entry = entries.create(amount: amount)
update_balance_with(entry)
end
def update_balance_with(entry)
Account.update_counters(id, balance: entry.amount)
end
end
class Entry < ExampleRecord
belongs_to :account, touch: true
def self.balance
sum(:amount)
end
end
This is very similar to the exercise 04, we are (kind of) dividing the process in two steps:
Answer
So, it could happen that the first time that the balance is saved will be with a result different than 100, because more entries have been created before that moment.
You can check this run as an example of this problem.
Here I try to share knowledge and fixes to common problems and struggles for ruby on rails developers, like How to fetch the latest-N-of-each record or How to test that an specific mail was sent or a Capybara cheatsheet. You can see more examples on Most recent posts or all posts.