ZeitwerkのEager LoadからActiveRecord統合を切り離す
ruby_llmのコアgemがZeitwerk::Loader.eager_load_allを呼ばれた際にActiveRecord統合コードを読み込んでしまうバグが修正されました。lib/ruby_llm/active_recordディレクトリをZeitwerkのignore対象とし、ロード責任をRailtieに明示的に委譲することで、スタンドアロン環境とRails環境の境界を明確にしています。
背景
lib/ruby_llm/active_recordはオプショナルなRails統合コードであるにもかかわらず、gemレベルのZeitwerkローダーの管理下に置かれていたため、Rails未起動環境でのeager loadが意図しない動作を引き起こしていました。
具体的には、bundle exec appraisal rails-7.2 ruby -e "require 'ruby_llm'; Zeitwerk::Loader.eager_load_all"を実行した際に、ActiveRecordがロードされていない状態でActiveRecord統合ファイルが読み込まれてしまいました。その結果、RubyLLM::ActiveRecord::ModelMethodsがActiveSupportのdelegateを使用しているにもかかわらず、そのrequireが済んでいないためNoMethodError: undefined method 'delegate' for module RubyLLM::ActiveRecord::ModelMethodsが発生していました。
直接的な原因は単一のrequire漏れでしたが、本質的な問題はActiveRecordディレクトリ全体がgemのZeitwerkオートロード対象に含まれていたことです。lib/ruby_llm.rbではtasks、generators、railtie.rbはすでにignore指定されており、active_recordディレクトリだけが例外的に漏れていた状態でした。
技術的な変更
この修正は4つの部分から構成されており、ロード境界の再定義とその副作用への対処を段階的に行っています。
① lib/ruby_llm/active_recordをZeitwerkのignore対象に追加
lib/ruby_llm.rbに1行追加することで、gemレベルのZeitwerkローダーがActiveRecordディレクトリを管理しないようにしました。
loader.ignore("#{__dir__}/tasks")
loader.ignore("#{__dir__}/generators")
loader.ignore("#{__dir__}/ruby_llm/active_record") # 追加
loader.ignore("#{__dir__}/ruby_llm/railtie.rb")
loader.setup
② lib/ruby_llm.rbからのActiveRecord直接requireを削除
変更前はRails::Railtieが定義されている場合にrailiteとacts_asの両方をrequireしていましたが、後者を削除してRailtieのみをrequireするように変更しました。
変更前:
if defined?(Rails::Railtie)
require 'ruby_llm/railtie'
require 'ruby_llm/active_record/acts_as'
end
変更後:
require 'ruby_llm/railtie' if defined?(Rails::Railtie)
③ RailtieがActiveRecord統合のロードを明示的に担当
lib/ruby_llm/railtie.rbのActiveSupport.on_load :active_recordブロック内で、ActiveRecord統合ファイルを明示的にrequireするよう変更しました。これにより、ActiveRecordがロードされるタイミングで必要なファイルが確実に読み込まれます。
initializer 'ruby_llm.active_record' do
ActiveSupport.on_load :active_record do
require 'ruby_llm/active_record/payload_helpers'
require 'ruby_llm/active_record/chat_methods'
require 'ruby_llm/active_record/message_methods'
require 'ruby_llm/active_record/model_methods'
require 'ruby_llm/active_record/tool_call_methods'
if RubyLLM.config.use_new_acts_as
require 'ruby_llm/active_record/acts_as'
::ActiveRecord::Base.include RubyLLM::ActiveRecord::ActsAs
# ...
end
end
end
④ 各ActiveRecordファイルに明示的なrequireを追加
Zeitwerkのオートロードに依存していた暗黙の依存関係を解消するため、各ファイルに必要なrequireを追加しました。Zeitwerk ignoreによってオートロードフォールバックが無効になるため、各ファイルが独立してロード可能である必要があります。
| ファイル | 追加されたrequire |
|---|---|
acts_as.rb |
active_support/concern, active_support/inflector
|
acts_as_legacy.rb |
active_support/concern, active_support/inflector
|
chat_methods.rb |
active_support/concern |
message_methods.rb |
active_support/concern, ruby_llm/active_record/payload_helpers
|
model_methods.rb |
active_support/concern, active_support/core_ext/module/delegation
|
payload_helpers.rb |
active_support/core_ext/object/blank, json
|
tool_call_methods.rb |
active_support/concern, ruby_llm/active_record/payload_helpers
|
設計判断
「Zeitwerk ignoreによるロード境界の明示」という設計方針が採用されました。
代替案として、各ファイルへのrequire追加だけで対処することも考えられましたが、それではZeitwerkの管理下に置きながらrequireを手動で補完するという矛盾した状態が続きます。このPRでは、オプショナルな統合コードをZeitwerkの管理外に置くという明確な境界を設け、ロードの責任をRailtieに一元化しています。
この判断は既存のコードとも一貫しています。tasks、generators、railtie.rbがすでにignore指定されているのと同じ扱いをActiveRecordディレクトリに適用したに過ぎず、「コアgemのオートロード対象外のものはignoreする」というルールが統一されました。
CIマトリクスにeager-loadガードが追加されたことも重要な設計判断です。bundle exec appraisal ${{ matrix.rails-version }} ruby -e "require 'ruby_llm'; Zeitwerk::Loader.eager_load_all"を各Railsバージョンで実行することで、将来のリグレッションをCI段階で検出できるようになりました。
まとめ
本PRは、Zeitwerkのignore1行と明示的なrequireの追加という最小限の変更で、オプショナルなRails統合コードのロード境界を正しく再定義しています。スタンドアロンeager loadの安全性を担保しつつ、RailsアプリでのActiveRecord統合の動作は変更していないため、既存ユーザーへの影響を抑えながらgemのロードセマンティクスを堅牢にした変更といえます。