Race Condition

Ruby on Rails

Posted by Theo Cha on March 20, 2024

“Race condition A race condition or race hazard is the condition of an electronics, software, or other system where the system’s substantive behavior is dependent on the sequence or timing of other uncontrollable events, leading to unexpected or inconsistent results. - Wikipedia -”

What is race condition?

As the below image, most Rails application has multiple processes such as API requests, Rake tasks, and background jobs. all these processes can access to the shared resource. Race conditions occur when multiple processors access the shared resources.

Multiple processors

Read-modify-write race condition:

A read-modify-write race condition is one of the most common race conditions in many web applications. here is an easy example to explain in a real-life situation in the Rails application. Let’s say there are two users and one shared resource - Idea.

  1. User A accessed @idea = Idea.find(params[:id])
  2. User A incremented total 1 vote @idea.votes += 1
  3. User B accessed @idea = Idea.find(params[:id])
  4. Just before User A saves the vote, User B incremented total 1 vote @idea.votes += 1
  5. User A saved total 1 vote
  6. User B saved total 1 vote
1
2
3
4
5
6
7
8
9
10
class IdeasController < ActionController::Base
  def vote
    # this part calls a critical area
    @idea = Idea.find(params[:id])
    @idea.votes += 1
    @idea.save!

    redirect_to ideas_path
  end
end

As you can see, the total vote should be 2 instead of 1. the problem is that before A user’s vote was saved, user B had access to the shared resource with the wrong vote data.


How to fix

Optimistic and pessimistic locks

Optimistic lock

Optimistic Locking is a technique where you keep track of a record’s version number (or use dates, timestamps, or checksums/hashes) when you first read it. Before updating the record, you make sure the version hasn’t changed. During the update, you specifically check that the version matches to ensure the update is done in one go (meaning the record hasn’t been altered by someone else in the meantime). You also update the version number to reflect the change.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Document < ApplicationRecord
  # Rails uses the 'lock_version' column by default for optimistic locking.
  # Make sure your model's table has this column.
end

# When updating a record
document = Document.find(1)
document.title = "New Title"

begin
  document.save
rescue ActiveRecord::StaleObjectError
  # This error is raised if the record has been updated by someone else since you loaded it.
  puts "Someone else has already updated this document."
end

Pessimistic locks

Pessimistic Locking means you lock a record so only you can use it until you’re done with it. It’s more secure than Optimistic Locking but you need to design your app carefully to avoid deadlocks.

1
2
3
4
5
6
7
8
9
10
11
12
13
class Document < ApplicationRecord
  # Assuming Document is your model
end

# To lock a record for exclusive use
Document.transaction do
  document = Document.lock.find(1)
  # Now the document is locked for exclusive access by this transaction

  document.update(title: "Exclusive Update")

  # The record is automatically unlocked at the end of the transaction block
end
1. Remove the critical area

By refactoring, we can use an atomic operation. in other word, there is no process like reading and modifying, while the operation is running.

  • From:
1
2
3
@idea = Idea.find(id)
@idea.votes + 1
@idea.save!
1
2
3
UPDATE "ideas"
SET "votes" = 1
WHERE "ideas"."id" = 1
  • To:
1
Idea.where(id: id).update_all('"votes" = "votes" +)
1
2
3
UPDATE "ideas"
SET "votes" = "votes" + 1
WHERE "ideas"."id" = 1
2. Detect and recover

Rails Active record support a Optimistic locking. using this, Rails will only apply an update to the database, if it is the same version of object in your memory. this can be a bit risky because it can silently ignore update.

To enable the optimistic locking:

  1. Add a lock_version column in database.
1
2
3
change_table :ideas do |t|
  t. integer :lock_version, default: 0
end

with this column, Rails will immediately pick it up and start using the optimistic locking.

1
2
3
4
5
6
7
8
9
10
11
12
class IdeasController < ActionController::Base
  def vote
    # this part calls a critical area
    @idea = Idea.find(params[:id])
    @idea.votes += 1
    @idea.save!

    redirect_to ideas_path
    rescue ActiveRecord::StableObjectError
      retry
  end
end
1
2
3
4
5
6
UPDATE "ideas"
SET   "votes" = 1,
      "lock_version" = 1

WHERE "ideas"."id" = 1
  AND "ideas". "lock_version" = 0
3. Protect critical area

Rails support a pessimistic lock to protect the critical area. with pessimistic locking, a process is telling the database that once you have extracted an object from the database, only it has access to it till a transaction is completed. if two request are coming, one of request will wait outside.

1
2
3
4
5
6
7
8
9
10
class IdeasController < ActionController::Base
  def vote
    Idea. transaction do
    @idea = Idea.lock.find(params [:id])
    @idea.votes += 1 @idea.save!
  end

  redirect_to ideas_path
  end
end
1
2
3
4
5
SELECT *
FROM "ideas"
WHERE "ideas"."id" = 1
LIMIT 1
FOR UPDATE
  • With with_advisory_lock
1
2
3
4
5
6
7
8
9
10
class IdeasController < ActionController::Base
  def vote
    Idea.with_advisory_lock("idea_vote_#{params|:id]}") do
    @idea = Idea.find(params|:id])
    @idea.votes += 1 @idea.save!
  end

  redirect_to ideas_path
  end
end

References