Skip to content

既存の世代の名前で snapshot create --full を打つと、full.tar.zst が変更分だけになり復元でファイルを失う #458

Closed

Description

@takemi-ohama

何を見つけたか

既にある世代の名前で 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、作業用のディレクトリ。利用者のボリュームは使っていない)。

  1. ai/a・ai/b を置き、tar --listed-incremental=/backup/snapshot.snar -cf /backup/full.tar -C /source ai
  2. ai/c を足し、同じ snar のまま同じコマンドで full.tar を作り直す
  3. 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

  1. takemi-ohama commented on Oct 12, 2026

    @takemi-ohama
    ContributorAuthor

    設計の途中で、本文の「何を見つけたか」と実際のコマンドの形(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 は前のまま
    • tar は先に snapshot.snar を書き換える(76 → 80 バイト)。その後に積んだ差分は、失敗した回の前に足したファイル(ai/c)を含まなかった

    利用者に見える形は「--full が失敗し、その後の差分が失敗の前の変更を落とす」である。直す向き(受け入れ条件 1〜10)は変わらず、受け入れ条件 5 は今の実装では snar の点で破れている。設計文書の「あるべき姿の根拠」に実測として書いた。

    範囲外と判断し、差分の経路で同じく snar が進んだまま残る件を #466 として残した。

  2. added 3 commits that reference this issue on Oct 12, 2026
  3. added a commit that references this issue on Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions