ClaudeがRailsのコールバックについて「知っている」と思い込んでいたこと
LineItemレコードとそのS3ファイルを削除するためのrakeタスクを実行しようとしました。Orderのような親モデルでの高コストなコールバックを避けたいと考えていました。
Claudeに助けを求めました。自信満々な回答が返ってきましたが、それは間違っていました。
Rails、counter_cache、そしてなぜAIのアドバイスを検証しなければならないのかについて学んだことを紹介します。
𝗧𝗵𝗲 𝗣𝗿𝗼𝗯𝗹𝗲𝗺
LineItemはOrderItemに属し、OrderItemはOrderに属しています。両方とも counter_cache と touch を使用しています。
LineItemを削除すると連鎖(cascade)が発生します。この連鎖により、配送見積もりや合計金額の再計算といった重いジョブが実行されます。
CPUとS3のコストを節約するために、この連鎖を止める必要がありました。
𝗧𝗵𝗲 𝗔𝗜 𝗠𝗶𝘀𝘁𝗮𝗸𝗲
Claudeは skip_callback を使うよう提案してきました。
これは良くないアイデアです。skip_callback はクラスをグローバルに変更してしまいます。アプリ内のすべてのスレッドに影響を与えます。もし再有効化する前にコードがクラッシュした場合、コールバックは死んだままになってしまいます。
次に no_touching を試しました。念のため、OrderItemとOrderの両方の呼び出しをラップしました。
テストはパスしましたが、コンソールでは異なる結果が表示されました。Orderのタイムスタンプが依然として変わっていたのです。
𝗧𝗵𝗲 𝗥𝗲𝗮𝗹 𝗥𝗲𝗮𝘀𝗼𝗻
問題は、counter_cache が touch とどのように組み合わさって動作するかでした。
counter_cache: trueとtouch: trueを併用すると、Railsはこれらをひとまとめにします。- 単一の生のSQL
UPDATE ALLコマンドを実行します。 - 生のSQLはActiveRecordのライフサイクルをバイパスします。
- ライフサイクルをバイパスするため、
after_commitフックが実行されません。
これにより、奇妙なパラドックスが生じました:
- 生のSQLによる一括処理のため、OrderItemのコールバックは実行されませんでした。
- しかし、チェーンの次のステップが通常の
touchであったため、Orderのコールバックは実行されました。
𝗧𝗵𝗲 𝗙𝗶𝘅
祖父母モデル(grandparent)を no_touching でラップするだけで十分でした。
Order.no_touching { line_item.destroy! }
これにより、ActiveRecordレベルの touch がOrderモデルに到達するのを阻止できます。OrderItemに対する生のSQLは止まりませんが、counter_cacheの一括処理ですでにそれらのコールバックはスキップされているため、問題ありません。
𝗞𝗲𝘆 𝗧𝗮𝗸𝗲𝗮𝘄𝗮𝘆𝘀
counter_cache: true+touch: true= 生のSQLUPDATE ALL。- 生のSQLはすべての
after_commitフックをスキップします。 - (counter_cacheのない)通常の
touchは標準的なActiveRecordのライフサイクルに従い、コールバックを実行します。 - AIのコードを盲信してはいけません。Claudeは答えを出そうとしますが、その答えがハルシネーション(幻覚)であるかどうかは気にしません。
常にRailsコンソールで自分の仮説をテストしてください。意図した動作が本当に止まるかどうかを確認するために、あえてコードを壊してみることも大切です。
Optional learning community: https://t.me/GyaanSetuAi