Correct retention before enabling automatic deletion (#8)
* Correct retention before enabling automatic deletion - Compute each retention tier's kept set independently from its own name range and union them, instead of carrying a single 'previous backup' comparison across tier boundaries - that carry-over was the actual cause of bug #32 (a coarser tier could evict a backup a finer tier, or the always-keep-latest rule, had already decided to retain) - Group weekly retention by (ISO year, ISO week) instead of ISO week number alone, fixing bug #33 (same week number in different years, or late-December dates that belong to next year's ISO week 1, were treated as duplicates/collisions) - Validate the computed retention plan (latest snapshot present, no phantom entries) before deleting anything - Fix cli.py to run initiate_backup before cleanup_old_backups, and skip cleanup entirely if the backup raised - retention must only run once a new snapshot is durable and complete, and a failed backup must never trigger deletion of history - Quarantine (rename) a deletion candidate before recursively removing it, reusing the already-hardened stale-staging-dir cleanup to finish the job if the process crashes mid-delete - Remove both retention xfail markers (bugs #32, #33) now that the tests they guarded pass; update pre-existing tests whose expectations encoded the old bug (deleting the latest snapshot when it collided with the next-newest one in a coarser bucket) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Address Copilot review: replace asserts with runtime checks, don't swallow quarantine-delete failures - Retention-plan validation now raises BackupFailedError instead of using assert, which python -O would silently strip ahead of a destructive operation - _quarantine_and_delete no longer passes ignore_errors=True to rmtree; a removal failure is now logged with the quarantine path so operators can find and retry it, instead of silently leaving a hidden directory consuming space Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
5 files changed
+209
-75
No files matched your search
+35
-3
@@ -466,12 +466,44 @@ class TestLockHandling:
|
||||
assert result == 1
|
||||
m_release.assert_called_once_with(str(backups_dir))
|
||||
|
||||
def test_skips_cleanup_when_backup_fails(self, tmp_path):
|
||||
"""
|
||||
A failed backup must prevent every retention deletion: if
|
||||
initiate_backup raises, cleanup_old_backups must never run.
|
||||
"""
|
||||
backups_dir = tmp_path / "backups"
|
||||
backups_dir.mkdir()
|
||||
source = tmp_path / "src"
|
||||
source.mkdir()
|
||||
|
||||
from curateipsum import backup
|
||||
|
||||
with mock.patch('sys.argv', ['cura-te-ipsum', '-b',
|
||||
str(backups_dir), str(source)]):
|
||||
with mock.patch('curateipsum.backup.set_backups_lock',
|
||||
return_value=True):
|
||||
with mock.patch(
|
||||
'curateipsum.backup.cleanup_old_backups') \
|
||||
as m_cleanup:
|
||||
with mock.patch(
|
||||
'curateipsum.backup.initiate_backup',
|
||||
side_effect=backup.BackupFailedError("boom")):
|
||||
with mock.patch(
|
||||
'curateipsum.backup.release_backups_lock'):
|
||||
cli.main()
|
||||
|
||||
m_cleanup.assert_not_called()
|
||||
|
||||
|
||||
class TestBackupExecution:
|
||||
"""Test backup execution flow."""
|
||||
|
||||
def test_calls_cleanup_before_initiate(self, tmp_path):
|
||||
"""Should call cleanup_old_backups before initiate_backup."""
|
||||
def test_calls_initiate_before_cleanup(self, tmp_path):
|
||||
"""
|
||||
Should call initiate_backup before cleanup_old_backups: retention
|
||||
deletes old snapshots, so it must only run once the new snapshot
|
||||
is durable, never before it.
|
||||
"""
|
||||
backups_dir = tmp_path / "backups"
|
||||
backups_dir.mkdir()
|
||||
source = tmp_path / "src"
|
||||
@@ -497,7 +529,7 @@ class TestBackupExecution:
|
||||
'curateipsum.backup.release_backups_lock'):
|
||||
cli.main()
|
||||
|
||||
assert call_order == ['cleanup', 'initiate']
|
||||
assert call_order == ['initiate', 'cleanup']
|
||||
|
||||
def test_passes_correct_arguments_to_initiate_backup(self, tmp_path):
|
||||
"""Should pass all required arguments to initiate_backup."""
|
||||
|
||||
Reference in new issue
Block a user