DEV Community

Vladimir Dementyev for Evil Martians

Posted on • Edited on • Originally published at evilmartians.com

Rails `after_commit` everywhere

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

That's it. Hope you like it)


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

Top comments (6)

Collapse
 
ben profile image
Ben Halpern

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

Collapse
 
andy profile image
Andy Zhao (he/him)

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

Collapse
 
akostadinov profile image
Aleksandar Kostadinov

Is it better than the after_commit_queue [1] gem? Seems like this one is decoupled from model being saved. Which might be expected and desirable or unexpected.

[1] github.com/Ragnarson/after_commit_...

Collapse
 
palkan_tula profile image
Vladimir Dementyev

Yes, this gem is better. It uses Active Record APIs (thus, battle-tested) and not a custom queue implementation.

Collapse
 
envek profile image
Andrey Novikov

I've extracted this into the gem: github.com/Envek/after_commit_ever...

Collapse
 
iridakos profile image
Lazarus Lazaridis

Cool gem!