Rails’ after_commit everywhere

Topics

Recently I’ve released a new gem—Isolator, which helps to detect non-database side effects during a database transaction.

Here is a quick example of such side effect:

def pay(user, order_params)
  Order.transaction do
    order = order.new(order_params)
    order.save!

    # HTTP API call
    PaymentsService.charge!(user, order)
  end
end

What if our transaction fails right after we made an HTTP call (and charged a user)? Hardly anything good.

That’s what Isolator is for: to prevent you from such situations.

Now, when we know what the problem is, how to fix it?

What about the following:

def pay(user, order_params)
  Order.transaction do
    order = order.new(order_params)
    order.save!
  end

  # we don't reach this line when the transaction fails
  PaymentsService.charge!(user, order)
end

Looks good, right? But what if you call pay somewhere in your code when the transaction has been already opened (i.e., in a nested transaction):

User.transaction do
  # do something with DB
  OrderService.pay(user, order_params)
  # whatever that may fail
end

Our HTTP call is made within a transaction. Again.

And that’s just a simple example. I found much more sophisticated examples in the project I’ve been working on, and came out with the solution—use ActiveRecord transaction callbacks.

You’ve probably heard about transactional callbacks (such as after_commit). These callbacks are smart enough to run after the final (outer) transaction* is committed.

* Usually, there is one real transaction and nested transactions are implemented through savepoints (see, for example, PostgreSQL).

How could these callbacks help us if they tight to ActiveRecord objects? Let’s take a look at the source code.

ActiveRecord has a Transactions module, which extends Base functionality.

It wraps persistence methods into with_transaction_returning_status, which in its turn call add_to_transaction—everything we need is there: we’re adding our record (self, ’cause we’re inside an ActiveRecord object) to the list of current_transaction.records.

When the transaction (and not a savepoint) is committed, on every record from records we invoke committed! method. That’s it!

So, in order to run arbitrary code after transaction commit, all we need is to add something quacking like an AR record to the list of transaction records!

Let’s add a special class called AfterCommitWrap:

# Quack like an ActiveRecord and
# respond to `committed!`
class AfterCommitWrap
  def initialize
    @callback = Proc.new
  end

  def committed!(*)
    @callback.call
  end

  def before_committed!(*); end

  def rolledback!(*); end
end

Now it’s time to use it:

def pay(user, order_params)
  Order.transaction do
    order = order.new(order_params)
    order.save!

    ActiveRecord::Base.connection.add_transaction_record(
      AfterCommitWrap.new { PaymentsService.charge!(user, order) }
    )
  end
end

We are safe now. But the code looks too awkward, doesn’t it? Let’s add some magic sugar.

I’m a big fan of refinements (yes, I am), and that’s what I did to make this code look simpler and more beautiful:

class AfterCommitWrap
  # ...
  module Helper
    refine ::Object do
      def after_commit(connection: ActiveRecord::Base.connection)
        connection.add_transaction_record(AfterCommitWrap.new(&Proc.new))
      end
    end
  end
end

And then:

# activate our refinement
using AfterCommitWrap::Helper

def pay(user, order_params)
  Order.transaction do
    order = order.new(order_params)
    order.save!

    after_commit { PaymentsService.charge!(user, order) }
  end
end

That’s it. I hope you like it!

Join our email newsletter

Get all the new posts delivered directly to your inbox. Unsubscribe anytime.