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.