Handling Race Conditions in Rails Application
By Veera Narayana & M. Santosh Dora
What are Race conditions?
- Race conditions are undesirable situations caused when the concurrent execution of the code causes unexpected results, compared to the scenario of having them executed sequentially.
- Race conditions occur when two or more concurrent processes access shared resources simultaneously.
Example
Refreshing User profile
- Let’s assume a scenario where the user updated her profile twice, and the system is processing both profile update requests using two different background jobs. If two jobs run at the same time, we will quite likely have the following scenario:
def refresh_user_in_background(user, details_to_update)
create_background_job do
refresh_user(user, details_to_update)
end
enddef refresh_user(user, details_to_update)
user_profile = UserProfile.find_or_create_by(user)
# Refresh User Profile
end
Request_1: Checks if user profile record exists and proceeds as no user profile record is found.
Request_2: Checks if user profile record exists and proceeds as no user profile record is found.
Request_1: Creates a new user profile record for the user.
Request_2: Creates a new user profile record for the user.
- We will end up creating two user profile records for the same user.
Techniques to prevent race conditions
Things to consider:
- Considered MySQL as the underlying database.
- MySQL runs transactions at the “Repeatable Read” isolation level by default.
- There is an option to set the isolation level to Serializable (highest level) to prevent race conditions overall.
- But the problem is there will be performance implications since MySQL internally uses locks for every transaction.
- Instead of changing the isolation level, we can use the following techniques to prevent the following types of race conditions.
1. Unique index on columns
i) find_or_create_by:
class AddUniqueIndexToUserId < ActiveRecord::Migration
def change
add_unique_index :user_id
end
end- If we have a unique index on the user_id column for the user profile table, the database will prevent the creation of duplicate user profile records.
- Database halts the execution of code with uniqueness exception.
- This way, we can prevent the creation of two user profile records.
- The problem with find_or_create_by is that Request_2 from the above example will not refresh the user profile record since the program stops with a uniqueness error.
ii) create_or_find_by:
def refresh_user_in_background(user, details_to_update)
create_background_job do
refresh_user(user, details_to_update)
end
enddef refresh_user(user, details_to_update)
user_profile = UserProfile.create_or_find_by(user)
# Refresh User Profile
end
- Rails 6 added
create_or_find_byandcreate_or_find_by!. These methods rely on unique constraints on the database level. If the creation fails, it is because of the unique constraints on given columns, and it will try to find the record using find_by!. - When we use create_or_find_by, we will have the following scenario:
Request_1: Creates user profile record
Request_2: Tries to create but fails with an uniqueness exception.
Request_1: Finds user profile record for the user.
Request_2: Finds user profile record for the user.
- This way, we prevent race conditions to happen.
When to Use:
- Use create_or_find_by in scenarios where creating a new record is more likely than updating the record and find_or_create_by in the opposite scenario.
2. Optimistic Locking
Optimistic locking is a strategy where you read a record, take note of a version number and check that the version hasn’t changed before you write the record back.
- Let’s assume a user tries to get a refund twice for a single item, roughly at the same time, and the system processes two requests simultaneously. We will quite likely have the following scenario:
Get Chronus Engineering’s stories in your inbox
Join Medium for free to get updates from this writer.
Request_1: Checks if refund is made and proceeds as refund is not done.
Request_2: Checks if refund is made and proceeds as refund is not done.
Request_1: Arrive at the code that creates a refund request for the user.
Request_2: Arrive at the code that creates a refund request for the same user.
- We will end up refunding twice for a single amount.
- Rails provide a way to handle this by using lock_version in models. ActiveRecord will prevent an update of the record if the
lock_versionfor that particular record has changed, by raisingActiveRecord::StaleObjectError. - This way, we can prevent race conditions using this technique.
- So, after adding the lock version, the behavior is the following:
- Drawback: Rollback transactions will be executed after
ActiveRecord::StaleObjectErroris raised.
When to use:
- This locking is suitable when the cost of rolling back a transaction is significantly less.
- Use optimistic locking in the case when users want to be notified about race conditions when updating the object.
3. Pessimistic Locking (Row-level locking)
- Pessimistic locking is when you lock the record for your exclusive use until you have finished with it.
- We will lock the record after reading it, such that other transactions won’t be able to update/delete the same record.
- We can solve the previously mentioned refund problem by adding an exclusive lock as shown below:
- When we lock user records using the lock method, other transactions won’t be able to update/delete the same. This way, we can prevent race conditions using this technique.
- Under the hood, ActiveRecord will perform a SELECT FOR UPDATE query, which will prevent any modification of that record within any other transaction. Also, it internally reloads the record (with reload method). That way, we can be sure we operate on the most recent data.
- With this approach, it is possible to have a deadlock: transaction A has lock 1, transaction B has lock 2, and transaction A is trying to acquire lock 2 while transaction B is trying to acquire lock 1.
- A simple way to avoid deadlocks is to establish a rule that you always have to acquire all the locks in ascending order. If you know you need lock numbers 1 and 2, you must acquire them in that order.
When to use:
- Pessimistic locking is suitable when the cost of rolling back a transaction is very high or when the rollback option is not possible (like when it involves calling 3rd party APIs).
4. Advisory Lock
- An advisory lock is a cooperative lock.
- An advisory lock is based on the concept that all processes will use an agreed-upon convention to cooperate to use a shared resource. That means the process that holds the lock can only use the shared resource.
- Advisory locks can easily prevent the concurrent execution of the code.
- Imagine a situation where you need to fetch the current amount in the account, modify the amount, and reinstate the correct value in the account after the modification. To be more precise, we have some background job workers to handle this logic, and the code looks like this:
- If handle_update_transaction is executed concurrently by n workers, depending on when the amount is fetched, and modification and updates happen, we can get different results. This inconsistency would not have occurred if the workers had executed the job sequentially.
- This can be handled by using an advisory lock, which prevents the concurrent execution of the code.
- Now with the lock “update_account_#{user_id}_lock’, what happens is all the process that needs to acquire the same lock will have to wait for the existing lock to be released.
- With this, we can guarantee that only a single background job can access the code simultaneously, solving our inconsistency problem.
When to use:
- Use Advisory locks to handle concurrency control mechanisms at the application level.
- Also, if we need to restrict multiple workers to access the same piece of code at a time, you should use advisory locks.
Conclusion
- If not appropriately handled, Race conditions can have terrible consequences. Fortunately, we can easily avoid these issues by understanding the possible scenarios and strategies to mitigate them.
- Before choosing any approach, understand the scenario for which it is to be used and the tradeoffs it carries.








