What Claude Thought He Knew About Rails Callbacks (And How Console Testing Proved Him Wrong)

ruby dev.to

tl;dr - counter_cache: true will run an update_all on all affected records, which bypasses all other callback hooks. So even if you have after_save, after_commit hooks etc, they will be skipped if there is an increment or decrement event. Also, when it comes to AI code, TRUST but VERIFY. AI doesn’t lie out of malice, but it wants to answer you so much that it’ll sometimes just say nonsense.

Introduction

This is the story of a one-shot rake task to delete LineItem records and their S3 files, and the surprisingly deep rabbit hole of Rails callback behavior we fell into along the way. It includes every wrong assumption Claude made, how we tested our way out of them, and what we actually learned — and a reminder that you should always verify what Claude tells you yourself.


The Task

We needed to bulk-delete all LineItem records as part of a database cleanup. The goal was twofold: remove unnecessary database records and clean up their associated S3 files, managed by CarrierWave. Without careful callback management, deleting these records would touch parent models, potentially triggering expensive callbacks like recalculate_totals, update_fulfillment_status, and ShippingEstimationService — jobs that absolutely should not fire as a side effect of a bulk delete. Left unchecked, this could mean thousands of unnecessary lambda invocations and thousands of extra S3 thumbnail files being created for no reason.

To understand why this was tricky, you need to know the model hierarchy. LineItem belongs to OrderItem with counter_cache: true, touch: true. OrderItem in turn belongs to Order with counter_cache: true, touch: true. Every line item deletion cascades upward through two touch hops, and each hop carries its own set of expensive callbacks.


The First Approach: remove! + delete

The initial implementation was explicit:

line_item.photo.remove!
line_item.line_item_detail&.delete
line_item.delete
Enter fullscreen mode Exit fullscreen mode

delete skips the entire ActiveRecord callback lifecycle. remove! manually replicates what CarrierWave's after_destroy hook would have done. No callbacks, no side effects. This approach works — but it could be seen as a poor decision. By calling delete instead of destroy!, we bypass all of the model's internal mechanisms: custom callbacks, dependent association cleanup, and anything else the AR lifecycle would normally handle. Any future logic added to those hooks would be silently skipped.


An Engineer Suggests skip_callback

A reviewer asked: "What about using destroy! but skipping unnecessary callbacks with skip_callback?"

This is where Claude makes its first appearance. When asked how to implement this, Claude suggested a combined approach: use skip_callback to disable the specific callbacks, and wrap the destroy call in no_touching on both OrderItem and Order to suppress the full touch cascade.

On the surface, reasonable. But skip_callback is a class-level mutation — it modifies the callback chain on the class itself, not on a single instance. Every thread in the process sees the change. If an exception is raised before you re-enable it in an ensure block, callbacks stay disabled until the process restarts.

There is also the issue of enumeration — with skip_callback, every callback must be listed explicitly. Miss one, and it fires. In an active codebase where callbacks are added or renamed, this is a maintenance hazard.

At this point in the story, we still believed that nested no_touching calls — wrapping in Order.no_touching inside OrderItem.no_touching — were necessary to suppress the full cascade at both levels.


The no_touching Approach

no_touching felt like a cleaner solution. It's thread-local (unlike skip_callback), requires no enumeration of callbacks, and lets CarrierWave's after_destroy handle S3 cleanup naturally:

Order.no_touching { OrderItem.no_touching { line_item.destroy! } }
Enter fullscreen mode Exit fullscreen mode

We updated the rake task, wrote specs asserting that order_item.updated_at and order.updated_at wouldn't change, and called it done.

Then we console tested.


Wrong Assumption #1: MySQL Was Auto-Updating the Timestamp

After running the rake task locally, both order_item.updated_at AND order.updated_at changed (despite writing test code that asserted updated_at does NOT change). Our first theory was MySQL's ON UPDATE CURRENT_TIMESTAMP — the database was automatically updating the timestamp during the raw counter cache SQL, bypassing Rails entirely. Claude was confident in this diagnosis.

This was wrong.

Looking at the actual SQL logs:

UPDATE order_items
SET line_items_count = COALESCE(...) - 1,
    updated_at = '2026-06-16 03:41:15'
WHERE id = 2
Enter fullscreen mode Exit fullscreen mode

Something was still updating order_item.updated_at even with no_touching in place — but it clearly wasn't MySQL acting on its own. And to make things more puzzling, OrderItem's after_commit callbacks weren't firing at all. The timestamp was changing, but the callbacks weren't. We didn't have an explanation yet. Rather than guess further, we decided to test how touch and after_commit actually interact.


Testing touch and after_commit

We were still confused. When I asked Claude directly whether after_commit always fires when a record is touched, Claude confidently said no — though it couldn't give a convincing reason. That was enough to console test it ourselves:

def recalculate_totals
  HOGEHOGE I BREAK   # NameError if this line is ever reached
  if (!destroyed? && saved_change_to_something?) || ...
Enter fullscreen mode Exit fullscreen mode

We destroyed a line item. No error appeared. The callback wasn't firing.


The Counter Cache Bundling Discovery

With the broken code in place, we ran the real test. Destroying a line item:

  • Did NOT fire after_commit hook recalculate_totals on OrderItem
  • DID fire after_commit hook update_fulfillment_status on Order (verified by adding broken code there too)

This seemed contradictory. Both models have belongs_to ... touch: true. Why does one fire and the other not?

The answer is in the SQL log labels:

  • OrderItem Update All — raw SQL update_all, counter cache bundled with touch → no after_commit
  • Order Update — standard AR operation → after_commit fires

When LineItem is destroyed, its belongs_to :order_item, counter_cache: true, touch: true bundles the counter decrement AND the timestamp update into a single raw SQL UPDATE ALL. This is completely invisible to the AR callback system. OrderItem was never "saved" through the AR lifecycle, so its after_commit callbacks never fire.

Order is a different story. No counter is being decremented on Order when a line item is destroyed — only OrderItem's line item count changes. So OrderItem's own belongs_to :order, touch: true fires as a plain AR touch — which IS an AR operation, DOES register the record for after_commit, and DOES fire the callbacks.


Wrong Assumption #2: OrderItem.no_touching Was the Right Target

The rake task had OrderItem.no_touching. But now the picture was clear:

  • OrderItem's callbacks don't fire because of counter cache bundling — no_touching here does nothing meaningful
  • Order's callbacks DO fire via the cascade touch — this is what needs blocking

The fix:

Order.no_touching { line_item.destroy! }
Enter fullscreen mode Exit fullscreen mode

Console testing confirmed: with Order.no_touching, the Order Update SQL doesn't appear in the logs at all. order.updated_at genuinely doesn't change. update_fulfillment_status doesn't fire.


The False Positive Spec

Our spec had an assertion that order_item.updated_at wouldn't change after the rake task. It was always passing — but for the wrong reason.

The counter cache SQL will always update order_item.updated_at. But the spec runs so fast that both the create! (in let!) and the destroy! happen within the same second, so the timestamp changes to the same value twice. The assertion passes not because nothing changed, but because timing luck made the before and after values equal.

We removed that test. We also added a test that waits a few seconds before checking updated_at, ensuring that even with time passing, the column does not change — ruling out any coincidence from rapid execution. The order.updated_at assertion is the meaningful one — it passes because Order.no_touching genuinely prevents the SQL from running, not because of a coincidence.


Why Does a Touch Count as an :update Event?

One more thing worth noting: after_commit :update_fulfillment_status, on: %i[create update] uses an on: guard. We initially wondered if a touch might not qualify as an :update event for this purpose.

Testing proved otherwise. When order.touch fires via AR (no counter cache bundling), Rails registers it as an :update lifecycle event on the Order instance. after_commit on: :update fires. A line item being destroyed becomes an update on its grandparent, from Rails' perspective.


Key Learnings

belongs_to counter_cache: true, touch: true bundles into UPDATE ALL

Rails merges the counter decrement and the timestamp update into a single raw SQL statement. This bypasses the AR callback system entirely. after_commit callbacks on the parent will NOT fire when a line item is destroyed.

A plain touch (no counter cache) DOES fire after_commit

When only touch: true is set (no counter cache being decremented), the touch goes through the AR touch method, registers the record for after_commit, and counts as an :update event. after_commit on: %i[create update] fires.

The cascade matters at each hop

Even if OrderItem's callbacks are shielded by counter cache bundling, Order's callbacks can still fire via the next touch hop — because that hop has no counter being decremented.

no_touching blocks AR-level touches only

It prevents AR from issuing the UPDATE ... SET updated_at = ... SQL. It cannot prevent raw SQL UPDATE ALL from the counter cache.

Always console test callback assumptions

Rails callback behavior with touch, counter_cache, and after_commit has surprising interactions that aren't obvious from reading the code. The only reliable way to verify is to deliberately break something and watch whether the error propagates.

Always verify what Claude tells you

Claude is eager to explain unexpected behavior and will often provide a confident, plausible-sounding hypothesis before fully reasoning through the problem. Treat every Claude explanation as a starting point for investigation, not a conclusion.


Final Implementation

LineItem.find_each do |line_item|
  Order.no_touching { line_item.destroy! }
end
Enter fullscreen mode Exit fullscreen mode
  • CarrierWave's after_destroy handles S3 cleanup (original + three thumbnail versions)
  • dependent: :destroy on has_one :line_item_detail handles the associated detail record
  • Order.no_touching blocks the touch cascade that would fire expensive callbacks on Order
  • OrderItem's callbacks are naturally shielded by the counter cache bundling — no extra work needed

Source: dev.to

arrow_back Back to Tutorials