From 4ad066ca5c536366b418df5f4520694a1e34280d Mon Sep 17 00:00:00 2001 From: disqualifier Date: Mon, 6 Jul 2026 16:46:51 -0400 Subject: [PATCH] fix: retier never deletes an intact plain roll in favor of a corrupt .gz twin the beyond-keep delete branch had no _gz_intact guard: when an intact plain file and its CORRUPT .gz twin (same mtime) straddled the keep boundary and listdir yielded the .gz first, the .gz sorted into the kept range while the intact plain sorted past it and was deleted - leaving the corrupt archive as the sole copy (permanent data loss), violating the 'corrupt .gz never wins over intact plain' invariant the two dedupe sites already enforce. the delete now skips an intact plain whose only surviving twin is a corrupt .gz; a later retier retires it once a clean .gz exists. reproduced with a forced .gz-first listdir: old deleted the plain, new keeps it. Signed-off-by: disqualifier --- src/log_setup/rotation.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/log_setup/rotation.py b/src/log_setup/rotation.py index 4967256..96b7def 100644 --- a/src/log_setup/rotation.py +++ b/src/log_setup/rotation.py @@ -288,8 +288,14 @@ def retier(log_dir: str, stem: str, keep_uncompressed: int, keep_compressed: int files.sort(key=lambda t: (t[1], t[2]), reverse=True) keep = keep_uncompressed + keep_compressed + present = {p for p, _, _ in files} for index, (path, _, _) in enumerate(files): if index >= keep: + # never delete an intact plain whose only surviving twin is a CORRUPT .gz - + # that would leave the corrupt archive as the sole copy (data loss). keep the + # plain; a later retier retires it once a clean .gz exists. + if not path.endswith(".gz") and (path + ".gz") in present and not _gz_intact(path + ".gz"): + continue try: os.remove(path) except OSError: