KatPadi's Point

Race Condition and Rails with_lock

At one point in a dev’s life, race condition will be something that one needs to solve. Race conditions happen when the outcome is dependent on the sequence or timing of other events like saving a record to the database.

If two users read a value from a record and then update it simultaneously, one of the values has to “win” and data integrity will be compromised. This is dangerous especially when dealing with money or inventory stocks.

For example:

  1. User has a card balance = 10
  2. User opens browser A and fill out 5 USD to buy
  3. User opens browser B and fill out 10 USD to buy
  4. User hits “Buy” button of both browsers at the same time
  5. Request A reads card balance=10
  6. Request B reads card balance=10
  7. Request A updates balance -= 5 (balance now is 10-5=5)
  8. Request B still has the instance card balance=10 (even though request A already decremented it)
  9. Request B updates balance -= 10 (balance now is 10-10=0)
  10. Final balance is now 0

User was able to purchase 15 USD when the initial balance was only 10 USD. This is race condition potentially at its worst case!

Also relevant is the famous Starbucks unlimited coffee hack: Starbucks Unlimited Coffee Hack


One way to handle concurrency problem like this is to use locks. There are two types of locking: Optimistic and Pessimistic.

In optimistic locking, we only lock it when updating the data. Other requests can still read the subject data. Pessimistic locking, on the other hand, locks all other access to the record. Even the read access is not allowed. With this type of locking, while the first request to the object is updating, all other requests will have to wait for their turn.

I haven’t tried optimistic locking personally but I read that it requires an extra table field lock_version to store the increments of the updates. It also raises an ActiveRecord::StaleObjectError if update is ignored.

Pessimistic Strategy

Rails doesn’t do locking when loading a row from the database by default. If the same row of data from a table is loaded by two different processes and then updated at different times, race conditions like the scenario mentioned above will occur.

See: activerecord/lib/active_record/locking/pessimistic.rb

Rails’ simple pessimistic locking implementation goes something like this:

coups = Coupon.all.sample
coups.lock! # no other users can read this coupon, they have to wait until the lock is released
coups.save! # lock is released, other users can now read this 

We can either use lock! or with_lock. lock! is cool in itself, but with_lock is even cooler. You can wrap a critical code with a with_lock method and live happily. The with_lock method accepts a block which is executed within a transaction and the instance is reloaded with lock: true.

# Get a coupon
@coupon = Coupon.all.sample
@coupon.with_lock do
  @coupon.balance += 5

Under the hood, this is what happens within a with_lock block:

  1. opens up a database transaction
  2. reloads the record instance
  3. requests exclusive access to the record

The lock is automatically released at the end of the transaction.

Two reasons why I like with_lock:

  1. It uses ActiveRecord::Transactions to make sure that the codes execute completely or not. All or nothing. Go big or go home. Atomicity.
  2. Instance is reloaded! Because instance is reloaded, validations work as expected. Pretty neat.

If you try to simulate the code below using two Rails consoles, you’ll see what I mean.

module Locky

  def unsafe(coupon, val = 1)
    sleep 8
    puts coupon.balance
    sleep 2
    coupon.balance += val
    puts "Final: #{coupon.balance}"

  def safe(coupon, val = 1)
    sleep 8
    coupon.with_lock do
      puts coupon.balance
      sleep 2
      coupon.balance += val
      puts "Final: #{coupon.balance}"

Oh and by the way, pessimistic locking is not without its flaws. It’s all fun and games until you encounter deadlocks and starvation.

Leave a Reply

Your email address will not be published. Required fields are marked *