Nested transaction gotchas in Rails

By Robert Kaufman, July 04, 2015 19:08

When you try to create a nested database transaction with Rails (specifically, ActiveRecord), if an ActiveRecord::Rollback exception is raised within the inner transaction, the default behavior is to do nothing. That is, it is equivalent to not having the inner transaction or rollback at all. Let's look at an example to illustrate.

Suppose we have a service that sells eggs. You can go to the website and order a dozen eggs any time, or you can sign up for a subscription to get a dozen eggs delivered every week. When someone places a regular order for eggs, we'll create a new Order model. When someone signs up for a subscription, we'll automatically create a new Order once a week.

Suppose you have a service class that creates an order, and then charge the user for it:

class OrderMaker
  def make
    ActiveRecord::Base.transaction do
      order = Order.new
      order.save!

      unless charge_user
        raise ActiveRecord::Rollback
      end
    end
  end

  private

  def charge_user
    # Communicate with credit card gateway
  end
end

Now, if you invoke OrderMaker.new.make from a controller, and the credit card fails to charge, your transaction gets rolled back and your order is never created.

Now suppose that a user wants to subscribe to regular egg deliveries. We could have a model called Subscription.

class Subscription < ActiveRecord::Base
  after_create :create_first_order

  private

  def create_first_order
    OrderMaker.new.make
  end
end

What would you expect to happen if the credit card fails to charge on the creation of a Subscription? You might expect that a Subscription was created, but that no Order was created. Imagine your surprise when both the Subscription and Order are created!

Why did this happen? It was due to the peculiar way Rails treats nested transactions. The behavior is clearly outlined in the documentation (http://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html), but that only helps if you've read that carefully, and in addition, realize that you're doing a nested transaction. In the above scenario, you might not realize that you're making a nested transaction.

In case you didn't realize it, ActiveRecord implicitly creates a transaction when you save a model, and includes the callbacks within it (with the exception of the after_commit and after_rollback callbacks). When the after_create callback invokes the service class, you've now created a nested transaction, and as you can see from the documentation, this does not affect the interaction with the database. However, this inner transaction block will still have an effect in how your code behaves, since it will catch an ActiveRecord::Rollback exception and then silently discard it.

If you do want to make a nested transaction, you'll need to pass the requires_new: true option to the ActiveRecord::Base.transaction call. In this context "requires new" means make a new transaction, rather than treating the block as a continuation of the current transaction. Most databases don't actually support nested transactions, so be careful! PostgreSQL for example, does not have nested transactions, but Rails will simulate them by using checkpoints.

You might never want to do a nested transaction, but as you can see, it can happen by accident too. I can see the motivation for why ActiveRecord's transaction API behaves this way, since the requires_new: true option won't always work, but given how unintuitive this is, it would be nice to get a warning when making a nested transaction.