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:
100100Why 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.