Race conditions are always surprising, which can occur in production and are difficult to reproduce. They can cause duplication of records in the database. Most of the time the locking mechanism is taken care of by the Rails framework. Users don't have to manage it; especially optimistic locking using the lock_version
column. In case of transactions and race around conditions, we can prevent these issues with Pessimistic Locking in ActiveRecord. It locks a record immediately as soon as the lock is requested(uses database row-level locking).
Race conditions happen when two users read and update a record at the same time, one of the values has to "win". Without locking, the outcome can be different every time.
The ActiveRecord::Locking::Pessimistic
module provides support for row-level locking using SELECT … FOR UPDATE
and other lock types.
1. lock!
Obtain a row lock on the record. Reloads the record to obtain the requested lock. Pass a SQL locking clause to append the end of the SELECT statement or pass true for “FOR UPDATE” (the default, an exclusive row lock). Returns the locked record.
The ActiveRecord::Base#lock!
method can be used to lock one record by id. This may be better if you don't need to lock every row.
Example:-
Account.transaction do
# SELECT * FROM accounts WHERE ...
accounts = Account.where(...)
account1 = accounts.detect { |account| ... }
account2 = accounts.detect { |account| ... }
# SELECT * FROM accounts WHERE id=? FOR UPDATE
account1.lock!
account2.lock!
account1.balance -= 100
account1.save!
account2.balance += 100
account2.save!
end
2. with_lock
Wraps the passed block in a transaction, locking the object before yielding. You can pass the SQL locking clause as argument (see lock!).
You can start a transaction and acquire the lock in one go by calling with_lock
with a block. The block is called from within a transaction, the object is already locked.
Example:-
account = Account.first
account.with_lock do
# This block is called within a transaction,
# account is already locked.
account.balance -= 100
account.save!
end
How to reproduce an issue related to race condition?
There was a project requirement which had many pages in the form of a tree structure. For this, the awesome_nested_set gem was used. When we move a page, all its children pages lft
(left column), rgt
(right column) values get updated. Because of the concurrency issue, the same lft
and rgt
value get assigned to the different children pages which makes the whole page tree corrupt. To solve this problem, we can't use validation, or indexes at the database level, and we certainly didn't want to patch the gem.
class Page < ApplicationRecord
belongs_to :site, touch: true
acts_as_nested_set scope: :site, dependent: :nullify
end
To reproduce the concurrency issue, the below snippet can help you. It simply created a different site page on a different thread which results in a page tree corruption. Whenever we create a new page, before_create :set_default_left_and_right
gets run which automatically sets the lft
and rgt
to the end of the tree.
class CreateCorruptPage
def run(site_id)
site = Site.find(site_id)
updater_threads = 2.times.map do
new_thread_connection do
site.pages.create!(title: 'Test')
end
end
updater_threads.each(&:join)
end
end
Before applying the with_lock
When we execute the above snippet CreateCorruptPage.new.run(id)
, it sets the same lft
/ rgt
column value for both the pages. Ideally, these values shall not be the same.
Page Create (0.6ms) INSERT INTO "pages" ("site_id", "title", "title_short", "permalink", "lft", "rgt", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING "id" [["site_id", 3], ["title", "Test"], ["title_short", "Test"], ["permalink", "test-6"], ["lft", 45], ["rgt", 46], ["created_at", "2020-11-30 04:49:21.284333"], ["updated_at", "2020-11-30 04:49:21.284333"]]
Page Create (0.6ms) INSERT INTO "pages" ("site_id", "title", "title_short", "permalink", "lft", "rgt", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING "id" [["site_id", 3], ["title", "Test"], ["title_short", "Test"], ["permalink", "test-6"], ["lft", 45], ["rgt", 46], ["created_at", "2020-11-30 04:49:21.286828"], ["updated_at", "2020-11-30 04:49:21.286828"]]
Solution is to apply with_lock
class Page < ApplicationRecord
belongs_to :site, touch: true
around_create :save_with_lock
acts_as_nested_set scope: :site, dependent: :nullify
private
def save_with_lock(&block)
site.save if site.changed?
site.with_lock(&block)
end
end
Here, around_create :save_with_lock
locks the site which has many pages. Working of with_lock
- Opens up a database transaction
- Reloads the record instance
- Requests exclusive access to the record
The lock is released automatically at the end of the transaction.
Now, when we execute the above snippet CreateCorruptPage.new.run(id)
, it sets the different lft
/ rgt
column value for both the pages.
Page Create (0.8ms) INSERT INTO "pages" ("site_id", "title", "title_short", "permalink", "lft", "rgt", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING "id" [["site_id", 3], ["title", "Test"], ["title_short", "Test"], ["permalink", "test-9"], ["lft", 49], ["rgt", 50], ["created_at", "2020-11-30 04:57:03.769774"], ["updated_at", "2020-11-30 04:57:03.769774"]]
Page Create (1.0ms) INSERT INTO "pages" ("site_id", "title", "title_short", "permalink", "lft", "rgt", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING "id" [["site_id", 3], ["title", "Test"], ["title_short", "Test"], ["permalink", "test-9"], ["lft", 51], ["rgt", 52], ["created_at", "2020-11-30 04:57:03.772106"], ["updated_at", "2020-11-30 04:57:03.772106"]]
Thank you for reading. ❤️
References