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.
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.
I've extracted this into the gem: github.com/Envek/after_commit_ever...
Cool gem!