5秒前まで正常に動作していた設定ファイルが、今や347バイトの壊れたJSONの断片と化している。CLIツールは起動しない。アップデートに予想以上の時間がかかっているため、単にCtrl-Cを押しただけのユーザーは、望んでもいないエラーのスタックトレースを目の当たりにすることになる。書き込み前に存在していた2KBの有効な設定は消え去り、半分だけ印刷されたレシートのようなデジタルな残骸に置き換わっている。

これは、writeFileがアトミック(不可分)ではないために起こる。既存のパスを開き、内容を切り詰め(truncate)、Nodeからカーネルのページキャッシュへデータをストリームし、最終的にファイル記述子を閉じる。切り詰めが行われた瞬間、古い内容はすでに失われている。その切り詰めから最終的なcloseまでの間は、脆弱な時間枠(window of vulnerability)となる。その最中にSIGINTが発生したり、停電が起きたり、ノートPCの蓋が閉じられたりすると、ファイルシステムには切り詰められた無残なデータが残される。NodeがPromiseの解決(resolved)を報告したとしても、オペレーティングシステムは依然としてメモリ内で書き込みをバッファリングしている可能性がある。利便性の高いメソッドはその隙間を隠蔽するが、取り除くわけではない。

解決策は、その場で上書きすることではない。書き込みという行為と、公開(反映)という行為を分離することだ。

隣接ファイルに書き込み、その後入れ替える

信頼性の高いパターンには5つのステップがある。どれも複雑なものではないが、これらを組み合わせることで、失敗が発生しうる時間枠を「ストリーミング書き込み全体」から「単一のファイルシステム・メタデータ操作」へと縮小できる。

まず、メモリ内でペイロード全体をシリアライズする。これは一時ファイルを作成する前に行う。循環参照を含むオブジェクトが渡されたためにJSON.stringifyが例外をスローした場合、ディスクに触れる前にその例外を上位に伝播(bubble up)させたいからだ。

次に、シリアライズされたデータを、ターゲットと同じディレクトリにある一時ファイルに書き込む。並行して実行された際に衝突しないよう、ランダムな名前を使用する。一時ファイルを同じディレクトリに置くことが重要なのは、renameが単一のファイルシステム内でのみアトミックだからである。もし一時ファイルが異なるパーティションにある場合、OSはコピーして削除するというシーケンスにフォールバックし、それ自体が新たな失敗モードを招き、アトミックではなくなってしまう。

第三に、カーネルに対してその一時ファイルを物理ストレージにフラッシュ(flush)するよう要求する。Nodeのfsync(ここではファイルハンドル上のsyncメソッドとして公開されている)は、バッファが物理媒体に書き込まれるまでブロックする。これは低速だが、設定ファイルの書き込みは頻繁に起こるものではないため、数ミリ秒を費やしてでも耐久性を確保する価値がある。

第四に、一時ファイルを元のパスに対してrenameする。POSIXシステムとWindowsの両方において、これがコミットポイントとなる。元のパスを開く読み取り側は、「完全に古いファイル」か「完全に新しいファイル」のどちらかを見ることになる。読み取り側がパスを開いた瞬間に、書き込み途中のバッファを

catch ブロック内でのクリーンアップは、意図的に防御的に設計されています。ファイルハンドルが開かれた後に何らかの例外が発生した場合、コードはハンドルを閉じ、一時ファイルを削除しようと試みます。その際、二次的なエラーを飲み込む(swallow)ことで、元の例外がクリーンに伝播するようにしています。クリーンアップ中の権限エラーによって、失敗の真の原因となったバグが隠されてしまうことを防ぐためです。

このパターンが通用しなくなる場面

アトミックなファイル置換は、不完全な書き込み(torn writes)を防ぎますが、更新の消失(lost updates)を防ぐことはできません。もし2つのCLIインスタンスが同時に同じ設定ファイルを読み込み、両方がメモリ上で編集を行い、両方が新しい一時ファイルを書き込み、両方がリネームを実行した場合、2番目のリネームが優先されます。最初のプロセスは、2番目のプロセスの変更を検知していなかったからです。アプリケーションによりますが、これは、あるユーザーが1つのターミナルで設定を追加し、別のユーザーが別のターミナルでそれを削除した場合に、最終的なファイルには最後の書き込み者による内容しか反映されない、という事態を意味する可能性があります。

もしツールが並行する変更(concurrent mutations)をサポートする必要があるなら、アトミックな書き込みの上に調整メカニズムを構築する必要があります。単純なケースであれば、アドバイザリ・ロックファイルが有効です。設定ファイル自体にバージョンベクトルや単調増加するリビジョン番号を持たせることで、衝突を検知し、2番目の書き込み者がリトライできるようにすることもできます。ただし、これらは複雑さを増大させ、複雑さはバグが潜む温床となります。

だからこそ、境界線が重要なのです。1つのプロセスが時折更新するだけの単一のJSONブロブであれば、アトミックなファイル書き込みの候補として適しています。しかし、複数のレコードを管理したり、スキーマを強制したり、並行する変更を気にしたりする必要が出てきたなら、それはすでにファイルシステムの限界を超えています。まさにそのためにSQLiteが存在します。SQLiteは、単一のホストローカルファイル内で、アトミックなトランザクション、ロールバックジャーナル、そして並行する読み手と書き手の適切なハンドリングを提供してくれます。巧妙なファイルプロトコルはデータベースではありません。そうでないふりをしてメンテナンス予算を費やすべきではありません。

真の教訓

次回、CLIツール内で writeFile を使おうとしたときは、一度立ち止まってください。難しいのはシリアライズではありません。耐久性(Durability)です。設定ファイルは、ストリーム処理するには小さすぎ、切り詰める(truncate)には重要すぎます。ペイロード全体を隠しファイルに書き込み、フラッシュし、リネームによってコミットし、ディレクトリに反映させます。ユーザーが Ctrl-C を押したり、電源コードを引き抜いたり、ノートPCの蓋を閉じたりしても大丈夫です。マシンが復旧したとき、ファイルには以前の状態か、あるいは新しい状態のどちらかが含まれています。その中間の状態になることはありません。