Action View テンプレートハンドラレジストリを Ractor 共有可能に
Action View のテンプレートハンドラレジストリをクラス変数からモジュールインスタンス変数へ移行し、Ractor 間で安全に共有できるようにしました。これにより、テンプレート検索やレンダリング時にレジストリへの書き込みが排除され、マルチ Ractor 環境でも安定して動作します。
背景
Ractor がマルチスレッドに代わる並行実行モデルとして利用される場所が増えているため、共有可能なデータ構造の整備が求められました。従来、ActionView::Template::Handlers は @@template_handlers などのクラス変数でハンドラを保持しており、別スレッドや Ractor から参照した際に潜在的な書き込み競合が発生するリスクがありました。レジストリを不変にすることで、読み取りだけがロックフリーで行える設計へと転換する必要がありました。結果として、レジストリの状態管理をモジュールインスタンス変数へ移行し、全体を freeze した不変オブジェクトに置き換えました。
技術的な変更
actionview/lib/action_view/template/handlers.rb ではクラス変数を廃止し、モジュールレベルのインスタンス変数を初期化しています。
module Handlers # :nodoc:
@template_handlers = {}.freeze
@template_extensions = [].freeze
@default_template_handler = nil
class << self
attr_reader :template_handlers, :default_template_handler
def extensions
@template_extensions
end
def register(*extensions, handler)
handler = ActiveSupport::Ractors.try_shareable_proc(handler) if handler.is_a?(Proc)
handlers = @template_handlers.dup
extensions.each { |ext| handlers[ext.to_sym] = handler }
@template_handlers = handlers.freeze
@template_extensions = @template_handlers.keys.freeze
end
def register_default(extension, handler)
register(extension, handler)
end
def unregister(*extensions)
handlers = @template_handlers.dup
extensions.each { |ext| handlers.delete(ext.to_sym) }
@template_handlers = handlers.freeze
@template_extensions = @template_handlers.keys.freeze
end
end
end
レジストリへの書き込みは コピーオンライト と freeze により不変化を保証し、register 呼び出し時に新しいハッシュを生成して置き換えます。handler が Proc の場合は ActiveSupport::Ractors.try_shareable_proc で共有可能な形に変換し、ビルトインハンドラはモジュールロード時に直接登録されます。
ビルトインハンドラの登録は self.extended フックを削除し、ロード時に以下のように行われます。
Handlers.register_default :raw, Raw.new
Handlers.register_default :erb, ERB.new
Handlers.register_default :html, Html.new
Handlers.register_default :builder, Builder.new
Handlers.register_default :ruby, ActiveSupport::Ractors.shareable_lambda { |_, source| source }
actionview/lib/action_view/template/handlers/builder.rb では、ハンドラがフリーズされた状態でも安全に builder ライブラリをロードできるよう、キャッシュ用インスタンス変数を除去し defined? ガードに置き換えました。
class Builder # :nodoc:
class_attribute :default_format, default: :xml
def call(template, source)
require "builder" unless defined?(::Builder)
"xml = xml = ::Builder::XmlMarkup.new(indent: 2, target: output_buffer.raw);" \
"#{source};" \
"output_buffer.to_s"
end
end
テストコード actionview/test/template/handlers_test.rb が新たに追加され、レジストリと拡張子リストが Ractor 共有可能であること、Ractor 内からハンドラが取得できること、そして Proc ハンドラ登録後もレジストリが共有可能であることを検証しています。
class TemplateHandlersTest < ActiveSupport::TestCase
include ActiveSupport::Testing::Isolation
include ActiveSupport::Testing::RactorsAssertions
def test_built_in_handler_registry_is_ractor_shareable
assert_ractor_shareable ActionView::Template::Handlers.template_handlers
assert_ractor_shareable ActionView::Template::Handlers.extensions
end
def test_registering_a_proc_handler_keeps_the_registry_shareable
ActiveSupport::Ractors.with(unshareable_proc_action: :raise) do
ActionView::Template.register_template_handler :custom, lambda { |_, src| src }
assert_ractor_shareable ActionView::Template::Handlers.template_handlers
end
ensure
ActionView::Template.unregister_template_handler :custom
end
end
これらの変更に伴い、actionview/test/template/render_test.rb でもレジストリ削除の手段が unregister_template_handler に置き換えられ、テストが不変レジストリに適合するよう修正されています。
設計判断
不変コピーオンライト + freeze の組み合わせは、Ractor が要求する「共有可能オブジェクトは不変」であるという前提と完全に合致します。ロックや同期機構を導入せずに読み取り専用にすることで、マルチ Ractor 環境でのオーバーヘッドを最小化しています。代替としては Concurrent::Map などのスレッド安全コレクションが考えられましたが、既存 API への影響を抑える点で今回の実装が選択されました。
ハンドラ自体を freeze しつつ、ActiveSupport::Ractors.shareable_lambda と try_shareable_proc を介して Proc オブジェクトを共有可能に変換する方針は、ユーザーが独自ハンドラを追加した際にも同様の安全性が保証される点がメリットです。これにより、アプリケーション側のコード変更は不要で、内部で自動的に共有化が行われます。
レジストリ状態管理を Handlers モジュールに集約し、self.extended フックを廃止したことは、初期化順序の曖昧さを解消し、ロード時に必ずハンドラが登録されることを保証します。結果として、テストや実行時に期待通りのハンドラが存在することが明確になり、コードベースの予測可能性が向上しました。
まとめ
この PR は Action View のテンプレートハンドラレジストリを不変かつ Ractor 共有可能な形に刷新し、クラス変数からモジュールインスタンス変数への移行、コピーオンライトと freeze による状態管理、そして共有可能な Proc 変換ロジックを導入しました。変更は既存の API にほとんど影響せず、マルチ Ractor 環境でも安全にハンドラ検索とテンプレートレンダリングが行えるようになっています。