インクリメンタルコンパイルの死角:Ruffで起きた Salsa の並行クエリ・キャンセルによるIDEフリーズ
1. 概要
近年の高速なリンターやコンパイラ(Ruffの次世代型チェッカー red_knot や Rustの rust-analyzer など)は、変更されたファイルだけを差分解析するために、Salsa(サルサ) というクエリベースのインクリメンタルコンパイル・データベースを並行スレッド上で駆動している。 しかし、マルチスレッド環境における「クエリのキャンセル(中断処理)」や「パニック(例外)の伝播」の処理に歪みがあると、クエリが死んでもデータベースのロックハンドルが解放されず、開発者がコードを編集するたびにエディタやIDE全体が永久にフリーズ(デッドロック)するバグが発生する。 本稿では、Ruffの型チェックエンジンにおけるSalsaのパニック伝播およびリソースリークバグ(astral-sh/ruff#24141)を事例に、マルチスレッド型キャッシュエンジンが直面する並行ロック制御の罠について分析する。
2. インシデントの分析:IDEをフリーズさせる「終わらないキャンセル」
2.1. 現象:ファイル編集時に発生するIDEのフリーズ
Ruffの型チェック機能をVS Code等のIDEで有効化してコードを執筆している際、ファイル編集(コードの書き換え)を行うたびに、型チェック処理がハングアップし、IDEのレスポンスが極端に悪化してフリーズする問題が報告された。
2.2. 原因①:Salsaにおける「キャンセルパニック」の誤抑制とデッドロック
Salsaのアーキテクチャでは、型チェック中に開発者が新しくキーボードを叩いてファイルを修正(書き込み)し始めると、実行中の「古いチェック処理」を即座に中断するため、スレッドに対して salsa::Cancelled というパニック(例外)を投げてスレッドを強制終了(Unwind)させようとする。
しかし、Ruffのチェック実装(check_file_impl)は、「スレッド内で発生したあらゆるパニックを安全のためにキャッチして無視(抑制)する」 という例外ガードを設けていた。
これが最悪のデッドロックを引き起こした。
- ユーザーがファイルを変更し、既存 of クエリをキャンセルする。
- スレッドにキャンセルシグナル(パニック)が飛ぶ。
- Ruffがそのパニックをキャッチしてしまい、スレッドを即座に殺さず、最後まで処理を実行し続けようとする。
- この間、キャンセルされたはずのスレッドはデータベースの読み取りロック(
dbハンドル)を掴み続ける。 - ファイル変更を反映させたい書き込みスレッド(Mutation)は、この古いスレッドがロックを離すのを待ち続ける。
- 結果として、処理がデッドロックし、IDE全体がフリーズする。
2.3. 原因②:パニック抑制による「キャッシュ依存関係グラフ」の崩壊
もう一つのねじれは、Salsaが「正常完了したクエリ」の依存関係しかデータベースに記録しないという仕様から発生した。
マルチスレッド上で、クエリ A がクエリ B に依存して走っている際、B がクラッシュ(パニック)すると、Salsaは A に対して Cancelled::PropagatingPanic(伝播したパニック)を投げる。 Ruff側でこのエラーをキャッチして無視し、「正常終了」したかのように扱ってしまうと、Salsaは A の依存関係(グラフ)を正しく保存しない。 結果として、「次に依存ファイルが修正されたとき、Salsaが依存関係の喪失によりクエリを再実行せず、古い不正なキャッシュデータを表示し続ける」 というキャッシュの一貫性崩壊が発生した。
3. 実体験:マルチスレッド開発における「安全装置としての try-catch」がもたらす悲劇
例外処理(try-catch や Rustの catch_unwind)は、プログラムのクラッシュを防ぐ安全装置であるが、マルチスレッド環境やロックを伴う処理において「良かれと思って例外を揉み消す」ことは、高確率でリソースリークやフリーズのトリガーとなる。
- 実体験エピソード: あるマルチスレッドサーバーの開発において、データベース接続プールから接続(接続オブジェクト)を取り出してデータ処理を行うスレッドプールを実装した。 処理中にエラーが発生してサーバー全体が巻き添えで落ちるのを防ぐため、スレッドの最上部に
catch_allブロックを設置し、どんなエラーが起きてもログを出して「処理をスキップしてスレッドを稼働させ続ける」ようにした。 リリース後、特定のクエリがエラーを吐くたびに、サーバーのレスポンスが次第に遅くなり、最終的に完全に無応答になる(ハングする)障害が起きた。 原因は、例外が発生した際、catch_allで例外を揉み消したため、データベース接続プールへのオブジェクトの返却(release())コードがスキップされ、スレッドが接続を掴んだまま生き残り続けた ことだった。 接続プールが空になり、後続のすべてのスレッドが接続待ちでデッドロックしたのである。 「死ぬべきスレッドを中途端に生かす」ことは、クラッシュよりも恐ろしいデッドロックを招く。
4. 解決策と「並行キャンセル」における設計原則
Ruffのチームは、PR #24141 において以下の修正を行い、並行処理とキャッシュの整合性を両立させた。
4.1. キャンセルパニックの即時伝播によるロック解放
データベース書き込みリクエストがある際は、db.unwind_if_cancelled を用いて、キャンセルパニックを揉み消さずに最速でスレッド外へ伝播させ、データベースハンドル(db)のロックを即座に解放する。
4.2. untracked_read による依存グラフの強制リセット
パニックが発生して依存関係が壊れたクエリに対しては、明示的に untracked_read(未追跡 of 読み込み)をデータベースに記録させる。これにより、次に何らかの変更があった際は、壊れた古いキャッシュを信用せず、強制的にクエリを最初から再実行させて整合性を保つ。
5. 結論
インクリメンタルコンパイルや高並行キャッシュシステムにおける「マルチスレッドのキャンセル・中断」は、極めて緻密なリソース(ロック)のライフサイクル管理を要求する。 例外キャッチは、時にスレッドの「正しい死(ロックの解放)」を妨害し、デッドロックやキャッシュの不整合を呼び込む。 並行インフラを設計する際は、「キャンセルは最速で伝播させてロックを離す」「パニック時の依存グラフは破棄し、untrackedとして再実行を義務付ける」 という、並行ライフサイクルに合致した例外設計を徹底しなければならない。
6. 参考文献 / 一次情報
- [ty] Fix Salsa panic propagation: astral-sh/ruff#24141
- Salsa panic propagation issue in Ty: astral-sh/ty#1565