A developer building a bulk-delete rake task for LineItem records stumbled into one of Rails' most insidious gotchas: counter_cache with touch bundles the entire operation into raw SQL that completely bypasses ActiveRecord's callback system. No after_save, no after_commit โ just gone. The kicker? When they asked Claude to explain the behavior, it confidently served up two wrong hypotheses before console testing revealed what was actually happening.
The Task: Delete LineItems Without Triggering a Cascade of Expensive Callbacks
The goal was straightforward: bulk-delete LineItem records and their associated S3 files managed by CarrierWave. The danger was the model hierarchy โ LineItem belongs_to OrderItem with counter_cache: true, touch: true, which in turn belongs_to Order with the same setup. Every deletion cascades upward through two touch hops, each carrying expensive callbacks like recalculate_totals, update_fulfillment_status, and ShippingEstimationService. Unchecked, this could mean thousands of unnecessary lambda invocations.
First Approach: remove! + delete
The initial implementation sidestepped the problem entirely by calling delete instead of destroy โ which skips the entire callback lifecycle. While effective, it was fragile. Any future logic added to those hooks would silently vanish. An engineer suggested using destroy! with skip_callback to disable specific callbacks and wrapping everything in no_touching on both OrderItem and Order.
Why skip_callback Is a Trap
Claude recommended this combined approach, but skip_callback is class-level mutation that affects every thread in the process. If an exception fires before re-enabling callbacks in an ensure block, they stay disabled until restart. It also requires enumerating every callback โ miss one and it fires anyway. The developer settled on Order.no_touching { OrderItem.no_touching { line_item.destroy! } }, wrote specs asserting updated_at wouldn't change, and called it done.
Console Testing Revealed the First Lie
After running the rake task locally, both order_item.updated_at AND order.updated_at changed despite the test assertions. Claude's first theory: MySQL's ON UPDATE CURRENT_TIMESTAMP was auto-updating timestamps during raw SQL execution. Wrong. The actual SQL showed something was updating order_item.updated_at even with no_touching in place โ but OrderItem's after_commit callbacks weren't firing at all. The timestamp changed, but the hooks didn't.
The Counter Cache Bundling Discovery
Console testing finally cracked it. When destroying a line item: recalculate_totals on OrderItem did NOT fire, but update_fulfillment_status on Order DID fire โ despite both having belongs_to ... touch: true. The difference was in the SQL logs. For OrderItem, Rails merged the counter decrement and timestamp update into a single raw UPDATE ALL statement, completely invisible to AR's callback system. Order never had its own counter decremented, so it received a plain AR touch that registered for after_commit normally.
The Fix Was Simpler Than Expected
OrderItem.no_touching was targeting the wrong model. Its callbacks don't fire because of counter cache bundling โ no_touching does nothing meaningful there. Order's callbacks fire via cascade touch from the next hop, where no counter is being decremented. The solution: just Order.no_touching { line_item.destroy! }. Console testing confirmed it. With this wrapper, Order Update SQL doesn't appear in logs at all.
Key Learnings
- counter_cache with touch bundles into UPDATE ALL โ after_commit hooks on the parent won't fire when a child is destroyed
- A plain touch (no counter cache) DOES go through AR lifecycle and fires after_commit
- Cascade hops matter: even if one model's callbacks are shielded, the next hop's can still trigger
- no_touching blocks AR-level touches only โ it can't prevent raw SQL UPDATE ALL from counter cache bundling
The Bottom Line
This is why you always verify what AI tells you about Rails internals. Claude was eager to explain and served up plausible-sounding hypotheses that turned out completely wrong. When dealing with callback chains, touch, and counter_cache, the only reliable verification is breaking something deliberately and watching whether it breaks. Trust but verify isn't just good practice โ it's survival.