diff --git a/CHANGELOG.md b/CHANGELOG.md
index fb01a6e5..7d98144c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -74,6 +74,11 @@
`VSCODE_IPC_HOOK_CLI`(ウィンドウが開いたままで接続できても)は使わないようにしました。tmux のセッション環境にいまの
クライアントの値があればそれを使い、無ければ SSH ではコマンドの提示、手元では手元の `code` の起動になります。
このとき `PATH` に残る Remote-SSH の `remote-cli` の `code` は使わず、手元の `code` を起動します(見つからなければスキップします)。
+- **同じ秒に `devbase snapshot restore` を続けて打っても、復元前バックアップが前の控えを上書きしないようにしました(#473)。**
+ 復元前バックアップの名前 `pre-restore-<時刻>`(秒まで)が既にある世代と重なったら、その世代には触らず、末尾に
+ 通し番号を付けた名前(`pre-restore-<時刻>-2`、それも重なれば `-3` …)で新しく作ります。復元する世代がちょうど
+ 同じ秒の `pre-restore-*` でも、展開するのは打つ前のその世代の中身です。通し番号の名前もほかの `pre-restore-*` と
+ 同じ規則で保持・削除されます。
## [4.1.0] - 2026-10-07
diff --git a/docs/specifications/snapshot-series.md b/docs/specifications/snapshot-series.md
index 91ec114b..953aa9cb 100644
--- a/docs/specifications/snapshot-series.md
+++ b/docs/specifications/snapshot-series.md
@@ -56,7 +56,7 @@
| 系列 | 対象ボリュームの組が同じ世代の集まり。`volumes` が無い・空・dict でないエントリ(PLAN39 より前の旧レイアウト)は `{'': devbase_home_ubuntu}` の組として 1 つの系列になる |
| 系列の最新の世代 | 系列の中で `created_at` が最も新しい世代。同じ値なら `snapshot.yml` で後ろのもの |
| 全体の上限 | 系列をまたいで数えた世代の数の上限(`max_total`) |
-| 復元前バックアップ | `restore` が書き戻す前に自動で作るフルの世代 `pre-restore-<時刻>`。復元する世代の対象ボリュームの組を控える。実行時の `DEVBASE_ACCOUNT_GROUP` には依らない。控えに失敗したら警告を出し、その呼び出しで作ったディレクトリだけを消して(既にあった同名の世代は消さない)、控え無しで復元を続ける |
+| 復元前バックアップ | `restore` が書き戻す前に自動で作るフルの世代 `pre-restore-<時刻>`。復元する世代の対象ボリュームの組を控える。実行時の `DEVBASE_ACCOUNT_GROUP` には依らない。名前が既にある世代(ディレクトリ・シンボリックリンク・`snapshot.yml` のエントリ)と重なったら、その世代を作り直さず、末尾に通し番号を付けた名前(`pre-restore-<時刻>-2`、それも重なれば `-3` …)で新しく作る。通し番号の名前もほかの `pre-restore-*` と同じ系列・保持の規則で扱う。控えに失敗したら警告を出し、その呼び出しで作ったディレクトリだけを消して、控え無しで復元を続ける |
| 世代の作り直し | [用語集](../glossary.md)の語。起きる場面と手順は下の「[世代の作り直し](#世代の作り直し)」 |
## 構成要素
@@ -288,19 +288,19 @@ classDiagram
世代の作り直しとして控える(`_has_previous_files`)。無ければ本来の名前(`full.tar.zst`・`snapshot.snar`)へ直接控え、最後に
`meta.yml` を書く(新しい世代の控えのコマンドと `meta.yml` はこの規則の前と同じ)。
-作り直しになる場面は、`_create_full` を呼ぶ 3 つの経路のうち、世代のディレクトリに前のファイルがあるものである。
+作り直しになる場面は、`_create_full` を呼ぶ経路のうち、世代のディレクトリに前のファイルがあるものである。
+復元前バックアップ(`_backup_before_restore`)は毎回新しいディレクトリへ作るため、作り直しにならない。
| 場面 | 呼び出し元 |
| --- | --- |
| 既にある世代の名前へ `devbase snapshot create --name <名前> --full` を打つ | `create` |
| `snapshot.snar` の無い世代へ差分を指定する(`create --name <名前>`・自動スナップショット)。組・控えの起点・除外の一覧の検査を通った後で full へ切り替わる | `_create_incremental` |
-| 復元前バックアップの名前 `pre-restore-<時刻>`(秒まで)が既にある世代と重なる | `_backup_before_restore` |
既にある名前への `--full` は拒まずに受け付ける。利用者の文書が名前付きの `--full` を例に挙げ、同じ名前で取り直す
使い方を禁じていないためである。前の時点を残したい利用者は、別の名前で作るか、先に `snapshot copy` で写す。
**判定を前のファイルの有無に置く理由**: 新しい世代の控えのコマンド・アーカイブ・`meta.yml` を 1 文字も変えずに済む。
-`create` の `is_new` で分けると、snar の無い世代への差分の切り替えと、重なった復元前バックアップを拾えない。
+`create` の `is_new` で分けると、snar の無い世代への差分の切り替えを拾えない。
作り直しの手順:
@@ -319,7 +319,7 @@ classDiagram
4. `full.tar.zst.new` を `full.tar.zst` へ改名する
5. `meta.yml` を新しい控えの内容(印無し)へ置き換える
6. `snapshot.snar.new` を `snapshot.snar` へ改名する
-4. INFO を 1 行出す(名前と除いた差分の本数)。`snapshot.yml` のエントリは `create` / `_backup_before_restore` が
+4. INFO を 1 行出す(名前と除いた差分の本数)。`snapshot.yml` のエントリは `create` が
今の規則(`_update_global_metadata`)で更新する。`incremental_count` は 0、`updated_at` は今の時刻、`volumes` は
作り直した `meta.yml` の組になり、`created_at` は変えない。エントリは 1 件のままである
@@ -364,7 +364,7 @@ zstd は既にある `full.tar.zst` を上書きしない。仮の名前と空
- `meta.yml` が `rebuilding: true` を持つ。`restore` はこの世代を、復元前バックアップとボリュームの消去の前に
`SnapshotError` で止める
- 作り直しても `snapshot.yml` のエントリは 1 件のままで、`created_at` は変わらない
-- 前のファイルを持たない世代の full の作成(新しい名前・`devbase up` の新しい世代・新しい復元前バックアップ)は、
+- 前のファイルを持たない世代の full の作成(新しい名前・`devbase up` の新しい世代・復元前バックアップ)は、
控えのコマンド・アーカイブ・`meta.yml` がこの規則の前と同じで、作り直しの INFO を出さない
- 作り直しの間に `backups/<名前>/` が一時に使うディスクは、最大で前の世代の大きさと新しい full の大きさの和である
(仮の名前のファイルを同じディレクトリに置き、改名で入れ替える)
@@ -374,9 +374,9 @@ zstd は既にある `full.tar.zst` を上書きしない。仮の名前と空
同じ名前への `--full` か次の `create` で作り直せる。仮の名前のファイルを消せないときだけは「ファイルは増えない」が破れる。
そのときは警告を出して残し、次の作り直しが控えの前に消す。
-同じ秒に復元前バックアップが重なると、既にある `pre-restore-<時刻>` の世代を今の復元先の中身で作り直す。復元する世代が
-その名前の世代そのものなら、展開の前に復元元が置き換わり、同じ秒に復元を 2 回打つと 1 回目の復元前バックアップが失われる
-(#473)。作り直しが失敗したときは、ほかの復元前バックアップの失敗と同じく警告 1 行で控え無しの復元を続け、既にあった世代は消さない。
+同じ秒に復元前バックアップの名前が重なっても、既にある `pre-restore-<時刻>` の世代は作り直さず、通し番号の名前で新しく作る
+(用語の表の「復元前バックアップ」)。復元する世代がその名前の世代そのものでも、展開するのは打つ前のその世代の中身であり、
+同じ秒に復元を 2 回打つと復元前バックアップが 2 つ残る。
## データ・設定
@@ -535,7 +535,9 @@ CLI と TUI:
`SnapshotError` で止まり、印が無ければ `full.tar.zst` と `meta.yml` の組が合って復元できること
- 作り直した後に `create(name='g')` を打つと `incr-001.tar.zst` ができ、実の GNU tar と zstd でその中身が作り直した後の変更だけであること
- `snapshot.snar` の無い世代(`incr-*` を持つ)への差分の指定が full へ切り替わり、前の `incr-*` が残らないこと
-- 同じ秒に重なった復元前バックアップが既にある世代を作り直し、その名前を返すこと
+- 同じ秒に重なった復元前バックアップが既にある世代とそのエントリを変えず、通し番号の名前(`-2`、重なれば `-3` …)で作ってその名前を返すこと
+- 復元する世代が同じ秒の `pre-restore-*` でも、展開の時点でその世代の full が打つ前の中身であること
+- 同じ秒に復元を 2 回打つと、復元前バックアップが 2 つ残ること
- 作り直したときに、名前と除いた差分の本数を含む INFO が 1 行出ること
- 前のファイルを持たない世代の `create`(`--full` の有無とも)の控えのコマンド・`meta.yml`・`snapshot.yml` がこの規則の前と同じで、
作り直しの INFO を出さないこと
diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py
index 030efd2c..c154f3dc 100644
--- a/lib/devbase/snapshot/manager.py
+++ b/lib/devbase/snapshot/manager.py
@@ -586,28 +586,25 @@ def _backup_before_restore(self, volumes: dict) -> Optional[str]:
"""復元前バックアップ ``pre-restore-<時刻>`` を、渡された組で作る。
組は復元する世代の検証済みの組 (``snapshot_volumes`` の戻り値) で、実行時の
- グループ (``self.volumes``) は読まない。失敗しても例外を外へ出さず、警告を
- 出してこの呼び出しで作ったディレクトリを消し、``None`` を返す。
+ グループ (``self.volumes``) は読まない。名前が既にある世代と重なれば、その世代を
+ 作り直さず、末尾に通し番号を付けた名前で新しい世代を作る (#473)。失敗しても例外を
+ 外へ出さず、警告を出してこの呼び出しで作ったディレクトリを消し、``None`` を返す。
Returns:
作った世代の名前。失敗したら None
"""
name = f"pre-restore-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
snap_dir: Optional[Path] = None
- created = False
- logger.info("復元前に復元先のボリュームの状態をバックアップします: %s (%s)",
- name, ', '.join(volumes.values()))
try:
- snap_dir = self._safe_snap_dir(name)
- if not snap_dir.exists():
- snap_dir.mkdir(parents=True)
- created = True
+ name, snap_dir = self._claim_pre_restore_dir(name)
+ logger.info("復元前に復元先のボリュームの状態をバックアップします: %s (%s)",
+ name, ', '.join(volumes.values()))
self._create_full(name, snap_dir, volumes)
self._update_global_metadata(name, snap_dir)
except Exception as e:
logger.warning("復元前バックアップに失敗しましたが続行します: %s", e)
- # 既にあった同名のディレクトリは前の控えなので消さない
- if created and snap_dir is not None:
+ # 消すのはこの呼び出しで作ったディレクトリだけ。既にあった世代には触らない
+ if snap_dir is not None:
try:
shutil.rmtree(snap_dir)
except OSError as rm_error:
@@ -616,6 +613,31 @@ def _backup_before_restore(self, volumes: dict) -> Optional[str]:
return None
return name
+ def _claim_pre_restore_dir(self, base: str) -> tuple[str, Path]:
+ """復元前バックアップの名前を決め、その世代のディレクトリを新しく作って返す。
+
+ ``base`` が既にある世代 (ディレクトリ・リンク・``snapshot.yml`` のエントリ) と
+ 重なれば ``-2``・``-3`` … と進める。ディレクトリは ``mkdir`` で
+ 排他的に作るため、同じ秒に並んだ復元が同じ世代を取り合わない。
+ """
+ taken = {
+ snap.get('name') for snap in self._load_metadata().get('snapshots') or []
+ if isinstance(snap, dict)
+ }
+ number = 1
+ while True:
+ name = base if number == 1 else f"{base}-{number}"
+ number += 1
+ raw = self.backups_dir / name
+ if name in taken or raw.is_symlink() or raw.exists():
+ continue
+ snap_dir = self._safe_snap_dir(name)
+ try:
+ snap_dir.mkdir()
+ except FileExistsError:
+ continue
+ return name, snap_dir
+
def _extract_archive(self, snap_dir: Path, archive: str, command: str,
volumes: dict, pre_restore_name: Optional[str],
skipped_renames: Optional[list] = None) -> None:
diff --git a/tests/snapshot/test_pre_restore_backup.py b/tests/snapshot/test_pre_restore_backup.py
index b64aa26a..8601aa40 100644
--- a/tests/snapshot/test_pre_restore_backup.py
+++ b/tests/snapshot/test_pre_restore_backup.py
@@ -211,6 +211,56 @@ def now(cls, tz=None):
assert (existing / "full.tar.zst").read_text() == "earlier"
+def _fix_now(monkeypatch) -> None:
+ import devbase.snapshot.manager as manager_module
+
+ class FixedDatetime(manager_module.datetime):
+ @classmethod
+ def now(cls, tz=None):
+ return cls(2026, 9, 28, 12, 0, 0)
+
+ monkeypatch.setattr(manager_module, "datetime", FixedDatetime)
+
+
+def test_a_failed_numbered_backup_removes_only_what_it_made(tmp_path, monkeypatch, caplog):
+ """重なって通し番号の名前で作るときに失敗しても、作った方だけを消す (#473)。"""
+ _write_generation(tmp_path, "X", {"volumes": INITECH_VOLUMES})
+ existing = _write_generation(tmp_path, "pre-restore-20260928-120000",
+ {"volumes": INITECH_VOLUMES})
+ before = {p.name: p.read_bytes() for p in existing.iterdir()}
+ _fix_now(monkeypatch)
+ mgr = RecordingManager(tmp_path, fail_backup=True)
+
+ with caplog.at_level("WARNING"):
+ mgr.restore("X")
+
+ assert len(_warnings(caplog)) == 1
+ assert len(mgr.restore_calls()) >= 1
+ assert _pre_restore_dirs(tmp_path) == [existing]
+ assert {p.name: p.read_bytes() for p in existing.iterdir()} == before
+ assert not any(e["name"].startswith("pre-restore-") for e in _entries(tmp_path))
+
+
+def test_a_numbered_backup_joins_the_same_series(tmp_path, monkeypatch):
+ """通し番号の名前も名前の検証を通り、ほかの ``pre-restore-*`` と同じ系列に入る (#473)。"""
+ _write_generation(tmp_path, "X", {"volumes": INITECH_VOLUMES})
+ _fix_now(monkeypatch)
+ mgr = RecordingManager(tmp_path)
+
+ mgr.restore("X")
+ mgr.restore("X")
+
+ names = [p.name for p in _pre_restore_dirs(tmp_path)]
+ assert names == ["pre-restore-20260928-120000", "pre-restore-20260928-120000-2"]
+ assert mgr._safe_snap_dir(names[1]) == (tmp_path / "backups" / names[1]).resolve()
+ listed = {s["name"]: s for s in mgr.list()}
+ assert listed[names[1]]["volumes"] == listed[names[0]]["volumes"] == INITECH_VOLUMES
+
+ mgr.rotate(keep=1)
+
+ assert len(_pre_restore_dirs(tmp_path)) == 1
+
+
def test_without_a_backup_the_failure_message_does_not_point_to_one(tmp_path):
"""控えも展開も失敗したら、別の世代から戻す案内になる (受け入れ条件 7)。"""
_write_generation(tmp_path, "X", {"volumes": INITECH_VOLUMES})
diff --git a/tests/snapshot/test_rebuild_generation.py b/tests/snapshot/test_rebuild_generation.py
index c9f3de26..d65c4a7f 100644
--- a/tests/snapshot/test_rebuild_generation.py
+++ b/tests/snapshot/test_rebuild_generation.py
@@ -1,7 +1,8 @@
"""世代の作り直し (#458)
-既にある世代へ full を作るとき (``create --name <既存> --full``・snar の無い世代への差分の指定・
-同じ秒の復元前バックアップ)、前の snar を引き継がずに仮の名前で控えてから入れ替える。
+既にある世代へ full を作るとき (``create --name <既存> --full``・snar の無い世代への差分の指定)、
+前の snar を引き継がずに仮の名前で控えてから入れ替える。
+同じ秒に重なった復元前バックアップは作り直さず、通し番号の名前で新しく作る (#473)。
``DEVBASE_ROOT`` を使わず ``tmp_path`` に閉じ、``_run_docker_tar`` を差し替えて Docker を起動しない。
"""
@@ -343,20 +344,95 @@ def test_marked_generation_is_not_restored(tmp_path, monkeypatch, stop, marked):
# ---------------------------------------------------------------------------
-# 同じ秒の復元前バックアップ (F3)
+# 同じ秒の復元前バックアップ (#473)
# ---------------------------------------------------------------------------
-def test_pre_restore_backup_rebuilds_an_existing_generation(tmp_path, monkeypatch):
+PRE_RESTORE = "pre-restore-20261012-120000"
+
+
+@pytest.fixture
+def fixed_now(monkeypatch):
class FixedNow(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 10, 12, 12, 0, 0)
monkeypatch.setattr(manager_mod, "datetime", FixedNow)
- mgr = FakeManager(tmp_path)
- name = "pre-restore-20261012-120000"
+
+
+def _existing_pre_restore(root: Path, name: str = PRE_RESTORE) -> FakeManager:
+ """同じ秒の名前の世代を、full と差分 1 本で作る。full の中身は ``before`` にする。"""
+ mgr = FakeManager(root)
mgr.create(name=name)
- (_backups(tmp_path) / name / "incr-001.tar.zst").write_text("old")
+ snap_dir = _backups(root) / name
+ (snap_dir / FULL_ARCHIVE).write_text("before")
+ (snap_dir / "incr-001.tar.zst").write_text("old")
+ mgr.calls.clear()
+ return mgr
+
+
+def test_pre_restore_backup_does_not_rebuild_an_existing_generation(tmp_path, fixed_now):
+ """重なったら通し番号の名前で作り、既にある世代とそのエントリは 1 バイトも変えない。"""
+ mgr = _existing_pre_restore(tmp_path)
+ existing = _backups(tmp_path) / PRE_RESTORE
+ before_files = _snapshot(existing)
+ before_entry = [e for e in _entries(tmp_path) if e["name"] == PRE_RESTORE]
+
+ assert mgr._backup_before_restore(mgr.volumes) == f"{PRE_RESTORE}-2"
+
+ assert _snapshot(existing) == before_files
+ assert [e for e in _entries(tmp_path) if e["name"] == PRE_RESTORE] == before_entry
+ second = _backups(tmp_path) / f"{PRE_RESTORE}-2"
+ assert _listing(second) == {FULL_ARCHIVE, SNAR_FILE, "meta.yml"}
+ assert _meta(second)["type"] == "full"
+ assert f"{PRE_RESTORE}-2" in [e["name"] for e in _entries(tmp_path)]
+
+
+def test_pre_restore_backup_skips_every_taken_number(tmp_path, fixed_now):
+ """``-2`` も重なれば ``-3`` で作る。エントリだけが残る名前も重なりとして避ける。"""
+ mgr = _existing_pre_restore(tmp_path)
+ (_backups(tmp_path) / f"{PRE_RESTORE}-2").mkdir()
+ meta = yaml.safe_load((_backups(tmp_path) / "snapshot.yml").read_text())
+ meta["snapshots"].append({"name": f"{PRE_RESTORE}-3", "created_at": "2026-10-12T00:00:00"})
+ (_backups(tmp_path) / "snapshot.yml").write_text(yaml.safe_dump(meta))
+
+ assert mgr._backup_before_restore(mgr.volumes) == f"{PRE_RESTORE}-4"
+ assert _listing(_backups(tmp_path) / f"{PRE_RESTORE}-2") == set()
+
+
+def test_restoring_the_same_second_pre_restore_extracts_its_own_content(tmp_path, fixed_now):
+ """復元元が同じ秒の ``pre-restore-*`` でも、展開するのは打つ前のその世代の中身である。"""
+ mgr = _existing_pre_restore(tmp_path)
+ source = _backups(tmp_path) / PRE_RESTORE
+ seen: list = []
+ record = mgr._run_docker_tar
+
+ def run(snap_dir, mode, command, volumes=None):
+ if mode == "restore":
+ seen.append((snap_dir, (snap_dir / FULL_ARCHIVE).read_text()))
+ return record(snap_dir, mode, command, volumes)
+
+ mgr._run_docker_tar = run
+ mgr.restore(PRE_RESTORE)
+
+ assert seen
+ assert seen[0] == (source, "before")
+ assert (source / "incr-001.tar.zst").read_text() == "old"
+
+
+def test_two_restores_in_the_same_second_keep_both_backups(tmp_path, fixed_now):
+ """同じ秒に復元を 2 回打つと、復元前バックアップが 2 つ残る。"""
+ mgr = FakeManager(tmp_path)
+ mgr.create(name="g")
- assert mgr._backup_before_restore(mgr.volumes) == name
- assert _listing(_backups(tmp_path) / name) == {FULL_ARCHIVE, SNAR_FILE, "meta.yml"}
+ mgr.restore("g")
+ first = _backups(tmp_path) / PRE_RESTORE
+ (first / FULL_ARCHIVE).write_text("state-before-first")
+ first_files = _snapshot(first)
+ mgr.restore("g")
+
+ assert _snapshot(first) == first_files
+ names = [e["name"] for e in _entries(tmp_path)]
+ assert names.count(PRE_RESTORE) == 1
+ assert names.count(f"{PRE_RESTORE}-2") == 1
+ assert (_backups(tmp_path) / f"{PRE_RESTORE}-2" / FULL_ARCHIVE).is_file()