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:
authored and GitHub committed 2026-08-03 22:47:43 +00:00
1 parent 72a8fe2b59
commit fd613edad0
5 files changed
+209 -75

No files matched your search

+106 -49
View File
@@ -548,62 +548,119 @@ def cleanup_old_backups(backups_dir: str,
.strftime(BACKUP_ENT_FMT)
)
keep = _compute_retention_plan(all_backups, thresholds)
# Validate the plan before deleting anything: the most recent snapshot
# must always survive, and the plan can never invent a backup that
# doesn't exist. Plain `assert` would be stripped under `python -O`,
# silently disabling this check ahead of a destructive operation, so
# this is an explicit, always-on check instead.
if all_backups[0] not in keep:
raise BackupFailedError(
"Refusing to run retention: computed plan drops the latest "
"snapshot %s" % all_backups[0].name
)
if not keep <= set(all_backups):
raise BackupFailedError(
"Refusing to run retention: computed plan keeps a backup "
"that doesn't exist"
)
backups_dir_abs = _canonical(backups_dir)
repo_id = _ensure_repo_id(backups_dir)
prev_backup = all_backups[0]
to_remove = {b: False for b in all_backups}
for backup in all_backups[1:]:
# skip all backups made after threshold
if backup.name > thresholds["all"]:
prev_backup = backup
continue
# leave only one backup per day for backups made after threshold
if backup.name > thresholds["daily"]:
if (_date_from_backup(prev_backup).date()
== _date_from_backup(backup).date()):
to_remove[prev_backup] = True
prev_backup = backup
continue
# leave only one backup per week for backups made after threshold
if backup.name > thresholds["weekly"]:
if (_date_from_backup(prev_backup).isocalendar()[1]
== _date_from_backup(backup).isocalendar()[1]):
to_remove[prev_backup] = True
prev_backup = backup
continue
# leave only one backup per month for backups made after threshold
if backup.name > thresholds["monthly"]:
if (_date_from_backup(prev_backup).date().replace(day=1)
== _date_from_backup(backup).date().replace(day=1)):
to_remove[prev_backup] = True
prev_backup = backup
continue
# leave only one backup per year for backups made after threshold
if backup.name > thresholds["yearly"]:
if (_date_from_backup(prev_backup).date().replace(month=1, day=1)
== _date_from_backup(backup).date().replace(month=1, day=1)):
to_remove[prev_backup] = True
prev_backup = backup
continue
to_remove[backup] = True
for backup, do_delete in to_remove.items():
if not do_delete:
for backup in all_backups:
if backup in keep:
continue
if dry_run:
_lg.info("Would remove old backup %s", backup.name)
continue
if not _verify_safe_to_delete(backup, backups_dir_abs, repo_id):
continue
_lg.info("Removing old backup %s", backup.name)
shutil.rmtree(backup.path)
_quarantine_and_delete(backup, backups_dir_abs, repo_id)
def _compute_retention_plan(all_backups: List[os.DirEntry],
thresholds: Dict[str, str]) -> set:
"""
Compute the set of backups to retain. Each tier's retained set is
computed independently from its own name range and unioned with the
rest, so a coarser tier (e.g. monthly) can never override a decision
a finer tier (e.g. weekly) already made about a backup that isn't
even in the coarser tier's range - unlike carrying a single "previous
backup compared against" value across tier boundaries, which is what
let a coarser tier silently evict a backup a finer tier had decided
to keep.
"""
def _oldest_per_group(members, key_func) -> set:
groups: Dict[object, os.DirEntry] = {}
for member in members:
key = key_func(member)
if key not in groups or member.name < groups[key].name:
groups[key] = member
return set(groups.values())
def _band(lower: str, upper: str) -> List[os.DirEntry]:
return [b for b in all_backups if lower < b.name <= upper]
keep = {all_backups[0]} # the most recent snapshot is always retained
keep |= {b for b in all_backups if b.name > thresholds["all"]}
keep |= _oldest_per_group(
_band(thresholds["daily"], thresholds["all"]),
lambda b: _date_from_backup(b).date(),
)
keep |= _oldest_per_group(
_band(thresholds["weekly"], thresholds["daily"]),
# (ISO year, ISO week): ISO week alone collides across years, and
# a year spanning ISO week 53 puts late-December dates in the
# next ISO year - isocalendar() already accounts for both.
lambda b: _date_from_backup(b).isocalendar()[:2],
)
keep |= _oldest_per_group(
_band(thresholds["monthly"], thresholds["weekly"]),
lambda b: (_date_from_backup(b).year, _date_from_backup(b).month),
)
keep |= _oldest_per_group(
_band(thresholds["yearly"], thresholds["monthly"]),
lambda b: _date_from_backup(b).year,
)
return keep
def _quarantine_and_delete(entry: os.DirEntry, backups_dir_abs: str,
repo_id: str):
"""
Quarantine a retention deletion candidate before recursively removing
it: rename it out of its snapshot name first. If the process crashes
between the rename and the removal, the quarantined directory keeps
the staging-dir prefix, so the existing stale-staging-dir cleanup -
with the same boundary/mount/symlink safety checks - finishes
removing it on the next run, rather than leaving a half-deleted
directory sitting under its original snapshot name.
"""
if not _verify_safe_to_delete(entry, backups_dir_abs, repo_id):
return
quarantine_path = os.path.join(
backups_dir_abs, "%strash-%s" % (STAGING_PREFIX, entry.name)
)
try:
os.rename(entry.path, quarantine_path)
except OSError as err:
_lg.error("Failed to quarantine %s before deletion: %s",
entry.path, err)
return
_lg.info("Removing old backup %s", entry.name)
try:
shutil.rmtree(quarantine_path)
except OSError as err:
_lg.error(
"Failed to remove quarantined backup %s: %s. It remains on "
"disk under %s and will be retried on the next cleanup or "
"backup startup.", entry.name, err, quarantine_path
)
def process_backed_entry(backup_dir: str,
+7 -2
View File
@@ -105,8 +105,11 @@ def main():
exit_code = 0
try:
backup.cleanup_old_backups(backups_dir=backups_dir_abs,
dry_run=args.dry_run)
# Retention deletes old snapshots, so it must only run once the
# new snapshot is durable and complete - never before, and never
# if the backup below raised. Running it first (or unconditionally
# after a failure) would let a failed backup destroy history
# that a successful one would have covered.
backup.initiate_backup(
sources=args.sources,
backups_dir=backups_dir_abs,
@@ -114,6 +117,8 @@ def main():
external_rsync=args.external_rsync,
external_hardlink=args.external_hardlink,
)
backup.cleanup_old_backups(backups_dir=backups_dir_abs,
dry_run=args.dry_run)
except backup.BackupFailedError as err:
_lg.error("Backup failed: %s", err)
exit_code = 1