コンテンツにスキップ

Ractor移行 — 段階化されたプラン

ステータス: 草案。フェーズ1着地済み;後のフェーズは保留中。

RigorのアナライザーはCPUバウンドのRubyです。MRIのGVLがスレッド全体でRubyコード実行をシリアライズするため、Threadベースの並列性はrigor checkにwall-clockの恩恵を与えません(プロトタイプ化 + 差し戻し済み;docs/CURRENT_WORK.mdオープンエンジニアリング項目#7を参照)。マルチコア利用への2つの現実的なパスは、フォークベースのワーカーRactor。このドキュメントはRactorパスを計画します。

RactorはRactor境界を越えるすべてのオブジェクトがRactor.shareable?である必要があります。最終的なエンドステート — コア全体にanalyze_fileをディスパッチするRactor分離ワーカープール — は、(Configuration, Environment, Scope, Type, TypeNode, FlowContribution, Plugin)データサーフェス全体がその制約を満たすことを必要とします。作業は単一のコミットには大きすぎ、投機的に行うのはリスクが高いので、フェーズで着地します。各フェーズは独立して有用で独立して取り消し可能です。

フェーズ1 — 値オブジェクトの共有可能性(着地済み)

Section titled “フェーズ1 — 値オブジェクトの共有可能性(着地済み)”

ゴール: エンジンがディスパッチを通じて運ぶすべてのリーフ値オブジェクトは、構築時にRactor.shareable?

今日のカバレッジ:

  • Rigor::Type::* — すべてのTypeキャリア(16クラス)。すべて共有可能。
  • Rigor::TypeNode::* — すべてのパーサ側ASTノード。コンストラクタで内部のString / Arrayフィールドを凍結して共有可能にした(このコミット)。
  • Rigor::Cache::Descriptor — すでに共有可能。
  • Rigor::Analysis::FactStore.empty — すでに共有可能。
  • Rigor::FlowContribution — すでに共有可能。

リグレッションガード: spec/rigor/ractor_readiness_spec.rbはカバーされたリストのすべてのコンストラクタでRactor.shareable?をアサートする。監査specを更新せずに新しい値オブジェクトクラスを追加すると、将来のドリフトをキャッチする。

フェーズ2 — Configuration / Scope / Environment

Section titled “フェーズ2 — Configuration / Scope / Environment”

ゴール: ランタイムコンテキストキャリアオブジェクトが乗るものが共有可能になる。

3つのクラスがこれをブロック:

Rigor::Configuration(着地済み — フェーズ2a)

Section titled “Rigor::Configuration(着地済み — フェーズ2a)”

共有可能でなかった理由: @paths配列が凍結されておらず、Configuration#initializeselffreezeを呼んでいなかった。他のすべてのivarはすでに凍結されていた(Symbol / nil / Boolean、または明示的に凍結されたコレクション / 値オブジェクト)。

着地した修正: @pathsの構築に.freezeを追加し、initializeの最後にfreeze行を追加。後方互換 — プロダクションコードは構築後のConfigurationをミューテートせず、監査specは2行の変更直後にパスする。spec/rigor/ractor_readiness_spec.rbRigor::Configuration例がskipからパスするアサーションに切り替わる。

共有可能でない理由: Scope.emptyがデフォルトのEnvironmentを参照し、それは共有可能ではない(下記参照)。

修正: Environmentが共有可能になれば、Scopeが続く。Scope値オブジェクト自体はすでにディープフリーズされている。

共有可能でない理由: Environment#rbs_loaderは、ミューテーション可能なプロセスごとのキャッシュ(@class_known_cache@instance_definition_cache@singleton_definition_cache)を持つRbsLoaderインスタンスを運ぶ。これらのキャッシュはパフォーマンスに重要 — すべてのclass_known? / instance_definitionルックアップがそれらを通る。

競合: 凍結されたEnvironmentは可変キャッシュを運べない。Ractorごとのキャッシュはクロスファイル共有の恩恵を打ち破る。

解決スケッチ(実質的なリファクタ):

  1. RbsLoaderを2つのサーフェスに分割:
    • Reflection — 読み取り専用RBSクエリインターフェース(class_known?instance_definitionなど)。構築後に凍結。Environmentがこれを運ぶ。
    • CacheLayer — Reflectionをラップする可変メモ化レイヤー。実行中のRactorが所有し、Ractor間で共有されない。
  2. Environmentは凍結されたReflectionのみを運ぶ。Ractorごとに、Ractorは共有されたReflection + 共有されたCache::Store(すでにMonitor保護された@memoを持つ)を指す自身のCacheLayerを実体化する。
  3. class_known? / instance_definitionはRactorごとのCacheLayer経由でディスパッチ;キャッシュフィルはCache::Store(Marshal-cleanエントリー、Ractor寿命を越えて耐久)経由で伝播する。

推定サイズ: 大(約300〜500 LoC + spec)。

トレードオフチェック: Ractorごとのキャッシュレイヤーは、共有モデルではゼロに対してRactorあたり1つのコールドスタートを払う。ワーカープールがNファイルを処理する場合、ウォームアップコストは最初のファイル後に償却される。設計にコミットする前にプロファイル確認。

ゴール: プラグインがRactorワーカーから実行できる。

フェーズ3a — Plugin::Blueprint + 実体化ファクトリー(着地済み)

Section titled “フェーズ3a — Plugin::Blueprint + 実体化ファクトリー(着地済み)”

プラグインリプレイのための最小のクロスRactorハンドルが、ライブコーディネーターパスを変更せずに着地した。新しいサーフェス:

  • Rigor::Plugin::Blueprint — 凍結されたRactor.shareable?値オブジェクトで、klass_name(String — 定数パス)プラスディープコピーされたRactor.make_shareable処理されたconfig Hashを運ぶ。構築はStringまたはModuleを取る;Module形はklass.nameを保存する。
  • Plugin::Blueprint#materialize(services:)Object.const_get(klass_name).new(services:, config:)、その後#init(services)をリプレイする。Loader#instantiateにビット単位で等価なので、ブループリントパスは設定パスと一貫している。
  • Plugin::Registry#blueprintspluginsと1:1で整列された凍結されたArray<Blueprint>。ローダーはplugin.class.name + plugin.config経由でpost-topo-sortのプラグインリストから派生させる。
  • Plugin::Registry.materialize(blueprints:, services:) — 各ブループリントを新鮮なプラグインインスタンスにマッピングして新しいRegistryを構築する。load_errorsは意図的に空(ロード失敗はすでにコーディネーターレジストリで表面化している;ワーカーごとに繰り返されない)。

プラグインインスタンスは意図的に非共有のまま。実行ごとの可変アキュムレータ状態をivar(rigor-sorbet@reachable_absurd_nodes / @reveal_type_calls / @assert_type_mismatches;ほとんどのRailsプラグインの*_index Hashes)に運ぶ。Ractorごとのパターンは、すべてのプラグイン作者にリファクタを強制せずに制約をサイドステップする: 境界越しにブループリントを送り、ワーカーごとに1度実体化し、各ワーカーが自身のインスタンスを寿命の間所有する。

監査カバレッジ: 新しい「フェーズ3 — プラグイン契約」describeの下でspec/rigor/ractor_readiness_spec.rbに4つのRactor.shareable? / frozen?アサーション。

フェーズ3b — クロスRactorプラグイン集約状態(先送り)

Section titled “フェーズ3b — クロスRactorプラグイン集約状態(先送り)”

Ractorごとのパターンは各プラグインの実行ごとの観察ビューをスライスする。rigor-sorbetスタイルの集約追跡(ALLファイル全体のabsurd-reachable、reveal-type、assert-type-mismatch)は、フェーズ4が出荷したときに協調プロトコルを必要とする。ADR-15 § OQ2に文書化された3つの候補形:

  1. 状態をPlugin::FactStore publish/consumeに移動。
  2. ランナーでプラグインごとの結果マージ。
  3. 並列性からのプラグインオプトアウト(manifest(serial: true))。

フェーズ3bの決定はフェーズ4が実際の使用を測定するまで先送り。ランナーがシーケンシャル(フェーズ4オプトインデフォルト)のままなら、バンドルされたプラグインのどれもクロスRactor集約を必要としない。

推定サイズ: フェーズ4がランディングしたら小(選ばれた形のために約50〜100 LoC)。

フェーズ4 — Ractor分離ファイルワーカー

Section titled “フェーズ4 — Ractor分離ファイルワーカー”

ゴール: Analysis::Runner#analyze_filesがファイルをRactorのプールにディスパッチする。

前提条件: フェーズ1〜3a。それらが着地したら、欠けている部分は:

今日Ractor境界を越えられるもの

Section titled “今日Ractor境界を越えられるもの”

フェーズ3a後、クロス境界ペイロードは完全にRactor.shareable?:

  • Rigor::Configuration(フェーズ2a — 凍結 + 共有可能)
  • cache_rootString、凍結 — Cache::Storeディレクトリパス;各ワーカーがそのrootで自身のStoreを構築する)
  • librariessignature_paths(凍結された文字列の凍結配列)
  • Array<Rigor::Plugin::Blueprint>(フェーズ3a — 凍結 + 共有可能)
  • ファイルパスのArray<String>(凍結)

ファクトファインディング監査(フェーズ3aに続くコミット)が、ワーカー設計が回避する必要がある3つのブロッカーを特定:

  1. Rigor::Environmentは共有可能でない — RbsLoaderが可変@class_known_cache / @instance_definition_cache / @singleton_definition_cacheプラス上流のRBS::Environment(可変、C拡張状態)を運ぶ。各ワーカーは自身のRactor本体内でEnvironment.for_project(libraries:, signature_paths:, cache_store:, ...)経由で自身のEnvironmentを構築しなければならない(MUST)。
  2. Cache::Storeは共有可能でない — Monitor + counter ivars + default_procを持つHashすべてが契約に違反する。フェーズ4aは、各ワーカーが同じディスク上のディレクトリを指す自身のStoreを構築することでこれをサイドステップする。インプロセスメモの恩恵はクロスRactorで失われるが、ディスクバックのキャッシュは共有される(ファイルシステムが協調ポイント)。将来の作業(フェーズ4b? 先送り): Storeを直接共有可能にするか、メモアクセスを単一所有者Ractor経由でチャネルするRactor共有可能プロキシでラップする。
  3. RbsExtended::Reporter + BoundaryCrossReporterMutexを使う — スレッドセーフだがRactor共有可能ではない。各ワーカーは自身のレポーターを構築しなければならない(MUST);ランナーはレポーターの既存の重複除去ロジック経由で最後にエントリーをマージする(キーごとのエントリー追加は(payload, source_location)で冪等なので、事後マージは安全)。

フェーズ4a(着地済み) — WorkerSession値キャリア(まだRactorなし)。{Rigor::Analysis::WorkerSession}は上記の共有可能な入力(configurationcache_store / cache_root、Array<Plugin::Blueprint>explain)を取り、内部で新鮮なPlugin::Services + Plugin::RegistryRegistry.materialize経由) + DependencySourceInference::Index + Environment + セッションごとのRbsExtended::Reporter + BoundaryCrossReporterを構築する。プラグインの#prepareは構築時に1度実行;raiseは#prepare_diagnosticsにキャプチャされ、呼び出し元がファイルごとの診断ストリームと一緒にそれらをドレインできる。#analyze(path)を公開し、ファイルごとの診断ストリーム(Runner#analyze_file + plugin_emitted_diagnostics + explain_diagnosticsの等価物)プラス#drain_reportersがプール終了マージのための凍結されたレポータースナップショットを返す。

Runner#analyze_fileとの等価性はspec/rigor/analysis/worker_session_spec.rbで証明: 同じconfiguration + cache_store + plugin_blueprints、同じファイルごとの診断ストリーム(severity-profile再スタンプを除く — セッションはそれを呼び出し元に実行ごとの集約の関心事として残す)。プラグインライフサイクル(init、prepare、diagnostics_for_file)は、Runnerが今日適用するのと同じランタイムエラー分離エンベロープでブループリントパスを経由してリプレイされる。

基板は意図的に加法的: Runnerはv0.1.4以前のリリースで引き続きファイルごとの解析を自身で駆動する。フェーズ4bはRunnerを変更してファイルごとの解析を1つの(Ractorなし)WorkerSessionに委譲;フェーズ4cがN個のセッションを保持するN個のワーカーRactorをスポーンする。

ループ内にまだRactorなし — これは基板。

フェーズ4b(着地済み) — WorkerSession周りのRactorプールAnalysis::Runner#initializeworkers: Nキーワードを取得する(デフォルト0 = シーケンシャル、文書化されたv0.1.4以前の動作)。N > 0のとき、Runner#analyze_filesは新しい私的な#analyze_files_in_pool(files)にディスパッチし、それがN個のRactorをスポーンする。各ワーカーは共有可能なペイロード(Configuration、cache_root String、Plugin::Blueprint Array、explain Boolean)を取り、内部で自身のWorkerSessionを構築し、Ractor.main.send経由でメインRactorのメールボックスに書き戻す(Ruby 4.0はRactor.yieldを削除した;メールボックスモデルは今や双方向)。3種類のメッセージ: [:prepare, diagnostics](コーディネーターは最初のワーカーのスナップショットを保持)、[:file, path, diagnostics](解析されたファイルごとに1つ)、[:done, drained_reporters](終了時のワーカーごとに1つ)。診断順序はパスごとの結果Hashを元の入力順序で再フローすることで再構築される。ワーカーごとのレポータースナップショットは既存のrecord_* API経由でランナー側のアキュムレータにマージされる(自然に重複除去)。Environment::ClassRegistry.defaultRactor.make_shareableされ、ワーカーがRactor::IsolationErrorなしに遅延読み取りできる;Runner#analyze_files_in_poolがメインRactorで最初にそれを事前ウォームする。WorkerSessionコンストラクタはもはやMethodDispatcher::FileFolding.fold_platform_specific_pathsプロセスグローバルに書き込まない(メイン以外の書き込みはraise) — Runnerがプールスポーン前にメインRactorで1度設定する。

等価性 + プラグインリプレイ + prepare重複除去はspec/rigor/analysis/runner_pool_spec.rbで証明。spec/rigor/ractor_readiness_spec.rb §「フェーズ4b — Ractorプール準備」での監査specカバレッジ。

フェーズ4c(着地済み) — デフォルト + フラグ。3つのオプトインサーフェス、優先順位はCLI > env > config > 0:

  • rigor check --workers=N — 明示的CLIオーバーライド。
  • RIGOR_RACTOR_WORKERS=N — env var、CIで有用。
  • .rigor.yml parallel: { workers: N } — プロジェクトデフォルト。

Configuration#parallel_workers(デフォルト0 = シーケンシャル)がYAMLを読む;CLIのresolve_workersプライベートメソッドが優先順位チェインをRunner.new(workers:)にスレッドする。負のenv var値は0にクランプされるので、迷子の-1がプールスポーンループをクラッシュさせない。非数値の設定値はArgumentErrorをraiseするので、タイポが大きく失敗する。

シーケンシャルは文書化されたデフォルトのまま。シーケンシャル対プールのwall-clockのベンチマークは、現在のプールパスが些細なソースファイルのみを確実に処理するため(RBS / RubyGems C拡張状態がワーカー内での最初の非自明なenvアクセスでRactor::IsolationErrorをトリップする)、フェーズ4b.xに先送り。

フェーズ4b.x — ワーカー側env構築安定性(先送り)

Section titled “フェーズ4b.x — ワーカー側env構築安定性(先送り)”

フェーズ4bプールのワーカーRBS::EnvironmentLoader.newパスは、非共有モジュール定数のチェイン(RBS::EnvironmentLoader::DEFAULT_CORE_ROOTRBS::Repository::DEFAULT_STDLIB_ROOTGem::Requirement::DefaultRequirement)を参照する。各々がワーカーRactorからアクセスされたときにRactor::IsolationErrorをトリップする。リーフでRactor.make_shareableを事前実行すると、RBSが続いてC拡張パス経由で使おうとするPathnameをディープフリーズし、クリーンなIsolation raiseではなくバスエラーを生成する(Ruby 4.0.4 + rbs 4.0.2 on macOS arm64で観察)。

3つの候補修正、各々が別個のサブフェーズ:

  • (a)コーディネーター側env + クロスRactorハンドル。メインRactorで1度envを構築し、ワーカーがRactor.main.send往復経由で参照するRactor共有可能なクエリファサード(Environment::Reflectionの凍結サブセット)を公開する。コスト: クラスルックアップごとのクロスRactor RPC;ホットディスパッチパスにとってレイテンシ的に法外。

  • (b) RubyGems / RBS上流パッチ。上流コードで違反する定数を共有可能にする。長期で最高のレバレッジ、2つの上流リポを協調する必要がある。

  • (c)代わりにフォークベースの並列性。Ractorをスキップ;Process.forkを使ってワーカーが完全なRBS / RubyGems状態を持つ自身のプロセスを取得する。Ractorへの並行オプションとしてこのドキュメントで以前破棄;(a) / (b)が解決不能と判明したら再検討。

これらのいずれかが着地するまで、デフォルトシーケンシャルモードがプロダクションパス。

フェーズ4aのオープン設計ポイント

Section titled “フェーズ4aのオープン設計ポイント”
  • プラグイン#prepareタイミングprepare_pluginsは実行ごとに1度、ファイル解析前に実行され(runner.rb:424)、各プラグインが自身のサービスのfact_storeにファクトを公開することを期待する。Ractor分離下では、各ワーカーが自身のサービス / fact_storeを持つ自身のプラグインインスタンスを持つ — しかしfact_storeはMutex共有クロスRactorできない。選択肢: (a)コーディネーターでprepareを1度実行、生成されたファクトを共有可能なHashとしてダンプ、リプレイのためにワーカーに送信;(b)prepareがワーカーごとに実行されることを受け入れる(ほとんどのプラグインは同じディスク入力を再読する — 重複作業だが正しい)。4aが1つを選ぶ必要がある;fact_store内容がMarshalableと仮定すれば、(a)がより低コストの選択肢。

  • dependency_source_index。すでに実行ごとに構築されている;共有可能性を検証するかワーカーごとに再構築する必要がある。

  • scope_indexer.discovered_classesのクロスファイルシード。一部のフローは、以前のファイルから発見されたクラスでスコープを事前シードする(ADR-14 ObservationCollector)。並列ワーカーがファイルを並行して処理する場合、このクロスファイルシードは破綻する。Pin: ワーカーは独立したファイルごとの解析を実行する;ObservationCollectorパスはシーケンシャルのまま(デフォルトのanalyze_fileではなく別個のコードパス)。

推定サイズ:

  • フェーズ4a: 約200〜300 LoC(WorkerSession + spec)
  • フェーズ4b: 約150〜250 LoC(Runner統合 + spec)
  • フェーズ4c: 約50 LoC(フラグ + ドキュメント)

期待される恩恵: 4ワーカー + RBSキャッシュウォームで、数百ファイル(推論が支配する)のプロジェクトでwall-clockが約3×ドロップするはず。小さなプロジェクトはRactor起動オーバーヘッドを払うが、あまり勝てない。

  • CRuby Ractor成熟度: Ractorは安定だが共有可能性制約は厳格。一部のRubyイディオム(クラスレベルの可変状態、動的に構築されるStringとfrozen-string-literalの相互作用)には各フェーズで慎重な監査が必要。
  • YJIT互換性: YJITはRuby 3.3+でRactorごと。ワーカー起動はYJITウォームアップコストを払う。
  • プラグイン作者負担: フェーズ3はプラグインがスレッド / Ractor認識である必要がある。フレームワークがレジストリリファクタ経由でほとんどを引き受けられるが、ステートフルフックを持つプラグイン作者はRactorごとの実体化にオプトインする必要がある。
  • 代替: フォークベースの並列性 — よりシンプル、今日動作するが、各ワーカーがEnvironment + RBSキャッシュを再構築し、フォークあたり約50〜200msかかる。スケールでのみ正味の恩恵。

フォークパスとRactorパスは相互排他的ではない。フォークベースは素早い勝利として最初に着地できる(CURRENT_WORKオープン項目#7)一方、Ractorフェーズは段階的に進む。

  1. ✅ フェーズ1 — 値オブジェクトの共有可能性。
  2. ✅ フェーズ2a — Configurationディープフリーズ。
  3. ✅ フェーズ2b — Environment::Reflection抽出(凍結された読み取り専用ファサード;上流のRBS::Location C拡張制約のためRactor共有可能ではない;各フェーズ4ワーカーは共有されたCache::Storeから自身のReflectionを構築する)。
  4. ✅ フェーズ3a — Plugin::Blueprint + Registry#blueprints + Registry.materializeファクトリー。
  5. ⏭ フェーズ3b — クロスRactorプラグイン集約状態契約(フェーズ4まで先送り)。
  6. ✅ フェーズ4a — WorkerSession値キャリア(まだRactorなし;プールの基板)。
  7. ✅ フェーズ4b — Runner#analyze_filesでのWorkerSession周りのRactorプール(プログラム的なworkers: Nオプトイン;シーケンシャルが引き続きデフォルト)。
  8. ✅ フェーズ4c — ユーザー向けオプトインフラグ(RIGOR_RACTOR_WORKERS env + .rigor.yml parallel.workers: + CLI --workers=N)。
  9. ✅ フェーズ4b.x — キャッシュ事前ウォーム経由のワーカー側env構築安定性。Runnerがプールスポーン前にメインRactorですべてのキャッシュ済みRBSプロデューサーをウォームする;ワーカーはディスク上のMarshal blobからすべてのリフレクションクエリを提供し、RubyGems / RBSモジュール定数分離チェインをサイドステップする。Rigor自身のディスパッチ定数が共有可能;MethodCatalogがYAMLを積極的にロードする。

各後続のフェーズは前のフェーズの監査specから読み取って前提条件を確認する。監査specがフェーズ間の契約。

© 2026 TypedDuck. Licensed under CC BY-SA 4.0.