Skip to content

check --repair: re-read only the packs the repair wrote, refs #8466 - #10379

Open
mr-raj12 wants to merge 2 commits into
borgbackup:masterfrom
mr-raj12:check-finish-narrow-walk-8466
Open

mr-raj12 wants to merge 2 commits into
borgbackup:masterfrom
mr-raj12:check-finish-narrow-walk-8466

Conversation

@mr-raj12

@mr-raj12 mr-raj12 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Description

Refs #8466, item 2 of #10318. Based on #10378, only the last commit is new.

With #10368 the checker and the repository share one chunk index, and put() / delete() update it for every pack they write. ArchiveChecker.finish() stores that index and re-reads only the packs the repair wrote, to confirm the index matches them.

  • ArchiveChecker records the packs repair writes in written_packs: from the put() and flush() results, and from delete(), which now returns compact_pack()'s (new_pack_id, dropped_bytes). A pack rewritten again is replaced by its new pack.
  • create_archive_entry() stores the pack writer buffer and records its packs, then creates the archives directory entry.
  • verify_written_packs() reads the object headers of each written pack with a validator and compares them with the index entries that name the pack:
    • an index entry names an object the pack does not hold: the entry is removed
    • the pack holds an object whose chunk id is not indexed: the object is indexed
    • the pack does not exist: its index entries are removed
    • each of these is a check finding
    • an object whose chunk id is indexed at another location is a superseded duplicate, not a finding: compact_pack copies one into the new pack when it lies in a byte range no index entry covers
  • finish() stores the index, then drops the in-memory one (invalidate_chunk_index() and self.chunks = None), because close() would persist it over the index just stored.
  • check() asserts that a repair has a validator, so every object it indexes is checked.
  • Repository.flush() returns the objects of the packs it stored, None if it stored none.

Tests (check_cmd_test.py), each asserting which packs finish() walks:

  • only the pack delete() wrote, with more than ten packs in the repository
  • no pack, for a defect chunk alone in its pack: delete() drops the pack and writes none
  • only the packs put() wrote
  • the pack finish()'s own flush() stores
  • a rewritten pack no index entry names, and a rewritten pack holding a superseded duplicate: neither is a finding
  • a wrong index entry for a written pack, and a written pack that is gone: both are findings, and the index is fixed
  • test_check_holds_a_single_chunk_index: the --repair case builds one index in the checker, not two
  • test_check_repair_stopped_in_the_index_store_marks_the_index_invalid: renamed, finish() stores the index instead of rebuilding it

Tests (repository_test.py): flush() returns the stored objects, None with nothing buffered or no pack writer.

Checklist

  • PR is against master (or maintenance branch if only applicable there)
  • New code has tests and docs where appropriate
  • Tests pass (run tox or the relevant test subset)
  • Commit messages are clean and reference related issues

@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.15%. Comparing base (1110bcd) to head (8903d7d).
⚠️ Report is 1 commits behind head on master.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #10379      +/-   ##
==========================================
+ Coverage   88.14%   88.15%   +0.01%     
==========================================
  Files         103      103              
  Lines       18896    18946      +50     
  Branches     2931     2944      +13     
==========================================
+ Hits        16655    16702      +47     
- Misses       1557     1561       +4     
+ Partials      684      683       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@mr-raj12
mr-raj12 force-pushed the check-finish-narrow-walk-8466 branch from 0a6e74a to 74fcb2b Compare September 16, 2026 13:29
@mr-raj12
mr-raj12 force-pushed the check-finish-narrow-walk-8466 branch from 74fcb2b to c25f8d1 Compare September 17, 2026 11:57
@ThomasWaldmann

Copy link
Copy Markdown
Member

@mr-raj12 #10368 is merged.

Every check --repair rebuild of the chunk index has an object validator
(borgbackup#10369), so drop_corrupt_tail was only reachable from tests.

- PackReader.iter_headers: remove the drop_corrupt_tail parameter. Without
  a validator, a corrupt object header raises IntegrityError.
- build_chunkindex_from_repo: remove the drop_corrupt_tail parameter.
- Repository: remove chunkindex_drop_corrupt_tail and chunkindex_validate.
  Since borgbackup#10368 the checker hands its index to the repository, so the lazy
  .chunks rebuild never runs during a check and nothing set either of them.
- ArchiveChecker.check: stop passing drop_corrupt_tail.
- tests: remove the 5 tests for drop_corrupt_tail, rewrite
  test_check_without_repair_does_not_drop_a_pack_tail as
  test_check_without_key_aborts_on_a_corrupt_pack_header.
@mr-raj12
mr-raj12 force-pushed the check-finish-narrow-walk-8466 branch from c25f8d1 to 84a6388 Compare September 18, 2026 04:42
…kup#8466

finish() validates the written packs against the shared index instead of rebuilding it from all packs.
@mr-raj12
mr-raj12 force-pushed the check-finish-narrow-walk-8466 branch from 84a6388 to 8903d7d Compare September 18, 2026 04:58
@mr-raj12
mr-raj12 marked this pull request as ready for review September 18, 2026 04:58
@mr-raj12

Copy link
Copy Markdown
Contributor Author

Rebased onto master and taken out of draft, all checks are green. Only the last commit is new, everything below it is #10378, so that one needs to go in first.

This PR and #10382 are not in the 2.0.0b25 milestone yet.

@ThomasWaldmann ThomasWaldmann left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the new commit (8903d7d); the first one is #10378.

The approach is sound: check() already rebuilds the index from all packs at the start of --repair and nothing invalidates the shared index during the repair, so finish() only needs to re-read the packs the repair wrote. The tests are good, especially the ones asserting which packs finish() walks.

One real defect (inline, verify_written_packs), one docs item, the rest is small.

Behaviour change to be aware of: if a defect chunk deleted by --verify-data has another copy in a pack the repair did not write, the old finish() re-indexed that copy via the full rebuild. The new finish() leaves the chunk unindexed until the next check --repair. That matches what the archives check has just reported, so I consider it neutral or an improvement.

Docs still describe the rebuild this PR removes

After this PR finish() verifies the written packs and stores the index, it does not rebuild it. These places still say otherwise:

  • docs/internals/packs.rst, "Index Namespace" section: "borg check --repair writes it before rebuilding the index after changing the packs"
  • docs/internals/data-structures.rst, chunkindex-invalid: "before borg check --repair rebuilds the index after changing the packs"
  • write_chunkindex_invalid() docstring in cache.py: "and before rebuilding the index after pack changes the fragments do not record"

Also, the Repository.check() docstring lost its pointer to ArchiveChecker.finish() completely. finish() still stores the index again after the archives phase, so a (reworded) cross-reference is still useful there.

Nits

  • "Re-reading the packs written by the repair: 3." reads like a label with a stray count, maybe "Re-reading 3 pack(s) written by the repair."
  • "Writing the rebuilt repository chunks index." now also covers the path where finish() rebuilt nothing.
  • An object at a wrong offset is counted twice ("not in the pack: 1" and "unindexed chunk id: 1"). OK as raw counts, just not obvious when reading the log.
Probe test for the inline finding (fails for [low], passes for [high] on 8903d7d)
import pytest

from ...archive import ArchiveChecker
from ...constants import ROBJ_FILE_STREAM
from ...manifest import Manifest
from ...repoobj import RepoObj
from ...repository import Repository
from . import cmd, create_test_files, RK_ENCRYPTION


@pytest.mark.parametrize("holder", ["low", "high"])
def test_probe_order_dependence(archiver, monkeypatch, holder):
    """Chunk X lives in one written pack, the index names the other written pack (bogus offset)."""
    monkeypatch.setenv("BORG_PACK_MAX_COUNT", "1")  # one object per pack
    create_test_files(archiver.input_path)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "archive1", "input")
    with Repository(archiver.repository_location, exclusive=True) as repository:
        manifest = Manifest.load(repository)
        ids = []
        for data in [b"aaa", b"bbb"]:
            cid = manifest.key.id_hash(data)
            repository.put(cid, manifest.repo_objs.format(cid, {}, data, ro_type=ROBJ_FILE_STREAM))
            ids.append(cid)
        repository.flush()
        entries = {cid: repository.chunks[cid] for cid in ids}
        by_pack = sorted(ids, key=lambda c: entries[c].pack_id)
        low_cid, high_cid = by_pack
        x, other = (low_cid, high_cid) if holder == "low" else (high_cid, low_cid)

        checker = ArchiveChecker()
        checker.repair = True
        checker.repository = repository
        checker.key = checker.make_key(repository)
        checker.repo_objs = RepoObj(checker.key)
        checker.chunks = repository.chunks
        checker.written_packs = {entries[low_cid].pack_id, entries[high_cid].pack_id}
        checker.chunks[x] = entries[x]._replace(pack_id=entries[other].pack_id, obj_offset=1)

        checker.verify_written_packs()

        assert checker.error_found
        assert x in checker.chunks, "chunk lost from the index although a written pack holds it"
        fixed = checker.chunks[x]
        assert (fixed.pack_id, fixed.obj_offset, fixed.obj_size) == (
            entries[x].pack_id,
            entries[x].obj_offset,
            entries[x].obj_size,
        )
Two-pass variant of verify_written_packs() I tried (both probe directions pass, all of check_cmd_test.py still passes)
diff --git a/src/borg/archive.py b/src/borg/archive.py
index cfbc02352..8146e686f 100644
--- a/src/borg/archive.py
+++ b/src/borg/archive.py
@@ -2816,23 +2816,37 @@ def verify_written_packs(self):
             if entries is not None:
                 entries.add((chunk_id, entry.obj_offset, entry.obj_size))
         validate = object_validator(self.repo_objs)
+        # pass 1: read the packs and remove the index entries that name an object a pack does not hold.
+        # All removals come before pass 2 indexes anything, so the result does not depend on the pack order.
+        found_in = {}  # pack_id -> list of (chunk_id, obj_offset, obj_size), None for a missing pack
+        not_found_in = {}
         for pack_id in pack_ids:
             # PackReader reads from the store, which does not refresh the repository lock.
             self.repository._lock_refresh()
-            pack_hex = bin_to_hex(pack_id)
-            expected = indexed.pop(pack_id)
+            expected = indexed[pack_id]
             reader = PackReader(self.repository.store, pack_id)
             # iter_headers() yields nothing for a missing pack: the store reports size 0 for it.
             if not self.repository.store.info(reader.key).exists:
                 self.error_found = True
-                logger.error(f"pack {pack_hex}: written by the repair, but it is missing. Removing its index entries.")
-                for chunk_id, _, _ in expected:
-                    del self.chunks[chunk_id]
-                continue
-            found = list(reader.iter_headers(validate=validate, on_drop=self.note_dropped_objects))
-            not_found = sorted(expected.difference(found))
+                logger.error(
+                    f"pack {bin_to_hex(pack_id)}: written by the repair, but it is missing. Removing its index entries."
+                )
+                found_in[pack_id] = None
+                not_found = sorted(expected)
+            else:
+                found_in[pack_id] = list(reader.iter_headers(validate=validate, on_drop=self.note_dropped_objects))
+                not_found = sorted(expected.difference(found_in[pack_id]))
+            not_found_in[pack_id] = not_found
             for chunk_id, _, _ in not_found:
                 del self.chunks[chunk_id]
+        # pass 2: index the objects whose chunk id is not indexed.
+        for pack_id in pack_ids:
+            found = found_in[pack_id]
+            if found is None:
+                continue
+            pack_hex = bin_to_hex(pack_id)
+            expected = indexed[pack_id]
+            not_found = not_found_in[pack_id]
             # the loop indexes each unindexed object, so of several unindexed copies of a chunk, the first
             # is indexed and the others are superseded duplicates.
             unindexed = []

Comment thread src/borg/archive.py
Comment on lines +2819 to +2851
for pack_id in pack_ids:
# PackReader reads from the store, which does not refresh the repository lock.
self.repository._lock_refresh()
pack_hex = bin_to_hex(pack_id)
expected = indexed.pop(pack_id)
reader = PackReader(self.repository.store, pack_id)
# iter_headers() yields nothing for a missing pack: the store reports size 0 for it.
if not self.repository.store.info(reader.key).exists:
self.error_found = True
logger.error(f"pack {pack_hex}: written by the repair, but it is missing. Removing its index entries.")
for chunk_id, _, _ in expected:
del self.chunks[chunk_id]
continue
found = list(reader.iter_headers(validate=validate, on_drop=self.note_dropped_objects))
not_found = sorted(expected.difference(found))
for chunk_id, _, _ in not_found:
del self.chunks[chunk_id]
# the loop indexes each unindexed object, so of several unindexed copies of a chunk, the first
# is indexed and the others are superseded duplicates.
unindexed = []
for obj in found:
chunk_id, obj_offset, obj_size = obj
if obj in expected:
continue
if chunk_id in self.chunks:
logger.debug(
f"pack {pack_hex}: {bin_to_hex(chunk_id)} at offset {obj_offset}, {obj_size} bytes: "
"superseded duplicate"
)
continue
unindexed.append(obj)
# size=0: the object header does not hold the plaintext size.
self.chunks[chunk_id] = ChunkIndexEntry(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The result depends on the pack order, and one order drops a chunk that a written pack holds.

Each pack gets its stale entries deleted and its unindexed objects indexed before the next pack is read, so the chunk_id in self.chunks test below runs against a half-fixed index.

Failing case: chunk X lives in written pack LOW, but the index names written pack HIGH.

  • LOW is read first: X is found, X is still in the index (pointing at HIGH), so it is skipped as a superseded duplicate.
  • HIGH is read next: X is in expected, not in found, so the entry is deleted.
  • X is gone from the index although LOW holds it. With the pack ids the other way round the same corruption is repaired correctly.

This needs the index to be wrong about a repair-written pack already, i.e. a bug in put() / compact_pack, but that is exactly the case this function exists for, and rebuild_archives has already run, so nothing notices the archives referencing the lost chunk.

Fix: do all removals before any insertion. Pass 1 reads each pack once and deletes its not_found entries, pass 2 indexes the unindexed objects. Probe test and a diff I tried are in the review body.

Comment thread src/borg/archive.py
expected = indexed.pop(pack_id)
reader = PackReader(self.repository.store, pack_id)
# iter_headers() yields nothing for a missing pack: the store reports size 0 for it.
if not self.repository.store.info(reader.key).exists:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a second store.info() per pack: iter_headers() does one itself via reader.size(). One lookup would do (e.g. get the info once and hand the size to the reader), which matters for ssh:// and rest:// when the repair wrote many packs.

Comment thread src/borg/archive.py
self.repository.flush()
# store the pack writer buffer before the index is written (close() requires an empty buffer, #10055).
self.record_stored(self.repository.flush())
if self.chunks_modified:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A non-empty written_packs implies chunks_modified today, but nothing enforces it, and the failure mode is a silently skipped verification. if self.chunks_modified or self.written_packs: (or an assert) would close that gap.

Comment thread src/borg/archive.py
Comment on lines +2230 to +2233
def create_archive_entry(self, name, id, ts):
"""Store the pack writer buffer, record the packs it wrote, create the archives directory entry."""
self.record_stored(self.repository.flush())
self.manifest.archives.create(name, id, ts)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please say in the docstring why this wrapper exists: Archives.create() flushes the pack writer itself and drops the result, so without the flush here the pack ids would never reach written_packs. Otherwise someone will inline it again, and any future archives.create() call in the checker loses its packs silently.

Comment thread src/borg/archive.py
assert not repair or validate is not None # a repair validates every object it indexes
# store the chunks buffered in the pack writer, so the index below has their pack locations
# (pack id, offset and size in the pack).
self.repository.flush()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The result is dropped here while the two other flush() calls record theirs. That is correct (this runs before the index build, which reads that pack anyway), but a short comment saying so would prevent a wrong "fix".

@ThomasWaldmann ThomasWaldmann added this to the 2.0.0b25 milestone Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants