From 99ed1a429c173836b8b18e14d61c936e945c9e2a 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 | 20 +++++++++- spec/ruby_llm/active_record/acts_as_spec.rb | 42 +++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/lib/ruby_llm/active_record/chat_methods.rb b/lib/ruby_llm/active_record/chat_methods.rb index f595ab393..99381f176 100644 --- a/lib/ruby_llm/active_record/chat_methods.rb +++ b/lib/ruby_llm/active_record/chat_methods.rb @@ -61,7 +61,7 @@ def to_llm provider: model_record.provider.to_sym, assume_model_exists: assume_model_exists || false ) - @chat.messages = messages_association.to_a + @chat.messages = eager_load_messages reapply_runtime_instructions(@chat) setup_persistence_callbacks @@ -282,7 +282,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? @@ -301,6 +301,22 @@ def cleanup_orphaned_tool_results # rubocop:disable Metrics/PerceivedComplexity end end + 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 + def find_or_create_model_record(model_info) model_class = self.class.model_class.constantize model_class.find_or_create_by!( diff --git a/spec/ruby_llm/active_record/acts_as_spec.rb b/spec/ruby_llm/active_record/acts_as_spec.rb index ac756897a..ec761d4ae 100644 --- a/spec/ruby_llm/active_record/acts_as_spec.rb +++ b/spec/ruby_llm/active_record/acts_as_spec.rb @@ -1136,6 +1136,48 @@ 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(:strict_loading_state) { {} } + + 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 + + before do + strict_loading_state[:by_default] = ApplicationRecord.strict_loading_by_default + strict_loading_state[:mode] = ApplicationRecord.strict_loading_mode + + ApplicationRecord.strict_loading_by_default = true + ApplicationRecord.strict_loading_mode = :n_plus_one_only + end + + after do + ApplicationRecord.strict_loading_by_default = strict_loading_state[:by_default] + ApplicationRecord.strict_loading_mode = strict_loading_state[:mode] + 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