Recipe 3.13. Protecting Against Race Conditions with TransactionsProblem
You've got a shopping application that adds items to a cart and then
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
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
SolutionUse 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
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.
<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>
DiscussionThe 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
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
|