Message associations eager‑loaded in to_llm to eliminate N+1 queries
ChatMethods#to_llm でメッセージを取得するたびに関連レコードを個別にロードしていたため、Rails の strict_loading_by_default = true 環境下で ActiveRecord::StrictLoadingViolationError が頻発していました。本 PR はその N+1 クエリを単一のプリロードで解消し、パフォーマンスと堅牢性を向上させます。
背景
従来の実装では messages_association.to_a でメッセージを取得し、各メッセージの tool_calls、parent_tool_call、model を Message#to_llm が参照するたびに個別の SQL が発行されていました。結果としてメッセージ件数が増えるほどクエリ数が比例し、strict_loading が有効なテストや本番環境で StrictLoadingViolationError が発生していました。
この問題は ChatMethods#cleanup_orphaned_tool_results でも同様に顕在化しており、最後のメッセージだけを取得して tool_call? や tool_result? を確認する際にも追加のクエリが走っていました。N+1 パターンはコード上からは見えにくく、パフォーマンス測定だけでは捕捉しにくい典型的なアンチパターンです。
修正の必要性は、Rails が提供する strict_loading 機能と整合性を保ちつつ、既存の公開 API を変更せずに内部処理だけでクエリ数を抑える点にあります。これにより開発者は設定を変えることなく安全に to_llm を利用できます。
技術的な変更
新しく導入された eager_load_messages ヘルパーは、メッセージコレクションを取得した後に ActiveRecord::Associations::Preloader を用いて tool_calls、parent_tool_call、model の3つの関連を一括でプリロードします。messages_association が ActiveRecord のリレーションでない場合は従来通り .to_a を返すフォールバックロジックを備え、テスト用スタブへの影響を防ぎます。
to_llm と cleanup_orphaned_tool_results の変更は次のとおりです。
@@
- @chat.messages = messages_association.to_a
+ @chat.messages = eager_load_messages
@@
- last = messages_association.order(:id).last
+ last = eager_load_messages.max_by(&:id)
eager_load_messages の実装は以下のようになります。
def eager_load_messages
assoc = messages_association
messages = assoc.to_a
return messages unless assoc.respond_to?(:klass)
msg_class = assoc.klass
associations = [
msg_class.tool_calls_association_name,
:parent_tool_call,
msg_class.model_association_name
].compact
::ActiveRecord::Associations::Preloader.new(records: messages, associations: associations).call
messages
end
影響範囲は ChatMethods 内部だけに留まり、外部から呼び出される公開インターフェースは変更していません。messages_association が偽装オブジェクトの場合は従来通り単一取得になるため、既存テストやスタブコードはそのまま動作します。
設計判断
設計方針は「最小侵入かつ後方互換性を維持」することでした。新しいメソッドをプライベートヘルパーとして追加し、既存の to_llm ロジックに差し替えるだけで実装を完結させました。これにより、呼び出し側のコードは変更不要で、Rails の strict_loading と自然に連携できます。
トレードオフとしては、eager_load_messages が ActiveRecord::Associations::Preloader に依存する点がありますが、これは標準的なプリロード手法であり、追加の gem や外部依存を導入しません。また、プリロード対象の関連が将来的に増える場合は配列 associations に項目を追加すれば拡張可能です。
結論は、N+1 クエリの根源を集中管理されたヘルパーで解決したことで、strict_loading がデフォルトでも例外が発生せず、安全に LLM との対話を処理できるようになった点です。コードベースのシンプルさとパフォーマンス向上のバランスが取れた実装と言えます。