fix(PLAN40): 差分スナップショットの復元が偽の rename エラーで止まる不具合を直す - #128
Conversation
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
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 1 | codex | REQUEST_CHANGES
rename 失敗の分類がデータ欠落を成功扱いし得るため、判定方式の修正が必要です。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 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
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 2 | codex | REQUEST_CHANGES
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 2 | gemini | REQUEST_CHANGES
_run_docker_tarの型ヒントの更新漏れ(-> Noneのまま戻り値を返している)- 復元後の空ディレクトリ検査で
.split()を使っているため、空白を含むパスが分断されてしまう問題
上記 2 点の正確性に関する修正を提案します。
復元後の空ディレクトリ検査で、コンテナの標準出力を 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
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 3 | codex | APPROVE
修正が必要な指摘はありません。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 3 | gemini | REQUEST_CHANGES
全体として、GNU tar の listed-incremental に起因する誤検知と復元失敗について正確に分析されており、解決策(偽の rename エラーのみを非致命として扱う)も既存のスナップショット形式やローテーションへの影響を与えない適切なアプローチです。実装のテストも十分に行われています。
1 点だけ、巨大なツリーの削除・入れ替えが発生した場合に ARG_MAX 制限に引っかかるリスクが見つかりました。以下のインラインコメントをご確認ください。
巨大なツリーを入れ替えると失敗した 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
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 4 | codex | APPROVE
修正が必要な問題は見つかりませんでした。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 4 | gemini | APPROVE
設計、実装、テスト、エラーハンドリングのすべてにおいて非常に高品質です。GNU tar の挙動に対する分析が正確であり、shlex.quote や ARG_MAX を考慮したチャンク分割など、シェル経由の実行も安全に実装されています。特に追加の修正を求める点はありません。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PExvVXeqkA7LWG5YfsLCEc
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.pystderrを持つSnapshotCommandErrorを追加lib/devbase/snapshot/manager.pyrename_only_failure()で失敗を判定。_extract_archive()が rename エラーだけの失敗を警告にして次の差分へ進むlib/devbase/snapshot/manager.pypre-restore-<name>からの戻し方をエラーに含めるdocs/user/snapshot-guide.mdtests/snapshot/test_restore_incremental.py調査で分かったこと(プランの前提を 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)。代替案の採否
snapshot.snarを渡す/dev/nullと完全に同一。展開側は状態ファイルを読まないmv後の差分にはディレクトリのエントリしか入らないやらないこと(スコープ外)
動作確認
uv run pytestが green(1,676 passed)python3 -m compileall -q lib binが通る復元先の内容が最後の差分時点のソースと完全一致する
incr-001で失敗する)devbase_home_ubuntu等)とbackups/は触っていない。使い捨てボリュームも残っていないdocs/user/snapshot-guide.md) を更新した復元後の欠落検知(クロスレビューで追加)
偽 rename と正当な rename の失敗は tar のエラー文だけでは区別できないが、展開後の状態でなら区別できる。
偽 rename の宛先はそのディレクトリ自身が新しく作られたものなので、アーカイブから中身が展開されて空になりません。一方、正当な
mvの差分にはディレクトリのエントリしか入らないため、取りこぼすと空のまま残ります。両ケースとも実機で確認済みです。restore()は飲み込んだ rename の宛先を集め、全アーカイブ適用後に 1 度だけ検査して、空のままなら欠落の可能性を警告します。検査は復元の完了後に走るため、検査自体の失敗は復元の失敗にしません。パス数が多い場合はchunk_paths()でdocker runの引数長を抑えて分割します。既知の制約
入っていないため、その世代からは復元できません。より古い世代から取り出す必要があります。
group側のボリュームだけで行いました。両レイアウトのai側はdevbase_home_ubuntuに固定されており、これは利用者の実データが入っているボリュームです。復元は中身を消してから展開するため実機テストの対象にしていません。レイアウトの違いは
対象ボリュームの解決とマウントにだけ効き、rename エラーの扱いには影響しないので、
その差はテストダブルで固定しています。
実機テストのログ(抜粋)
クロスレビュー
/ndf:cross-reviewで codex / gemini 両者 APPROVE に収束(4 ラウンド)。指摘 5 件はすべて修正・Resolve 済みです。🤖 Generated with Claude Code
https://claude.ai/code/session_01PExvVXeqkA7LWG5YfsLCEc