From b47b15c7a5cdac0d80441bfd05356d104e233aa2 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Thu, 2 Apr 2026 09:23:53 -0600 Subject: [PATCH] Eager-load message associations in to_llm to prevent N+1 queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatMethods#to_llm loads all messages then iterates calling msg.to_llm on each, which accesses tool_calls, parent_tool_call, and model associations per message — causing N+1 queries. This is invisible without strict_loading, but with Rails' strict_loading_by_default = true and :n_plus_one_only mode, every call to conversation.ask raises StrictLoadingViolationError. Fix by adding an eager_load_messages helper that preloads all associations accessed by Message#to_llm in a single query. Applied to both to_llm and cleanup_orphaned_tool_results. --- lib/ruby_llm/active_record/chat_methods.rb | 14 ++++++-- spec/ruby_llm/active_record/acts_as_spec.rb | 37 +++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/lib/ruby_llm/active_record/chat_methods.rb b/lib/ruby_llm/active_record/chat_methods.rb index 75a14afd4..987baa2e4 100644 --- a/lib/ruby_llm/active_record/chat_methods.rb +++ b/lib/ruby_llm/active_record/chat_methods.rb @@ -84,7 +84,7 @@ def to_llm ) @chat.reset_messages! - ordered_messages = order_messages_for_llm(messages_association.to_a) + ordered_messages = order_messages_for_llm(eager_load_messages) ordered_messages.each do |msg| @chat.add_message(msg.to_llm) end @@ -236,7 +236,7 @@ def cleanup_failed_messages def cleanup_orphaned_tool_results # rubocop:disable Metrics/PerceivedComplexity messages_association.reload - last = messages_association.order(:id).last + last = eager_load_messages.max_by(&:id) return unless last&.tool_call? || last&.tool_result? @@ -255,6 +255,16 @@ def cleanup_orphaned_tool_results # rubocop:disable Metrics/PerceivedComplexity end end + def eager_load_messages + assoc = messages_association + return assoc.to_a unless assoc.respond_to?(:includes) + + msg_class = assoc.klass + tool_calls_name = msg_class.tool_calls_association_name + model_name = msg_class.model_association_name + assoc.includes(tool_calls_name, :parent_tool_call, model_name).to_a + end + def setup_persistence_callbacks return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup) diff --git a/spec/ruby_llm/active_record/acts_as_spec.rb b/spec/ruby_llm/active_record/acts_as_spec.rb index 1bd648ec9..92289cbcb 100644 --- a/spec/ruby_llm/active_record/acts_as_spec.rb +++ b/spec/ruby_llm/active_record/acts_as_spec.rb @@ -960,6 +960,43 @@ class ToolCall < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclara end end + describe 'strict_loading compatibility' do + # Verify that to_llm and cleanup_orphaned_tool_results eager-load message + # associations, preventing N+1 queries with strict_loading enabled. + + let(:chat_with_tool_calls) do + chat = Chat.create!(model: model) + chat.messages.create!(role: 'user', content: "What's 2 + 2?") + assistant_msg = chat.messages.create!(role: 'assistant', content: nil) + tool_call = assistant_msg.tool_calls.create!( + tool_call_id: 'call_strict_1', + name: 'calculator', + arguments: { expression: '2 + 2' }.to_json + ) + chat.messages.create!(role: 'tool', content: '4', parent_tool_call: tool_call) + chat.messages.create!(role: 'assistant', content: 'The answer is 4.') + chat + end + + around do |example| + chat_with_tool_calls # create data before enabling strict_loading + ApplicationRecord.strict_loading_by_default = true + ApplicationRecord.strict_loading_mode = :n_plus_one_only + example.run + ensure + ApplicationRecord.strict_loading_by_default = false + ApplicationRecord.strict_loading_mode = :all + end + + it 'to_llm does not raise StrictLoadingViolationError' do + expect { chat_with_tool_calls.reload.to_llm }.not_to raise_error + end + + it 'cleanup_orphaned_tool_results does not raise StrictLoadingViolationError' do + expect { chat_with_tool_calls.reload.send(:cleanup_orphaned_tool_results) }.not_to raise_error + end + end + describe 'extended thinking persistence' do def thinking_config_for(provider) case provider