コンテンツにスキップ

CLIエディタモード — ディスクバック`ProjectScan`スナップショットキャッシュ

ステータス:設計ノート。2026-05-18に著作。具体的なエディタ拡張需要が表面化するまで実装は先送り。LSPパス(インメモリProjectContext + Analysis::ProjectScan、v0.1.6で着地)はすでに典型的なエディタケースに対処する;このノートはCLIシェルアウトニッチの実装パスを記録し、次の実装者が冷たい状態から拾い上げられるようにする。

rigor check --tmp-file=X --instead-of=Y libはPHPStanスタイルのエディタ拡張がバッファ保存ごとにシェルアウトするCLIサーフェスである(docs/design/20260516-editor-mode.md §「CLIサーフェス」を参照)。各呼び出しは新鮮なプロセスなので、呼び出し全体で共有するインメモリProjectContextは存在しない。rigor自身のlib/に対するウォームキャッシュ呼び出しでの計測内訳(2026-05-17ベンチマーク):

フェーズコスト
Ruby + bundlerブート + rigorライブラリロード約200 ms
Environment.for_project(ディスクキャッシュヒット)約300 ms
プレパス(Plugin::Loader.load、プラグイン#prepareDependencySourceInference::Builder.buildSyntheticMethodScanner.scanProjectPatchedScanner.scan約500 ms(プロジェクトサイズ依存)
バッファパース + analyze_file約50〜100 ms

合計: 約1050 ms。ブートフロア(累積約500 ms)は根本的;約500 msのプレパスコストが対処可能なヘッドルーム。

ディスクバックのスナップショットはウォームヒットでプレパスコストを削り取り、CLIエディタモードを約500 msの壁に持ってくる — PHPStanのバッファごとのフィードバックと競争力がある。LSPパスはすでにインメモリProjectScanキャッシュ経由でpublishあたり≤5 msを達成しているので、この最適化はCLIシェルアウトニッチのみ向け(LSPサポートのないエディタ、またはバッチツーリング)。

フェーズA — Marshalフレンドリーなスナップショット

Section titled “フェーズA — Marshalフレンドリーなスナップショット”

既存のRigor::Analysis::ProjectScan(v0.1.6)値オブジェクトは6つのスロットをバンドルする;5つはMarshalフレンドリー、1つはそうでない:

スロットMarshalフレンドリー?備考
dependency_source_indexプレーンなHash-of-CatalogEntryデータ。
synthetic_method_index凍結されたSyntheticMethod Data値の凍結Hash。
project_patched_methods凍結されたHash。
plugin_prepare_diagnosticsDiagnostic Data値のArray。
pre_eval_diagnosticsプレーンHashのArray。
plugin_registryプラグインインスタンスはPlugin::Servicesを保持し、Cache::Store(Mutex)、IoBoundary(Mutex)、Plugin::FactStore(これも可変)を持つ。

解決策Rigor::Analysis::MarshalableProjectScanを導入する — 5つのMarshalフレンドリーなスロットPLUS plugin_registryが間接的に運んだプラグインごとに公開されたファクトスナップショット#prepare実行後のPlugin::FactStoreの状態)。これはライブのプラグインインスタンスをドロップし、下流のディスパッチ層が実際に参照するデータのみを保持する。

MarshalableProjectScan = Data.define(
:dependency_source_index,
:synthetic_method_index,
:project_patched_methods,
:plugin_prepare_diagnostics,
:pre_eval_diagnostics,
:fact_store_snapshot # Hash[plugin_id => Hash[fact_name => marshalable_value]]
)

ウォームロード時:

  1. スナップショットをMarshalロードする。
  2. Plugin::Loader.load(configuration:, services:)を呼んでplugin_registryを再構築する(gemは前の実行からすでにrequire済み — Kernel.requireはfalseを返す;コストはPlugin.register / Blueprint作業によって支配される、約5〜20 ms)。
  3. スナップショットされたファクトを再アタッチ、各プラグインのservices.fact_storeに、そのためディスパッチ消費者は#prepareを再実行せずに公開されたファクトを見る。
  4. 再水和されたplugin_registry + 不変のスナップショットスロットからランタイムProjectScanを構築する。
  5. Runner.new(prebuilt: ...)に渡す。

フェーズB — キャッシュキー導出

Section titled “フェーズB — キャッシュキー導出”

スナップショットは、プレパス出力に影響するプロジェクト入力のいずれかが変更されたときに無効化されなければならない。キャッシュキー候補は:

SHA256(
configuration_digest # .rigor.yml + bundler / collection軸
+ plugin_manifest_digest # プラグインgemバージョン + プラグインごとの設定
+ project_paths_digest # `paths:`-expanded .rbファイル、mtime + サイズ
+ pre_eval_paths_digest # pre_eval:ファイル、mtime + サイズ
+ dependencies_digest # dependencies.source_inference:設定
)

project_paths_digestが支配的なコスト: すべてのCLI呼び出しでmtime+sizeのためにpaths:を歩く。5000ファイルでFile.stat呼び出しに約250 ms。これは大規模プロジェクトでプレパス節約をほぼ相殺する。2つの緩和策:

  • (α)ディレクトリmtime経由の安価な事前チェック。ほとんどのファイルシステムは、エントリーが追加または削除されたとき(内容が変更されたときではない)にディレクトリのmtimeを更新する。paths:ディレクトリのみをmtimeのために歩く → 約ms。キャッシュされたスナップショットのビルド時間から変更されていない場合、ファイルリストが変更されていないと仮定し、ファイルごとのmtime走査をスキップする。ファイルごとのmtime+sizeはディレクトリのmtimeが変更されたときのみキックインする。これは実装者が採用前にベンチマークすべき高速パス最適化。
  • (β)バッファのみのパス引数のキー導出をスキップrigor check --tmp-file=X --instead-of=Ypaths:を設定のプロジェクトルートにデフォルトして呼ばれるとき、導出は避けられない。呼び出し元が単一のファイルパス(rigor check --tmp-file=X --instead-of=Y lib/foo.rb)のみを渡すとき、キーはプレパスが消費するものだけをカバーすればよい — それでもなおpaths:下のプロジェクト全体、スキャナがプロジェクトを歩くため。だから(β)は役に立たない;(α)が正しいレバー。

新しいプロデューサーでRigor::Cache::Storeを再利用する:

module Rigor
module Cache
class ProjectScanSnapshot
PRODUCER_ID = "analysis.project_scan_snapshot"
def self.fetch(loader:, store:, configuration:)
descriptor = build_descriptor(configuration)
store.fetch_or_compute(
producer_id: PRODUCER_ID,
params: {},
descriptor: descriptor
) { build_fresh(configuration) }
end
def self.build_descriptor(configuration)
Descriptor.new(
configs: [config_entry(configuration)],
files: project_path_file_entries(configuration) +
pre_eval_file_entries(configuration),
plugins: plugin_entries(configuration)
)
end
def self.build_fresh(configuration)
# プロジェクトのみのRunnerをスピンアップ(バッファなし)、
# prepare_project_scanを呼び、fact_storeをスナップショット、
# MarshalableProjectScanを返す。
end
end
end
end

Cache::StoreはすでにMarshalラウンドトリップ、シャード化ストレージ、ファイルごとのflock、ディスクリプタベースの無効化を処理する。新しいプロデューサーはディスクリプタ + 新鮮なビルドを提供するだけでよい。

Runnerはすでにprebuilt:を受け入れる。CLIコマンドパスは次のようにスレッド化する:

def cli_run_check(configuration:, buffer_binding:)
snapshot = Cache::ProjectScanSnapshot.fetch(
loader: nil, store: cache_store, configuration: configuration
)
prebuilt = rehydrate(snapshot, configuration: configuration,
cache_store: cache_store)
Runner.new(
configuration: configuration,
cache_store: cache_store,
buffer: buffer_binding,
prebuilt: prebuilt
).run([buffer_binding.logical_path])
end

rehydrateはフェーズAで記述されたPlugin::Loader.load + fact_store再アタッチを行う。

フェーズE — 書き込み時のスナップショットの鮮度

Section titled “フェーズE — 書き込み時のスナップショットの鮮度”

キャッシュキーがミスを示すとき、新鮮なビルドはプロジェクトのみのrunnerに対してRunner#prepare_project_scanを実行し、それからシリアライズする:

  • 5つのMarshalフレンドリーなスロットをそのままスナップショット。
  • プラグインごとのfact_storeをスナップショット: plugin_registry.pluginsを反復、plugin.services.fact_store.factsをキャプチャ(またはFactStoreのアクセサが公開するもの — Plugin::FactStore#snapshot_for_cacheメソッドが必要かもしれない)。
  • Marshalフレンドリーな値のみ。非Marshalフレンドリーなファクト(Mutex、Procなど)を公開するプラグインはスナップショットを壊す — プロデューサーはレスキューし「この設定にはキャッシュなし」に縮退するか、FactStoreのスナップショットメソッドが違反するプラグインを指す明確なエラーをraiseすべき。
  1. FactStoreスナップショットAPIPlugin::FactStoreは現在「すべての公開されたファクトをシリアライズ」サーフェスを公開しない。正しい形は、ストアがプラグインごとにファクトをキー付けするかどうか(する、ADR-9に従い)と、値がMarshalフレンドリー型に制約されるかどうか(今日明示的な制約はない)に依存する。小さなPlugin::FactStore#to_snapshot / .from_snapshotペアが統合をスコープする。
  2. Marshalバージョン安定性Cache::StoreはすでにSCHEMA_VERSIONでキーするので、Rubyバージョンバンプはエントリーを無効化する。MarshalableProjectScanスナップショットはこの不変条件を継承する。
  3. プラグインgemバージョンのピン留め。プラグインアップグレードはスナップショットを無効化すべき。今日のCache::Descriptor::PluginEntryversion: + config_hash:を含む — プロデューサーのディスクリプタはプラグインごとにこれらのいずれかを含まなければならない。
  4. プレパス診断の再発行順plugin_prepare_diagnosticsスナップショットは、ソースプラグインが発行した順序を保持しなければならない、そうすればCLI診断ストリームはコールド / ウォーム実行をまたいで安定したまま。MarshalラウンドトリップはArray順を保持する — specで検証。
  5. キャッシュ書き込み競合。スナップショットを書く2つのrigor check呼び出しがプロデューサーのキャッシュファイルで衝突する。Cache::Storeはファイルごとのflock経由ですでにこれを処理する;最初のライターが勝ち、2番目は計算した値を破棄する。
プロジェクトサイズCLIエディタモードのウォーム壁(今日)スナップショットキャッシュ後Δ
些細(プラグインなし)約500 ms約500 ms0(プレパスはすでに安価)
小Rails(5プラグイン)約700〜900 ms約500〜550 ms-200〜-350 ms
中Rails(10プラグイン + 基板)約1000〜1500 ms約550〜650 ms-450〜-850 ms
大規模モノレポ(5000+ファイル、基板使用プラグイン)2+秒約700 ms> -1.3秒

勝利はプラグイン / 基板 / ファイル数でスケールする。

  • LSPがエディタケースの90%+をカバーrigor lsp(v0.1.6)が推奨されるエディタ統合。publishごとの作業はウォームで≤5 ms。LSPを話せるエディタ拡張はそのパスを使うべき。
  • 実装サーフェスエリアが実質的。プラグインファクトのMarshalフレンドリーさはプラグイン契約が公開する(または不透明に壊す)新しい不変条件。FactStoreがMarshalフレンドリーさを強制するか単に優雅に縮退するかの決定は実質的なADRレベルの質問。
  • 具体的なエディタ拡張消費者は今日存在しない。CLIエディタモードのCLI形はv0.1.6(--tmp-file / --instead-of)で出荷されたが、我々が知るエディタ拡張は保存時にそれにシェルアウトしていない。1つが表面化し>500 msの壁をUX問題として報告したとき、このスライスは改善のブロックを解除する。

隣接するレバー(優先順位低)

Section titled “隣接するレバー(優先順位低)”
  • LSP / CLI全体のインメモリEnvironment.for_projectキャッシュ。v0.1.6はすでにLSP ProjectContextでEnvironmentをキャッシュする。CLIはそのキャッシュを共有できないが、Environment構築自体は既存のオンディスクCache::RbsEnvironmentからのMarshal.loadによって支配される — すでにウォームキャッシュ最適化済み。
  • CLIブートコストを削減。約200 msのブートはRuby + bundler + rigorライブラリロード。それを削除するには永続デーモン(= LSP)が必要。スコープ外。

このスライスが拾い上げられるとき:

  • docs/CURRENT_WORK.md §「オープンエンジニアリング項目」を更新。
  • CHANGELOG.md[Unreleased] § Performance下のエントリーを追加。
  • コミットでこの設計ノートを参照。
  • spec/rigor/public_api_drift_spec.rbスナップショットにPlugin::FactStore#to_snapshot / .from_snapshotを追加。

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