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
Now suppose that a user wants to subscribe to regular egg deliveries. We could
have a model called
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
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
after_rollback callbacks). When the
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
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.