Clever Hack for Deferring touch with ActiveRecord
Brief Review of Touch
touch
is an instance method available on ActiveRecord objects to simply bump a record’s updated_at
or updated_on
timestamp. This is often useful for child classes to “poke” their parent class when saved in order to keep timestamps up-to-date and run callbacks. Here’s an example:
class Order < ActiveRecord::Base
:line_items
has_many end
class LineItem < ActiveRecord::Base
:order, :touch => true
belongs_to end
Now, when you update a line item, the order will be saved with an updated timestamp and all callbacks will be fired as part of that save.
Why Defer Touch
In very rare edge cases, it may make sense for performance reasons to defer calls to touch for a time. At work, we have a similar setup with Order and LineItem. Updating a LineItem touches the order in order to fire some important callbacks.
However, part of our checkout process involves several sequential saves to line items. I’m not going to go into the details but imagine something like this:
def checkout(order)
do |line_item|
order.line_items.each
line_item.some_operation_that_savesend
end
This means for every line item saved, there will be an UPDATE on order to save the timestamp. In this case, the complete accuracy of the order timestamp is not important. It happened that in this specific case, the callbacks on the order update were fairly expensive and made checkout with large orders far too slow.
Beat It With The Metaprogramming Stick
I usually try to use metaprogramming sparingly because I think that it can quickly make your code unreadable. However, I think there is no getting around it here. I needed to record all calls to touch
on specific models in a code block and then play them back at the end, removing unnecessary duplicate calls.
I needed to institute a strict no-touching policy:
module DeferredTouching
def self.no_touching!(*klasses, &block)
Set.new
to_touch = # 1.8.7 compatibility
:touch); acc }
old_methods = klasses.inject({}) {|acc, klass| acc[klass] = klass.instance_method(
begin
do |klass|
klasses.each :define_method, :touch) do
klass.send(self
to_touch << end
end
block.call
:touch)
to_touch.each(&ensure
do |klass, meth|
old_methods.each :define_method, :touch, meth)
klass.send(end
end
end
end
This works by setting up a unique set of records to be touched in the closure of the method. It then caches the previously existing touch methods from each class and redefines them to push the record into the set when touched. Lastly, it puts the old methods back when the block is done executing and touches each member of the set.
Here’s some example usage
# Specify only the classes for which to defer touching
DeferredTouching.no_touching!(Order, LineItem) do
do |line_item|
order.line_items.each
line_item.some_operation_that_saves# Just for fun, these touches will get folded into 1 touch
line_item.touch
line_item.touch
line_item.touch
line_item.touch
order.touchend
end
# order and each line item will be touched exactly once.
Note a couple of gotchas with this approach:
- It does not support arguments. Touch takes an optional column argument, and this approach would need some tweaking to support that.
- It operates outside of the transaction. If halfway through the block, something blows up, and all updates get rolled back, the ensure will ensure that each record that was touched gets touched, regardless. This was not a problem for my use case.
Disclaimer
Realize also that the fact that this is necessary is probably a code smell. If you find that you must do battle with ActiveRecord like this for performance reasons, it probably means that you are leaning far too heavily on ActiveRecord callbacks, and should consider opting for injecting just the right context at the right time so you aren’t running unnecessary database calls in the first place.
That being said, I was working on a legacy project that already reeks like a garbage barge. I had a very specific performance problem to address and this allowed me to resolve it quickly for a massive reduction in response time for pathologically large orders. If you are in a position to make a more principaled approach, please do so, but it always helps to have tricks like this in your back pocket.