5

How to prevent race condition in Ruby on Rails applications?

 3 years ago
source link: https://blog.kiprosh.com/how-to-prevent-race-condition-in-ruby-on-rails/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
Published on 29 December 2020 in mult-threading

How to prevent race condition in Ruby on Rails applications?

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 andupdate 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"]]

If you face any issue, drop a comment 📝 in the comments section below 👇 mentioning the issue and error-details if any.

We would ❤️ to hear from you. Thank you.


References


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK