Recipe 3.13. Protecting Against Race Conditions with Transactions


Problem

You've got a shopping application that adds items to a cart and then removes those items from inventory. These two steps are part of a single operation. Both the number of items in the cart and the amount remaining in inventory are stored in separate tables in a database. You recognize that it's possible that when a specific number of items are added to the cart that there could be insufficient inventory to fill the order.

You could try to get around this by checking for available inventory prior to adding items to the cart, but it's still possible for another user to deplete the inventory in between your check for availability and the cart quantity update.

You want to ensure that if there isn't enough of an item in inventory, the amount added to the cart is rolled back to its original state. In other words, you want both operations to complete successfully, or neither to make any changes.

Solution

Use Active Record transactions.

First, create a very simple database to store items in the cart and those remaining in inventory. Populate the inventory table with 50 laptops. Use the following migration to set this up:

db/migrate/001_build_db.rb:

class BuildDb < ActiveRecord::Migration   def self.up     create_table :cart_items do |t|       t.column :user_id, :integer       t.column :name, :string       t.column :quantity, :integer, { :default => 0 }     end     create_table :inventory_items do |t|       t.column :name, :string       t.column :on_hand, :integer     end     InventoryItem.create :name => "Laptop",                          :on_hand => 50   end   def self.down     drop_table :cart_items     drop_table :inventory_items   end end 

Create a model for inventory that subtracts items from the quantity on hand. Add a validation method that ensures that the amount of an item in inventory cannot be negative.

app/models/inventory_item.rb:

class InventoryItem < ActiveRecord::Base   def subtract_from_inventory(total)     self.on_hand -= total     self.save!     return self.on_hand   end   protected     def validate       errors.add("on_hand", "can't be negative") unless on_hand >= 0     end      end

Next, create a cart model with an accessor method for adding items:

app/models/cart_item.rb:

class CartItem < ActiveRecord::Base   def add_to_cart(qty)     self.quantity += qty     self.save!     return self.quantity   end end

In the Cart Controller, create a method that adds five laptops to a shopping cart. Pass a block containing the related operations into an Active Record transaction method. Further surround this transaction with exception handling.

app/controllers/cart_controller.rb:

class CartController < ApplicationController   def add_items      item = params[:item] || "Laptop"      quantity = params[:quantity].to_i || 5     @new_item = CartItem.find_or_create_by_name(item)     @inv_item = InventoryItem.find_by_name(@new_item.name)     begin          CartItem.transaction(@new_item, @inv_item) do         @new_item.add_to_cart(quantity)           @inv_item.subtract_from_inventory(quantity)       end          rescue         flash[:error] = "Sorry, we don't have #{quantity} of that item left!"        render :action => "add_items"        return       end   end end

Finally, create a view that displays the number of items in the cart with the number left in inventory, as well as a form for adding more items:

app/views/cart/add_items.rhtml:

<h1>Simple Cart</h1> <% if flash[:error] %>   <p style="color: red; font-weight: bold;"><%= flash[:error] %></p> <% end %> <p>Items in cart: <b><%= @new_item.quantity %></b>   <%= @new_item.name.pluralize %><p> <p>Items remaining in inventory: <b><%= @inv_item.on_hand %></b>   <%= @inv_item.name.pluralize %><p> <form action="add_items" method="post">   <input type="text" name="quantity" value="1" size="2">   <select name="item">     <option value="Laptop">Laptop</option>   </select>   <input type="submit" value="Add to cart"> </form>

Discussion

The solution uses Active Record's transaction facility to guarantee that all operations within the transaction block are performed successfully or that none of them are.

In the solution, the model definitions take care of incrementing and decrementing the quantities involved. The save! method that Active Record provides will commit the changed object to the database. save! differs from save because it raises a RecordInvalid exception if the save fails, instead of returning false. The rescue block in the Cart Controller catches the error, should one occur; this block defines the error message to be sent to the user.

Passing a block of code to the transaction method takes care of rolling back partial database changes made by that code, upon error. To return the objects involved in the transaction to their original state, you have to pass them as arguments to the transaction call as well as the code block.

Figure 3-6 shows the cart from the solution after a successful "add to cart" attempt.

Figure 3-6. A laptop successfully added to the cart and removed from inventory


At the mysql prompt, you can confirm that the quantities change as expected. More importantly, you can confirm that the transaction actually rolls back any database changes upon error.

mysql> select quantity, on_hand from cart_items ci, inventory_items ii where ci.name = ii.name; +----------+---------+ | quantity | on_hand | +----------+---------+ |       32 |      18 | +----------+---------+ 1 row in set (0.01 sec)

Figure 3-7 shows the results of trying to add more than what's left in inventory.

Figure 3-7. The results of a failed transaction


Sure enough, the update of quantity was rolled back because the decrement of on_hand failed its validation check:

mysql> select quantity, on_hand from cart_items ci, inventory_items ii where ci.name = ii.name; +----------+---------+ | quantity | on_hand | +----------+---------+ |       50 |       0 | +----------+---------+ 1 row in set (0.01 sec)

For Active Record transactions to work, your database needs to have transaction support. The default database engine for MySQL is MyISAM, which does not support transactions. The solution specifies that MySQL use the InnoDB storage engine in the table creation statements. InnoDB has transaction support. If you're using PostgreSQL, you have transaction support by default.

See Also

  • Rails API documentation for ActiveRecord::Transactions, http://api.rubyonrails.com/classes/ActiveRecord/Transactions/ClassMethods.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