Skip to content

Eager-load message associations in to_llm to prevent N+1 queries - #717

Merged
crmne merged 1 commit into
crmne:mainfrom
matthewbjones:fix/eager-load-message-associations
Jul 3, 2026
Merged

crmne merged 1 commit into
crmne:mainfrom
matthewbjones:fix/eager-load-message-associations

Conversation

@matthewbjones

Copy link
Copy Markdown
Contributor

What this does

ChatMethods#to_llm loads all messages via messages_association.to_a, then iterates calling msg.to_llm on each. Message#to_llm accesses tool_calls, parent_tool_call, and model associations per message — an N+1 query pattern.

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 ActiveRecord::StrictLoadingViolationError.

The same issue exists in cleanup_orphaned_tool_results, which reloads messages and accesses tool_call? / tool_result? on the last message without eager-loading.

Fix: add an eager_load_messages helper that preloads tool_calls, parent_tool_call, and model in a single query. Falls back to plain .to_a when messages_association is not an ActiveRecord relation (e.g. in test stubs).

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • Performance improvement

Scope check

  • I read the Contributing Guide
  • This aligns with RubyLLM's focus on LLM communication
  • This isn't application-specific logic that belongs in user code
  • This benefits most users, not just my specific use case

Quality check

  • I ran overcommit --install and all hooks pass
  • I tested my changes thoroughly
  • For provider changes: Re-recorded VCR cassettes with bundle exec rake vcr:record[provider_name]
  • All tests pass: bundle exec rspec
  • I updated documentation if needed
  • I didn't modify auto-generated files manually (models.json, aliases.json)

AI-generated code

  • I used AI tools to help write this code
  • I have reviewed and understand all generated code (required if above is checked)

API changes

  • Breaking change
  • New public methods/classes
  • Changed method signatures
  • No API changes

@crmne
crmne force-pushed the fix/eager-load-message-associations branch from cd02004 to 7746e37 Compare July 3, 2026 11:21
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.
@crmne
crmne force-pushed the fix/eager-load-message-associations branch from 7746e37 to 99ed1a4 Compare July 3, 2026 11:23
@crmne
crmne merged commit e3e432c into crmne:main Jul 3, 2026
2 of 21 checks passed
@crmne

crmne commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Thanks for your work!

MatheusRich added a commit to MatheusRich/ruby_llm that referenced this pull request Sep 4, 2026
Building a message payload walked each ActiveStorage::Attachment and
reached through it to the blob for the filename and the download, so a
message with four attachments spent four queries on
active_storage_blobs instead of one. The existence check in front of
the walk ran once per message too, which cost a query per message even
on a transcript with no attachments at all.

This commit preloads the blob for the attachment rows it is about to
read, and adds the attachment association to the transcript-wide
preload that crmne#717 introduced, so a whole conversation resolves its
attachments in two queries. A twenty-message chat with two attachments
each drops from 107 queries to 9.

Preloader is used rather than includes because it leaves an
application's own eager loading in place, which includes would discard
by building a fresh relation. The with_attached_ scope is not used for
the transcript preload because it also loads variant records and
preview images, which the payload never reads.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants