Repository navigation
既存の世代の名前で snapshot create --full を打つと、full.tar.zst が変更分だけになり復元でファイルを失う #458
Description
何を見つけたか
既にある世代の名前で devbase snapshot create --name <名前> --full を打つと、full.tar.zst が「前の控えからの変更分」だけのアーカイブになる。_create_full は世代のディレクトリにある snapshot.snar を消さずに tar --listed-incremental=/backup/snapshot.snar を打つため、GNU tar は差分(レベル 1)として控える。
devbase-snapshot:latest の GNU tar で、同じコマンドの形を使い確かめた(2026-10-12、作業用のディレクトリ。利用者のボリュームは使っていない)。
ai/a・ai/bを置き、tar --listed-incremental=/backup/snapshot.snar -cf /backup/full.tar -C /source aiai/cを足し、同じ snar のまま同じコマンドでfull.tarを作り直すtar -tf /backup/full.tar→ai/とai/cだけ(ai/a・ai/bが無い)
あわせて、前からあった incr-*.tar.zst は消されずに残り、meta.yml の files だけが [full.tar.zst] に戻る(復元は incr-*.tar.zst を glob で拾うため、残った差分も適用される)。
どこで見つけたか
lib/devbase/snapshot/manager.py の create(is_new が偽で full=True のとき)→ _create_full → backup_command。#368(スナップショットの除外)の設計で、除外を変えた後に利用者が既存の世代へ --full を打つ経路を調べていて見つけた。
なぜこの変更の範囲外なのか
#368 の受け入れ条件は、除外の適用と、除外の一覧が違う世代へ差分を積まないこと(受け入れ条件 6・7)までで、既存の世代を --full で作り直す経路の振る舞いを含まない。対象範囲の「含まない」にある「復元の対象・順序・失敗時の案内の変更」「世代の保持・系列の規則の変更」にも当たらない別の不具合で、除外の有無に関わらず起きる。根拠: Value 2(MVV 版 1)
直さないと何が起きるか
既存の世代の名前で --full を打った後にその世代を復元すると、復元はボリュームを空にしてから full.tar.zst を展開するため、前の控えから変わっていないファイル(多くは会話の記録や設定)が復元後のボリュームに無い。利用者は「フルを取り直した」と思っているため、失ったことに復元の後まで気づかない。--name を省いた create と devbase up の自動スナップショット(新しい世代は新しいディレクトリ)では起きない。
起票者の推定
- 利用者が既存の世代の名前で
--fullを打つことがある(推した出所:docs/user/cli-reference/05-snapshot.mdの--nameと--fullの説明が、既存の名前を禁じていない) - 復元で変わっていないファイルを失う(推した出所: 上の手順 3 の中身の一覧と、
manager.pyのrestoreがclear_commandの後に full を展開すること。復元までは実際に試していない)
由来
issue #368
依頼(原文)
上の「何を見つけたか」から「由来」までの節が、この課題の依頼の原文である。
(書き手: 起票者、日付: 2026-10-12。#368 の設計の途中で AI が見つけて起票した本文で、利用者の指示の原文ではない。
マイルストーン「04 CLI 引数と文書」の説明にある「実害の観測があり自動で前倒しした課題」も起票者・棚卸しの記録である)
#458。人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く。
(書き手: AI の起動指示、日付: 2026-10-12)
利用者の指示の原文は無い。直す向き(--full は既存の世代でもその時点のボリューム全体を控える)は、
docs/user/cli-reference/05-snapshot.md の --full「フルバックアップを強制作成」と、docs/user/snapshot-guide.md の
「差分ではなく、強制的にフルバックアップを作成します」から読んだ。文書が約束している振る舞いに実装を合わせる変更である。
起票者の推定の扱い
上の「起票者の推定」の節の 2 行を写し、この要求での扱いを書き足す。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。
| # | 条件 | 推した出所 | この要求での扱い |
|---|---|---|---|
| S1 | 利用者が既存の世代の名前で --full を打つことがある |
docs/user/cli-reference/05-snapshot.md の --name と --full の説明が、既存の名前を禁じていない |
前提 1。打つことの有無に関わらず、文書が禁じていない操作として直す |
| S2 | 復元で変わっていないファイルを失う | 本文の手順 3 と、manager.py の restore が clear_command の後に full を展開すること(起票の時点で復元は試していない) |
受け入れ条件 1・2(作り直した後の復元で全ファイルが戻ることを、実の GNU tar で確かめる) |
目的
- 既存の世代の名前で
devbase snapshot create --name <名前> --fullを打ったとき、その世代が「打った時点のボリュームの
全体(除外の一覧を除く)」を 1 本のfull.tar.zstで持つ状態にし、復元でファイルを失わないようにする - 作り直しに失敗しても、それまでの世代の控えを失わない(Value 2「作り直しで失わない」)
前提
- 前提 1: 既存の世代の名前で
--fullを打つ操作は禁じず、受け付けて世代を作り直す(止めて新しい名前を求める形は採らない。
文書が「名前付きフルバックアップ」の例として--name before-upgrade --fullを挙げ、同じ名前で取り直す使い方を禁じていないため) - 前提 2: 作り直した世代は、前の
full.tar.zst・incr-*.tar.zstの中身を引き継がない。作り直しが成功した後、
その世代から前の時点へ戻す手段は無くなる(前の時点を残したい利用者は別の名前で作る。今の文書の範囲で読める) - 前提 3:
snapshot.ymlのその世代のcreated_atは今の振る舞いのまま変えない(差分を積んだときと同じくupdated_atと
incremental_countだけを更新する)。系列の最新の世代とローテーションの順はcreated_atで決まるため、変えると
別の課題の振る舞いが変わる - 前提 4: CI の
pytest(ubuntu の runner)には GNU tar と zstd があり、実の tar で走るテスト(tests/snapshot/test_backup_real_tar.py
の形)が飛ばされずに走る。macOS の手元では飛ばされ得るため、実の tar の確かめは CI の結果かdevbase-snapshot:latestの中で取る - 前提 5: 「作り直しに失敗した」は、控えのコマンド(docker の中の tar・zstd)が失敗して
SnapshotErrorになった場合を指す。
ホストのプロセスが途中で殺された・電源が落ちた場合の中途の状態は、この課題では扱わない
対象範囲
含む:
- 既存の世代のディレクトリへ
full.tar.zstを作る経路の振る舞い(create --name <既存> --full)。同じ関数を通る
ほかの経路(snapshot.snarが無い世代への差分の指定が full へ切り替わる経路、同じ秒に重なった復元前バックアップ)も、
作り直した世代に前の差分が残らない同じ規則に従う - 作り直しの後の世代の
meta.yml(files・incremental_count・exclude・type)とsnapshot.ymlのエントリの値 - 作り直しに失敗したときに、世代のファイルを作り直す前の状態に保つこと
- この振る舞いを利用者の文書(
docs/user/cli-reference/05-snapshot.md・docs/user/snapshot-guide.md)と確定仕様
(docs/specifications/snapshot-series.mdかsnapshot-exclude.md)に書くこと、CHANGELOG.mdの[Unreleased]
含まない:
- 新しい名前の世代の作成(
--nameを省いたcreate、devbase upの自動スナップショット)。この不具合は起きておらず、振る舞いを変えない - 既存の世代へ差分を積む経路(
--fullなしの名前の指定)の判定の規則(対象ボリューム → 控えの起点 → 除外の一覧) - 復元の対象・順序・失敗時の案内、
--pointの意味 - 世代の保持・系列・ローテーションの規則、
created_atの扱い(前提 3) - すでに壊れた形で作り直された世代(
full.tar.zstが差分で、前のincr-*が残る世代)を見つける・直す手段。
壊れた世代の中身は控えた時点から戻せないため、検出と案内を足すかは未決 1 に置く snapshot copy・deleteの振る舞いdevbase-snapshotのイメージの中身(tar・zstd の版)の変更
ドメインイベント
| # | イベント | 引き金 | 失敗したとき | 順序の前提 |
|---|---|---|---|---|
| E1 | 利用者が既存の世代の名前で snapshot create --full を打った |
利用者の操作 | 名前が不正・場所がディレクトリでないときは今のとおり SnapshotError で止まり、世代を変えない |
世代 backups/<名前>/ があり、full.tar.zst と 0 個以上の incr-*.tar.zst・snapshot.snar を持つ |
| E2 | 前の snapshot.snar を引き継がずに、ボリュームの全体を新しい full として控えた |
E1 | tar・zstd が失敗したら E3・E4・E5 を行わず、世代のファイルを E1 の前と同じに保ち、終了コード 1 で止まる(受け入れ条件 5) | E1 |
| E3 | 前の差分アーカイブ(incr-*.tar.zst)と前の状態の控え(snapshot.snar.bak)を世代から除いた |
E2 の成功 | 除けなかったら、その世代は復元で前の差分を当ててしまうため、成功として報告しない(受け入れ条件 6) | E2 の成功の後(前に除くと、E2 の失敗で前の控えを失う) |
| E4 | 世代の meta.yml を full の形(files: [full.tar.zst]・incremental_count: 0・今の除外の一覧)で書いた |
E3 | 今のとおり例外で止まる | E3 |
| E5 | snapshot.yml のその世代の incremental_count と updated_at を更新した |
E4 | 今のとおり例外で止まる | E4 |
| E6 | 利用者がその世代を復元し、ボリュームが E2 の時点の中身になった | 利用者の snapshot restore |
今の復元の失敗の案内のとおり | E5 |
| E7 | 次の devbase up の自動スナップショットが、その世代へ incr-001 を積んだ |
devbase up |
今の自動スナップショットの規則のとおり | E5(作り直した世代が系列の最新の世代で、積める条件を満たすとき) |
用語
| 用語 | 意味 |
|---|---|
| 世代 | backups/<名前>/ の 1 つ。full.tar.zst 1 つと、0 個以上の incr-NNN.tar.zst からなる(用語集の語) |
| 復元前バックアップ | restore が書き戻す前に自動で作るフルの世代 pre-restore-<時刻>(用語集の語) |
| 除外の一覧 | スナップショットのアーカイブに入れないパスの一覧(用語集の語) |
受け入れ条件
- 1. 前提: 世代
gのfull.tar.zstにファイルa・bがあり、その後incr-001.tar.zstが積まれている。ボリュームにcを足した
操作:create(name='g', full=True)
結果:g/full.tar.zstの中身の一覧(tar -tf)にa・b・cがすべて入る(実の GNU tar と zstd で確かめる) - 2. 1 の後に
gを復元すると、復元先のボリュームにa・b・cがあり、a・bの中身は作り直した時点の中身と同じになる
(作り直す前のincr-001で変わった中身が上書きで戻らない) - 3. 1 の後、
gのディレクトリにincr-*.tar.zstとsnapshot.snar.bakが無く、g/meta.ymlはtype: full・files: [full.tar.zst]・
incremental_count: 0になり、excludeは作り直した時点の除外の一覧になる - 4. 1 の後、
snapshot.ymlのgのエントリは 1 件のままで、incremental_countが 0 になり、created_atは作り直す前と同じ値である - 5. 前提: 世代
gにfull.tar.zst・incr-001.tar.zst・snapshot.snar・meta.ymlがある
操作:create(name='g', full=True)の控えのコマンドが失敗する
結果:SnapshotErrorになり(CLI は終了コード 1)、gの 4 つのファイルは作り直す前とバイト列が同じで、ほかのファイルが増えていない。
snapshot.ymlも変わらない - 6. 新しい full の控えには成功し、前の差分アーカイブを除くのに失敗したとき、
createは成功として返らない(SnapshotError、CLI は終了コード 1) - 7. 1 の後に
create(name='g')(--fullなし)を打つとg/incr-001.tar.zstができ、その中身は 1 の後に変えたファイルだけである
(作り直した full から差分を積み直せる) - 8.
snapshot.snarの無い世代g(incr-*.tar.zstを持つ)へcreate(name='g')を打ち full へ切り替わったときも、3 と同じく
前のincr-*.tar.zstが残らない - 9. 退行しない: 新しい名前の世代の
create(--fullの有無とも)とdevbase upの自動スナップショットのアーカイブ・meta.yml・
snapshot.ymlは今と同じになり、既存のテスト(tests/snapshot/・tests/commands/)がすべて通る - 10.
docs/user/cli-reference/05-snapshot.mdとdocs/user/snapshot-guide.mdに「既にある世代の名前で--fullを打つと、その世代を
今のボリュームの全体で作り直し、前の差分は消える(前の時点へは戻せない)」ことが書かれている
非機能の条件
| 大項目 | 条件 |
|---|---|
| 可用性 | 作り直しに失敗しても、その世代は作り直す前と同じに復元できる(受け入れ条件 5) |
| 性能・拡張性 | 作り直しの間は、前の世代のファイルと新しい full.tar.zst が同時に backups/<名前>/ にあり得る。一時に要るディスクは最大で「前の世代の大きさ + 新しい full の大きさ」で、これを超える置き場を足さない |
| 運用・保守性 | 作り直したことが INFO の 1 行で分かる(世代の名前と、除いた差分の本数を含む) |
影響
| 対象 | 影響 |
|---|---|
| 公開インタフェース | 変わらない(snapshot create の引数と終了コードは同じ。既存の世代への --full の結果だけが文書のとおりになる) |
| データ | 作り直した世代では前の incr-*.tar.zst・snapshot.snar.bak を消す。すでに壊れた形で作り直された世代は自動で直さない(未決 1) |
| 既存の振る舞い | 既存の世代の名前での --full(と、snar の無い世代への差分の切り替え)の結果。新しい世代・差分の積み増し・復元は変わらない |
検証手段
| 項目 | 手段 |
|---|---|
| テスト | env -u DEVBASE_ROOT uv run --locked pytest tests/ -q。受け入れ条件 1・2・7 は実の GNU tar と zstd で控えのコマンドを走らせるテスト(test_backup_real_tar.py の形)で、ほかは docker を差し替えたテストで確かめる |
| 静的解析 | uvx ruff check --select=E9,F63,F7,F82 lib |
| 固有の語 | python3 .github/scripts/proper_term_check.py(「語の一覧が無いため飛ばした」と出たら検査していないと書く) |
| 手動確認 | リリース後テストで、作業用の名前の世代に対し devbase snapshot create --name <作業用> --full を 2 回(間にファイルを足して)打ち、snapshot restore を作業用のボリュームへ当てて全ファイルが戻ることを見る。利用者のボリュームへは復元しない。ボリュームを丸ごと写さない(AGENTS.md の落とし穴) |
前提とする取り決め
| 項目 | 参照先 / 決めたこと |
|---|---|
| プロジェクト構造 | 実装は lib/devbase/snapshot/、テストは tests/snapshot/。containers/snapshot/ のイメージは変えない |
| コーディング規約 | AGENTS.md の「書き方」と周りのコード。lint は上の検証手段 |
| テスト戦略 | tar の中身に関わる条件(1・2・7)は実の GNU tar の結合のテスト、ファイルの残り・meta.yml・失敗の扱い(3〜6・8)は docker を差し替えた単体のテスト |
境界
| 区分 | 内容 |
|---|---|
| 常に行う | 全体のテスト・lint・固有の語の検査、CHANGELOG.md の [Unreleased] への記入、利用者の文書と確定仕様の更新 |
| 確認してから行う | 承認ゲートで人が見る: 作り直した世代から前の差分を消すこと(前提 2)、未決 1 の扱い |
| 行わない | 利用者の実のボリュームでの復元、devbase up の実行(P3)、devbase-snapshot のイメージの道具や版の変更、すでにある利用者の世代の書き換え |
未決
| 項目 | 誰が決めるか | 期限 |
|---|---|---|
1. すでに壊れた形で作り直された世代(meta.yml の files が [full.tar.zst] なのに incr-*.tar.zst が残る世代)を、list や restore で見つけて警告するか。足さないなら、壊れた世代があり得ることを CHANGELOG.md に書くだけにする |
承認ゲート 1 で利用者(設計の案はこの課題の設計で示す) | 設計の承認まで |
進行
モード: —
- 要求と受け入れ条件 — 2026-10-12 22:07
- 作業場所の用意 — 2026-10-12 22:53
- 設計 — 2026-10-12 22:11
- 素材の収集と出典の確定
- ドキュメント再構成
- ドキュメントレビュー
- 計画 — 2026-10-12 22:53
- 実装 — 2026-10-12 22:55
- 構造改善 — 2026-10-12 23:51
- 実装レビュー — 2026-10-13 00:09
- 完了判定 — 2026-10-12 23:05
- Pull Request — 2026-10-12 23:10
- 確定仕様化 — 2026-10-13 00:50
- 後片付け — 2026-10-13 01:00
- 配布 — 2026-10-13 01:00
- 体裁レビュー
- リリース後テスト — 2026-10-13 01:03
- 振り返り — 2026-10-13 01:03
- 棚卸し — 2026-10-13 01:19
振り返り: #472 (comment)
Activity
takemi-ohama commentedon Oct 12, 2026
設計の途中で、本文の「何を見つけたか」と実際のコマンドの形(
tar ... -cf - | zstd -1 -T0 -o /backup/full.tar.zst)での振る舞いが違うことを確かめた(2026-10-12、devbase-snapshot:latest・GNU tar 1.35・zstd 1.5.7、作業用のディレクトリ)。full.tar.zstへ同じコマンドを打つと、zstd がalready exists; stdin is an input - not proceeding.で上書きを拒み、終了コード 1(SnapshotError)になる。full.tar.zstは前のままsnapshot.snarを書き換える(76 → 80 バイト)。その後に積んだ差分は、失敗した回の前に足したファイル(ai/c)を含まなかった利用者に見える形は「
--fullが失敗し、その後の差分が失敗の前の変更を落とす」である。直す向き(受け入れ条件 1〜10)は変わらず、受け入れ条件 5 は今の実装では snar の点で破れている。設計文書の「あるべき姿の根拠」に実測として書いた。範囲外と判断し、差分の経路で同じく snar が進んだまま残る件を #466 として残した。
Docs: #458 の設計で作り直しの途中の full と meta.yml の組の食い違いを復元させない
Merge pull request #467 from devbasex/design/issue-458
Merge pull request #469 from devbasex/feat/issue-458-m8b
Merge pull request #474 from devbasex/docs/m8b-spec