How to Avoid Race Conditions in Rails

Race conditions are hard to debug—especially when you don't know it's a race condition! This article looks at some common race conditions and the best solutions for handling each one.

When two users read and update a database record at the same time, you might run into critical problems that are undesirable. Let's say that for some reason, a customer clicks the pay button on the checkout page of an e-commerce website. It is possible to have a scenario where a particular customer is charged twice for the same order because the two requests to charge the order were made at almost the same time. This situation is called a "race condition."

A race condition occurs when two or more threads can access shared data and try to change it at the same time. Because the thread scheduling algorithm can swap between threads at any time, you don't know the order in which the threads will attempt to access the shared data. Therefore, the result of the data change is dependent on the thread scheduling algorithm (i.e., both threads are "racing" to access/change the data). Problems often occur when one thread does a "check-then-act" (e.g., "Check" if the value is X, and then "act" to do something that depends on the value being X) and another thread does something to the value in between the "check" and the "act" processes. —Stack Overflow

In this post, we will take a look at locking, database constraints, and uniqueness, which are some of the approaches used to avoid race conditions in a Rails application.

Locking

When your system allows multiple users to operate on the same records, you want to avoid situations where one user overrides changes made by another user without even looking at them.

For example, in an e-commerce system, you often have multiple admins managing the product inventory. A race condition occurs when two admins try to update the same product:

  • Admin 1 adds a new product "Bench Fitness Equipment for Home."
  • Admin 2 sees the new product and decides to edit the product's name to be more descriptive and changes it to "RELIFE REBUILD YOUR LIFE Sit Up Bench Adjustable Workout Foldable Bench Fitness Equipment for Home Gym Ab Exercises New Version."
  • At the same time, Admin 1 notices that the initial name was not descriptive and decides to rename it to "Adjustable Workout Foldable Bench Fitness Equipment for Home Gym."
  • Admin 1 saves the new name a few milliseconds after Admin 2, overriding Admin 2's more descriptive naming.

Locking is a way to prevent such a scenario.

Optimistic locking

Optimistic locking allows multiple users to access the same record for edits and assumes a minimum of conflicts with the data. It does this by checking whether another process has made changes to a record since it was opened; an ActiveRecord::StaleObjectError exception is thrown if it has occurred, and the update is ignored. -Rails Guide

To implement optimistic locking in Rails, add a lock_version column to the table in which you want to place the lock. Rails will automatically check this column before updating the record. Each update to the record increments the lock_version column, and the locking facilities ensure that records instantiated twice will let the last one saved raise a StaleObjectError if the first was also updated.

product1 = Product.find(1)
product2 = Product.find(1)

product1.name =  "RELIFE REBUILD YOUR LIFE Sit Up Bench Adjustable Workout Foldable Bench Fitness Equipment for Home Gym Ab Exercises New Version"
product1.save

product2.name = "Adjustable Workout Foldable Bench Fitness Equipment for Home Gym"
product2.save # Raises an ActiveRecord::StaleObjectError

Pessimistic locking

Pessimistic locking locks a record until all transactions on the record are done. Once the record is locked, another user cannot modify the record until the lock is released. While optimistic locking is used when database transactions are less likely to happen, it is used when database transaction conflicts are more likely to happen. It provides support for row-level locking using SELECT … FOR UPDATE and other lock types.

To implement pessimistic locking in Rails, chain ActiveRecord::Base#find to ActiveRecord::QueryMethods#lock:

product = Product.lock.find(1) #lock the record

product.name = "RELIFE REBUILD YOUR LIFE Sit Up Bench Adjustable Workout Foldable Bench Fitness Equipment for Home Gym Ab Exercises New Version"

product.save! #release the lock

Alternatively, you can use the ActiveRecord::Base#lock! method to lock a record by its ID:

  product = Product.find(1)
  order = Order.find(order_id)
  ActiveRecord::Base.transaction do
    product.lock! 
    product.name  = "RELIFE REBUILD YOUR LIFE Sit Up Bench Adjustable Workout Foldable Bench Fitness Equipment for Home Gym Ab Exercises New Version"
    product.save!
    order.paid!
  end

You can also start a transaction and acquire a lock at the same time using with_lock:

product = Product.find(1)
product.with_lock do #lock the record
product.name = "RELIFE REBUILD YOUR LIFE Sit Up Bench Adjustable Workout Foldable Bench Fitness Equipment for Home Gym Ab Exercises New Version"

product.save!
end

Advisory locking

Advisory locking is a mechanism used to prevent concurrent execution of code without necessarily locking the database table or row. In core Ruby, this is implemented using mutex. In Rails, the Ruby gem with_advisory_lock can be used to add advisory locking (mutexes) to ActiveRecord when used with MySQL or PostgreSQL.

This gem automatically includes the WithAdvisoryLock module in all of your ActiveRecord models. Here's an example of how to use it when the Product is an ActiveRecord model, and lock name is a string:

Product.with_advisory_lock("product_lock") do
  product = Product.find(1)

  product.name =  "RELIFE REBUILD YOUR LIFE Sit Up Bench Adjustable Workout Foldable Bench Fitness Equipment for Home Gym Ab Exercises New Version"
  product.save
end

What happens:

  • The thread will wait indefinitely until the lock is acquired.
  • While inside the block, you will exclusively own the advisory lock.
  • The lock will be released after your block ends, even if an exception is raised in the block.

Use a unique index instead of uniqueness validation

Rails' ActiveRecord validation, such as the one below, is not a database-level validation, It is an application-level validation and works well if there is no race condition:

Let's look at a typical real-life example. During the signup process, you require users to provide phone numbers and passwords. You don't want multiple users to sign up with the same phone number, so you add a uniqueness validation that should throw an error if there are duplicate phone numbers.

class User < ApplicationRecord
  validates :phone_number, uniqueness: true
end

If a user mistakenly clicks the signup button two or more times in a row within a few milliseconds of each other, which often occurs in a web application, you are quite likely to have the following scenario:

  • Request 1 - Check if a user exists with that phone number and proceed, as no user is found.
  • Request 2 - Check if a user exists with that phone number and proceed, as no user is found.
  • Request 1 - Arrive at the code that inserts data into the database. A new user record gets created.
  • Request 2 - Arrive at the code that inserts data into the database. A new user record gets created.

Request 2 passes the uniqueness validation because at the point of checking if a user exists, Request 1 is yet to save the phone number to the database. Hence, both requests eventually get to insert new records into the database.

The way to prevent this kind of race condition is by adding a unique index constraint, such as the one below, which performs validation on the database level.

class AddUniqueIndexToUsers < ActiveRecord::Migration
  def change
    # Have the database raise an exception anytime any process tries to
    # submit a record that has a code duplicated for any particular account
    add_index :users, :phone_number, unique: true
  end
end

Sidekiq unique jobs

If you use Sidekiq workers to make changes to your database, you can use SidekiqUniqueJobs to add unique constraints to Sidekiq queues. Uniqueness is achieved by acquiring locks for a hash of a queue name, a worker class, and a job's arguments. By default, only one lock for a given hash can be acquired. If an attempt to acquire a new lock is made, an exception SidekiqUniqueJobs::ScriptError is raised.

Usage with Sidekiq is simple; all you have to do is configure it as a middleware for both the Sidekiq client and server. Add the following code to your /config/initializers/sidekiq.rb:

Sidekiq.configure_server do |config|
  config.redis = { url: ENV["REDIS_URL"], driver: :hiredis }

  config.client_middleware do |chain|
    chain.add SidekiqUniqueJobs::Middleware::Client
  end

  config.server_middleware do |chain|
    chain.add SidekiqUniqueJobs::Middleware::Server
  end

  SidekiqUniqueJobs::Server.configure(config)
end

Sidekiq.configure_client do |config|
  config.redis = { url: ENV["REDIS_URL"], driver: :hiredis }

  config.client_middleware do |chain|
    chain.add SidekiqUniqueJobs::Middleware::Client
  end
end

Conclusion

Race conditions, if not prevented, can lead to data integrity issues and sometimes even security issues. If you understand what can cause them and how to avoid them, you can build systems with consistent data and avoid spending hours debugging data integrity issues.

Honeybadger has your back when it counts.

We're the only error tracker that combines exception monitoring, uptime monitoring, and cron monitoring into a single, simple to use platform. Our mission: to tame production and make you a better, more productive developer.

Learn more
author photo

Godwin Ekuma

Godwin enjoys learning new things through his work at FairMoney.ai, whose mission is increasing access to financial services in developing markets.

More articles by Godwin Ekuma
“We've looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release.” 
Michael Smith
Try Error Monitoring Free for 15 Days
Are you using Bugsnag, Rollbar, or Airbrake for your monitoring? Honeybadger includes exception, uptime, and check-in monitoring — all for probably less than you're paying now. Discover why so many companies are switching to Honeybadger here.
Try Error Monitoring Free for 15 Days
Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
Try Error Monitoring Free for 15 Days
"Wow — Customers are blown away that I email them so quickly after an error."
Chris Patton
Try Error Monitoring Free for 15 Days