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!
Top comments (6)
@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.
I've extracted this into the gem: github.com/Envek/after_commit_ever...
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_...
Yes, this gem is better. It uses Active Record APIs (thus, battle-tested) and not a custom queue implementation.
Cool gem!