Message associations eager‑loaded in to_llm to eliminate N+1 queries

crmne/ruby_llm

ChatMethods#to_llm でメッセージを取得するたびに関連レコードを個別にロードしていたため、Rails の strict_loading_by_default = true 環境下で ActiveRecord::StrictLoadingViolationError が頻発していました。本 PR はその N+1 クエリを単一のプリロードで解消し、パフォーマンスと堅牢性を向上させます。

背景

従来の実装では messages_association.to_a でメッセージを取得し、各メッセージの tool_callsparent_tool_callmodelMessage#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_callsparent_tool_callmodel の3つの関連を一括でプリロードします。messages_association が ActiveRecord のリレーションでない場合は従来通り .to_a を返すフォールバックロジックを備え、テスト用スタブへの影響を防ぎます。

to_llmcleanup_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_messagesActiveRecord::Associations::Preloader に依存する点がありますが、これは標準的なプリロード手法であり、追加の gem や外部依存を導入しません。また、プリロード対象の関連が将来的に増える場合は配列 associations に項目を追加すれば拡張可能です。

結論は、N+1 クエリの根源を集中管理されたヘルパーで解決したことで、strict_loading がデフォルトでも例外が発生せず、安全に LLM との対話を処理できるようになった点です。コードベースのシンプルさとパフォーマンス向上のバランスが取れた実装と言えます。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
2e792389

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文、背景、技術的変更、設計判断、結論が明確に記載されており、全体が総論→各論→結論の流れになっている。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

コードブロックは `ruby:ファイルパス` 形式で正しく記述され、PRリンクは `[PR #717](URL)` と正しいGitHubリンク記法が使用されている。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

専門的なRails/ActiveRecordの知識を前提とした内容で、不要な初心者向け解説は含まれていない。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションは総論・各論・結論の段落構成で、段落はトピックセンテンスで始まり1段落1トピック、6文以下に収まっている。空行で区切られている。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事中のコードブロックは提供されたDiffと完全に一致し、ファイル名・変更内容も合致している。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

使用されている技術用語はPRおよびDiffと一致し、誤用や不適切な表現はない。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

技術的な説明はPRの目的とDiffの実装に沿っており、因果関係も正しく述べられている。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事の全ての主張はPRのタイトル、説明、Diffで裏付けられており、根拠のない推測はない。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号 #717 の記載が正確で、その他数値・固有名詞の誤りは見られない。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事タイトルはPRタイトルと意味的に一致しており、内容の食い違いはない。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

記事内にPRに記載されていない外部知識(バージョンサポート等)は含まれていない。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現に関する記述はなく、PR情報と矛盾する表現もない。