When saving computed values in the database in your rails app, you must be aware that is possible to find unexpected errors in the result thanks to race conditions.
Here I want to help you visualize how race conditions can make you save incorrect values even when your calculation is correct.
I will show you how it can happen with different ways of saving the same value and show you a tool that you can use to explore a little more.
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
One possible solution to calculate the balance, is start with zero and sum the amount
of each new entry
to the current balance
.
Something like this:
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
That looks ok, and if you write something like this…
account = Account.first
4.times { |i| account.create_entry(amount: 100) }
puts account.reload.balance
You can expect a printed balance of 400
. If you run the code that will be correct.
But if instead of creating the entries serially, you create each entry in a different process or thread, concurrently, maybe on different background jobs, different requests or with 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
Now the saved balance at the end of the process could have a wrong value.
… Can you see why?
If you don’t know why, don’t worry, it is not obvious at all!
Let’s start by breaking what is happening each time we create an entry.
Each time we call account.create_entry
we:
When we create all entries serially, each step happen like in the next code block:
# Run with SerialTransaction
# [0] Record created
# [0] Balance calculated: 100
# [0] Balance saved: 100
# [1] Record created
# [1] Balance calculated: 200
# [1] Balance saved: 200
# [2] Record created
# [2] Balance calculated: 300
# [2] Balance saved: 300
# [3] Record created
# [3] Balance calculated: 400
# [3] Balance saved: 400
# Output: 400
Each step is in the “expeted order”.
Each call to account.create_entry
is represented by an index inside brackets, like [0]
, [1]
, etc.
But when we create the entries concurrently, we will see that the steps of each call to account.create_entry
will be mixed.
For example the next code block is an example run of running each call to account.create_entry
on a new thread:
# Run with ThreadsTransaction
# [3] Record created: 08
# [3] Balance calculated: 100
# [1] Record created: 09
# [1] Balance calculated: 100
# [0] Record created: 07
# [0] Balance calculated: 100
# [2] Record created: 10
# [2] Balance calculated: 100
# [0] Balance saved: 100
# [1] Balance saved: 100
# [3] Balance saved: 100
# [2] Balance saved: 100
# Output: 100
And as you can see the final output is 100
not 400
!
If we see with care, we can see some funky things, like:
100
100
Why do you think that every balance calculation were 100
?
The reason is that the four calls to account.create_entry
, start at the same time and at that time the current balance is 0 for each call, then we “correctly” sum the entry.amount
of 100
and the total was 100
every time.
But what if instead of using the currently saved balance, we recalculate the balance?
Maybe with something like this:
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
If we break what is happening each time we create an entry, we can see that we:
Yes, just as in the previous example!
And when we run the example creating each entry on a different thread, we can see that it can also can have problems:
# Run with ThreadsTransaction
# [1] Record created: 23
# [1] Balance calculated: 100
# [2] Record created: 25
# [0] Record created: 26
# [2] Balance calculated: 300
# [3] Record created: 24
# [0] Balance calculated: 400
# [3] Balance calculated: 400
# [1] Balance saved: 100
# [0] Balance saved: 400
# [3] Balance saved: 400
# [2] Balance saved: 300
# Output: 300
Although sometimes we are going to get the right value… We can’t know when it is going to be wrong.
Now (I hope) you understand that saving a computed value that can be updated by processes or threads running concurrently, maybe on different background jobs or different web requests, can cause race conditions and incorrect saved values.
I really don’t know 😅
There are different techniques that you can try but I don’t know a way that will always work and won’t give you troubles in other way.
Still, here are some options that you can try:
I built a repo with the setup tor run the examples of this post (and some more), that can help you explore other solutions that could have errors and others that won’t.
You can get more details on the post: Examples to explore possible race conditions when caching custom computed values in Rails
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.