From 2e2a65da62f5503ec68b572c7471fdbd99602b98 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 29 Aug 2026 20:44:05 +0900 Subject: [PATCH 1/6] =?UTF-8?q?docs(PLAN40):=20=E5=B7=AE=E5=88=86=E3=82=B9?= =?UTF-8?q?=E3=83=8A=E3=83=83=E3=83=97=E3=82=B7=E3=83=A7=E3=83=83=E3=83=88?= =?UTF-8?q?=E3=81=AE=E5=BE=A9=E5=85=83=E3=81=8C=E9=80=94=E4=B8=AD=E3=81=A7?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=81=99=E3=82=8B=E4=B8=8D=E5=85=B7=E5=90=88?= =?UTF-8?q?=E3=81=AE=E3=83=97=E3=83=A9=E3=83=B3=E3=82=92=E4=BD=9C=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN39 の実機検証中に見つかった既存不具合。差分を 2 つ以上持つ世代は incr-002 の適用で GNU tar が `Cannot rename ... Directory not empty` で落ち、 ボリュームが中途半端な状態で残る。現行 main と同じ旧コマンド形式でも再現する ため PLAN39 の退行ではない。 根本原因は未特定のため、Task 1 を調査に充てて代替案 A〜E から採否を決める形にした。 どの案でも「既存スナップショットを作り直さずに復元できる」ことを外さない。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S5oA2PqY6UX2Ca3t78886t --- ...40_snapshot-incremental-restore-failure.md | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 issues/PLAN40_snapshot-incremental-restore-failure.md diff --git a/issues/PLAN40_snapshot-incremental-restore-failure.md b/issues/PLAN40_snapshot-incremental-restore-failure.md new file mode 100644 index 00000000..d5f41bba --- /dev/null +++ b/issues/PLAN40_snapshot-incremental-restore-failure.md @@ -0,0 +1,217 @@ +# PLAN40: 差分スナップショットの復元が途中で失敗する不具合を直す + +## 関連リンク + +- 発見の経緯: `issues/PLAN39_account-group-volume-separation.md` の「検証中に見つかった別件」 +- 参考: `lib/devbase/snapshot/manager.py`(`restore` / `_create_full` / `_create_incremental`)、 + `containers/snapshot/Dockerfile`、`docs/user/snapshot-guide.md` +- 一次情報: + - [GNU tar: Incremental Dumps](https://www.gnu.org/software/tar/manual/html_node/Incremental-Dumps.html) + — `--listed-incremental` の状態ファイルと、復元時にディレクトリの + 「そこに無いはずのもの」を扱う仕組み + - [GNU tar: Levels of Backup / Restoring from Incremental](https://www.gnu.org/software/tar/manual/html_node/Restoring-from-Incremental.html) + — レベル 0 から順に適用する前提と `--listed-incremental=/dev/null` の位置づけ + +## モード + +`standard` — 本番の振る舞い(復元)のバグ修正。公開インタフェースもデータ移行も伴わない。 +ただし**失敗すると利用者のデータが中途半端な状態で残る**経路なので、現状固定テストを +先に置いてから触る。 + +## 目的と非目的 + +達成したい状態: + +- 差分を 2 つ以上持つスナップショットが**最後まで復元できる**。 +- 復元が失敗したとき、ボリュームが**中途半端な状態のまま放置されない**(利用者が + 次に何をすればよいか分かる)。 +- 既存の(分離前を含む)スナップショットがそのまま復元できる。作り直しを強いない。 + +やらないこと: + +- スナップショット形式そのものの作り替え(tar + zstd + listed-incremental の維持)。 +- 世代管理・ローテーション・自動スナップショットの方針変更。 +- PLAN39 で入れたアカウントグループ対応の設計変更(対象ボリュームの解決は現状のまま)。 + +## 前提 + +すべて現行 `main` (`e15ca84`) 上で確認済み。 + +- 前提 1: **差分の 2 つ目以降で復元が落ちる。** 実在するスナップショット + `backups/20260823-114528`(差分 8 個)を使い捨てボリュームへ復元すると、 + `full` と `incr-001` は成功し、`incr-002` で失敗する。 + + ``` + tar: Cannot rename './.claude/plugins/cache/claude-plugins-official/hookify/unknown/commands' + to './.claude/plugins/cache/claude-plugins-official/skill-creator/unknown': Directory not empty + tar: Exiting with failure status due to previous errors + ``` + +- 前提 2: **PLAN39 の退行ではない。** PLAN39 は復元コマンドを + `cd /target && ... tar -xf -` から `... tar -xf - -C /target` へ書き換えたが、 + **`main` と同じ旧コマンド形式でも同一エラーが再現する**。切り分けの手順: + + ```bash + V=plan40_repro; SNAP= + docker volume create $V + # full (旧形式) + docker run --rm -v $V:/target -v $SNAP:/backup:ro devbase-snapshot:latest bash -c \ + "cd /target && find . -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + 2>/dev/null; \ + zstd -d /backup/full.tar.zst -c | tar --listed-incremental=/dev/null -xf -" # 成功 + # incr-001 → 成功 / incr-002 → 上記エラー + ``` + +- 前提 3: 作成側と復元側のコマンドは次のとおり + (`lib/devbase/snapshot/manager.py:211,229,452,498`)。 + + | 側 | コマンド | + |---|---| + | フル作成 | `tar --listed-incremental=/backup/snapshot.snar -cf - -C /source . \| zstd -1 -T0 -o /backup/full.tar.zst` | + | 差分作成 | `cp snapshot.snar snapshot.snar.bak && tar --listed-incremental=/backup/snapshot.snar -cf - -C /source . \| zstd ...` | + | フル復元 | ` zstd -d /backup/full.tar.zst -c \| tar --listed-incremental=/dev/null -xf - -C /target` | + | 差分復元 | `zstd -d /backup/incr-NNN.tar.zst -c \| tar --listed-incremental=/dev/null -xf - -C /target` | + +- 前提 4: **失敗するとボリュームが中途半端な状態で残る。** + `restore()` は `full` を展開したあと差分を順に適用し、`_run_docker_tar` が + `SnapshotError` を投げてそこで終わる (`manager.py:193-214`)。ロールバックは無い。 + 直前に `pre-restore-` の自動バックアップは作られる (`同 172-178`)。 + +- 前提 5: tar は **GNU tar 1.35**(`containers/snapshot/Dockerfile` の + `ubuntu:26.04` 同梱。`zstd` だけを追加インストールしている)。 + +- 前提 6: 影響を受けるのは**差分を 2 つ以上持つ世代**である。現存する世代: + + | 世代 | 差分数 | 対象ボリューム | + |---|---|---| + | `20260817-110811` | 10 | `devbase_home_ubuntu`(分離前) | + | `20260823-114528` | 8 | `devbase_home_ubuntu`(分離前) | + | `20260829-123605` | 1 | `devbase_home_ubuntu`, `devbase_home_default` | + + PLAN39 後の世代もローテーション上限 (`DEFAULT_MAX_INCREMENTALS = 10`) まで差分を + 積むので、放置すれば**新しい世代でも同じことが起きる**。 + +- 前提 7: エラーが出ているのは `~/.claude/plugins/cache/` 配下、すなわち + **プラグインのキャッシュがディレクトリごと入れ替わる**場所である。世代間で + ディレクトリの中身が総入れ替えになる箇所で再現しているという状況証拠がある。 + ただし**根本原因はまだ特定していない**(Task 1 で確定させる)。 + +## 受け入れ条件 + +- [ ] AC1: 差分を 2 つ以上持つ既存スナップショットが**最後の差分まで復元できる**。 + 検証: `backups/20260823-114528`(差分 8)を使い捨てボリュームへ復元し、 + `incr-008` まで適用されて終了コード 0 になること。 +- [ ] AC2: 復元後の中身が**期待どおり**である。検証: 復元先で + `~/.claude/.credentials.json` と `history.jsonl` がファイルとして存在しサイズが 0 でないこと、 + `.claude/plugins` が壊れていないこと(PLAN39 の切り戻し手順の検証項目と同じ)。 +- [ ] AC3: **新しく作った**スナップショット(フル + 差分 3 つ以上、途中でディレクトリの + 入れ替えを含む)が復元できる。前提 7 の状況を人工的に作って再現テストにする。 +- [ ] AC4: 復元が失敗したとき、**何が起きたか・次に何をすればよいか**がエラーに出る。 + 少なくとも「どの差分で失敗したか」と「`pre-restore-` から戻せること」を示す。 +- [ ] AC5: 旧レイアウト(`volume: devbase_home_ubuntu` のみ)と新レイアウト + (`volumes: {ai, group}`)の**両方**で AC1 が成り立つ。 +- [ ] AC6: `uv run pytest` が green で、再現ケースが**テストとして固定**されている。 + Docker を要するテストは、Docker が無い環境では skip する。 + +## 代替案と採否 + +現時点では**原因が未特定**のため、採否は Task 1 の結果で確定させる。候補は次のとおり。 + +| 案 | 内容 | 見込み | +|---|---|---| +| A. 復元時に `--incremental` の状態を正しく渡す | `--listed-incremental=/dev/null` をやめ、復元専用の状態ファイルを世代ごとに持ち回る | 公式手順に近づく。ただし状態ファイルは**作成側**の記録なので、復元側で何を渡すべきかは要検証 | +| B. 差分適用前に対象ディレクトリを整える | tar が rename しようとする先を空にする / 事前に消す | 症状は消えるが、tar の削除セマンティクスを人手で再実装することになり脆い | +| C. 各差分を一時ディレクトリへ展開してから同期 | `rsync --delete` 相当を自前で行う | tar の incremental 依存を切れるが、削除の判定を自前で持つ必要があり形式変更に近い | +| D. 作成側を変える(差分の作り方を見直す) | `--level=N` など | 既存スナップショットが救えないため、単独では AC1 を満たせない | +| E. tar のバージョン / 実装を変える | busybox tar 等 | listed-incremental 非対応のものが多く、退行が大きい | + +**A を第一候補**とし、Task 1 で「なぜ rename が起きるのか」を確定してから決める。 +どの案でも**既存スナップショットを復元できること**(AC1・AC5)を満たさない案は採らない。 + +## 不変条件 + +- 既存のスナップショットは**作り直さずに**復元できる。 +- 復元は `full` → `incr-001` → … の順に適用する(順序を入れ替えない)。 +- 復元前の自動バックアップ (`pre-restore-*`) は必ず作られる。 +- 復元は対象ボリュームの**中身だけ**を操作し、マウントポイント自体は消さない。 +- スナップショットのメタデータ由来のボリューム名は PLAN39 の検証を通す + (devbase が作るボリュームだけを対象にする)。 + +## 修正対象 + +- `lib/devbase/snapshot/manager.py` — `restore()` と `_run_docker_tar()` の復元コマンド +- `containers/snapshot/Dockerfile` — (必要なら)tar の版や補助ツール +- `tests/snapshot/` — 再現テストと現状固定テスト +- `docs/user/snapshot-guide.md` — 復元の制約と、失敗したときの戻し方 + +## タスク分解 + +### Task 1: 根本原因の特定(調査) + +- **対象:** 調査のみ。コードは変更しない +- **やること:** + 1. `incr-002` の中身を `tar -tvf` で開き、失敗している rename に対応する + エントリ(`GNUTYPE_DUMPDIR` を含む)を確認する + 2. 世代作成時の `snapshot.snar` と、その世代の各差分の関係を確認する + 3. `--listed-incremental=/dev/null` で復元したときに tar が何を根拠に rename するのかを + 公式ドキュメントと突き合わせる + 4. **最小再現**を作る: 使い捨てボリュームで「フル → ディレクトリを総入れ替え → 差分 → + さらに入れ替え → 差分」を作り、同じエラーが出ることを確認する +- **満たす受け入れ条件:** (調査。AC3 の再現手順の材料になる) +- **進め方:** 実機。`ndf:investigation-rules` に従い、**無いことの主張には検索結果を添える** + +### Task 2: 再現テストを先に置く + +- **対象ファイル:** `tests/snapshot/test_restore_incremental.py`(新規) +- **やること:** Task 1 の最小再現をテストにする。Docker を使うため + `pytest.mark.skipif` で Docker 不在時は skip する。**この時点では失敗する**テストにする +- **満たす受け入れ条件:** AC6(の失敗側) +- **進め方:** テスト駆動 + +### Task 3: 復元コマンドの修正 + +- **対象ファイル:** `lib/devbase/snapshot/manager.py`(必要なら `containers/snapshot/Dockerfile`) +- **やること:** Task 1 で確定した原因に応じて代替案 A〜C から選び、Task 2 のテストを通す +- **満たす受け入れ条件:** AC1, AC3, AC5, AC6 +- **進め方:** テスト駆動 + +### Task 4: 失敗時の扱いを改善する + +- **対象ファイル:** `lib/devbase/snapshot/manager.py`, `docs/user/snapshot-guide.md` +- **やること:** 差分の適用に失敗したとき、**どの差分で落ちたか**と + **`pre-restore-` から戻せること**をエラーメッセージに含める。 + 中途半端な状態で放置される旨も明示する +- **満たす受け入れ条件:** AC4 +- **進め方:** テスト駆動(メッセージの内容を固定する) + +### Task 5: 既存スナップショットでの実機確認 + +- **対象:** 検証のみ +- **やること:** `backups/20260823-114528`(差分 8・旧レイアウト)と、 + PLAN39 後の新レイアウト世代の両方を**使い捨てボリューム**へ復元し、AC1 / AC2 / AC5 を確認する。 + 実データのボリュームは触らない(メタデータの複製に対して行う) +- **満たす受け入れ条件:** AC1, AC2, AC5 +- **進め方:** 実機 + +## 影響範囲 + +- 復元経路のみ。作成・一覧・ローテーション・自動スナップショットは変えない見込み + (Task 1 の結果で作成側も触る場合は、既存スナップショットの復元互換を AC1 で担保する) +- `containers/snapshot` イメージを変える場合は再ビルドが要る + (`_ensure_snapshot_image` が自動ビルドするため利用者の手作業は不要) + +## リスクと対処 + +| リスク | 対処 | +|---|---| +| 修正が既存スナップショットの復元を壊す | AC1 / AC5 を実在する世代に対して確認する。テストは使い捨てボリュームで行い実データを触らない | +| 復元の検証中に実データのボリュームを消す | 検証はメタデータを複製し**使い捨てボリューム名へ書き換えて**から行う(PLAN39 の検証で使った手順。アーカイブ本体はハードリンクで持ってくる) | +| tar の削除セマンティクスを自前で再実装して別のデータ欠損を生む | 代替案 B / C を採る場合は、削除・rename の各ケースをテストで固定してから入れる | +| 原因が tar のバグで手元では直せない | その場合は代替案 C(tar の incremental 依存を切る)へ倒す。形式変更になるため、既存スナップショットの読み出し互換を別途 AC に足す | + +## 完了の定義 + +- [ ] AC1〜AC6 を満たし、条件ごとに検証手段と結果が対応している +- [ ] `uv run pytest` が green +- [ ] `/ndf:cross-review` で APPROVE 収束済み +- [ ] `docs/user/snapshot-guide.md` が復元の制約と失敗時の戻し方を説明している +- [ ] 実在する差分 8 個の世代を最後まで復元できることを実機で確認している From 523a03e9bc4f80db8a65b8a118c677ed4ffbb132 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 29 Aug 2026 22:03:10 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix(snapshot):=20=E5=81=BD=E3=81=AE=20renam?= =?UTF-8?q?e=20=E3=82=A8=E3=83=A9=E3=83=BC=E3=81=A7=E5=B7=AE=E5=88=86?= =?UTF-8?q?=E3=81=AE=E5=BE=A9=E5=85=83=E3=81=8C=E6=AD=A2=E3=81=BE=E3=82=89?= =?UTF-8?q?=E3=81=AA=E3=81=84=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GNU tar の incremental はディレクトリを (dev, ino) で追跡して rename を検出する。 ディレクトリが削除され作り直されると inode 番号が再利用されるため、tar は無関係な ディレクトリを rename されたものと誤判定し、dumpdir に偽の R/T レコードを書く。 復元側の tar はそれを rename() として実行して失敗し、restore() がそこで中断していた。 tar は rename に失敗しても展開自体は完遂しているため、この失敗だけを警告として扱い 次の差分へ進める。それ以外の失敗は従来どおり止めるが、どのアーカイブで落ちたかと pre-restore- からの戻し方をエラーに含めるようにした。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PExvVXeqkA7LWG5YfsLCEc --- docs/user/snapshot-guide.md | 37 +++ ...40_snapshot-incremental-restore-failure.md | 182 ++++++++-- lib/devbase/errors.py | 12 + lib/devbase/snapshot/manager.py | 91 ++++- tests/snapshot/test_restore_incremental.py | 314 ++++++++++++++++++ 5 files changed, 593 insertions(+), 43 deletions(-) create mode 100644 tests/snapshot/test_restore_incremental.py diff --git a/docs/user/snapshot-guide.md b/docs/user/snapshot-guide.md index 85f026c4..bd927636 100644 --- a/docs/user/snapshot-guide.md +++ b/docs/user/snapshot-guide.md @@ -249,6 +249,43 @@ graph LR devbase snapshot restore pre-restore-20260221-150000 ``` +#### 復元中に出る rename の警告 + +差分の適用中に、次のような警告が出ることがあります。**復元は続行され、内容も正しく復元されます。** + +``` +WARNING incr-002.tar.zst の展開で tar が rename に失敗しました。GNU tar の incremental が +inode 番号の再利用でディレクトリの rename を誤検出したものとみなし、復元を続けます: +tar: Cannot rename './ai/.claude/plugins/cache/foo' to './ai/.claude/plugins/cache/bar': Directory not empty +``` + +これは GNU tar の増分バックアップの仕組みに由来します。tar はディレクトリを +**inode 番号**で追跡して「名前の変更」を検出しますが、`~/.claude/plugins/cache/` のように +ディレクトリごと作り直される場所では、削除されたディレクトリの inode 番号が新しい +ディレクトリに再利用されます。すると tar は無関係なディレクトリを「名前が変わった」と +誤検出し、復元時にその名前変更を実行しようとして失敗します。 + +tar は名前変更に失敗しても展開そのものは最後まで行うため、devbase はこの失敗だけを +警告として扱い、次の差分へ進みます。**警告が出ても対応は不要です。** + +> **Note:** 警告に出たパスが、利用者が実際に `mv` したディレクトリだった場合に限り、 +> そのディレクトリの中身が復元されない可能性があります。心当たりがある場合だけ、 +> 警告に出たパスを確認してください。 + +#### 復元が失敗したとき + +rename 以外の理由で失敗した場合、復元はその場で止まります。このとき +**対象ボリュームは途中まで書き換わっている可能性があります**。エラーには、どのアーカイブの +展開中に失敗したかと、元に戻す手順が出ます。 + +``` +復元に失敗しました (incr-002.tar.zst の展開中)。対象ボリュームは途中まで +書き換わっている可能性があります。復元前の状態は 'pre-restore-20260221-150000' に退避してあります。 +元に戻すには devbase snapshot restore pre-restore-20260221-150000 を実行してください。 +``` + +案内のとおり `pre-restore-` から復元すれば、復元を始める前の状態に戻せます。 + ### スナップショットのコピー ```bash diff --git a/issues/PLAN40_snapshot-incremental-restore-failure.md b/issues/PLAN40_snapshot-incremental-restore-failure.md index d5f41bba..90a8cfdb 100644 --- a/issues/PLAN40_snapshot-incremental-restore-failure.md +++ b/issues/PLAN40_snapshot-incremental-restore-failure.md @@ -37,9 +37,10 @@ すべて現行 `main` (`e15ca84`) 上で確認済み。 -- 前提 1: **差分の 2 つ目以降で復元が落ちる。** 実在するスナップショット - `backups/20260823-114528`(差分 8 個)を使い捨てボリュームへ復元すると、 - `full` と `incr-001` は成功し、`incr-002` で失敗する。 +- 前提 1(**Task 1 で訂正**): **差分の適用が rename で落ちる。** 当初は + 「差分の 2 つ目以降」と書いたが、Task 1 の再現では `incr-001` から落ちる。 + 条件は差分の個数ではなく、**ディレクトリが総入れ替えされたか**である。 + 発生は非決定的で、同一手順 4 回中 3 回失敗した(inode の割り当て順に依存する)。 ``` tar: Cannot rename './.claude/plugins/cache/claude-plugins-official/hookify/unknown/commands' @@ -79,42 +80,113 @@ - 前提 5: tar は **GNU tar 1.35**(`containers/snapshot/Dockerfile` の `ubuntu:26.04` 同梱。`zstd` だけを追加インストールしている)。 -- 前提 6: 影響を受けるのは**差分を 2 つ以上持つ世代**である。現存する世代: +- 前提 6(**Task 1 で訂正**): 当初挙げた `20260817-110811`(差分 10)と + `20260823-114528`(差分 8)は `max_generations: 3` のローテーションで**削除済み**である + (`find ~ -maxdepth 6 -name "*20260823*"` および `"*20260817-110811*"` で該当なし)。 + 2026-08-29 時点で現存する世代: | 世代 | 差分数 | 対象ボリューム | |---|---|---| - | `20260817-110811` | 10 | `devbase_home_ubuntu`(分離前) | - | `20260823-114528` | 8 | `devbase_home_ubuntu`(分離前) | - | `20260829-123605` | 1 | `devbase_home_ubuntu`, `devbase_home_default` | + | `20260829-123605` | 1 | `ai: devbase_home_ubuntu`, `group: devbase_home_default` | + | `20260829-150154` | 0 | `ai: devbase_home_ubuntu`, `group: devbase_home_kkg` | + | `20260829-182126` | 1 | `ai: devbase_home_ubuntu`, `group: devbase_home_default` | + 差分が 1 個でも発生するため(前提 1)、現存世代も影響を受ける。実際に + `20260829-182126/incr-001.tar.zst` は偽の rename レコードを保持している(Task 1 の結果を参照)。 PLAN39 後の世代もローテーション上限 (`DEFAULT_MAX_INCREMENTALS = 10`) まで差分を 積むので、放置すれば**新しい世代でも同じことが起きる**。 -- 前提 7: エラーが出ているのは `~/.claude/plugins/cache/` 配下、すなわち - **プラグインのキャッシュがディレクトリごと入れ替わる**場所である。世代間で - ディレクトリの中身が総入れ替えになる箇所で再現しているという状況証拠がある。 - ただし**根本原因はまだ特定していない**(Task 1 で確定させる)。 +- 前提 7(**Task 1 で確定**): エラーが出ているのは `~/.claude/plugins/cache/` 配下、すなわち + **プラグインのキャッシュがディレクトリごと入れ替わる**場所である。これは状況証拠ではなく + 根本原因そのものだった。詳細は次節「Task 1 の結果」を参照。 + + +## Task 1 の結果(根本原因) + +**GNU tar 1.35 の incremental 作成時の rename 検出が、inode 番号の再利用で誤爆している。** + +tar は `--listed-incremental` でディレクトリを **(dev, ino)** の組で追跡する。ディレクトリが +削除され新しいディレクトリが作られると、ファイルシステムが**同じ inode 番号を再利用**するため、 +tar は「別名へ rename された」と誤判定し、親の dumpdir (`GNUTYPE_DUMPDIR`) に偽の +`R`(rename 元)/ `T`(rename 先)レコードを書き込む。復元側の +`tar --listed-incremental=/dev/null` はそれを忠実に `rename()` として実行し、 +`No such file or directory` / `Directory not empty` で失敗して終了コード 2 を返す。 +`_run_docker_tar` がそれを `SnapshotError` にするため、`restore()` がそこで中断する。 + +### エビデンス + +**1. 偽の rename レコードが実在する。** 最小再現の `incr-001` の dumpdir をデコードした結果: + +``` +'R./.claude/plugins/cache/A1' ← 前の世代で削除済みのディレクトリ +'T./.claude/plugins/cache/B1/unknown/commands' ← 新しく作ったディレクトリ +``` + +**2. inode が実際に再利用されている。** 同一ボリューム上で計測: + +| パス | inode | +|---|---| +| `cache/C1` | 1159533 | +| `cache/D1`(`C1` を削除した後に新規作成) | **1159533** | +| `cache/C1/unknown/commands` | 1159541 | +| `cache/D1/unknown/commands` | **1159541** | + +**3. 本番のスナップショットも汚染されている。** +`backups/20260829-182126/incr-001.tar.zst` を全走査(dumpdir 24,392 件)したところ 1 件が +rename レコードを保持していた。無関係なツリー間の rename であり、偽物であることが明白である: + +``` +'R./ai/.codex/.tmp/marketplaces/ai-plugins/plugins/playwright-kit' +'T./ai/.claude/plugins/cache/temp_git_1788002903410_i7hzgk/.git/objects' +``` + +**4. tar は rename 失敗後も展開を完遂している。** 失敗した実行でも、復元先の `find | sort` は +最後の差分時点のソースと**完全に一致**した(差分 0 行)。壊しているのは tar の展開ではなく、 +**最初の失敗で `restore()` が中断すること**である。 + +**5. 発生は非決定的。** 同一手順を 4 回実行して 3 回失敗した。inode の割り当て順に依存する。 + +### 案 A(当初の第一候補)の棄却 + +復元時に作成側の `snapshot.snar` を渡して実験したところ、`/dev/null` を渡した場合と +**結果が完全に同一**だった(`full` / `incr-001` とも rc=0、内容一致)。tar の展開側は +状態ファイルの中身を読まず書き出すだけなので、**復元専用の状態を持ち回っても何も変わらない**。 + +### 案 B / C に伴う data loss リスク + +**rename を一律に無視・除去する案は、正当な rename のデータを失う。** 正当な `mv` を挟んで +差分を作ると、アーカイブには**ディレクトリのエントリしか入らない**: + +``` +drwxr-xr-x root/root 41 ./data/newdir/ ← f1〜f5 は入っていない +``` + +中身は rename レコードによる移動でしか復元されない(現行方式では f1〜f5 が正しく +`newdir` 配下へ出ることを確認済み)。したがって dumpdir から `R`/`T` を除去する実装や、 +incremental 指定を外した素の `tar -xf` は採らない。素の `tar -xf` は削除セマンティクスも +失うため、最小再現で 160 行の取り残しが出ることも確認した。 ## 受け入れ条件 -- [ ] AC1: 差分を 2 つ以上持つ既存スナップショットが**最後の差分まで復元できる**。 - 検証: `backups/20260823-114528`(差分 8)を使い捨てボリュームへ復元し、 - `incr-008` まで適用されて終了コード 0 になること。 -- [ ] AC2: 復元後の中身が**期待どおり**である。検証: 復元先で - `~/.claude/.credentials.json` と `history.jsonl` がファイルとして存在しサイズが 0 でないこと、 - `.claude/plugins` が壊れていないこと(PLAN39 の切り戻し手順の検証項目と同じ)。 -- [ ] AC3: **新しく作った**スナップショット(フル + 差分 3 つ以上、途中でディレクトリの +- [x] AC1: 偽の rename レコードを含む世代が**最後の差分まで復元できる**。 + 検証: 使い捨てボリュームに「フル → ディレクトリの総入れ替え → 差分」を 3 回以上 + 繰り返した合成世代を作り、`incr-003` まで適用されて `restore()` が完走すること。 + (当初の検証対象 `backups/20260823-114528` はローテーションで消滅済み。前提 6 を参照) +- [x] AC2: 復元後の中身が**期待どおり**である。検証: 合成世代の復元先の `find | sort` が、 + 最後の差分を取った時点のソースの `find | sort` と**完全に一致**すること + (偽 rename を非致命扱いしても内容が欠けないことを固定する)。 +- [x] AC3: **新しく作った**スナップショット(フル + 差分 3 つ以上、途中でディレクトリの 入れ替えを含む)が復元できる。前提 7 の状況を人工的に作って再現テストにする。 -- [ ] AC4: 復元が失敗したとき、**何が起きたか・次に何をすればよいか**がエラーに出る。 +- [x] AC4: 復元が失敗したとき、**何が起きたか・次に何をすればよいか**がエラーに出る。 少なくとも「どの差分で失敗したか」と「`pre-restore-` から戻せること」を示す。 -- [ ] AC5: 旧レイアウト(`volume: devbase_home_ubuntu` のみ)と新レイアウト +- [x] AC5: 旧レイアウト(`volume: devbase_home_ubuntu` のみ)と新レイアウト (`volumes: {ai, group}`)の**両方**で AC1 が成り立つ。 -- [ ] AC6: `uv run pytest` が green で、再現ケースが**テストとして固定**されている。 +- [x] AC6: `uv run pytest` が green で、再現ケースが**テストとして固定**されている。 Docker を要するテストは、Docker が無い環境では skip する。 ## 代替案と採否 -現時点では**原因が未特定**のため、採否は Task 1 の結果で確定させる。候補は次のとおり。 +Task 1 で原因が確定したため、採否を次のとおり確定した(採用は **F**)。 | 案 | 内容 | 見込み | |---|---|---| @@ -123,9 +195,27 @@ | C. 各差分を一時ディレクトリへ展開してから同期 | `rsync --delete` 相当を自前で行う | tar の incremental 依存を切れるが、削除の判定を自前で持つ必要があり形式変更に近い | | D. 作成側を変える(差分の作り方を見直す) | `--level=N` など | 既存スナップショットが救えないため、単独では AC1 を満たせない | | E. tar のバージョン / 実装を変える | busybox tar 等 | listed-incremental 非対応のものが多く、退行が大きい | +| **F. rename エラーだけを非致命として扱う** | 復元時、tar の stderr が `Cannot rename` 行だけなら警告を出して次の差分へ進む | **採用** | -**A を第一候補**とし、Task 1 で「なぜ rename が起きるのか」を確定してから決める。 -どの案でも**既存スナップショットを復元できること**(AC1・AC5)を満たさない案は採らない。 +| 案 | 採否 | 理由 | +|---|---|---| +| A | **棄却** | 展開側は状態ファイルを読まないことを実験で確認した(「Task 1 の結果」参照)。何も変わらない | +| B | **棄却** | 偽 rename と正当な rename を事前に区別できず、tar の削除セマンティクスを人手で再実装することになる | +| C | **棄却** | 正当な rename のデータを失う(「Task 1 の結果」参照)。形式変更で `architecture` へ格上げにもなる | +| D | **棄却** | 既存スナップショットを救えず AC1 を満たさない | +| E | **棄却** | busybox tar 等は listed-incremental 非対応で退行が大きい | +| **F** | **採用** | tar が rename 失敗後も展開を完遂している実測(差分 0 行)に基づく。既存スナップショットを +そのまま救え、削除セマンティクスも維持し、イメージも形式も変えない | + +**採用案 F の内容:** `_run_docker_tar` を「終了コードと stderr を呼び出し側へ返す」形にし、 +`restore()` 側で判定する。stderr の全行が `tar: Cannot rename ... ` か +`tar: Exiting with failure status due to previous errors` のいずれかに一致する場合だけ、 +偽の rename とみなして `logger.warning` を出し次の差分へ進む。それ以外のエラーは従来どおり +`SnapshotError` にする。 + +**案 F の残るリスク:** 正当な rename が失敗した場合も見逃す。ただし現行は同じ場面で +**復元ごと中断する**ため、退行ではない。見逃しを可視化するため、警告には失敗した rename の +パスをそのまま出す。 ## 不変条件 @@ -136,6 +226,24 @@ - スナップショットのメタデータ由来のボリューム名は PLAN39 の検証を通す (devbase が作るボリュームだけを対象にする)。 +## 検証結果 + +| 受け入れ条件 | 検証手段 | 結果 | +|---|---|---| +| AC1 | `tests/snapshot/test_restore_incremental.py::test_a_generation_with_swapped_directories_restores_completely`(実 Docker・実 tar) | 総入れ替えを挟んだ差分 3 個の世代が `incr-003` まで適用され `restore()` が完走。3 回連続で `incr-001`〜`incr-003` すべてが偽 rename に当たり、すべて警告として飲み込まれた | +| AC2 | 同上(復元先の `find` の一覧と、最後の差分時点のソースの一覧を比較) | 完全一致 | +| AC3 | 同上(世代はテスト内で新規に作成する) | 満たす | +| AC4 | `test_the_failure_message_says_how_to_get_back` / `test_a_full_restore_failure_also_says_how_to_get_back` / `test_a_real_tar_error_still_stops_the_restore` / `docs/user/snapshot-guide.md` | どの差分で落ちたか・`pre-restore-` から戻す手順を出す | +| AC5 | `test_both_layouts_survive_a_bogus_rename`(旧 `volume:` / 新 `volumes: {ai, group}` の 2 レイアウト) | 両方で後続の差分まで適用しきる | +| AC6 | `uv run pytest` | 1,663 passed。Docker が無い環境では実機テストだけ skip する | + +**AC5 の実機確認の範囲について。** 実 Docker の検証は `group` 側のボリューム +(`devbase_home_plan40test`) だけで行った。旧レイアウトと新レイアウトの `ai` 側は +`devbase_home_ubuntu` に固定されており (`_validate_volumes`)、これは**利用者の実データが +入っているボリューム**である。復元は対象ボリュームの中身を消してから展開するため、 +実機テストの対象にしていない。レイアウトの違いは対象ボリュームの解決とマウントにだけ効き、 +rename エラーの扱いには影響しないので、その差はテストダブルで固定している。 + ## 修正対象 - `lib/devbase/snapshot/manager.py` — `restore()` と `_run_docker_tar()` の復元コマンド @@ -145,7 +253,7 @@ ## タスク分解 -### Task 1: 根本原因の特定(調査) +### Task 1: 根本原因の特定(調査) — **完了** - **対象:** 調査のみ。コードは変更しない - **やること:** @@ -158,6 +266,8 @@ さらに入れ替え → 差分」を作り、同じエラーが出ることを確認する - **満たす受け入れ条件:** (調査。AC3 の再現手順の材料になる) - **進め方:** 実機。`ndf:investigation-rules` に従い、**無いことの主張には検索結果を添える** +- **結果:** 「Task 1 の結果」節に記載。原因は inode 再利用による偽 rename レコード。 + 採用案は F(rename エラーだけを非致命扱い) ### Task 2: 再現テストを先に置く @@ -170,7 +280,8 @@ ### Task 3: 復元コマンドの修正 - **対象ファイル:** `lib/devbase/snapshot/manager.py`(必要なら `containers/snapshot/Dockerfile`) -- **やること:** Task 1 で確定した原因に応じて代替案 A〜C から選び、Task 2 のテストを通す +- **やること:** 採用案 F を実装する。`_run_docker_tar` が終了コードと stderr を返すようにし、 + `restore()` が「`Cannot rename` 行だけの失敗」を警告として飲み込んで次の差分へ進む - **満たす受け入れ条件:** AC1, AC3, AC5, AC6 - **進め方:** テスト駆動 @@ -183,12 +294,14 @@ - **満たす受け入れ条件:** AC4 - **進め方:** テスト駆動(メッセージの内容を固定する) -### Task 5: 既存スナップショットでの実機確認 +### Task 5: 合成世代での実機確認 - **対象:** 検証のみ -- **やること:** `backups/20260823-114528`(差分 8・旧レイアウト)と、 - PLAN39 後の新レイアウト世代の両方を**使い捨てボリューム**へ復元し、AC1 / AC2 / AC5 を確認する。 - 実データのボリュームは触らない(メタデータの複製に対して行う) +- **やること:** 合成世代(フル → 総入れ替え → 差分 を 3 回以上)を**旧レイアウト** + (`volume: devbase_home_ubuntu` 相当 1 本)と**新レイアウト**(`volumes: {ai, group}`)の + 両方で作り、使い捨てボリュームへ復元して AC1 / AC2 / AC5 を確認する。 + 実データのボリュームと実データの世代は触らない + (当初の対象 `20260823-114528` は消滅済み。現存世代の復元確認は行わない — 利用者の判断) - **満たす受け入れ条件:** AC1, AC2, AC5 - **進め方:** 実機 @@ -210,8 +323,9 @@ ## 完了の定義 -- [ ] AC1〜AC6 を満たし、条件ごとに検証手段と結果が対応している -- [ ] `uv run pytest` が green +- [x] AC1〜AC6 を満たし、条件ごとに検証手段と結果が対応している(「検証結果」節) +- [x] `uv run pytest` が green(1,663 passed) - [ ] `/ndf:cross-review` で APPROVE 収束済み -- [ ] `docs/user/snapshot-guide.md` が復元の制約と失敗時の戻し方を説明している -- [ ] 実在する差分 8 個の世代を最後まで復元できることを実機で確認している +- [x] `docs/user/snapshot-guide.md` が復元の制約と失敗時の戻し方を説明している +- [x] 総入れ替えを挟んだ差分 3 個の世代を最後まで復元できることを実機で確認している + (当初の「実在する差分 8 個の世代」はローテーションで消滅済み。前提 6 を参照) diff --git a/lib/devbase/errors.py b/lib/devbase/errors.py index c3cc9bad..2e7546dc 100644 --- a/lib/devbase/errors.py +++ b/lib/devbase/errors.py @@ -23,3 +23,15 @@ class ConfigError(DevbaseError): class SnapshotError(DevbaseError): """スナップショット操作エラー""" + + +class SnapshotCommandError(SnapshotError): + """スナップショットのコンテナ内コマンドが失敗した。 + + 呼び出し側が**失敗の中身**で分岐できるよう ``stderr`` を持つ + (復元は tar の rename エラーだけを警告として飲み込む — PLAN40)。 + """ + + def __init__(self, message: str, stderr: str = ''): + super().__init__(message) + self.stderr = stderr diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py index bb92faf4..e577c18b 100644 --- a/lib/devbase/snapshot/manager.py +++ b/lib/devbase/snapshot/manager.py @@ -9,7 +9,7 @@ import yaml -from devbase.errors import DevbaseError, SnapshotError +from devbase.errors import DevbaseError, SnapshotCommandError, SnapshotError from devbase.log import get_logger from devbase.volume.manager import ( HOME_UBUNTU_VOLUME, @@ -36,6 +36,32 @@ METADATA_FILE = 'snapshot.yml' _VALID_NAME_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._-]*$') +# GNU tar の incremental はディレクトリを (dev, ino) で追跡して rename を検出する。 +# ディレクトリが削除され作り直されると **inode 番号が再利用される**ため、tar は無関係な +# ディレクトリを rename されたものと誤判定し、dumpdir に偽の R/T レコードを書く。 +# 復元側はそれを rename() として実行して失敗するが、**展開自体は完遂している**ので、 +# この失敗だけは警告にして復元を続ける (PLAN40)。 +_RENAME_ERROR_RE = re.compile(r"^tar: Cannot rename '.*' to '.*': .+$") +_TAR_EXIT_LINE = 'tar: Exiting with failure status due to previous errors' + + +def rename_only_failure(stderr: str) -> Optional[list]: + """tar の失敗が rename エラーだけなら、その行の一覧を返す。 + + 1 行でも別のエラーが混ざっていれば ``None`` を返す。stderr が空の場合も、 + 失敗の理由が分からない以上見逃してはならないので ``None`` を返す。 + """ + renames = [] + for line in stderr.splitlines(): + line = line.strip() + if not line or line == _TAR_EXIT_LINE: + continue + if _RENAME_ERROR_RE.match(line): + renames.append(line) + continue + return None + return renames or None + class SnapshotManager: """Docker volumeのスナップショット管理""" @@ -198,18 +224,20 @@ def restore(self, name: str, point: int | None = None) -> None: self.create(name=pre_restore_name, full=True) except Exception as e: logger.warning("復元前バックアップに失敗しましたが続行します: %s", e) + # 失敗時の案内で「戻せる」と書けなくなるので、無いことを覚えておく + pre_restore_name = None volumes = self.snapshot_volumes(snap_dir) logger.info("復元先のボリューム: %s", ', '.join(volumes.values())) # フルバックアップの復元 logger.info("フルバックアップを復元中...") - self._run_docker_tar( - snap_dir, 'restore', + self._extract_archive( + snap_dir, 'full.tar.zst', self.clear_command(volumes) + "zstd -d /backup/full.tar.zst -c | " "tar --listed-incremental=/dev/null -xf - -C /target", - volumes=volumes, + volumes, pre_restore_name, ) # 差分バックアップを順番に適用(pointが指定されていればそこまで) @@ -223,11 +251,11 @@ def restore(self, name: str, point: int | None = None) -> None: if int(m.group(1)) > point: break logger.info("差分バックアップを適用中: %s", incr.name) - self._run_docker_tar( - snap_dir, 'restore', + self._extract_archive( + snap_dir, incr.name, f"zstd -d /backup/{incr.name} -c | " f"tar --listed-incremental=/dev/null -xf - -C /target", - volumes=volumes, + volumes, pre_restore_name, ) if point is not None: @@ -235,6 +263,50 @@ def restore(self, name: str, point: int | None = None) -> None: else: logger.info("復元完了: %s", name) + def _extract_archive(self, snap_dir: Path, archive: str, command: str, + volumes: dict, pre_restore_name: Optional[str]) -> None: + """アーカイブを 1 つ展開する。偽の rename エラーだけは飲み込む。 + + GNU tar が inode 番号の再利用で誤検出した rename は、復元時に必ず失敗する。 + tar はその後も展開を続けて完遂しているため、ここで止めると**かえって** + ボリュームが中途半端な状態で残る。失敗した rename は警告として出し、 + 見逃しが分かるようにする (PLAN40)。 + """ + try: + self._run_docker_tar(snap_dir, 'restore', command, volumes=volumes) + except SnapshotError as e: + # stderr を持たない失敗 (イメージのビルド失敗など) は判断材料が無いので、 + # rename エラーとはみなさず従来どおり止める。 + stderr = e.stderr if isinstance(e, SnapshotCommandError) else '' + renames = rename_only_failure(stderr) + if renames is None: + raise SnapshotError( + self._restore_failure_message(archive, pre_restore_name, e) + ) from e + logger.warning( + "%s の展開で tar が rename に失敗しました。GNU tar の incremental が " + "inode 番号の再利用でディレクトリの rename を誤検出したものとみなし、" + "復元を続けます:\n%s", + archive, '\n'.join(renames)) + + @staticmethod + def _restore_failure_message(archive: str, pre_restore_name: Optional[str], + error: Exception) -> str: + """復元の失敗を、次に何をすればよいかまで含めて説明する。""" + if pre_restore_name: + recovery = ( + f"復元前の状態は '{pre_restore_name}' に退避してあります。" + f"元に戻すには devbase snapshot restore {pre_restore_name} " + f"を実行してください。") + else: + recovery = ("復元前の自動バックアップは作成できていません。" + "別のスナップショットから復元してください " + "(devbase snapshot list で確認できます)。") + return ( + f"復元に失敗しました ({archive} の展開中)。" + f"対象ボリュームは途中まで書き換わっている可能性があります。" + f"{recovery}\n{error}") + def copy(self, name: str, new_name: str) -> None: """スナップショットをコピーする""" src = self._safe_snap_dir(name) @@ -440,8 +512,9 @@ def _run_docker_tar(self, snap_dir: Path, mode: str, command: str, if result.stdout.strip(): logger.debug(result.stdout.strip()) except subprocess.CalledProcessError as e: - raise SnapshotError( - f"Dockerでのtar操作に失敗しました: {e.stderr}" + raise SnapshotCommandError( + f"Dockerでのtar操作に失敗しました: {e.stderr}", + stderr=e.stderr or '', ) from e def _create_full(self, name: str, snap_dir: Path) -> None: diff --git a/tests/snapshot/test_restore_incremental.py b/tests/snapshot/test_restore_incremental.py new file mode 100644 index 00000000..32f5cebd --- /dev/null +++ b/tests/snapshot/test_restore_incremental.py @@ -0,0 +1,314 @@ +"""差分スナップショットの復元 (PLAN40) + +GNU tar の incremental は、ディレクトリを ``(dev, ino)`` で追跡して rename を検出する。 +ディレクトリが削除され作り直されると **inode 番号が再利用される**ため、tar は無関係な +ディレクトリを「rename された」と誤判定し、dumpdir に偽の ``R``/``T`` レコードを書く。 +復元側の ``tar --listed-incremental=/dev/null`` はそれを ``rename()`` として実行して失敗し、 +終了コード 2 を返す。従来はここで ``restore()`` が中断し、ボリュームが中途半端なまま残った。 + +**tar は rename に失敗しても展開自体は完遂している。** そのため復元は続行してよい。 +このテストは「rename エラーだけの失敗は警告にして続ける」「それ以外の失敗は従来どおり +止める」の両方を固定する。 +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from pathlib import Path + +import pytest +import yaml + +from devbase.errors import SnapshotCommandError, SnapshotError +from devbase.snapshot.manager import SnapshotManager, rename_only_failure + +@pytest.fixture(autouse=True) +def _clean_group_env(monkeypatch): + """復元前の自動バックアップがグループを解決するので、環境で揺らさない。""" + monkeypatch.delenv("DEVBASE_ACCOUNT_GROUP", raising=False) + + +# Task 1 で実際に採取した stderr +BOGUS_RENAME_STDERR = ( + "tar: Cannot rename './.claude/plugins/cache/B9/unknown/commands/unknown/commands' " + "to './.claude/plugins/cache/B10': No such file or directory\n" + "tar: Exiting with failure status due to previous errors\n" +) +# 本番の backups/20260829-182126 で観測した形 (Directory not empty) +PRODUCTION_RENAME_STDERR = ( + "tar: Cannot rename './ai/.codex/.tmp/marketplaces/ai-plugins/plugins/playwright-kit' " + "to './ai/.claude/plugins/cache/temp_git_1788002903410_i7hzgk/.git/objects': " + "Directory not empty\n" + "tar: Exiting with failure status due to previous errors\n" +) +REAL_ERROR_STDERR = ( + "tar: ./ai/.claude/history.jsonl: Cannot write: No space left on device\n" + "tar: Exiting with failure status due to previous errors\n" +) + + +# --------------------------------------------------------------------------- +# stderr の判定 (純粋関数) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("stderr", [BOGUS_RENAME_STDERR, PRODUCTION_RENAME_STDERR]) +def test_rename_only_failure_is_detected(stderr): + """rename エラーだけの失敗は、失敗した rename の一覧を返す。""" + assert rename_only_failure(stderr) is not None + assert len(rename_only_failure(stderr)) == 1 + + +def test_real_error_is_not_treated_as_rename_failure(): + assert rename_only_failure(REAL_ERROR_STDERR) is None + + +def test_rename_error_mixed_with_a_real_error_is_not_tolerated(): + """1 行でも別のエラーが混ざれば見逃さない。""" + assert rename_only_failure(BOGUS_RENAME_STDERR + REAL_ERROR_STDERR) is None + + +def test_empty_stderr_is_not_treated_as_rename_failure(): + """終了コードが 0 でないのに stderr が空なら、理由が分からないので止める。""" + assert rename_only_failure("") is None + assert rename_only_failure(" \n") is None + + +# --------------------------------------------------------------------------- +# restore() の振る舞い (Docker を起動しない) +# --------------------------------------------------------------------------- + +# 新レイアウト (PLAN39 以降) と旧レイアウト (共通ボリューム 1 本) の両方を通す。 +NEW_LAYOUT = {'volumes': {'ai': 'devbase_home_ubuntu', 'group': 'devbase_home_default'}} +OLD_LAYOUT = {'volume': 'devbase_home_ubuntu'} + + +def _write_generation(root: Path, name: str, incrementals: int, + layout: dict | None = None) -> Path: + snap_dir = root / 'backups' / name + snap_dir.mkdir(parents=True) + (snap_dir / 'full.tar.zst').write_text('archive') + (snap_dir / 'snapshot.snar').write_text('snar') + files = ['full.tar.zst'] + for i in range(1, incrementals + 1): + incr = f'incr-{i:03d}.tar.zst' + (snap_dir / incr).write_text('archive') + files.append(incr) + (snap_dir / 'meta.yml').write_text(yaml.dump({ + 'name': name, 'type': 'incremental', 'files': files, + 'incremental_count': incrementals, + **(layout if layout is not None else NEW_LAYOUT), + })) + return snap_dir + + +class StubManager(SnapshotManager): + """``docker run`` を起こさず、指定したアーカイブでだけ失敗させる。""" + + def __init__(self, root: Path, failures: dict[str, str]): + super().__init__(root) + self._failures = failures + self.restored: list[str] = [] + + def _run_docker_tar(self, snap_dir, mode, command, volumes=None): + if mode == 'backup': + (snap_dir / 'full.tar.zst').write_text('archive') + (snap_dir / 'snapshot.snar').write_text('snar') + return + archive = self._archive_in(command) + self.restored.append(archive) + if archive in self._failures: + stderr = self._failures[archive] + raise SnapshotCommandError( + f"Dockerでのtar操作に失敗しました: {stderr}", stderr=stderr) + + @staticmethod + def _archive_in(command: str) -> str: + """復元コマンドから、いま展開しているアーカイブ名を取り出す。""" + match = re.search(r'full\.tar\.zst|incr-\d+\.tar\.zst', command) + assert match is not None, f"アーカイブ名が見つからない: {command}" + return match.group(0) + + +def test_bogus_rename_does_not_stop_the_restore(tmp_path): + """AC1: 偽 rename で落ちても、後続の差分まで適用しきる。""" + _write_generation(tmp_path, 'gen', incrementals=3) + mgr = StubManager(tmp_path, {'incr-001.tar.zst': BOGUS_RENAME_STDERR}) + + mgr.restore('gen') + + assert mgr.restored == [ + 'full.tar.zst', 'incr-001.tar.zst', 'incr-002.tar.zst', 'incr-003.tar.zst'] + + +def test_bogus_rename_is_reported_as_a_warning(tmp_path, caplog): + """見逃しを可視化する: 失敗した rename のパスを警告に出す。""" + _write_generation(tmp_path, 'gen', incrementals=1) + mgr = StubManager(tmp_path, {'incr-001.tar.zst': BOGUS_RENAME_STDERR}) + + with caplog.at_level('WARNING'): + mgr.restore('gen') + + warnings = '\n'.join(r.getMessage() + for r in caplog.records if r.levelname == 'WARNING') + assert 'incr-001.tar.zst' in warnings + assert './.claude/plugins/cache/B10' in warnings + + +def test_a_failure_without_stderr_is_not_tolerated(tmp_path): + """イメージのビルド失敗など、stderr を持たない失敗は見逃さない。""" + _write_generation(tmp_path, 'gen', incrementals=2) + + class NoStderrManager(StubManager): + def _run_docker_tar(self, snap_dir, mode, command, volumes=None): + if mode == 'backup': + return super()._run_docker_tar(snap_dir, mode, command, volumes) + raise SnapshotError("devbase-snapshotのビルドに失敗") + + mgr = NoStderrManager(tmp_path, {}) + with pytest.raises(SnapshotError) as e: + mgr.restore('gen') + assert 'full.tar.zst' in str(e.value) + + +@pytest.mark.parametrize("layout, label", [ + (NEW_LAYOUT, "新レイアウト (ai + group)"), + (OLD_LAYOUT, "旧レイアウト (共通ボリューム 1 本)"), +]) +def test_both_layouts_survive_a_bogus_rename(tmp_path, layout, label): + """AC5: 対象ボリュームのレイアウトに関係なく、偽 rename では止まらない。""" + _write_generation(tmp_path, 'gen', incrementals=2, layout=layout) + mgr = StubManager(tmp_path, {'incr-001.tar.zst': BOGUS_RENAME_STDERR}) + + mgr.restore('gen') + + assert mgr.restored == [ + 'full.tar.zst', 'incr-001.tar.zst', 'incr-002.tar.zst'], label + + +def test_a_real_tar_error_still_stops_the_restore(tmp_path): + """AC4: rename 以外の失敗は従来どおり止める。""" + _write_generation(tmp_path, 'gen', incrementals=3) + mgr = StubManager(tmp_path, {'incr-002.tar.zst': REAL_ERROR_STDERR}) + + with pytest.raises(SnapshotError) as e: + mgr.restore('gen') + + assert 'incr-002.tar.zst' in str(e.value) + assert 'incr-003.tar.zst' not in mgr.restored + + +def test_the_failure_message_says_how_to_get_back(tmp_path): + """AC4: どの差分で落ちたかと、pre-restore から戻せることを示す。""" + _write_generation(tmp_path, 'gen', incrementals=2) + mgr = StubManager(tmp_path, {'incr-001.tar.zst': REAL_ERROR_STDERR}) + + with pytest.raises(SnapshotError) as e: + mgr.restore('gen') + + message = str(e.value) + assert 'incr-001.tar.zst' in message + assert 'pre-restore-' in message + assert 'devbase snapshot restore' in message + + +def test_a_full_restore_failure_also_says_how_to_get_back(tmp_path): + """フルの展開で落ちた場合も同じ案内を出す。""" + _write_generation(tmp_path, 'gen', incrementals=1) + mgr = StubManager(tmp_path, {'full.tar.zst': REAL_ERROR_STDERR}) + + with pytest.raises(SnapshotError) as e: + mgr.restore('gen') + + assert 'full.tar.zst' in str(e.value) + assert 'pre-restore-' in str(e.value) + + +# --------------------------------------------------------------------------- +# 実機 (Docker が要る) +# --------------------------------------------------------------------------- + +def _docker_available() -> bool: + if shutil.which('docker') is None: + return False + try: + subprocess.run(['docker', 'info'], capture_output=True, timeout=30, + check=True) + except (subprocess.SubprocessError, OSError): + return False + try: + out = subprocess.run( + ['docker', 'image', 'inspect', 'devbase-snapshot:latest'], + capture_output=True, timeout=30) + return out.returncode == 0 + except (subprocess.SubprocessError, OSError): + return False + + +# group 側だけを対象にする。共通側 (ai) は devbase_home_ubuntu しか許されず、 +# 実データのボリュームを消してしまうため実機テストでは使わない。 +TEST_VOLUME = 'devbase_home_plan40test' + + +def _docker(*args: str, **kwargs) -> subprocess.CompletedProcess: + return subprocess.run(['docker', *args], capture_output=True, text=True, + **kwargs) + + +@pytest.fixture +def throwaway_volume(): + # 収集時ではなくこのテストを実行するときだけ Docker を叩く + if not _docker_available(): + pytest.skip("Docker と devbase-snapshot:latest イメージが要る") + _docker('volume', 'rm', '-f', TEST_VOLUME) + _docker('volume', 'create', TEST_VOLUME) + yield TEST_VOLUME + _docker('volume', 'rm', '-f', TEST_VOLUME) + + +def _in_volume(script: str) -> subprocess.CompletedProcess: + return _docker('run', '--rm', '-v', f'{TEST_VOLUME}:/work', + 'devbase-snapshot:latest', 'bash', '-c', script) + + +def _swap_directories(generation: str) -> None: + """ディレクトリを総入れ替えして inode を再利用させる。""" + _in_volume( + 'rm -rf /work/.claude/plugins/cache; ' + 'mkdir -p /work/.claude/plugins/cache; ' + f'for i in $(seq 1 40); do ' + f' d=/work/.claude/plugins/cache/{generation}$i/unknown/commands; ' + f' mkdir -p $d; echo {generation}$i > $d/file.txt; ' + f'done; ' + f'echo {generation} > /work/marker.txt') + + +def _listing() -> str: + return _in_volume('cd /work && find . | sort').stdout + + +def test_a_generation_with_swapped_directories_restores_completely( + tmp_path, throwaway_volume): + """AC1/AC2/AC3: 総入れ替えを挟んだ差分 3 個の世代が、最後まで復元でき内容も一致する。""" + mgr = SnapshotManager(tmp_path) + # 作成側の対象を使い捨てボリュームへ差し替える。こうしないと復元前の自動バックアップが + # 実データのボリューム (devbase_home_ubuntu) を対象にしてしまう。 + mgr._volumes = {'group': TEST_VOLUME} + + _swap_directories('A') + mgr.create(name='plan40gen', full=True) + for generation in ('B', 'C', 'D'): + _swap_directories(generation) + mgr.create(name='plan40gen') + + snap_dir = tmp_path / 'backups' / 'plan40gen' + assert sorted(p.name for p in snap_dir.glob('incr-*.tar.zst')) == [ + 'incr-001.tar.zst', 'incr-002.tar.zst', 'incr-003.tar.zst'] + + expected = _listing() + assert './marker.txt' in expected + + mgr.restore('plan40gen') + + assert _listing() == expected From 4b341e3e8428811ed9c06aeccf5eb10b2c87a74d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 29 Aug 2026 22:14:21 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix(snapshot):=20=E9=A3=B2=E3=81=BF?= =?UTF-8?q?=E8=BE=BC=E3=82=93=E3=81=A0=20rename=20=E3=81=AE=E5=AE=9B?= =?UTF-8?q?=E5=85=88=E3=82=92=E5=BE=A9=E5=85=83=E5=BE=8C=E3=81=AB=E6=A4=9C?= =?UTF-8?q?=E8=A8=BC=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 偽 rename と正当な rename の失敗は、tar のエラーメッセージだけでは区別できない。 しかし展開後の状態でなら見分けられる。偽 rename の宛先はそのディレクトリ自身が 新しく作られたものなのでアーカイブから中身が展開されて空にならないが、正当な rename を取りこぼした場合は中身が rename でしか移動しないため宛先が空のまま残る。 復元の最後に、飲み込んだ rename の宛先が空のままかどうかを検査し、空であれば 中身が復元されていない可能性を警告するようにした。パスは tar の出力由来なので シェルへ渡す前に shlex.quote で引用する。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PExvVXeqkA7LWG5YfsLCEc --- docs/user/snapshot-guide.md | 28 ++++++- lib/devbase/snapshot/manager.py | 66 ++++++++++++++- tests/snapshot/test_restore_incremental.py | 93 ++++++++++++++++++++-- 3 files changed, 175 insertions(+), 12 deletions(-) diff --git a/docs/user/snapshot-guide.md b/docs/user/snapshot-guide.md index bd927636..f0f8cd7b 100644 --- a/docs/user/snapshot-guide.md +++ b/docs/user/snapshot-guide.md @@ -268,9 +268,31 @@ tar: Cannot rename './ai/.claude/plugins/cache/foo' to './ai/.claude/plugins/cac tar は名前変更に失敗しても展開そのものは最後まで行うため、devbase はこの失敗だけを 警告として扱い、次の差分へ進みます。**警告が出ても対応は不要です。** -> **Note:** 警告に出たパスが、利用者が実際に `mv` したディレクトリだった場合に限り、 -> そのディレクトリの中身が復元されない可能性があります。心当たりがある場合だけ、 -> 警告に出たパスを確認してください。 +#### 中身の欠落を検知したとき + +上の警告とは別に、次の警告が出た場合は**対応が必要です**。 + +``` +WARNING 復元後、次のディレクトリが空のままです。GNU tar が記録した rename を適用できなかった +ため、中身が復元されていない可能性があります。利用者が実際に mv したディレクトリであれば、 +そのスナップショットからは中身を復元できません: +./ai/.claude/some-renamed-dir +``` + +devbase は復元の最後に、飲み込んだ rename の**宛先**を検査します。判定は次のとおりです。 + +| 宛先の状態 | 意味 | 対応 | +|---|---|---| +| 存在しない / 中身がある | 偽の rename。欠落なし | 不要 | +| **存在するが空のまま** | 正当な rename を適用できなかった疑い | 内容を確認する | + +偽の rename の宛先は、そのディレクトリ自身が新しく作られたものなので、アーカイブから中身が +展開されて空にはなりません。一方、利用者が実際に `mv` したディレクトリは、差分アーカイブに +**ディレクトリのエントリしか入らない**(中身は rename でしか移動しない)ため、rename を +適用できないと空のまま残ります。 + +このディレクトリの中身は、そのスナップショットからは復元できません。より古い世代の +スナップショット(`mv` する前のもの)から取り出してください。 #### 復元が失敗したとき diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py index e577c18b..ec4adaa1 100644 --- a/lib/devbase/snapshot/manager.py +++ b/lib/devbase/snapshot/manager.py @@ -1,6 +1,7 @@ """スナップショット管理のコアロジック""" import re +import shlex import shutil import subprocess from datetime import datetime, timezone @@ -41,7 +42,7 @@ # ディレクトリを rename されたものと誤判定し、dumpdir に偽の R/T レコードを書く。 # 復元側はそれを rename() として実行して失敗するが、**展開自体は完遂している**ので、 # この失敗だけは警告にして復元を続ける (PLAN40)。 -_RENAME_ERROR_RE = re.compile(r"^tar: Cannot rename '.*' to '.*': .+$") +_RENAME_ERROR_RE = re.compile(r"^tar: Cannot rename '(?P.*)' to '(?P.*)': .+$") _TAR_EXIT_LINE = 'tar: Exiting with failure status due to previous errors' @@ -63,6 +64,21 @@ def rename_only_failure(stderr: str) -> Optional[list]: return renames or None +def rename_targets(lines: list) -> list: + """rename エラーの行から**宛先**のパスだけを取り出す。 + + 展開後にその宛先が空のままなら、偽 rename ではなく**正当な rename を + 取りこぼした**可能性がある。正当な rename の差分にはディレクトリの + エントリしか入らず、中身は rename でしか移動しないためである。 + """ + targets = [] + for line in lines: + match = _RENAME_ERROR_RE.match(line.strip()) + if match: + targets.append(match.group('dst')) + return targets + + class SnapshotManager: """Docker volumeのスナップショット管理""" @@ -230,6 +246,10 @@ def restore(self, name: str, point: int | None = None) -> None: volumes = self.snapshot_volumes(snap_dir) logger.info("復元先のボリューム: %s", ', '.join(volumes.values())) + # 偽 rename として飲み込んだ宛先。全アーカイブ適用後にまとめて検証する + # (後続の差分が中身を埋める場合があるので、途中では判断できない)。 + skipped_renames: list = [] + # フルバックアップの復元 logger.info("フルバックアップを復元中...") self._extract_archive( @@ -237,7 +257,7 @@ def restore(self, name: str, point: int | None = None) -> None: self.clear_command(volumes) + "zstd -d /backup/full.tar.zst -c | " "tar --listed-incremental=/dev/null -xf - -C /target", - volumes, pre_restore_name, + volumes, pre_restore_name, skipped_renames, ) # 差分バックアップを順番に適用(pointが指定されていればそこまで) @@ -255,16 +275,19 @@ def restore(self, name: str, point: int | None = None) -> None: snap_dir, incr.name, f"zstd -d /backup/{incr.name} -c | " f"tar --listed-incremental=/dev/null -xf - -C /target", - volumes, pre_restore_name, + volumes, pre_restore_name, skipped_renames, ) + self._warn_about_lost_renames(snap_dir, volumes, skipped_renames) + if point is not None: logger.info("復元完了: %s (incr-%03d まで)", name, point) else: logger.info("復元完了: %s", name) def _extract_archive(self, snap_dir: Path, archive: str, command: str, - volumes: dict, pre_restore_name: Optional[str]) -> None: + volumes: dict, pre_restore_name: Optional[str], + skipped_renames: Optional[list] = None) -> None: """アーカイブを 1 つ展開する。偽の rename エラーだけは飲み込む。 GNU tar が inode 番号の再利用で誤検出した rename は、復元時に必ず失敗する。 @@ -283,12 +306,46 @@ def _extract_archive(self, snap_dir: Path, archive: str, command: str, raise SnapshotError( self._restore_failure_message(archive, pre_restore_name, e) ) from e + if skipped_renames is not None: + skipped_renames.extend(rename_targets(renames)) logger.warning( "%s の展開で tar が rename に失敗しました。GNU tar の incremental が " "inode 番号の再利用でディレクトリの rename を誤検出したものとみなし、" "復元を続けます:\n%s", archive, '\n'.join(renames)) + def _warn_about_lost_renames(self, snap_dir: Path, volumes: dict, + targets: list) -> None: + """飲み込んだ rename の宛先が**空のまま**なら、内容の欠落を警告する。 + + 偽 rename の宛先は、そのディレクトリ自身が新しく作られたものなので、 + アーカイブから中身が展開されて空にはならない。一方、**正当な** rename を + 取りこぼした場合は、中身が rename でしか移動しないため宛先が空のまま残る。 + 両者はエラーメッセージだけでは区別できないが、展開後の状態でなら見分けられる。 + """ + if not targets: + return + quoted = ' '.join(shlex.quote(t) for t in sorted(set(targets))) + result = self._run_docker_tar( + snap_dir, 'restore', + 'for p in ' + quoted + '; do ' + 'full="/target/${p#./}"; ' + 'if [ -d "$full" ] && [ -z "$(ls -A "$full" 2>/dev/null)" ]; then ' + 'echo "$p"; fi; ' + 'done', + volumes=volumes, + ) + # テストダブルは戻り値を返さない。その場合は検証を行わない。 + empty = result.stdout.split() if result is not None and result.stdout else [] + if not empty: + return + logger.warning( + "復元後、次のディレクトリが空のままです。GNU tar が記録した rename を " + "適用できなかったため、**中身が復元されていない可能性があります**。" + "利用者が実際に mv したディレクトリであれば、そのスナップショットからは " + "中身を復元できません:\n%s", + '\n'.join(sorted(empty))) + @staticmethod def _restore_failure_message(archive: str, pre_restore_name: Optional[str], error: Exception) -> str: @@ -511,6 +568,7 @@ def _run_docker_tar(self, snap_dir: Path, mode: str, command: str, result = subprocess.run(cmd, capture_output=True, text=True, check=True) if result.stdout.strip(): logger.debug(result.stdout.strip()) + return result except subprocess.CalledProcessError as e: raise SnapshotCommandError( f"Dockerでのtar操作に失敗しました: {e.stderr}", diff --git a/tests/snapshot/test_restore_incremental.py b/tests/snapshot/test_restore_incremental.py index 32f5cebd..06e650ba 100644 --- a/tests/snapshot/test_restore_incremental.py +++ b/tests/snapshot/test_restore_incremental.py @@ -14,6 +14,7 @@ from __future__ import annotations import re +import shlex import shutil import subprocess from pathlib import Path @@ -22,7 +23,9 @@ import yaml from devbase.errors import SnapshotCommandError, SnapshotError -from devbase.snapshot.manager import SnapshotManager, rename_only_failure +from devbase.snapshot.manager import ( + SnapshotManager, rename_only_failure, rename_targets, +) @pytest.fixture(autouse=True) def _clean_group_env(monkeypatch): @@ -60,6 +63,19 @@ def test_rename_only_failure_is_detected(stderr): assert len(rename_only_failure(stderr)) == 1 +def test_rename_targets_extracts_the_destination(): + """欠落の判定に使うのは rename の**宛先**である。""" + lines = rename_only_failure(BOGUS_RENAME_STDERR) + assert rename_targets(lines) == ['./.claude/plugins/cache/B10'] + + +def test_rename_targets_extracts_the_destination_with_spaces(): + """パスに空白が入っていても宛先を取り違えない。""" + stderr = ("tar: Cannot rename './a b/old dir' to './a b/new dir': " + "Directory not empty\n") + assert rename_targets(rename_only_failure(stderr)) == ['./a b/new dir'] + + def test_real_error_is_not_treated_as_rename_failure(): assert rename_only_failure(REAL_ERROR_STDERR) is None @@ -110,6 +126,7 @@ def __init__(self, root: Path, failures: dict[str, str]): super().__init__(root) self._failures = failures self.restored: list[str] = [] + self.checked: str | None = None def _run_docker_tar(self, snap_dir, mode, command, volumes=None): if mode == 'backup': @@ -117,6 +134,10 @@ def _run_docker_tar(self, snap_dir, mode, command, volumes=None): (snap_dir / 'snapshot.snar').write_text('snar') return archive = self._archive_in(command) + if archive is None: + # 展開ではなく、復元後の rename 宛先の検証コマンド + self.checked = command + return self.restored.append(archive) if archive in self._failures: stderr = self._failures[archive] @@ -124,11 +145,13 @@ def _run_docker_tar(self, snap_dir, mode, command, volumes=None): f"Dockerでのtar操作に失敗しました: {stderr}", stderr=stderr) @staticmethod - def _archive_in(command: str) -> str: - """復元コマンドから、いま展開しているアーカイブ名を取り出す。""" + def _archive_in(command: str): + """復元コマンドから、いま展開しているアーカイブ名を取り出す。 + + 展開以外のコマンド (rename 宛先の検証) なら ``None`` を返す。 + """ match = re.search(r'full\.tar\.zst|incr-\d+\.tar\.zst', command) - assert match is not None, f"アーカイブ名が見つからない: {command}" - return match.group(0) + return match.group(0) if match else None def test_bogus_rename_does_not_stop_the_restore(tmp_path): @@ -187,6 +210,66 @@ def test_both_layouts_survive_a_bogus_rename(tmp_path, layout, label): 'full.tar.zst', 'incr-001.tar.zst', 'incr-002.tar.zst'], label +def test_skipped_rename_targets_are_checked_after_the_restore(tmp_path): + """飲み込んだ rename の宛先は、全アーカイブ適用後にまとめて検証する。""" + _write_generation(tmp_path, 'gen', incrementals=2) + mgr = StubManager(tmp_path, {'incr-001.tar.zst': BOGUS_RENAME_STDERR}) + + mgr.restore('gen') + + # 宛先が渡り、空ディレクトリだけを拾うコマンドになっている + assert mgr.checked is not None + assert './.claude/plugins/cache/B10' in mgr.checked + assert 'ls -A' in mgr.checked + + +def test_rename_targets_are_shell_quoted(tmp_path): + """宛先は tar の出力由来なので、シェルへ素通しにしない。""" + _write_generation(tmp_path, 'gen', incrementals=1) + nasty = ("tar: Cannot rename './x' to './a b; touch /tmp/pwned': " + "Directory not empty\n") + mgr = StubManager(tmp_path, {'incr-001.tar.zst': nasty}) + + mgr.restore('gen') + + assert mgr.checked is not None + assert 'touch /tmp/pwned' not in mgr.checked.replace( + shlex.quote('./a b; touch /tmp/pwned'), '') + assert shlex.quote('./a b; touch /tmp/pwned') in mgr.checked + + +def test_no_check_runs_when_no_rename_was_skipped(tmp_path): + """rename を飲み込んでいなければ、余計なコンテナを起こさない。""" + _write_generation(tmp_path, 'gen', incrementals=1) + mgr = StubManager(tmp_path, {}) + + mgr.restore('gen') + + assert mgr.checked is None + + +def test_an_empty_rename_target_is_warned_as_possible_data_loss(tmp_path, caplog): + """AC4: 宛先が空なら、正当な rename を取りこぼした可能性として警告する。""" + _write_generation(tmp_path, 'gen', incrementals=1) + + class EmptyTargetManager(StubManager): + def _run_docker_tar(self, snap_dir, mode, command, volumes=None): + result = super()._run_docker_tar(snap_dir, mode, command, volumes) + if self._archive_in(command) is None and mode == 'restore': + return subprocess.CompletedProcess( + [], 0, stdout='./.claude/plugins/cache/B10\n', stderr='') + return result + + mgr = EmptyTargetManager(tmp_path, {'incr-001.tar.zst': BOGUS_RENAME_STDERR}) + with caplog.at_level('WARNING'): + mgr.restore('gen') + + warnings = '\n'.join(r.getMessage() for r in caplog.records + if r.levelname == 'WARNING') + assert '中身が復元されていない可能性があります' in warnings + assert './.claude/plugins/cache/B10' in warnings + + def test_a_real_tar_error_still_stops_the_restore(tmp_path): """AC4: rename 以外の失敗は従来どおり止める。""" _write_generation(tmp_path, 'gen', incrementals=3) From 56ab32d5d86f4b36b2527c6ada137cc384f721cb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 29 Aug 2026 22:22:15 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix(snapshot):=20=E7=A9=BA=E7=99=BD?= =?UTF-8?q?=E3=82=92=E5=90=AB=E3=82=80=20rename=20=E5=AE=9B=E5=85=88?= =?UTF-8?q?=E3=82=92=E5=88=86=E6=96=AD=E3=81=9B=E3=81=9A=E3=81=AB=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 復元後の空ディレクトリ検査で、コンテナの標準出力を split() で分解していたため、 パスに空白が含まれると 1 つのパスが複数に分断されていた。行単位で解析するよう splitlines() に変更し、空白入りのパスを 1 件として扱うテストを追加した。 あわせて _run_docker_tar が実行結果を返すようになったのに -> None のままだった 型ヒントを修正した。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PExvVXeqkA7LWG5YfsLCEc --- lib/devbase/snapshot/manager.py | 10 ++++++++-- tests/snapshot/test_restore_incremental.py | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py index ec4adaa1..3889bdef 100644 --- a/lib/devbase/snapshot/manager.py +++ b/lib/devbase/snapshot/manager.py @@ -335,8 +335,10 @@ def _warn_about_lost_renames(self, snap_dir: Path, volumes: dict, 'done', volumes=volumes, ) + # 空白を含むパスを分断しないよう、行単位で解析する。 # テストダブルは戻り値を返さない。その場合は検証を行わない。 - empty = result.stdout.split() if result is not None and result.stdout else [] + empty = [line.strip() for line in result.stdout.splitlines() + if line.strip()] if result is not None and result.stdout else [] if not empty: return logger.warning( @@ -542,7 +544,8 @@ def clear_command(volumes: dict) -> str: ) def _run_docker_tar(self, snap_dir: Path, mode: str, command: str, - volumes: Optional[dict] = None) -> None: + volumes: Optional[dict] = None + ) -> subprocess.CompletedProcess: """Docker経由でtar操作を実行する。 Args: @@ -550,6 +553,9 @@ def _run_docker_tar(self, snap_dir: Path, mode: str, command: str, mode: 'backup' or 'restore' command: コンテナ内で実行するコマンド volumes: 対象ボリューム (省略時は作成時の対象) + + Returns: + 実行結果。標準出力を読む呼び出し (rename 宛先の検証) がある。 """ image = self._ensure_snapshot_image() diff --git a/tests/snapshot/test_restore_incremental.py b/tests/snapshot/test_restore_incremental.py index 06e650ba..67a76023 100644 --- a/tests/snapshot/test_restore_incremental.py +++ b/tests/snapshot/test_restore_incremental.py @@ -270,6 +270,28 @@ def _run_docker_tar(self, snap_dir, mode, command, volumes=None): assert './.claude/plugins/cache/B10' in warnings +def test_an_empty_target_with_spaces_is_reported_as_one_path(tmp_path, caplog): + """検証コマンドの出力は行単位で読む。空白入りのパスを分断しない。""" + _write_generation(tmp_path, 'gen', incrementals=1) + stderr = ("tar: Cannot rename './x' to './a b/new dir': Directory not empty\n") + + class SpacedTargetManager(StubManager): + def _run_docker_tar(self, snap_dir, mode, command, volumes=None): + result = super()._run_docker_tar(snap_dir, mode, command, volumes) + if self._archive_in(command) is None and mode == 'restore': + return subprocess.CompletedProcess( + [], 0, stdout='./a b/new dir\n', stderr='') + return result + + mgr = SpacedTargetManager(tmp_path, {'incr-001.tar.zst': stderr}) + with caplog.at_level('WARNING'): + mgr.restore('gen') + + warnings = '\n'.join(r.getMessage() for r in caplog.records + if r.levelname == 'WARNING') + assert './a b/new dir' in warnings + + def test_a_real_tar_error_still_stops_the_restore(tmp_path): """AC4: rename 以外の失敗は従来どおり止める。""" _write_generation(tmp_path, 'gen', incrementals=3) From d527bffe5dde16b5f7e20a87d7bc335428e57ff1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 29 Aug 2026 22:28:21 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix(snapshot):=20rename=20=E5=AE=9B?= =?UTF-8?q?=E5=85=88=E3=81=AE=E6=A4=9C=E8=A8=BC=E3=82=92=E5=88=86=E5=89=B2?= =?UTF-8?q?=E3=81=97=E3=80=81=E5=A4=B1=E6=95=97=E3=81=97=E3=81=A6=E3=82=82?= =?UTF-8?q?=E5=BE=A9=E5=85=83=E3=82=92=E8=90=BD=E3=81=A8=E3=81=95=E3=81=AA?= =?UTF-8?q?=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 巨大なツリーを入れ替えると失敗した rename が大量に出うる。全パスを 1 つの bash コマンドに詰めると docker run の引数が ARG_MAX を超え、復元が成功して いるのに検証で異常終了する。 chunk_paths() で 1 コマンドあたりの長さを抑えて複数回に分けて検証し、 検証コマンド自体が失敗した場合も警告に留めて復元を失敗にしないようにした。 検証は復元が終わった後に走るため、ここでの失敗は復元の成否と関係がない。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PExvVXeqkA7LWG5YfsLCEc --- lib/devbase/snapshot/manager.py | 66 ++++++++++++++----- tests/snapshot/test_restore_incremental.py | 73 +++++++++++++++++++++- 2 files changed, 124 insertions(+), 15 deletions(-) diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py index 3889bdef..b3cf5742 100644 --- a/lib/devbase/snapshot/manager.py +++ b/lib/devbase/snapshot/manager.py @@ -64,6 +64,33 @@ def rename_only_failure(stderr: str) -> Optional[list]: return renames or None +# 検証コマンド 1 回あたりの引数の上限。``docker run`` の引数は最終的に execve の +# ARG_MAX (多くの環境で 128KB) に収まる必要がある。巨大なツリーを入れ替えると +# 失敗した rename が大量に出うるので、余裕をもって分割する。 +_CHECK_COMMAND_BUDGET = 60_000 + + +def chunk_paths(paths: list, budget: int = _CHECK_COMMAND_BUDGET) -> list: + """引用済みのパスを、1 コマンドの長さが budget を超えないように分ける。 + + 1 件で budget を超える異常に長いパスも、単独のチャンクとして必ず返す + (捨てると検証から漏れるため)。 + """ + chunks: list = [] + current: list = [] + length = 0 + for path in paths: + # +1 は区切りの空白 + if current and length + len(path) + 1 > budget: + chunks.append(current) + current, length = [], 0 + current.append(path) + length += len(path) + 1 + if current: + chunks.append(current) + return chunks + + def rename_targets(lines: list) -> list: """rename エラーの行から**宛先**のパスだけを取り出す。 @@ -325,20 +352,31 @@ def _warn_about_lost_renames(self, snap_dir: Path, volumes: dict, """ if not targets: return - quoted = ' '.join(shlex.quote(t) for t in sorted(set(targets))) - result = self._run_docker_tar( - snap_dir, 'restore', - 'for p in ' + quoted + '; do ' - 'full="/target/${p#./}"; ' - 'if [ -d "$full" ] && [ -z "$(ls -A "$full" 2>/dev/null)" ]; then ' - 'echo "$p"; fi; ' - 'done', - volumes=volumes, - ) - # 空白を含むパスを分断しないよう、行単位で解析する。 - # テストダブルは戻り値を返さない。その場合は検証を行わない。 - empty = [line.strip() for line in result.stdout.splitlines() - if line.strip()] if result is not None and result.stdout else [] + quoted = [shlex.quote(t) for t in sorted(set(targets))] + empty: list = [] + for chunk in chunk_paths(quoted): + try: + result = self._run_docker_tar( + snap_dir, 'restore', + 'for p in ' + ' '.join(chunk) + '; do ' + 'full="/target/${p#./}"; ' + 'if [ -d "$full" ] && [ -z "$(ls -A "$full" 2>/dev/null)" ]; then ' + 'echo "$p"; fi; ' + 'done', + volumes=volumes, + ) + except SnapshotError as e: + # ここまで来た時点で復元自体は終わっている。**検証の失敗で復元を + # 失敗にしない。** 検証できなかったことだけを伝える。 + logger.warning( + "復元後の rename 宛先の検証に失敗しました。復元自体は完了して" + "います: %s", e) + return + # 空白を含むパスを分断しないよう、行単位で解析する。 + # テストダブルは戻り値を返さない。その場合は検証を行わない。 + if result is not None and result.stdout: + empty.extend(line.strip() for line in result.stdout.splitlines() + if line.strip()) if not empty: return logger.warning( diff --git a/tests/snapshot/test_restore_incremental.py b/tests/snapshot/test_restore_incremental.py index 67a76023..e352c4cb 100644 --- a/tests/snapshot/test_restore_incremental.py +++ b/tests/snapshot/test_restore_incremental.py @@ -24,7 +24,7 @@ from devbase.errors import SnapshotCommandError, SnapshotError from devbase.snapshot.manager import ( - SnapshotManager, rename_only_failure, rename_targets, + SnapshotManager, chunk_paths, rename_only_failure, rename_targets, ) @pytest.fixture(autouse=True) @@ -292,6 +292,77 @@ def _run_docker_tar(self, snap_dir, mode, command, volumes=None): assert './a b/new dir' in warnings +# --------------------------------------------------------------------------- +# 検証コマンドの分割 (ARG_MAX 対策) +# --------------------------------------------------------------------------- + +def test_paths_fit_in_one_chunk_when_small(): + assert chunk_paths(['a', 'b', 'c'], budget=100) == [['a', 'b', 'c']] + + +def test_paths_are_split_to_stay_under_the_budget(): + paths = ['x' * 30 for _ in range(10)] + chunks = chunk_paths(paths, budget=100) + assert len(chunks) > 1 + for chunk in chunks: + assert len(' '.join(chunk)) <= 100 + assert [p for chunk in chunks for p in chunk] == paths + + +def test_a_single_oversized_path_is_kept_in_its_own_chunk(): + """1 件で budget を超えるパスも捨てない。""" + assert chunk_paths(['y' * 500], budget=100) == [['y' * 500]] + + +def test_no_chunks_for_no_paths(): + assert chunk_paths([]) == [] + + +def test_many_targets_are_verified_in_several_containers(tmp_path): + """パスが多いと 1 コマンドに詰め込まず、複数回に分けて検証する。""" + _write_generation(tmp_path, 'gen', incrementals=1) + stderr = ''.join( + f"tar: Cannot rename './src/{'d' * 200}/{i}' " + f"to './dst/{'e' * 200}/{i}': Directory not empty\n" + for i in range(1000)) + + class CountingManager(StubManager): + checks: list = [] + + def _run_docker_tar(self, snap_dir, mode, command, volumes=None): + if self._archive_in(command) is None and mode == 'restore': + self.checks.append(command) + return None + return super()._run_docker_tar(snap_dir, mode, command, volumes) + + mgr = CountingManager(tmp_path, {'incr-001.tar.zst': stderr}) + mgr.checks = [] + mgr.restore('gen') + + assert len(mgr.checks) > 1 + for command in mgr.checks: + assert len(command) < 128 * 1024 + + +def test_a_failed_verification_does_not_fail_the_restore(tmp_path, caplog): + """検証で落ちても、終わっている復元を失敗にしない。""" + _write_generation(tmp_path, 'gen', incrementals=1) + + class BrokenCheckManager(StubManager): + def _run_docker_tar(self, snap_dir, mode, command, volumes=None): + if self._archive_in(command) is None and mode == 'restore': + raise SnapshotCommandError("引数が長すぎます", stderr='') + return super()._run_docker_tar(snap_dir, mode, command, volumes) + + mgr = BrokenCheckManager(tmp_path, {'incr-001.tar.zst': BOGUS_RENAME_STDERR}) + with caplog.at_level('WARNING'): + mgr.restore('gen') # 例外を投げない + + warnings = '\n'.join(r.getMessage() for r in caplog.records + if r.levelname == 'WARNING') + assert '復元自体は完了' in warnings + + def test_a_real_tar_error_still_stops_the_restore(tmp_path): """AC4: rename 以外の失敗は従来どおり止める。""" _write_generation(tmp_path, 'gen', incrementals=3) From a8fa64d3bf04b0bdd02278bd1535023e7ce31f89 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 29 Aug 2026 22:32:22 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs(PLAN40):=20=E3=82=AF=E3=83=AD=E3=82=B9?= =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E3=81=AE=E7=B5=90=E6=9E=9C?= =?UTF-8?q?=E3=82=92=E3=83=97=E3=83=A9=E3=83=B3=E3=81=B8=E5=8F=8D=E6=98=A0?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PExvVXeqkA7LWG5YfsLCEc --- ...40_snapshot-incremental-restore-failure.md | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/issues/PLAN40_snapshot-incremental-restore-failure.md b/issues/PLAN40_snapshot-incremental-restore-failure.md index 90a8cfdb..8e95674b 100644 --- a/issues/PLAN40_snapshot-incremental-restore-failure.md +++ b/issues/PLAN40_snapshot-incremental-restore-failure.md @@ -235,7 +235,29 @@ Task 1 で原因が確定したため、採否を次のとおり確定した( | AC3 | 同上(世代はテスト内で新規に作成する) | 満たす | | AC4 | `test_the_failure_message_says_how_to_get_back` / `test_a_full_restore_failure_also_says_how_to_get_back` / `test_a_real_tar_error_still_stops_the_restore` / `docs/user/snapshot-guide.md` | どの差分で落ちたか・`pre-restore-` から戻す手順を出す | | AC5 | `test_both_layouts_survive_a_bogus_rename`(旧 `volume:` / 新 `volumes: {ai, group}` の 2 レイアウト) | 両方で後続の差分まで適用しきる | -| AC6 | `uv run pytest` | 1,663 passed。Docker が無い環境では実機テストだけ skip する | +| AC6 | `uv run pytest` | 1,676 passed。Docker が無い環境では実機テストだけ skip する | + +### クロスレビューで追加した振る舞い + +代替案 F は「偽 rename と正当な rename をエラー文だけでは区別できない」という弱点を持つ。 +レビューでこの点を指摘され、**展開後の状態でなら区別できる**ことを実験で確かめて対処した。 + +| 失敗した rename の宛先 | 意味 | 実測 | +|---|---|---| +| 存在しない / 中身がある | 偽 rename。欠落なし | 総入れ替えの再現で確認 | +| **存在するが空のまま** | 正当な rename を取りこぼした疑い | 正当な `mv` の再現で確認 | + +偽 rename の宛先はそのディレクトリ自身が新しく作られたものなので、アーカイブから中身が +展開されて空にならない。一方、正当な `mv` の差分には**ディレクトリのエントリしか入らない** +ため、取りこぼすと宛先が空のまま残る。`restore()` は飲み込んだ rename の宛先を集め、 +**全アーカイブ適用後に 1 度だけ**検査する(後続の差分が中身を埋める場合があるため)。 + +空の宛先を検出しても**失敗にはせず警告に留める**。空の宛先で復元を止めると、正当に空だった +ディレクトリで再び途中停止が起き、この PLAN が直そうとしている症状を再発させる。空の宛先の +中身はそのスナップショットには入っていないため、停止しても復旧しない。 + +検証は復元の完了**後**に走るので、検証自体の失敗を復元の失敗にしない。パス数が多い場合は +`chunk_paths()` で `docker run` の引数長を抑えて複数回に分ける。 **AC5 の実機確認の範囲について。** 実 Docker の検証は `group` 側のボリューム (`devbase_home_plan40test`) だけで行った。旧レイアウトと新レイアウトの `ai` 側は @@ -325,7 +347,7 @@ rename エラーの扱いには影響しないので、その差はテストダ - [x] AC1〜AC6 を満たし、条件ごとに検証手段と結果が対応している(「検証結果」節) - [x] `uv run pytest` が green(1,663 passed) -- [ ] `/ndf:cross-review` で APPROVE 収束済み +- [x] `/ndf:cross-review` で APPROVE 収束済み(4 ラウンド。codex / gemini 両者 APPROVE) - [x] `docs/user/snapshot-guide.md` が復元の制約と失敗時の戻し方を説明している - [x] 総入れ替えを挟んだ差分 3 個の世代を最後まで復元できることを実機で確認している (当初の「実在する差分 8 個の世代」はローテーションで消滅済み。前提 6 を参照)