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

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