loading...
Evil Martians

Rails `after_commit` everywhere

palkan_tula profile image Vladimir Dementyev Updated on ・3 min read

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 fail 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 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. Hope you like it)


Read more dev articles on https://evilmartians.com/chronicles!

Posted on by:

palkan_tula profile

Vladimir Dementyev

@palkan_tula

A mathematician found his happiness in programming Ruby and Erlang, contributing to open source and being an Evil Martian.

Evil Martians

Evil Martians is a distributed product development consultancy that works with startups and established businesses, and creates open source-based products and services.

Discussion

pic
Editor guide
 

@andy this seems like something to bookmark for possible use in the future.

 

This is some next level Ruby stuff. Definitely a great use case! Thanks for the post.