Recipe 3.18. Avoiding Race Conditions with Optimistic Locking


Problem

Contributed by: Chris Wong

By default, Rails doesn't use database locking when loading a row from the database. If the same row of data from a table is loaded by two different processes (or even loaded twice in the same process) and then updated at different times, race conditions can occur. You want to avoid race conditions and the possibility for data loss.

Solution

There is no way to force Rails to lock a row for later update. This is commonly known as pessimistic locking or select for update. To lock a row with Active Record, you need to use optimistic locking.

If you're building a new application with new tables, you can simply add a column named lock_version to the table. This column must have a default value of zero.

For example, you have a table created using the following migration:

db/migrate/001_create_books.rb:

class CreateBooks < ActiveRecord::Migration   def self.up     create_table :books do |t|       t.column :name, :string       t.column :description, :text       t.column :lock_version, :integer, { :default => 0 }     end   end   def self.down     drop_table :books   end end 

If you load the same record into two different objects and modify them differently, Active Record raises a ActiveRecord::StaleObjectError exception when you try to save the objects:

book1 = Book.find(1) same_book = Book.find(1) book1.name = "Rails Cookbook" same_book.name = "Rails Cookbook, 2nd Edition" book1.save      # this object saves successfully same_book.save  # Raises ActiveRecord::StaleObjectError

You can handle the StaleObjectError with code like this:

def update   book = Book.find(params[:id])   book.update_attributes(params[:book]) rescue ActiveRecord::StaleObjectError => e   flash[:notice] =      "The book was modified while you were editing it.  Please retry..."   redirect :action => 'edit'    end

What if your company already has an established naming convention for the locking column? Let's say it's named record_version instead of locking_version. You can override the name of the locking column globally in environment.rb:

config/environment.rb:

ActiveRecord::Base.set_locking_column 'record_version'

You can also override the name at the individual class level:

app/models/book.rb:

class Book < ActiveRecord::Base   set_locking_column 'record_version' end

Discussion

Using optimistic transactions simply means that you avoid holding a database transaction open for a long time, which inevitably creates the nastiest lock contention problems. Web applications only scale well with optimistic locking. That's why Rails by default provides only optimistic locking.

In a high traffic site, you simply don't know when the user will come back with the updated record. By the time the record is updated by John, Mary may have sent back her updated record. It's imperative that you don't let the old data (the unmodified fields) in John's record overwrite the new data Mary just updated. In a traditional transactional environment (like a relational database), the record is locked. Only one user gets to update it at a time; the other has to wait. And if the user who acquires the lock decides to go out for dinner or to quit for the night, he can hold the lock for a very long time. When you claim the write-lock, no one can read until you commit your write operation.

Does optimistic locking mean that you don't need transactions at all? No, you still need transactions. Optimistic locking simply lets you detect if the data your object is holding has gone stale (or is out of sync with the database). It doesn't ensure atomicity with related write operations. For example, if you are transferring money from one account to another, optimistic locking won't ensure that the debit and credit happen or fail together.

checking_account = Account.find_by_account_number('999-999-9999') saving_account   = Account.find_by_account_number('111-111-1111') checking_account.withdraw 100 saving_account.deposit 100 checking_account.save saving_account.save    # Let's assume it raises StaleObjectException here # Now you just lost 100 big shiny dollars... # The right way begin   Account.transaction(checking_account, saving_account) do     checking_account.withdraw 100     saving_account.deposit 100   end rescue ActiveRecord::StaleObjectError => e   # Handle optimistic locking problem here end

See Also

  • Rail API documentation for ActiveRecord::Locking, http://api.rubyonrails.com/classes/ActiveRecord/Locking.html




Rails Cookbook
Rails Cookbook (Cookbooks (OReilly))
ISBN: 0596527314
EAN: 2147483647
Year: 2007
Pages: 250
Authors: Rob Orsini

flylib.com © 2008-2017.
If you may any questions please contact us: flylib@qtcs.net