From 3824ac0e00c1f0383b2e3dc03e80dd584bd45ac6 Mon Sep 17 00:00:00 2001 From: Charmi Kadi <68164274+charmikadi@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:49:38 -0400 Subject: [PATCH 1/4] check: summarize archive problems found and repairs Track problem/repair counts in ArchiveChecker and print them at the end of the archive consistency check. --- src/borg/archive.py | 52 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/src/borg/archive.py b/src/borg/archive.py index 23dcc7ed57..4d3670efe7 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -1901,11 +1901,19 @@ class ArchiveChecker: def __init__(self): self.error_found = False + self.problems_found = 0 + self.repairs_done = 0 self.key = None # True once repair drops a defect chunk or writes a new one, i.e. once the chunks index no # longer matches the packs. self.chunks_modified = False + def _note_problem(self, *, repaired=False): + self.error_found = True + self.problems_found += 1 + if repaired and self.repair: + self.repairs_done += 1 + def check( self, repository, @@ -1966,16 +1974,18 @@ def check( repository.get_manifest() except NoManifestError: logger.error("Repository manifest is missing.") - self.error_found = True rebuild_manifest = True else: try: self.manifest = Manifest.load(repository, (Manifest.Operation.CHECK,), key=self.key) except IntegrityErrorBase as exc: logger.error("Repository manifest is corrupted: %s", exc) - self.error_found = True rebuild_manifest = True if rebuild_manifest: + if self.repair: + self._note_problem(repaired=True) + else: + self._note_problem() self.manifest = self.rebuild_manifest() # On Ctrl-C, skip any scan not yet started; a scan already running stops at its own boundary. if find_lost_archives and not sig_int: @@ -2000,7 +2010,17 @@ def check( logger.info("Archive consistency check interrupted, no problems found so far.") raise Error("Got Ctrl-C / SIGINT.") if self.error_found: - logger.error("Archive consistency check complete, problems found.") + if self.repair and self.repairs_done: + logger.error( + "Archive consistency check complete, %d problem(s) found, %d repaired.", + self.problems_found, + self.repairs_done, + ) + else: + logger.error( + "Archive consistency check complete, %d problem(s) found.", + self.problems_found, + ) else: logger.info("Archive consistency check complete, no problems found.") return self.repair or not self.error_found @@ -2059,11 +2079,14 @@ def verify_data(self): try: encrypted_data = self.repository.get(chunk_id) except (Repository.ObjectNotFound, IntegrityErrorBase) as err: - self.error_found = True errors += 1 logger.error("chunk %s: %s", bin_to_hex(chunk_id), err) if isinstance(err, IntegrityErrorBase): defect_chunks.append(chunk_id) + if not self.repair: + self._note_problem() + else: + self._note_problem() else: try: # we must decompress, so it'll call assert_id() in there. @@ -2073,10 +2096,11 @@ def verify_data(self): chunk_id, encrypted_data, decompress=True, ro_type=ROBJ_DONTCARE, assert_id_place="verify_data" ) except IntegrityErrorBase as integrity_error: - self.error_found = True errors += 1 logger.error("chunk %s, integrity error: %s", bin_to_hex(chunk_id), integrity_error) defect_chunks.append(chunk_id) + if not self.repair: + self._note_problem() pi.finish() if defect_chunks: if self.repair: @@ -2105,6 +2129,7 @@ def verify_data(self): self.chunks_modified = True # drop it from our own index too, so rebuild_archives reports the file it belongs to. del self.chunks[defect_chunk] + self._note_problem(repaired=True) else: logger.warning("chunk %s not deleted, did not consistently fail.", bin_to_hex(defect_chunk)) else: @@ -2169,7 +2194,7 @@ def valid_archive(obj): meta = self.repo_objs.parse_meta(chunk_id, cdata, ro_type=ROBJ_DONTCARE) except IntegrityErrorBase as exc: logger.error("Skipping corrupted chunk: %s", exc) - self.error_found = True + self._note_problem() continue if meta["type"] != ROBJ_ARCHIVE_META: continue @@ -2179,7 +2204,7 @@ def valid_archive(obj): meta, data = self.repo_objs.parse(chunk_id, cdata, ro_type=ROBJ_DONTCARE) except IntegrityErrorBase as exc: logger.error("Skipping corrupted chunk: %s", exc) - self.error_found = True + self._note_problem() continue if meta["type"] != ROBJ_ARCHIVE_META: continue # should never happen @@ -2200,12 +2225,13 @@ def valid_archive(obj): f"We already have a soft-deleted archives directory entry for {name} {archive_id_hex}." ) else: - self.error_found = True if self.repair: logger.warning(f"Creating archives directory entry for {name} {archive_id_hex}.") self.manifest.archives.create(name, archive_id, archive.time) + self._note_problem(repaired=True) else: logger.warning(f"Would create archives directory entry for {name} {archive_id_hex}.") + self._note_problem() pi.finish() if sig_int: @@ -2273,7 +2299,7 @@ def verify_file_chunks(archive_name, item): ) ) record_missing_chunk(archive_name, item.path, chunk_id, size) - self.error_found = True + self._note_problem() offset += size if "size" in item: item_size = item.size @@ -2325,7 +2351,7 @@ def missing_chunk_detector(chunk_id): def report(msg, chunk_id, chunk_no): cid = bin_to_hex(chunk_id) msg += " [chunk: %06d_%s]" % (chunk_no, cid) # see "debug dump-archive-items" - self.error_found = True + self._note_problem() logger.error(msg) def list_keys_safe(keys): @@ -2424,24 +2450,26 @@ def valid_item(obj): logger.info(f"Analyzing archive {formatted} ({i + 1}/{num_archives})") if archive_id not in self.chunks: logger.error(f"Archive metadata block {archive_id_hex} is missing!") - self.error_found = True if self.repair: logger.error(f"Deleting broken archive {info.name} {archive_id_hex}.") self.manifest.archives.delete_by_id(archive_id) + self._note_problem(repaired=True) else: logger.error(f"Would delete broken archive {info.name} {archive_id_hex}.") + self._note_problem() continue cdata = self.repository.get(archive_id) try: _, data = self.repo_objs.parse(archive_id, cdata, ro_type=ROBJ_ARCHIVE_META) except IntegrityErrorBase as integrity_error: logger.error(f"Archive metadata block {archive_id_hex} is corrupted: {integrity_error}") - self.error_found = True if self.repair: logger.error(f"Deleting broken archive {info.name} {archive_id_hex}.") self.manifest.archives.delete_by_id(archive_id) + self._note_problem(repaired=True) else: logger.error(f"Would delete broken archive {info.name} {archive_id_hex}.") + self._note_problem() continue archive = self.key.unpack_archive(data) archive = ArchiveItem(internal_dict=archive) From 8fe3819c5135b237dee7c0b625b563d6da97d52e Mon Sep 17 00:00:00 2001 From: Charmi Kadi <68164274+charmikadi@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:17:45 -0400 Subject: [PATCH 2/4] check: assert summary counts in repair tests --- src/borg/testsuite/archiver/check_cmd_test.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/borg/testsuite/archiver/check_cmd_test.py b/src/borg/testsuite/archiver/check_cmd_test.py index 34a9ee5cb3..c8d60a7995 100644 --- a/src/borg/testsuite/archiver/check_cmd_test.py +++ b/src/borg/testsuite/archiver/check_cmd_test.py @@ -558,6 +558,8 @@ def test_corrupted_manifest(archivers, request): output = cmd(archiver, "check", "-v", "--repair", exit_code=0) assert "archive1" in output assert "archive2" in output + assert "problem(s) found" in output + assert "repaired" in output cmd(archiver, "check", exit_code=0) @@ -778,6 +780,8 @@ def test_verify_data(archivers, request, init_args): assert "The following chunks are missing in the repository:" in output assert bin_to_hex(chunk.id) in output assert src_file in output + assert "problem(s) found" in output + assert "repaired" in output # run with --verify-data again, it will notice the missing chunk. output = cmd(archiver, "check", "--archives-only", "--verify-data", exit_code=1) @@ -869,6 +873,8 @@ def test_corrupted_file_chunk(archivers, request, init_args): assert "The following chunks are missing in the repository:" in output assert bin_to_hex(chunk.id) in output assert src_file in output + assert "problem(s) found" in output + assert "repaired" in output # run normal check again cmd(archiver, "check", "--repository-only", exit_code=0) From 809862a11770a51e69bef4014a9c6eb7f4f57034 Mon Sep 17 00:00:00 2001 From: Charmi Kadi <68164274+charmikadi@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:26:34 -0400 Subject: [PATCH 3/4] check: black-format archive check summary log line --- src/borg/archive.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/borg/archive.py b/src/borg/archive.py index 4d3670efe7..71eca16111 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -2017,10 +2017,7 @@ def check( self.repairs_done, ) else: - logger.error( - "Archive consistency check complete, %d problem(s) found.", - self.problems_found, - ) + logger.error("Archive consistency check complete, %d problem(s) found.", self.problems_found) else: logger.info("Archive consistency check complete, no problems found.") return self.repair or not self.error_found From 85c94cb2a030e9c0c3b28da2a068b54c11ca236b Mon Sep 17 00:00:00 2001 From: Charmi Kadi Date: Thu, 20 Aug 2026 13:26:32 -0400 Subject: [PATCH 4/4] check: address review feedback for problem/repair summary Require explicit repaired=, note the manifest only after rebuild, always report repair counts under --repair, and do not count defect-chunk deletion as a repair. --- src/borg/archive.py | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/borg/archive.py b/src/borg/archive.py index 71eca16111..b43ea97739 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -1908,7 +1908,12 @@ def __init__(self): # longer matches the packs. self.chunks_modified = False - def _note_problem(self, *, repaired=False): + def _note_problem(self, *, repaired): + """Record a problem found during the check. + + Callers must always pass repaired= explicitly: True only when the problem was + actually fixed (not merely detected, and not when --repair only discarded data). + """ self.error_found = True self.problems_found += 1 if repaired and self.repair: @@ -1982,11 +1987,11 @@ def check( logger.error("Repository manifest is corrupted: %s", exc) rebuild_manifest = True if rebuild_manifest: - if self.repair: - self._note_problem(repaired=True) - else: - self._note_problem() + # Rebuild first; only then can we say whether the problem was repaired. + # Without --repair, rebuild_manifest() only builds an in-memory manifest for the + # rest of this check run; finish() writes it only when self.repair is set. self.manifest = self.rebuild_manifest() + self._note_problem(repaired=self.repair) # On Ctrl-C, skip any scan not yet started; a scan already running stops at its own boundary. if find_lost_archives and not sig_int: self.rebuild_archives_directory() @@ -2010,7 +2015,8 @@ def check( logger.info("Archive consistency check interrupted, no problems found so far.") raise Error("Got Ctrl-C / SIGINT.") if self.error_found: - if self.repair and self.repairs_done: + if self.repair: + # Always report the repair count in --repair mode, including 0 repaired. logger.error( "Archive consistency check complete, %d problem(s) found, %d repaired.", self.problems_found, @@ -2081,9 +2087,9 @@ def verify_data(self): if isinstance(err, IntegrityErrorBase): defect_chunks.append(chunk_id) if not self.repair: - self._note_problem() + self._note_problem(repaired=False) else: - self._note_problem() + self._note_problem(repaired=False) else: try: # we must decompress, so it'll call assert_id() in there. @@ -2097,7 +2103,7 @@ def verify_data(self): logger.error("chunk %s, integrity error: %s", bin_to_hex(chunk_id), integrity_error) defect_chunks.append(chunk_id) if not self.repair: - self._note_problem() + self._note_problem(repaired=False) pi.finish() if defect_chunks: if self.repair: @@ -2126,7 +2132,8 @@ def verify_data(self): self.chunks_modified = True # drop it from our own index too, so rebuild_archives reports the file it belongs to. del self.chunks[defect_chunk] - self._note_problem(repaired=True) + # Removing a defect chunk is not a repair: referenced data is still lost. + self._note_problem(repaired=False) else: logger.warning("chunk %s not deleted, did not consistently fail.", bin_to_hex(defect_chunk)) else: @@ -2191,7 +2198,7 @@ def valid_archive(obj): meta = self.repo_objs.parse_meta(chunk_id, cdata, ro_type=ROBJ_DONTCARE) except IntegrityErrorBase as exc: logger.error("Skipping corrupted chunk: %s", exc) - self._note_problem() + self._note_problem(repaired=False) continue if meta["type"] != ROBJ_ARCHIVE_META: continue @@ -2201,7 +2208,7 @@ def valid_archive(obj): meta, data = self.repo_objs.parse(chunk_id, cdata, ro_type=ROBJ_DONTCARE) except IntegrityErrorBase as exc: logger.error("Skipping corrupted chunk: %s", exc) - self._note_problem() + self._note_problem(repaired=False) continue if meta["type"] != ROBJ_ARCHIVE_META: continue # should never happen @@ -2228,7 +2235,7 @@ def valid_archive(obj): self._note_problem(repaired=True) else: logger.warning(f"Would create archives directory entry for {name} {archive_id_hex}.") - self._note_problem() + self._note_problem(repaired=False) pi.finish() if sig_int: @@ -2296,7 +2303,7 @@ def verify_file_chunks(archive_name, item): ) ) record_missing_chunk(archive_name, item.path, chunk_id, size) - self._note_problem() + self._note_problem(repaired=False) offset += size if "size" in item: item_size = item.size @@ -2348,7 +2355,7 @@ def missing_chunk_detector(chunk_id): def report(msg, chunk_id, chunk_no): cid = bin_to_hex(chunk_id) msg += " [chunk: %06d_%s]" % (chunk_no, cid) # see "debug dump-archive-items" - self._note_problem() + self._note_problem(repaired=False) logger.error(msg) def list_keys_safe(keys): @@ -2453,7 +2460,7 @@ def valid_item(obj): self._note_problem(repaired=True) else: logger.error(f"Would delete broken archive {info.name} {archive_id_hex}.") - self._note_problem() + self._note_problem(repaired=False) continue cdata = self.repository.get(archive_id) try: @@ -2466,7 +2473,7 @@ def valid_item(obj): self._note_problem(repaired=True) else: logger.error(f"Would delete broken archive {info.name} {archive_id_hex}.") - self._note_problem() + self._note_problem(repaired=False) continue archive = self.key.unpack_archive(data) archive = ArchiveItem(internal_dict=archive)