From 57fc7c96bc533092c271c29ba3aeb504cd947e0c Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sun, 16 Aug 2026 13:54:15 +0200 Subject: [PATCH] fix(backup): claim the generation dir exclusively A run starting in the same wall-clock second as its predecessor reused that predecessor's generation directory: mkdir carried exist_ok=True, so rsync --delete overwrote a finished generation before create_stamp_file refused the already-stamped directory and exited 2. The guard fired after the damage. Claim the directory exclusively instead. create_version_directory is the first filesystem action of a run, so the abort now happens with zero writes. Co-Authored-By: Claude Opus 5 (1M context) --- src/baudolo/backup/layout.py | 11 +++++++++-- tests/unit/backup/test_layout.py | 10 ++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/baudolo/backup/layout.py b/src/baudolo/backup/layout.py index 9b8954c..6f99fbe 100644 --- a/src/baudolo/backup/layout.py +++ b/src/baudolo/backup/layout.py @@ -7,7 +7,7 @@ import pathlib from dirval import create_stamp_file -from .shell import execute_shell_command +from .shell import BackupException, execute_shell_command def get_machine_id() -> str: @@ -23,7 +23,14 @@ def stamp_directory(version_dir: str) -> None: def create_version_directory(versions_dir: str, backup_time: str) -> str: version_dir = os.path.join(versions_dir, backup_time) - pathlib.Path(version_dir).mkdir(parents=True, exist_ok=True) + try: + pathlib.Path(version_dir).mkdir(parents=True) + except FileExistsError: + raise BackupException( + f"generation {backup_time} already exists at {version_dir}; " + "another run claimed this second - refusing to write into it, " + "since rsync --delete would overwrite that generation" + ) from None return version_dir diff --git a/tests/unit/backup/test_layout.py b/tests/unit/backup/test_layout.py index 977a11e..ce1e892 100644 --- a/tests/unit/backup/test_layout.py +++ b/tests/unit/backup/test_layout.py @@ -8,6 +8,7 @@ from pathlib import Path from unittest import mock from baudolo.backup import layout as mod +from baudolo.backup.shell import BackupException class TestVersionDirectory(unittest.TestCase): @@ -17,11 +18,12 @@ class TestVersionDirectory(unittest.TestCase): self.assertTrue(Path(created).is_dir()) self.assertEqual(Path(created).name, "20260731020304") - def test_it_is_idempotent(self) -> None: + def test_it_refuses_a_generation_another_run_already_claimed(self) -> None: with tempfile.TemporaryDirectory() as tmp: - first = mod.create_version_directory(tmp, "20260731") - second = mod.create_version_directory(tmp, "20260731") - self.assertEqual(first, second) + mod.create_version_directory(tmp, "20260731") + with self.assertRaises(BackupException) as caught: + mod.create_version_directory(tmp, "20260731") + self.assertIn("20260731", str(caught.exception)) def test_it_creates_missing_parents(self) -> None: with tempfile.TemporaryDirectory() as tmp: