Benito Serna Tips and tools for Ruby on Rails developers

Will it save the wrong value?

January 3, 2023

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.

Your task

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.

Problem description

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.

Exercise 00 - Add balance

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

Answer

It can save the wrong value due to race conditions. You can see this example.

Exercise 01 - Sum balance in ruby

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

Answer

It can save the wrong value due to race conditions. You can see this example.

Exercise 02 - Sum balance in database

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

Answer

It can save the wrong value due to race conditions. You can see this example.

Exercise 03 - Sum balance in database on after touch

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

Answer

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 after_touch.

Apparently after_touch works something like this:

  • There is a change in the association.
  • Within the same transaction, rails updates the associated record (account).
  • Then it performs the other operation that you have configured (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.

Exercise 04 - Sum balance in database using with_lock

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

Answer

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.

But as we are dividing the process in two steps:

  1. Creating the entry
  2. Calculating and saving the balance

… 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
  

Exercise 05 - Sum balance in database using with_lock on after touch

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

Answer

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.

Exercise 06 - Sum balance in database using with_lock on full operation

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

Answer

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.

Exercise 07 - Updating counters

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

Answer

This is very similar to the exercise 04, we are (kind of) dividing the process in two steps:

  1. Creating the entry
  2. Calculating and saving the balance

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.

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.