Ractor‑shareable TaggedLogging の導入
ActiveSupport::TaggedLogging に Ractor で安全に共有できるロガー生成メソッドが追加され、マルチ Ractor アプリケーションでも従来と同等のタグ付けロギングが利用可能になりました。この変更により、ActiveSupport::Ractors::Logger を内部に持つロガーを任意の Ractor から呼び出せるようになります。
背景
Ruby 4.0 で導入された Ractor はスレッドに代わる並行実行モデルとして期待されていますが、標準の Logger は内部でミューテックスを使用しているため、複数 Ractor から直接書き込みたときに安全性が保証されませんでした。Rails のロギングは ActiveSupport::TaggedLogging を介してタグ付けされることが前提となっており、Ractor 環境で同様の機能を提供する手段が欠如していました。この PR は、Ractor でもタグ付きロギングを継続利用できるようにすることを目的としています。
技術的な変更
ActiveSupport::Ractors 名前空間にロガー関連クラスを追加し、autoload による遅延ロードを設定しました。
module ActiveSupport
# Shims for +Ractor+ shareability methods so framework code can call them
# unconditionally regardless of the Ruby version.
module Ractors # :nodoc:
extend ActiveSupport::Autoload
autoload :Logger
class << self
attr_accessor :unshareable_proc_action
ActiveSupport::Ractors::Logger は ActiveSupport::Logger を継承し、DeviceProxy をログデバイスとして利用します。コンストラクタは従来の引数に加えて logdev_options を受け取り、バックグラウンドの Writer へ委譲します。
module ActiveSupport
module Ractors # :nodoc:
# A Ractor-shareable +ActiveSupport::Logger+.
class Logger < ActiveSupport::Logger # :nodoc:
def initialize(*args, level: ::Logger::DEBUG, progname: nil, formatter: nil, datetime_format: nil, **logdev_options)
super(nil, level: level, progname: progname, formatter: formatter, datetime_format: datetime_format)
@logdev = DeviceProxy.new(*args, **logdev_options)
end
delegate :flush, to: :@logdev
extend ActiveSupport::Autoload
autoload :DeviceProxy
autoload :Writer
end
end
end
DeviceProxy は実際の書き込みを Writer の Ractor に非同期で送信し、write はバイト数を返すだけです。また flush、close、reopen も適切に委譲します。
module ActiveSupport
module Ractors # :nodoc:
class Logger # :nodoc:
class DeviceProxy # :nodoc:
def initialize(*args, **logdev_options)
@writer = Writer.spawn(*args, **logdev_options)
@closed = false
end
def write(message)
return if @closed
@writer.async(message)
message.bytesize
end
def flush
@writer.flush unless @closed
true
end
# ... (close, reopen omitted for brevity)
end
end
end
end
Writer は Ractor::Port を用いてメッセージキューを構築し、async で書き込み要求を受け取り、call で同期的操作(flush, reopen など)を実行します。ポートが閉じられた場合は例外を捕捉し、呼び出し側にエラーを伝搬しません。
module ActiveSupport
module Ractors # :nodoc:
class Logger # :nodoc:
class Writer # :nodoc:
def self.spawn(...)
new.spawn(...)
end
def initialize
unless defined?(::Ractor::Port)
raise NotImplementedError, "ActiveSupport::Ractors::Logger requires Ractor::Port support"
end
@port = ::Ractor::Port.new
end
# ... (spawn, async, call, flush, reopen omitted for brevity)
end
end
end
end
最後に ActiveSupport::TaggedLogging.ractor_logger が公開インターフェースとして追加され、従来の logger と同様のシグネチャで呼び出せます。
class << self
def logger(*args, **kwargs)
new ActiveSupport::Logger.new(*args, **kwargs)
end
# Returns a logger that can be used from Ractors. Accepts the same arguments
# as Logger.new. The returned logger is an ActiveSupport::Ractors::Logger
# (a ::Logger subclass whose device proxies writes to a background Writer)
# wrapped with tagged logging, so it can be made shareable and used from any
# Ractor.
def ractor_logger(*args, **kwargs) # :nodoc:
new(ActiveSupport::Ractors::Logger.new(*args, **kwargs))
end
end
設計判断
Ractor‑shareable ロガーは既存の ActiveSupport::Logger を継承し、内部だけを置き換える形で実装 されています。このアプローチにより、外部からは従来通り ::Logger のインターフェースを利用でき、既存コードとの互換性が保たれます。新しいクラスは :nodoc で隠蔽され、公開は ActiveSupport::TaggedLogging.ractor_logger のみとすることで API の肥大化を防いでいます。
また、autoload と DeviceProxy / Writer の分割により、Ractor が利用できない環境では NotImplementedError を早期に発生させ、起動時の失敗を防止しています。これにより、Ractor 非対応の Ruby バージョンでも安全にデプロイ可能です。さらに、flush を Rails のリクエストサイクルに合わせて呼び出す設計は、既存のロガー契約を尊重した上で非同期化を導入した点が特徴です。
まとめ
今回の PR は、ActiveSupport::TaggedLogging.ractor_logger というシンプルな入口を通じて、Ractor 環境でも安全にタグ付きロギングを利用できる基盤を提供します。内部実装は ActiveSupport::Ractors::Logger とその DeviceProxy/ Writer に分離され、非同期書き込みとリクエスト単位のフラッシュを保証。既存コードへの影響は最小限に抑えつつ、マルチ Ractor アプリケーションへの拡張性を大きく向上させました。