強制再起動、プロセスのキル、あるいは書き込み途中でディスクが満杯になったことによるものであれ、途中で死んでしまう保存は、インプレース書き込みを中心に構築された形式にとって伝統的に一つのことを意味してきた:中断前にディスクに到達したバイトがそのまま結果として返ってくる、そして途中で切れたワークブックは二度と開けない。HotXLSは、それが書き込むあらゆるXLSX、ODS、そして従来のXLSファイルに使われるクラッシュセーフな保存経路でこの失敗モードを閉じる。各SaveAs呼び出しは、完全な新しいファイルを目的地の隣に作成された一時ファイルに書き込み、その後Windows APIの単一のアトミックなMoveFileExWのリネームでそれをコミットする。そのため、中断された保存は新しいファイルを生成できないだけであり、すでに持っていたファイルを損傷させることは決してない。この同じステージ・アンド・スワップの規律は、従来のXLSの背後にあるBIFF8ライターとXLSX・ODSの背後にあるOOXMLライターという、HotXLSの2つの保存エンジンの両方にわたって一様に実行されており、これは自分のDelphiコードが直接上書きするどんなファイルに対しても、スプレッドシートであろうとなかろうと、借用する価値のあるパターンである
ワークブックの保存が途中で中断されるとどうなるのか
直接の答えは、ライターが目的地のファイルにどう触れるかに完全に依存するということであり、一般的な実装——目的のファイルを開き、新しいコンテンツをそこへ直接ストリーミングする——は、何も問題が起きない限り問題ない。何かが起きた瞬間、クラッシュ、強制的なプロセスキル、書き込み途中で切断されるネットワーク共有——ディスク上のファイルは、ライターが到達したどんな中間状態にでも取り残される:XLSXやODSの場合、ZIPの中央ディレクトリが一度も追加されないままだったり、従来のXLSの場合、リーダーが期待するレコードが欠けたBIFFストリームだったりする。Excelはそれを優雅に修復してはくれないし、完全なファイルを期待する他のどんな消費者もそうだ。そのため実際の結果は、昨日は問題なく開けたのに今日は開くことを拒否するワークブックである
HotXLSはどうやってすべての保存を一つのアトミックなスワップの背後にステージングするのか
HotXLSは、保存する3つの形式のいずれについても、目的地のファイルを書き込み用に直接開くことは決してない。その手順は毎回同じ形をしている:ユーザーがすでにディスク上に持っているファイルではないどこかに完全な出力を構築し、その構築が完全に成功して初めてそれを所定の場所に移動する。具体的には、SaveAsはターゲットパスと同じフォルダに空の一時ファイルを作成し、その一時ファイルに新しいワークブック全体を書き込み、その書き込みがエラーなく戻った後になって初めて、単一のリネームでその一時ファイルを目的地の上にコミットする。これは有効化すべきプロパティを一切必要としない。それは単にSaveAsが普通のファイルパスに対して、あらゆる呼び出しで行うことである
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
begin
Book := TXLSXWorkbook.Create;
try
Sheet := Book.Sheets.Add('Report');
Sheet.Cells[1, 1].Value := 'Nothing special to enable here';
// If this call is interrupted, monthly-report.xlsx on disk stays
// either the old version, complete, or the new version, complete
if Book.SaveAs('monthly-report.xlsx', xlsxOpenXMLWorkbook) <> 1 then
raise Exception.Create('Save failed, see Book.LastDiagnostic');
finally
Book.Free;
end;
end;
同じ規律は、OOXMLのものだけでなく従来のXLSライターにも適用され、この2つの一時ファイルは命名規則さえ共有している:どちらもhxlという接頭辞を付けてWindowsのGetTempFileNameW APIを呼ぶ。そのためクリーンアップの前に中断された保存は、ワークブックの隣にhxl4C2A.tmpのような名前の見慣れないファイルを残すことがある。そのファイルは破損ではなく、この仕組みが設計通りに正確に機能した証拠である:不完全な書き込みはそこで止まり、実際のワークブックはそもそも書き込み用に開かれることが一切なかった。クラッシュの後にそれを見るのは削除しても安全であり、調査すべきことは何もない
なぜ一時ファイルを%TEMP%ではなくワークブックの隣にステージングするのか
手短な答えは、MoveFileExWのリネームはソースと目的地が同じボリューム上にある場合にのみアトミックであり、呼び出し元に何も設定させることなくそれを保証する最も確実な方法は、目的地のパス自体から一時ファイルの場所を導出することだからである。HotXLSはターゲット自身のフォルダを計算し、そのディレクトリを直接GetTempFileNameWに渡す。そのため一時ファイルは常に、それが置き換えようとしているファイルと同じドライブ、同じボリューム上に、あらゆる保存に対して自動的に作成される。もしライブラリが代わりにシステムの一時フォルダに書き込みをステージングしていたら、別のドライブやマッピングされたネットワークボリューム上のターゲットパスは、最終ステップをボリューム間の操作に変えてしまい、Windows APIはそれを完全に拒否するか、あるいは呼び出し元がHotXLSがここで設定していない追加のフラグで明示的にオプトインした場合、非アトミックなコピーとそれに続く削除へと静かに劣化してしまう。これはまさにこの仕組み全体が閉じるために存在する中断のウィンドウを再び開いてしまうことになる
コミットステップ:MoveFileExW、書き込み保証、失敗時に何が起こるか
あらゆる保存の最終ステップは、それぞれ異なる仕事をする2つのフラグを伴う、正確に1回のWindows API呼び出しであるMoveFileExWである。MOVEFILE_REPLACE_EXISTINGは、そのリネームがすでに存在するファイルの上に着地することを許可するものである:これがなければ、既存のパスをターゲットとするリネームは単に失敗し、それはすでに持っているワークブックを置き換えることを意図した保存全体の目的を打ち砕いてしまう。MOVEFILE_WRITE_THROUGHは耐久性をカバーする:それは、リネームが単にキューに入れられた時点ですぐに戻るのではなく、移動がディスク上で実際に完了するまでこの関数が戻らないよう指示する。これは、SaveAsが戻った直後のクラッシュがスワップの実行中を捉えてしまうという、より狭いが現実の競合を閉じる。一時ファイルが作成できない場合、あるいは最終的なリネームが何らかの理由(権限の問題、ロックされた目的地、ボリュームの不一致)で失敗する場合、HotXLSはゴミを残す代わりに一時ファイル自体を削除し、目的地のファイルは呼び出し前と全く同じ状態のままにされる
Result := Book.SaveAs(TargetPath, xlsxOpenXMLWorkbook);
if Result <> 1 then
begin
// TargetPath on disk is unchanged; safe to retry, alert, or
// fall back to a different path without touching prior output
LogWriter.Write(Format('SaveAs failed (%d): %s',
[Book.LastDiagnostic.Code, Book.LastDiagnostic.Message]));
Exit(False);
end;
SaveAs自体は、成功時は1、失敗時は負の数というHotXLS全体で共有される戻り値の規約を保っているが、単なる整数は保存がなぜ失敗したかを語らず、すべての負の結果を同じように扱うことは、リトライポリシーが実際に使えたはずの情報を捨ててしまう。LastDiagnosticプロパティと、その背後にあるより充実したDiagnosticsコレクションは、HotXLSが内部で生成したメッセージを運び、作成できなかった一時ファイルとWindowsが拒否したリネームとを区別する。失敗したすべてのSaveAsのCodeとMessageをログに記録するバッチジョブは、顧客が何も起こらずに静かに終わった保存を報告してきたときにまさに欲しい証拠を積み上げてくれる
従来のXLSはメモリで代償を払い、XLSXとODSはディスクで代償を払う
2つの保存エンジンは異なる経路で同じクラッシュセーフな結果に到達しており、その違いは、すでに大規模なバッチジョブ向けにどちらかをチューニングしているなら重要になる。従来のXLSライターは、まずメモリハンドルに裏付けられた構造化ストレージを使って、OLEコンパウンドドキュメント全体をメモリ上に構築し、その完成したバッファを兄弟の一時ファイルへ一度の書き込みでコピーするだけである。HotXLS自身のソースにあるその理由は率直だ:まずファイル全体をメモリ上に構築することこそが、失敗またはキャンセルされた保存が目的地を切り詰めてしまうことを決して起こさせない方法である。XLSXとODSのライターは代わりに、そのZIPエントリが生成されるたびにそれを一時ファイルへストリーミングする。これは同じファイルレベルのステージングだが、メモリプロファイルは異なる。すでにStreamingWriteに頼って大きなXLSXエクスポートをコンテナのメモリ制限内に収めているなら、従来のXLSエクスポートには同等のレバーが同じ形では存在しないことを知っておいてほしい:クラッシュセーフの保証はどちらの場合も無条件だが、非常に大きなレガシー.xlsエクスポートはそれに関わらず完全な出力をRAM上に保持する。このトレードオフはサーバーバッチジョブ向けストリーミング書き込みに関する記事でより詳しく扱われている
同じパターンをHotXLSの外に適用する、そして保証が終わるところ
このパターンを借りることは、主にHotXLSが内部で頼っている同じ2つのWindows API呼び出しを配線する問題である。GetTempFileNameWは、選んだフォルダの中に一意な名前の空ファイルを渡してくれ、MoveFileExWは完成した書き込みを一つのステップで実際の目的地にコミットする。HotXLSがあらゆるSaveAsの前に実行しているのと同じルーチンの最小版は次のようになる
function SaveFileAtomically(const Path: WideString; const Contents: TBytes): Boolean;
var
Dir, TempName: WideString;
Buffer: array[0..MAX_PATH] of WideChar;
FS: TFileStream;
begin
Result := False;
Dir := ExtractFilePath(ExpandFileName(Path));
FillChar(Buffer, SizeOf(Buffer), 0);
if GetTempFileNameW(PWideChar(Dir), 'app', 0, @Buffer[0]) = 0 then
Exit;
TempName := PWideChar(@Buffer[0]);
try
FS := TFileStream.Create(TempName, fmCreate or fmShareExclusive);
try
FS.WriteBuffer(Contents[0], Length(Contents));
finally
FS.Free;
end;
Result := MoveFileExW(PWideChar(TempName), PWideChar(ExpandFileName(Path)),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH);
finally
if not Result then
DeleteFileW(PWideChar(TempName));
end;
end;
この保証には、盲目的に頼る前に知っておく価値のある本物の限界がある。元のファイルを置き換える前に完全なコピーをステージングするということは、保存が書き込みの間、古いファイルと新しいファイルの両方のためにおおよそワークブックのサイズの2倍のディスク空き容量を一時的に必要とすることを意味し、レポートであれば問題ないが、ほぼ満杯のボリュームに対して実行される数ギガバイトのエクスポートでは確認する価値がある。一時ファイルはまた目的地と同じフォルダに着地しなければならないため、HotXLSが実行されているアカウントは、そのフォルダに対する具体的なファイル作成権限を必要とする——それはすでに知っている1つのファイルを上書きする権限だけではない。特定の既存ファイル名のインプレース編集にまで目的地フォルダをロックダウンしている(フォルダレベルの書き込みアクセスではなく)デプロイメントは、同等の直接書き込みなら成功していたはずのところでSaveAsが一時ファイルのステップで失敗するのを見ることになる
率直に指摘しておく価値のある境界がさらに2つある。ネットワーク共有上の目的地や、OneDriveあるいは類似のクライアントによって同期されるフォルダの内部は、Windowsがそれを単一のボリュームとして報告し続けていても、ローカルのNTFSとは異なる振る舞いをすることがある。なぜならその手前にあるファイルシステムドライバがリネームを同じように実装していないことがあるからだ。デプロイ先がネットワークパスにまたがって保存するなら、ローカルディスクの挙動がそのまま持ち込まれると仮定するのではなく、そこで具体的に強制中断をテストする価値がある。そしてこの仕組み全体は、名前付きファイルへの保存にスコープされている。代わりにTStreamに対してSaveAsを呼べば、HotXLSは渡されたストリームへ直接書き込むだけであり、ステージングまたは保護すべき目的地ファイルは存在しない。なぜならそのストリーム(メモリバッファ、ネットワークアップロード、データベースBLOB)の耐久性は、その時点からあなたのコードの責任だからである
検証パスは、その後まさにこの保証に頼ることができる。ワークブック監査・変換ワークベンチに組み込まれている種類のものも含めてだ:再オープンしたファイルが短かったり欠けていたりするなら、それは追いかけるべき本物の変換上の問題であり、途中で中断されディスク上に曖昧な何かを残した保存であることは決してない。クラッシュセーフなステージング書き込みは、DelphiおよびC++Builder向けHotXLSコンポーネントが生成するあらゆるXLSX、ODS、従来のXLSワークブックに対してSaveAsに組み込まれており、それを有効にするための設定は一切必要ない