2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「起きなかった」は、直った証拠になるのか? SQLite WAL-reset bugの確かめ方

2
Posted at

「監視の1行」で、「起きた1回」を待つ確かめ方

안녕하신게라!パナソニック コネクト株式会社クラウドソリューション部の加賀です。

SQLiteは、稼働中のデータベース(以下DB)が1兆を超えると公式資料が推定する、広く使われているOSSで、徹底したテストでも知られます。そのWALには、コミット済みのデータを失う競合が2010年から潜んでいました。2026年、Tailscale社の障害をきっかけに見つかり、修正されました。

この記事で扱うのは、その修正をどう確かめたかです。修正後に障害が起きないことだけで、直ったと証明するのは難しく、いわば「悪魔の証明」です。実際、Tailscale社でも、6週間破損が起きない時期の後に、クリスマスに再発しました。

同社は、修正後に監視用の1行を加え、危険な条件が実際に起きたという記録で確かめました。無事故の期間ではなく「起きた1回」で確かめる、ただ待つのとは違うこの方法が面白く、その「監視の1行」に至る調査の道のりごと紹介したくなりました。

この記事の要点

  • バグが起きるのは、WALモードのDBを複数の接続が別々のスレッドまたはプロセスで開き、書き込みとチェックポイントが重なったときです。コミット済みのデータが失われることがあり、影響範囲は3.7.0から3.51.2までとみられます。
  • 対応は、3.51.3、3.53.0以降、またはバックポート版の3.44.6・3.50.7へ更新することです。3.52.0にも修正は含まれていましたが、撤回済みです。該当するかどうかの確認は、自分の環境が該当するかの確認と更新にまとめています。
  • 確かめ方として、Tailscale社は、監視用の1行が残した警告の記録を、直ったことの根拠にしました。経緯と考え方は、修正を確かめる方法で説明しています。

半年で19回の破損

このバグを開発元へ持ち込んだのがTailscale社です。同社のコントロールプレーンはSQLiteで動いており、半年間でDBが19回も破損しました。破損したシャードでは復旧中にコントロールプレーンが停止し、端末の新規接続や設定変更が一時的にできなくなりました。影響したのは設定やデバイスのメタデータで、暗号鍵や通信内容ではありません。

単一ライタ構成と手動チェックポイント

コントロールプレーンは「シャード」に分かれ、各シャードのSQLiteファイルを単一のGoプロセスが排他的に扱います。同社はこの構成を、SQLiteの想定どおりの使い方だと述べています。

同社は数分ごとにDB全体をスナップショットしてS3へ保存し、バックアップを速く、一貫した状態で取るため、公式に文書化された手動チェックポイントも高頻度に実行していました。この積極的な手動チェックポイントは標準的な使い方から外れていたと、同社は後に振り返っています。同社によると、SQLite開発者は、同社が他の利用者より遭遇しやすかった理由を、チェックポイントを手動で積極的に実行していたためだと説明しました。

共通点のない破損と仮説の検証

2025年8月、S3バックアップを読むデータパイプラインがエラーを検出し、PRAGMA integrity_check でも破損が確認されました。発生するシャード、顧客、機能、時間帯、負荷に共通点は見つからず、再現もできませんでした。そこで本番環境に計測を仕掛け、次の発生を待ちました。

同社はSQLiteの商用サポートを契約し、開発者と共同調査を始めました。close() によるPOSIXアドバイザリロックの解除など、既知の破損原因を調べましたが、どれも該当しませんでした。並行して、破損時に即停止する設定、バックアップの継続的な integrity_check、運用手順の整備を進め、復旧を1時間未満に短縮しました。

ログによる原因の特定

同社は復旧用に、DBを変更するSQL文を記録するトランザクションログを構築しました。単一ライタの履歴は順序が決まるため、正常なバックアップに再適用できます。ところが2件のインシデントで再適用に失敗しました。コミット済みのデータが後続のトランザクションから見えず、エラーも出ていませんでした。

メトリクスにも、WALにあるページ数より多いページを書き戻したと報告する異常がありました。たとえば、WALに10ページしかないのに20ページを書き戻した、という状態です。進捗値が誤っていたのは、データ消失と同じ競合が原因でした。

SQLite開発者は、VFS(OSとのインタフェース層)をラップして、DBへの変更に関する追跡情報とログを出すシム tmstmpvfs を作りました。Tailscale社がこれを本番環境に導入し、次に起きた破損のログから、SQLite開発者がデータ競合を特定しました。Tailscale社は、このOSSのシムの開発に資金を提供したと書いています。

バグの仕組みと修正

WALとチェックポイント

SQLiteのDBは、固定長の「ページ」で構成されます。WAL(Write-Ahead Log)は、変更後のページを -wal ファイルへ追記する方式です。コミットはWALに記録された時点で成立し、DB本体への反映は後のチェックポイントで行います。WAL内でページを探す索引がwal-indexで、-shm という共有メモリファイルに記録され、チェックポイントの進捗(nBackfill)も保持します。

チェックポイントは、WALのページをDB本体へ書き戻す処理です。既定ではWALが1,000ページ以上になると自動で走り、PRAGMA wal_checkpoint や sqlite3_wal_checkpoint_v2() でも実行できます。すべてのページを書き戻して同期し、WALを読む接続もなくなると、次に書き込む接続(ライタ)がWALを先頭へ巻き戻して再利用します。これが「WALのリセット」で、WALが際限なく大きくならないようにするための動作です。リセット時にはWALヘッダの「salt」が変わり、読み手はこれでWALの世代を判別します。

発生条件

公式資料が「WAL-reset bug」と呼ぶこの不具合の発生条件は、WALモードのDBを複数の接続が別々のスレッドまたはプロセスで開き、同じタイミングで書き込みとチェックポイントを試みることです。同じプロセス内でも、別スレッドから開かれていれば条件を満たします。単一ライタでも複数接続はあり得ます。Tailscale社の場合も、SQLite開発者がこのバグが原因だと確認しており、複数接続の条件に該当していたと考えられます。影響範囲は3.7.0(2010-07-21)から3.51.2(2026-01-09)までとみられます。

公式は、テレメトリから見た発生率がSSD故障や宇宙線ヒットと同程度以下で、緊急ではないとしています。一方で、起きた場合の影響は深刻なため、修正版への更新を勧めています。データが消えても気づけないことがあり(後述)、発生率が低くても、更新は早めに済ませておくほうが安心です。

ページが飛ばされるまで

  1. 1回目のチェックポイントがすべてのページを書き戻して完了し、WALがリセット可能な状態になります。
  2. 直後に2回目のチェックポイントが始まります。この開始処理の最中に別の接続がコミットしてWALをリセットすると、saltが変わり、データ競合(data race)が起きます。
  3. 2回目のチェックポイントは世代交代に気づかず、実際には未反映のページまで「書き戻し済み」として、wal-indexの nBackfill に記録します。
  4. その後トランザクションが追加され、WALが1回目より大きくなると、3回目のチェックポイントは誤った nBackfill をもとに、リセット後のページを飛ばします。

図の黄色の範囲が競合の起きる区間、赤の範囲がその後の影響です。ページが飛ばされると、コミット済みで SQLITE_OK が返った書き込みでも、本体へ反映されずに消えることがあります。消えたページを参照するインデックスなどが残っていれば、PRAGMA integrity_check が不整合を検出します。データが消えただけなら検出できず、破損は副作用です。

修正の中身

公式の修正日は2026年3月3日です。src/wal.c にsaltの比較を追加し、回帰テスト test/walrestart.test も新設しました。以下は修正の抜粋で、一部を簡略化しています。

       u32 nBackfill = pInfo->nBackfill;
-      pInfo->nBackfillAttempted = mxSafeFrame; SEH_INJECT_FAULT;
+      WalIndexHdr *pLive = (WalIndexHdr*)walIndexHdr(pWal);
+
+      /* Now that read-lock slot 0 is locked, check that the wal has not been
+      ** wrapped since the header was read for this checkpoint. (略)
+      ** pInfo->nBackfill had already been set to 0 by the writer that wrapped
+      ** the wal file. */
+      int bChg = memcmp(pLive->aSalt, pWal->hdr.aSalt, sizeof(pWal->hdr.aSalt));
+      if( 0==bChg ){
+        pInfo->nBackfillAttempted = mxSafeFrame; SEH_INJECT_FAULT;
+        ...
+      }

修正の要点は、saltでWALの世代を比べることです。WALを折り返したライタは nBackfill を0に戻しますが、従来のチェックポイントはそれに気づかず、書き戻し処理を誤って進めていました。修正後は、saltが変わっていれば進捗を更新しません。この条件分岐を、以下では「ガード」と呼びます。差分は src/wal.c で73行追加・58行削除ですが、追加された条件は if( 0==bChg ){ の1つだけで、大半は既存処理のインデント変更でした。

発覚から確認までの経緯

時期 出来事
2025年8月 最初の破損を検知。以降の半年間で19回発生
2026年3月3日 SQLite開発者が修正をコミット
3月13日 3.52.0の撤回を経て、3.51.3を公開
修正後 監視の1行を仕込み、初めて警告が出るまで待つ

発覚から修正まで約7か月、その後の確認にさらに数か月かかっています。Tailscale社は、この対応にエンジニアリングとサポートのチーム、SQLiteのメンテナを含む数十人が関わったと書いています。

同じコミットで新設された回帰テスト test/walrestart.test は87行あり、実質的に追加された条件よりも、再現と検証のほうにコードを費やしていることが分かります。

通常のテストで見つからなかった理由

SQLite開発者も当初は自然に再現できず、sqlite3_test_control() 経由のコールバックで狙った瞬間に書き込みを差し込み、ようやく再現しました(公式資料の§11.2)。

SQLiteのテストは、公式資料によると、独立した4つのテスト基盤を持ち、TH3のデフォルト構成ではコアとUnix VFSで100%の分岐網羅と100% MC/DCを達成しています。テストコードとスクリプトは、3.42.0(2023-05-16)時点で本体の約590倍です。それでも今回のバグには、入力に加えて「2回目のチェックポイント開始中に、別の接続がWALをリセットする」という時間的な重なりが必要で、同じSQLを同じ順番で実行しても、スレッドのスケジューリング次第で発生したりしなかったりします。分岐網羅は分岐を通る順序までは保証しないため、通常のテストやファジングでは見つけにくかったと考えられます。

後から公開された再現手順

2026年8月、決定論的テスト基盤のAntithesis社が再現成功を公表しました。SQLite 3.51.2のソースにアサーションを加えた計測用ビルドと、並行書き込みとチェックポイントを行うワークロードを使い、作成にはAIエージェント(Claude)を使ったとのことです。バグの残る3.51.2で初回実行から15分で再現し、「コミット済みデータが消えない」「DBが破損しない」という汎用的なアサーションで検出しました。ワークロードもアサーションも、このバグ専用のものではありません。同じ条件で3.51.3に問題がないことも確認しています。ただし、すでに知られていたバグを対象にした検証です。

8月23日には、Phil Eaton氏が、約100行のCコードと公開APIを使う別の再現手順を公開しました。PRAGMA mmap_size とDBを大きくしてチェックポイント中の munmap を遅くし、その間に別スレッドがWALをリセットして書き込むと競合が起き、SQLITE_OK が返った件数より読み戻せた件数が少なくなる様子が数秒で確認できます。破損を伴わないデータ消失も確認され、Eaton氏によれば、Tailscale社が integrity_check で発見できたのは、ある程度は幸運でした。翌8月24日には、公式資料の§11.2にもこの手順へのリンクが追記されました。自然には再現しにくいバグでも、競合の窓を意図的に広げる手段が見つかれば、公開APIだけで再現できることが分かります。

修正を確かめる方法

無発生期間では判断できない

半年で19回も起きていたバグでも、6週間の空白の後にクリスマスに再発しました。発生間隔が読めない以上、一定期間の無発生だけでは修正の効果を判断できません。

ガードが働いた記録を残す

Tailscale社は、修正を含むSQLiteの開発版(trunk)のコードにパッチを当て、書き込みとWALリセットが重なったときに警告を出しました。使ったのは修正と同じsalt比較で、SQLite側のコードへ追加したのは差分の + で示した監視の「たった1行」だけです。同じコミットには、リリース版と開発版(trunk)を切り替えるビルド設定の変更もあります。前後のコードは修正後の wal.c そのままで、Tailscale社の監視パッチのコミットから引用しました。

 int bChg = memcmp(pLive->aSalt, pWal->hdr.aSalt, sizeof(pWal->hdr.aSalt));
+if( bChg ) sqlite3_log(SQLITE_WARNING, "wal wrapped after header was read for this checkpoint");
 if( 0==bChg ){
   pInfo->nBackfillAttempted = mxSafeFrame; SEH_INJECT_FAULT;
   ...
 }

saltが変わった(bChg が真)とき、修正版は書き戻し処理を行わず、進捗も更新しません。つまり警告が出た回は、ガードが働いた回です。監視パッチは2026年3月4日(UTC)、SQLite側の修正の翌日にコミットされました。

警告の読み方

警告はデプロイから約2か月後に初めて出ました。同社によると、その警告が出てからブログの執筆時点までの約4か月間、DBの障害は起きていません。期間は同社の概算の表現で、日付から単純に数えた期間とは完全には一致しません。

ただし警告は、WALの世代が変わる競合を検知した記録です。公式の手順では、このあと別のトランザクションでWALが1回目より大きくなり、3回目のチェックポイントが走って初めてページが飛ばされます。警告は、そこまで進んだかどうかや、個々のトランザクションの永続性までは検査しません。そのため、警告は「防げた」証拠ではなく、危険な条件が実際に起き、ガードが働いた記録として読むのが正確です。警告後に障害が起きなかったことと、前述のAntithesis社による3.51.3の検証は、それぞれ別の観測結果です。

計測を使える条件

この計測は、修正コードの判定を計測点として再利用した方法です。再発防止策の効果測定についての記事で触れた「測る仕組み」の一例でもあります。この方法には、修正が自分のビルドに含まれ、発動条件が単一の分岐で、その都度ログを出せることが必要です。マネージドサービス内部で修正されたバグには使えません。

自分の環境が該当するかの確認と更新

条件に当てはまるかを見る

PRAGMA journal_mode が wal で、複数の接続(別スレッドまたは別プロセス)が同じDBファイルを開いていれば、前述の発生条件に当てはまる可能性があります。頻繁な手動チェックポイントや、自動チェックポイントを無効にして別スレッド・プロセスで実行する構成は、競合の機会を増やす可能性があります。1プロセス1接続なら、複数接続の条件には該当しません。更新以外の回避策は、公式資料では確認できませんでした。

バージョンを見る

SELECT sqlite_version();

このSQLは、アプリケーションが実際に使う接続で実行してください。CLIとアプリで、SQLiteのバージョンが異なる場合があるためです。OSやディストリビューションの同梱版は、バックポートの状況を各ベンダーの情報で確認してください。

2026年10月14日時点の最新は3.54.0です。使うバージョンは、3.51.3(2026-03-13)、3.53.0以降、またはバックポート版の3.44.6・3.50.7にしてください。3.52.0(2026-03-06)にもWALリセットの修正は含まれていましたが、次項の式インデックスの誤検知が原因で撤回され、3.52.0向けだった機能は3.53.0へ移されました。3.52.0を使っている場合は、撤回されたバージョンのため更新が必要です。言語ドライバがSQLite本体を同梱している場合は、ドライバも更新してください。

3.52.0で起きた誤検知

Tailscale社が修正を含む3.52.0を、まず一部のカナリアシャードで試し、問題がないため残りへ展開すると、バックアップ監視が13個のDBを破損と誤判定しました。原因は、3.52.0に含まれていた別の最適化で、文字列から浮動小数点数への丸め方が変わったことです。同社はtimestampをテキストで保存し、VIRTUAL generated columnで浮動小数点数に変換していたため、この列の式インデックスが古い値のままになり、PRAGMA integrity_check が破損として報告しました。カナリアシャードには丸め方の変化が表れるtimestampがなく、全体展開後に表面化しました。SQLite開発者はこの誤検知を理由に3.52.0を撤回し、WALリセットの修正と軽微な修正だけを含む3.51.3を公開しました。

同社はtimestampを整数秒に変更し、SQLiteは3.53.0でself-healing index(古くなった式インデックスを自動で直す機能)を追加しました。

更新するときの注意

3.53.0以降へ更新する場合も、この式インデックスの件に注意してください。3.53.0は、浮動小数点数と文字列の変換の丸めを、既定で15桁から17桁に変え、式インデックスを再構築する REINDEX EXPRESSIONS も追加しました。式インデックスやVIRTUAL generated columnを使っている場合、更新直後の PRAGMA integrity_check が、実際の破損ではなく式インデックスの不一致を報告することがあります。3.51.3は、この丸めの変更を含みません。

過去に発生したかの確認

過去の発生を確実に確かめる方法はありません。PRAGMA integrity_check では、データが消えただけの場合を検出できないためです。SQLiteは修正後も、書き込みの消失そのものを検出する仕組みを持たないとEaton氏は指摘しています。アプリ側で持っている件数とDBの中身の食い違いが、手がかりになります。

おわりに

WALのリセットとチェックポイントが重なる、ごく短い時間に起きるバグでした。調査の記録や、後に公開された再現手順からは、道具や計測では見えなかったバグが見えるようになっていく過程が分かり、そこが面白いと感じました。

修正の本体は条件分岐1つ、確認のための監視はわずか1行で、見た目はごく小さな差分でした。しかしそれらは、半年で19回の破損、仮説を1つずつ除外した調査、専用のVFSシムとトランザクションログ、そして自然には再現できないバグを狙って起こすテストの積み重ねの上に立っています。公開された経緯を読むと、その小さな差分を確信を持って書くまでに、どれだけの人の手間と辛抱があったかが分かります。

障害の経緯とバグの詳細を公開してくれたTailscale社とSQLite開発チーム、再現手順を公開してくれたAntithesis社とEaton氏に感謝します。

冒頭で「ただ待つのとは違う確かめ方」と紹介しましたが、Tailscale社も、警告が出るまで約2か月は待っていました。警告が壊れているのか、仮説が間違っているのかと疑いながらです。違うのは、何も起きないことを待つのではなく、起きる1回を待っていた点でした。この疑いながら待つ辛抱強さこそ、爪の垢を煎じて飲みたいところです。


お断り
記事内容は個人の見解であり、所属組織の立場や戦略・意見を代表するものではありません。
あくまでエンジニアとしての経験や考えを発信していますので、ご了承ください。
本記事で参照した障害事例および調査経緯は、すべて各社・各開発者が公開した情報に基づいています。公開情報からの筆者の解釈を含む箇所は、断定を避けて書いています。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?