default_scope を Ractor セーフにする変更
ActiveRecord の default_scope が Ractor 環境で安全に共有できるよう、スコープオブジェクトをデフォルトで凍結し、プロックを共有可能に変換する処理が追加されました。これにより、マルチスレッド・マルチプロセス環境でのデフォルトスコープ利用時にデータ競合やコピーコストが排除されます。
背景
default_scope は内部でプロックを保持し、クエリ生成時に instance_exec で評価されますが、Ractor 間でそのプロックが非共有(unshareable)であると例外が発生します。Rails 7 以降で Ractor の利用が推奨される中、デフォルトスコープが原因でアプリケーションが起動できないケースが報告されました。今回の PR はその問題を根本的に解決し、Ractor との互換性を確保することを目的としています。
技術的な変更
DefaultScope のコンストラクタでプロックを ActiveSupport::Ractors.try_shareable_proc にラップし、生成されたインスタンスを freeze しています。これにより、作成時点でオブジェクトが不変となり、Ractor 間で安全に共有できます。
class DefaultScope # :nodoc:
attr_reader :scope, :all_queries
def initialize(scope, all_queries = nil)
@scope = ActiveSupport::Ractors.try_shareable_proc(scope)
@all_queries = all_queries
freeze
end
end
クラス属性 default_scopes のデフォルト値を [].freeze に変更し、配列自体も不変にしました。これにより、スコープが追加されるたびに新しい配列を生成し、古い配列は変更不能となります。
included do
# Stores the default scope for the class.
class_attribute :default_scopes, instance_writer: false, instance_predicate: false, default: [].freeze
end
default_scope メソッドの実装を更新し、self.default_scopes = [*default_scopes, default_scope].freeze という形で配列を再構築して凍結しています。従来のミュータブルな += 操作を排除し、すべてのスコープ集合が共有可能であることを保証します。
def default_scope(scope = nil, all_queries: nil, &block) # :doc:
if scope
default_scope = DefaultScope.new(scope, all_queries)
self.default_scopes = [*default_scopes, default_scope].freeze
end
...
end
テスト側では ActiveSupport::Testing::RactorsAssertions を導入し、assert_ractor_shareable マクロで default_scopes が Ractor 共有可能であることを検証しています。新規テストは Ractor コンテキスト内でモデルを定義し、default_scope が正しく評価されることも合わせて確認します。
require "active_support/testing/ractors_assertions"
require "active_support/core_ext/object/with"
class DefaultScopingTest < ActiveRecord::TestCase
include ActiveSupport::Testing::RactorsAssertions
...
def test_default_scopes_are_ractor_shareable
ActiveSupport::Ractors.with(unshareable_proc_action: :raise) do
model = Class.new(ActiveRecord::Base) do
def self.name = "ractor_safe_posts"
self.table_name = "posts"
default_scope -> { ractor_safe }
def self.ractor_safe
where(type: "ractor_safe")
end
end
select_sql = capture_sql { model.all.to_a }.first
assert_match(/type/, select_sql)
assert_ractor_shareable model.default_scopes
end
end
end
設計判断
本変更は 「不変オブジェクト化」 と 「プロックの共有可能化」 という二本柱で実装されています。freeze による不変化は既存の API 挙動を変えず、単に内部データ構造の安全性を高めるだけで済みます。また、try_shareable_proc を使用することで、非共有プロックが渡された場合は例外を即座に通知し、開発者が早期に問題を検出できるようにしています。配列操作をミュータブルからイミュータブルに切り替える設計は、Ractor が前提となる環境でのデータ競合を根本的に防止するための最小侵襲なアプローチです。
まとめ
default_scope が Ractor 安全になることで、Rails アプリケーションはマルチプロセス実行時にスコープ定義の共有が保証され、予期しない例外やパフォーマンス低下を回避できます。変更は不変化と共有可能プロックの導入に絞られ、既存コードへの影響は最小限に抑えられています。