Benito Serna Tips and tools for Ruby on Rails developers

Saving incorrect computed values thanks to race conditions

December 22, 2022

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.

Using the account balance as an example

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

Adding to the current state

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 try to understand why it can save a wrong value

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:

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.

Recalculating the value

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.

Be aware race conditions

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.

How to solve these kind of problems?

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:

A tool to explore more

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

Related articles

Weekly tips and tools for Ruby on Rails developers

I send an email each week, trying 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 post by topic.