コンテンツにスキップ

ADR-6: キャッシュ永続化バックエンド

ステータス: 承認済み

v0.0.8実装向けに作業上の決定を記録;RBS環境キャッシュ、プロセスごとのメモ、Monitor-safeスレッドレイヤーがv0.1.4でランドした時点で承認された。

ADR-2 § 「登録、設定、キャッシュ」は、rigor check実行間の永続キャッシュをRigorにコミットし、キャッシュ無効化ディスクリプタのスキーマを固定する。これは型付きの4スロット(files, gems, plugins, configs)形状であり、docs/design/20260505-cache-slice-taxonomy.mdでスロットごとのエントリー定義、合成ルール、および正規キャッシュキー導出に展開されている。

スキーマは設計上ストレージ非依存だ。分類ドキュメントはこう締め括っている。

ディスク上のフォーマット(sqlite、msgpack、単一ファイルフラット、シャード ディレクトリ、…)はこのドラフトのスコープ外である。以下のスキーマは ストレージ非依存である。

このADRはv0.0.8の最初の実装に向けてその未解決の問いを解決する。この選択はプロデューサー向けAPI、ロックモデル、エビクションポリシー、gemspecが広告する依存関係のフットプリントなど、後続のすべてを制約する。

最初のキャッシュバックエンドはmsgpackファイルのシャードディレクトリである。キャッシュスライス(slice)ごとに1ファイル、パスは正規キャッシュキー(.rigor/cache/<producer-id>/<key-prefix>/<key-suffix>.msgpack)から導出される。キャッシュレイヤーは最初の実装で新しいgem依存関係ゼロで出荷する。msgpackシリアライズはキャッシュレイヤー自身が書くカスタム正規バイナリフォーマットに置き換えられる。

バイナリフォーマットを読み取れない環境(エントリー破損、スキーマバージョン不一致)向けにピュアRubyのフォールバックパスが使用可能なまま残る。デシリアライズに失敗した読み取りはキャッシュミスとして扱われ、エントリーは削除され、プロデューサーが再実行される。

3つの候補バックエンドは次の通りだった。

バックエンド長所短所判定
sqliteアトミック書き込み、クエリ能力、十分にテストされた並行読み取り、VACUUMによる簡単なサイズ上限。Cエクステンション付きのsqlite3gemを追加する。マルチプロセス下のロックセマンティクスに意外な落とし穴がある。クエリ能力はキャッシュスキーマが必要とするもの(述語ではなくキーで検索)を超えている。v0.0.8では却下。シャードディレクトリがスケール問題に直面した場合に再検討。
msgpack単一ファイルコンパクトなバイナリ。1ファイルはロックが簡単。msgpackgem(または手動サブセット)が必要。変更ごとのキャッシュ全体書き直しは〜100エントリー以降でスケールが悪い。v0.0.8では却下。キャッシュ全体書き直しが失格理由。
シャードディレクトリスライスごとに1ファイル → 部分的な無効化はリテラルなunlink。読み取りは独立しており、ヒット数に比例してスケールする。キャッシュレイヤーが独自の正規フォーマットを書けば新しいgem依存関係不要。inode数が多い。並行書き込みはグローバルロックではなくファイルごとのロックが必要。v0.0.8で選択
.rigor/cache/
schema_version.txt
<producer-id>/
<ab>/
<ab1234567890…>.entry
  • schema_version.txtはキャッシュスライス分類のスキーマバージョンを表す単一の整数を持つ。バンプするとすべてのキャッシュファイルが暗黙的に無効化される(キャッシュレイヤーはエントリーを読む前にバージョンを読む。不一致はディレクトリを削除する)。
  • <producer-id>はプロデューサーの識別子。各プロデューサーは自身のサブツリーを所有し、クロスプロデューサーファイルはない。
  • キャッシュキー(分類ドキュメントに従って正規JSON→SHA-256)はファイルシステムフレンドリーなファンアウトのために2文字のプレフィックスと62文字のサフィックスに分割される。

キャッシュエントリーはこのレイアウトの単一バイナリファイルである。

"RIGOR\0\1" - magic (5 bytes) + format version (1 byte)
length (varint) - byte length of the descriptor payload
descriptor payload - canonical-JSON-encoded Descriptor (UTF-8)
length (varint) - byte length of the value payload
value payload - producer-defined bytes (typically Marshal.dump)
sha256 (32 bytes) - integrity check over the prior bytes
  • マジック+バージョンペアにより、将来のフォーマット移行が古いファイルを安価に検出してミスとして扱える。
  • ディスクリプタは値とは別に格納されるため、キャッシュ検証は値をデシリアライズせずにディスクリプタだけを読み取れる(安価)。
  • 末尾のSHA-256は部分書き込み破損(プロセスキルによる切り詰め書き込み)に対する防御だ。ADR-2の信頼されたgemモデルに従い、セキュリティ境界ではない
  • プロデューサーの値はデフォルトでMarshal.dumpを使う。Rigorのキャリア(carrier)はMarshalラウンドトリップが明確に定義された不変値オブジェクトだからだ。marshalと互換性のないオブジェクト(例:生のIO)を返すプロデューサーはカスタムシリアライザを登録しなければならない(MUST)。

書き込みは標準のrename-into-placeダンスに従う。

  1. 宛先ディレクトリをmkdir -pする。
  2. 兄弟テンポラリファイル(<key>.entry.tmp.<pid>.<rand>)に書き込む。
  3. テンポラリファイルをfsyncする。
  4. 宛先にrenameする。

POSIXは同じファイルシステム上でrenameがアトミックであることを保証する。部分的なエントリーを見る読み取り側は、古い(コミット済み)ファイルかファイルなし(まだエントリーなし)のどちらかを見る——切り詰め書き込みは決して見ない。

ファイルごと: 書き込もうとするプロデューサーは宛先ファイルにflock(LOCK_EX)を取得する(必要に応じて作成)。読み取り側はロックしない。古いバージョンを見ることを許容する。キャッシュレイヤーはベストエフォートであり、古いエントリーは次のキャッシュチェック時に再読み込みを引き起こすだけだ。

最初の実装はエビクションを行わない。キャッシュは際限なく増大する。rigor check --clear-cache.rigor/cacheディレクトリ全体を削除する。将来のADR修正でプロジェクトサイズが際限のない増大を問題にするときに、設定可能なバイト上限付きのLRUポリシーを導入する。v0.0.8スライスを扱いやすくするためにポリシーを延期する。

v0.0.8はシングルライターモデルで出荷した。プロジェクトごとに1度に1つのrigor check実行。並行実行は同じキャッシュディレクトリを使ってもよい(MAY)。ファイルごとのロックが書き込みをシリアライズし、結果は正しい(すべての読み取り側はコミット済みエントリーかミスのどちらかを得る)。コーディネータープロセスなし、共有インメモリ状態なし。

v0.1.4インプロセスレイヤーCache::Storeは今、プロセスローカルのインメモリメモを保持する(コミット5c30b37)ため、同じプロセス内の繰り返しのfetch_or_compute呼び出しはウォームパスでディスク読み取りAND Marshal.loadデシリアライズステップをスキップする。再入可能なMonitor(コミット31e95c8)がメモ + ヒット / ミス / ライトカウンタをガードし、StoreがワーカースレッドまたはRactorからの並行アクセス下で安全になる。

ADR-15境界 — クロスRactor共有ADR-15Cache::Storeをキャッシュ値のための唯一のクロスRactor共有ポイントとして指定する: ワーカープールの各Ractorは自身のプロセスごとの推論キャッシュ(Environmentキャッシュレイヤー)を保持するが、共有されたCache::Storeを介して読み取る。現在の実装はまだRactor.shareable?ではない(Monitor + Hash + counter ivarsがそれをブロックする);移行のフェーズ4が次のいずれかを決定する:

(a) MonitorをRactorに優しいプリミティブに置き換えてCache::Storeを直接Ractor.shareable?にする、 (b) Ractor.send経由で基底のStoreに呼び出しを転送する薄いRactor.shareable?プロキシでラップする、または (c) Storeを凍結されたオンディスクルートを共有するRactorごとの可変インスタンスにシャード化する(最もシンプルな道;ADR-15 § OQ1を参照)。

決定はRactor移行のフェーズ4で着地する;このエントリーは制約を記録するため、将来のCache::Store作業が誤って共有可能性から設計を遠ざけることがないようにする。

長時間実行デーモン/LSPモードは別のv0.1.0+サーフェス(surface)であり、インプロセスメモ(すでに配置済み)とフェーズ4のクロスRactor共有可能性作業の両方の恩恵を受ける。

7. プロデューサーAPIサーフェス

Section titled “7. プロデューサーAPIサーフェス”
Rigor::Cache::Store.new(root: ".rigor/cache").fetch_or_compute(
producer_id: "reflection.instance_method_definition",
params: { class_name: "Hash", method_name: :fetch },
descriptor: descriptor_value,
) do
# build the cached value
end
  • producer_idは安定した文字列。所有していないプロデューサーidの下に値を書かないこと。
  • paramsはプロデューサーが呼び出された際の入力。これらをキャッシュキーに混ぜるのはプロデューサーの責任ではなくキャッシュレイヤーの責任だ。
  • descriptorRigor::Cache::Descriptor値オブジェクト。
  • ブロックはキャッシュミス時のみ実行される。

v0.0.8でこのAPIを通じて最初に接続されるプロデューサーはRBSの翻訳定数テーブルだ——読み込まれたRBS環境で宣言されたすべての定数名をキーとしたHash<String, Rigor::Type>で、一度実体化materializeされて再利用される。これは最大のコールドスタートコストではない(それはRBS::EnvironmentLoader#build_env自体)が、カスタムシリアライザなしでキャッシュ機構が消費できる最大のコストだ。以下の「RBS::Environmentシリアライズ」を参照。他のプロデューサー(リフレクション、スコープインデックス、カタログローダー)は後続のv0.0.8スライスまたはv0.0.9で続く。

RBS::Environmentとその推移的ASTノードはRBS::Locationインスタンスを持つ。RBS::Location_dump_data/_load_dataのないCエクステンションクラスであるため、単純なMarshal.dumpTypeErrorで失敗する。RBS::Environment自体をキャッシュするには次のいずれかが必要だ。

  • キャッシュStoreのカスタムシリアライザサーフェス(プロデューサーがfetch_or_computeと並行してdump/loadコーラブルを登録する)と、RBS::Location/RBS::Bufferを取り除いて再構築するシリアライザ、または
  • スキーマ安定な中間表現(関連するすべてのRBSノードがMarshalセーフな形状にウォークされる)。

どちらもv0.0.8スライスの予算の範囲外の実質的な作業だ。したがってv0.0.8の最初のプロデューサーは翻訳後アーティファクト(Rigor::Type値、明確に定義されたMarshalラウンドトリップを持つプレーンな凍結値オブジェクト)をキャッシュする。後続のスライスでは、実際のコールドスタート回帰がその作業を動機付けた後でカスタムシリアライザルートを再検討する。

候補ステータス理由
v0.0.8バックエンドとしてのsqlite却下スキーマが必要としないクエリ能力のためにsqlite3gem依存関係(Cエクステンション)を追加する。シャードディレクトリのI/Oコストが支配的になった場合に再検討。
単一msgpackファイル却下変更ごとのキャッシュ全体書き直しは〜100エントリー以降でスケールが悪い。
クロスマシンキャッシュ共有延期v0.0.8のスコープ外。スキーマはパス相対であるため、あるマシンで構築されたキャッシュを同じプロジェクト/gem状態の別のマシンに移動できるが、Rigorはそれを調整しない。
LRUエビクションポリシー延期最初の実装は際限なし。必要なユーザーは--clear-cacheを実行する。
長時間実行デーモン/LSPキャッシュモード延期別のv0.1.0+サーフェス。
msgpackgem依存関係却下キャッシュレイヤーがゼロ実行時依存プロパティを保つために独自の正規バイナリフォーマットを書く。
ディスクリプタペイロードにmarshal却下ディスクリプタは正規JSONであるため、異なるコードパスで構築された2つの等価なディスクリプタが同一のバイトを生成する。Marshalはそれを全Rubyバージョン間で保証しない。
セキュリティ境界としてのキャッシュ整合性却下ADR-2の信頼されたgemモデルに従い、プラグインは信頼されたRubyコード。末尾のSHA-256は悪意あるタンパリングではなく偶発的な破損(部分書き込み、FSエラー)を検出する。
  • Cache::StoreRactor.shareable?準拠ADR-15 Ractor移行のフェーズ4は、複数のワーカーRactorがそれを介して読み取れるよう、StoreがRactor共有可能である必要がある。現在の実装はMonitorセーフだがRactor共有可能ではない。決定はフェーズ4に先送り(3つの候補アプローチについてはADR-15 § OQ1を参照)。制約は記録されており、将来のCache::Store作業が誤って共有可能性から設計を遠ざけることがないようにする。
  • ファイルシステムの大文字小文字区別。プロデューサーidとキャッシュキーは[a-z0-9._-]のみを使用するため、大文字小文字を区別しないファイルシステム(macOS HFS+、NTFS)では衝突が発生しない。キャッシュレイヤーは書き込み時にこの文字セットを強制する。
  • シンボリックリンク/ネットワークファイルシステム。v0.0.8はキャッシュルートが実際のローカルディレクトリであると仮定する。NFS/ネットワークFSは動作するがflockセマンティクスはFS固有であり、v0.0.8ではNFSをテストしない。
  • スキーマバージョン移行のUX。バンプするとキャッシュ全体が無効化される。キャッシュレイヤーは最初の検出時に1行の:info診断を出力すべきか? 作業上の答えはYesだが、診断はv0.1.0まで--cache-statsの後ろにゲートされる。

ポジティブ:

  • 新しいgem依存関係ゼロ。rigortypegemは現在の(prism, rbs)実行時サーフェスのままだ。
  • キャッシュの読み取りと書き込みはO(キャッシュ全体)ではなくO(スライスごと)。部分的な無効化は1ファイルのunlink
  • ディスク上のレイアウトはls .rigor/cachexxd <entry>で人間が検査できる。

ネガティブ:

  • 単一ファイルバックエンドよりもinodeが多い。
  • ファイルごとのロックセマンティクスはFS依存。macOS/Linuxはサポート対象ターゲット。Windowsはフォローアップ評価が必要。
  • v0.0.8ではサイズ上限なし。小さなディスクのユーザーは手動でキャッシュをクリアする必要がある。
  1. ADR-2 § 「登録、設定、キャッシュ」 — このADRが基礎とするスキーマレベルの決定。
  2. docs/design/20260505-cache-slice-taxonomy.md — スロットごとの詳細、合成、キャッシュキー導出。
  3. このADR — バックエンド選択、ファイルフォーマット、アトミック性、ロック、エビクション。
  4. docs/design/20260505-v0.1.0-readiness.md — v0.1.0シーケンスにキャッシュスライスがどう収まるか。

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