Skip to content

fix(PLAN40): 差分スナップショットの復元が偽の rename エラーで止まる不具合を直す - #128

Merged
takemi-ohama merged 6 commits into
mainfrom
docs/PLAN40-snapshot-restore
Aug 29, 2026
Merged

takemi-ohama merged 6 commits into
mainfrom
docs/PLAN40-snapshot-restore

Conversation

@takemi-ohama

@takemi-ohama takemi-ohama commented Aug 29, 2026 •

Copy link
Copy Markdown
Contributor

Pull Request

概要

差分を持つスナップショットの復元が、GNU tar の rename エラーで途中停止する不具合を直します。
PLAN39 の実機検証中に見つかった既存不具合です。プラン文書(issues/PLAN40_*.md)と実装の両方を含みます。

根本原因: GNU tar の incremental が、inode 番号の再利用でディレクトリの rename を誤検出していました。

tar は --listed-incremental でディレクトリを (dev, ino) で追跡します。~/.claude/plugins/cache/ の
ようにディレクトリごと作り直される場所では、削除されたディレクトリの inode 番号が新しい
ディレクトリに再利用されるため、tar は無関係なディレクトリを「rename された」と誤判定し、
dumpdir に偽の R/T レコードを書きます。復元側はそれを rename() として実行して失敗し、
restore() がそこで中断してボリュームが中途半端な状態で残っていました。

tar は rename に失敗しても展開自体は完遂しています。 壊していたのは tar ではなく、
最初の失敗で復元を止めていたことでした。

関連 Issue

  • 発見の経緯: issues/PLAN39_account-group-volume-separation.md の「検証中に見つかった別件」
  • プラン: issues/PLAN40_snapshot-incremental-restore-failure.md

変更点

ファイル 変更
lib/devbase/errors.py stderr を持つ SnapshotCommandError を追加
lib/devbase/snapshot/manager.py rename_only_failure() で失敗を判定。_extract_archive() が rename エラーだけの失敗を警告にして次の差分へ進む
lib/devbase/snapshot/manager.py rename 以外の失敗は従来どおり停止するが、どのアーカイブで落ちたかと pre-restore-<name> からの戻し方をエラーに含める
docs/user/snapshot-guide.md rename 警告の意味(対応不要)と、失敗時の復旧手順を追記
tests/snapshot/test_restore_incremental.py 新規。判定の純粋関数・復元の継続・失敗時の案内・両レイアウト・実機再現を固定

調査で分かったこと(プランの前提を 2 点訂正)

  • 「差分の 2 つ目以降で落ちる」は不正確でした。 incr-001 から落ちます。条件は差分の個数ではなく
    ディレクトリの総入れ替えが起きたかで、発生は非決定的です(同一手順 4 回中 3 回失敗)。
  • 当初の検証対象が消滅していました。 20260823-114528(差分 8)と 20260817-110811 は
    max_generations: 3 のローテーションで削除済みです。合成世代で検証しています。
    現存する 20260829-182126/incr-001.tar.zst も偽 rename レコードを保持していることは確認済みです
    (.codex/.tmp/... → .claude/plugins/cache/temp_git_.../.git/objects という無関係なツリー間の rename)。

代替案の採否

案 採否 理由
A. 復元に作成側の snapshot.snar を渡す 棄却 実験の結果 /dev/null と完全に同一。展開側は状態ファイルを読まない
B / C. rename レコードを除去・無視する 棄却 正当な rename のデータを失う。正当な mv 後の差分にはディレクトリのエントリしか入らない
D. 作成側だけを変える 棄却 既存スナップショットを救えない
E. tar の実装を変える 棄却 listed-incremental 非対応のものが多く退行が大きい
F. rename エラーだけを非致命として扱う 採用 既存スナップショットをそのまま救え、削除セマンティクスも維持し、イメージも形式も変えない

やらないこと(スコープ外)

  • スナップショット形式そのものの作り替え(tar + zstd + listed-incremental は維持)
  • 世代管理・ローテーション・自動スナップショットの方針変更
  • PLAN39 のアカウントグループ対応の設計変更

動作確認

  • uv run pytest が green(1,676 passed)
  • python3 -m compileall -q lib bin が通る
  • 実機テスト(実 Docker・実 tar)で、総入れ替えを挟んだ差分 3 個の世代が最後まで復元でき、
    復元先の内容が最後の差分時点のソースと完全一致する
  • red → green を確認(寛容化を一時的に無効化すると同テストが incr-001 で失敗する)
  • 実データのボリューム(devbase_home_ubuntu 等)と backups/ は触っていない。使い捨てボリュームも残っていない
  • ドキュメント (docs/user/snapshot-guide.md) を更新した

復元後の欠落検知(クロスレビューで追加)

偽 rename と正当な rename の失敗は tar のエラー文だけでは区別できないが、展開後の状態でなら区別できる。

失敗した rename の宛先 意味
存在しない / 中身がある 偽 rename(欠落なし)
存在するが空のまま 正当な rename を取りこぼした疑い

偽 rename の宛先はそのディレクトリ自身が新しく作られたものなので、アーカイブから中身が展開されて空になりません。一方、正当な mv の差分にはディレクトリのエントリしか入らないため、取りこぼすと空のまま残ります。両ケースとも実機で確認済みです。

restore() は飲み込んだ rename の宛先を集め、全アーカイブ適用後に 1 度だけ検査して、空のままなら欠落の可能性を警告します。検査は復元の完了後に走るため、検査自体の失敗は復元の失敗にしません。パス数が多い場合は chunk_paths() で docker run の引数長を抑えて分割します。

既知の制約

  • 正当な rename の取りこぼしは検知して警告します(上記)。ただし中身はそのスナップショットには
    入っていないため、その世代からは復元できません。より古い世代から取り出す必要があります。
  • AC5 の実機確認は group 側のボリュームだけで行いました。両レイアウトの ai 側は
    devbase_home_ubuntu に固定されており、これは利用者の実データが入っているボリュームです。
    復元は中身を消してから展開するため実機テストの対象にしていません。レイアウトの違いは
    対象ボリュームの解決とマウントにだけ効き、rename エラーの扱いには影響しないので、
    その差はテストダブルで固定しています。

実機テストのログ(抜粋)

WARNING incr-001.tar.zst の展開で tar が rename に失敗しました。GNU tar の incremental が
inode 番号の再利用でディレクトリの rename を誤検出したものとみなし、復元を続けます:
tar: Cannot rename './group/.claude/plugins/cache/B9/unknown/commands/unknown/commands'
     to './group/.claude/plugins/cache/B10': No such file or directory

クロスレビュー

/ndf:cross-review で codex / gemini 両者 APPROVE に収束(4 ラウンド)。指摘 5 件はすべて修正・Resolve 済みです。

🤖 Generated with Claude Code

https://claude.ai/code/session_01PExvVXeqkA7LWG5YfsLCEc

takemi-ohama and others added 2 commits August 29, 2026 20:44
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5oA2PqY6UX2Ca3t78886t
GNU tar の incremental はディレクトリを (dev, ino) で追跡して rename を検出する。
ディレクトリが削除され作り直されると inode 番号が再利用されるため、tar は無関係な
ディレクトリを rename されたものと誤判定し、dumpdir に偽の R/T レコードを書く。
復元側の tar はそれを rename() として実行して失敗し、restore() がそこで中断していた。

tar は rename に失敗しても展開自体は完遂しているため、この失敗だけを警告として扱い
次の差分へ進める。それ以外の失敗は従来どおり止めるが、どのアーカイブで落ちたかと
pre-restore-<name> からの戻し方をエラーに含めるようにした。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PExvVXeqkA7LWG5YfsLCEc
@takemi-ohama takemi-ohama changed the title docs(PLAN40): 差分スナップショットの復元が途中で失敗する不具合のプランを作る fix(PLAN40): 差分スナップショットの復元が偽の rename エラーで止まる不具合を直す Aug 29, 2026

@takemi-ohama takemi-ohama left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 1 | codex | REQUEST_CHANGES

rename 失敗の分類がデータ欠落を成功扱いし得るため、判定方式の修正が必要です。

Comment thread lib/devbase/snapshot/manager.py

@takemi-ohama takemi-ohama left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 1 | gemini | APPROVE

設計、実装、テストともに非常によくまとめられています。
tar の rename エラーを正規表現で正確に捕捉し、他のエラーや警告が混ざった場合には安全側に倒して(失敗として)扱う判断は適切です。
また、エラーメッセージの改善により、失敗時のユーザーのリカバリ手順が明確になっている点も素晴らしいです。

修正すべき点はありません。マージして問題ないと考えます。

偽 rename と正当な rename の失敗は、tar のエラーメッセージだけでは区別できない。
しかし展開後の状態でなら見分けられる。偽 rename の宛先はそのディレクトリ自身が
新しく作られたものなのでアーカイブから中身が展開されて空にならないが、正当な
rename を取りこぼした場合は中身が rename でしか移動しないため宛先が空のまま残る。

復元の最後に、飲み込んだ rename の宛先が空のままかどうかを検査し、空であれば
中身が復元されていない可能性を警告するようにした。パスは tar の出力由来なので
シェルへ渡す前に shlex.quote で引用する。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PExvVXeqkA7LWG5YfsLCEc

@takemi-ohama takemi-ohama left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 2 | codex | REQUEST_CHANGES

Comment thread lib/devbase/snapshot/manager.py Outdated

@takemi-ohama takemi-ohama left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 2 | gemini | REQUEST_CHANGES

  • _run_docker_tar の型ヒントの更新漏れ(-> None のまま戻り値を返している)
  • 復元後の空ディレクトリ検査で .split() を使っているため、空白を含むパスが分断されてしまう問題

上記 2 点の正確性に関する修正を提案します。

Comment thread lib/devbase/snapshot/manager.py Outdated
Comment thread lib/devbase/snapshot/manager.py
復元後の空ディレクトリ検査で、コンテナの標準出力を split() で分解していたため、
パスに空白が含まれると 1 つのパスが複数に分断されていた。行単位で解析するよう
splitlines() に変更し、空白入りのパスを 1 件として扱うテストを追加した。

あわせて _run_docker_tar が実行結果を返すようになったのに -> None のままだった
型ヒントを修正した。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PExvVXeqkA7LWG5YfsLCEc

@takemi-ohama takemi-ohama left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 3 | codex | APPROVE

修正が必要な指摘はありません。

@takemi-ohama takemi-ohama left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 3 | gemini | REQUEST_CHANGES

全体として、GNU tar の listed-incremental に起因する誤検知と復元失敗について正確に分析されており、解決策(偽の rename エラーのみを非致命として扱う)も既存のスナップショット形式やローテーションへの影響を与えない適切なアプローチです。実装のテストも十分に行われています。

1 点だけ、巨大なツリーの削除・入れ替えが発生した場合に ARG_MAX 制限に引っかかるリスクが見つかりました。以下のインラインコメントをご確認ください。

Comment thread lib/devbase/snapshot/manager.py Outdated
巨大なツリーを入れ替えると失敗した rename が大量に出うる。全パスを 1 つの
bash コマンドに詰めると docker run の引数が ARG_MAX を超え、復元が成功して
いるのに検証で異常終了する。

chunk_paths() で 1 コマンドあたりの長さを抑えて複数回に分けて検証し、
検証コマンド自体が失敗した場合も警告に留めて復元を失敗にしないようにした。
検証は復元が終わった後に走るため、ここでの失敗は復元の成否と関係がない。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PExvVXeqkA7LWG5YfsLCEc

@takemi-ohama takemi-ohama left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 4 | codex | APPROVE

修正が必要な問題は見つかりませんでした。

@takemi-ohama takemi-ohama left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 4 | gemini | APPROVE

設計、実装、テスト、エラーハンドリングのすべてにおいて非常に高品質です。GNU tar の挙動に対する分析が正確であり、shlex.quote や ARG_MAX を考慮したチャンク分割など、シェル経由の実行も安全に実装されています。特に追加の修正を求める点はありません。

@takemi-ohama
takemi-ohama merged commit 48b2f45 into main Aug 29, 2026
5 checks passed
@takemi-ohama
takemi-ohama deleted the docs/PLAN40-snapshot-restore branch August 30, 2026 01:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant