index rebuild: abort cleanly on a corrupt object header, #10122 - #10168
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #10168 +/- ##
==========================================
+ Coverage 87.86% 87.89% +0.03%
==========================================
Files 103 103
Lines 18878 18889 +11
Branches 2916 2916
==========================================
+ Hits 16587 16603 +16
+ Misses 1589 1586 -3
+ Partials 702 700 -2 ☔ View full report in Codecov by Harness. |
|
@mr-raj12 please rebase onto current master. and then ask claude for feedback. |
|
@mr-raj12 ping |
e2a4fd1 to
5252918
Compare
|
review by claude fable 5.1 max Verdict: mergeable after one small change. The first commit passes the validator to the lazy Findings1. A plain Reproduced the PR's scenario on the PR head: one flipped header byte in the 2nd object of a pack, So the check tells the user to run # repository.chunks is a separate index, lazily built when repository.get() resolves a
# chunk location. It walks the same packs, so give it the same corrupt-header handling
# the rebuild above got - otherwise the check aborts at a header it just resynced past.
self.repository.chunkindex_validate = validate
self.repository.chunkindex_drop_corrupt_tail = repairVerified with that change: the plain check completes its diagnosis with rc 1 (the doubled resync warning is finding 2):
2. Follow-up, not for this PR: 3. Nit: the rebuild in 4. Nit, taste: One observation, not against the PR: any command persists a lazily rebuilt index at Verified
|
|
@mr-raj12 ping?! |
…8476 ArchiveChecker builds its own chunks index with a validate callable, but repository.get() resolves chunk locations through repository.chunks, a separate index that is built lazily on first access. That rebuild had no validator and no drop_corrupt_tail, so a check aborted at a corrupt object header it had already resynced past (or dropped the tail at) when building the checker index - with --repair as well as without it, where the check stopped halfway through its diagnosis and told the user to run the command they were running. Add Repository.chunkindex_validate and Repository.chunkindex_drop_corrupt_tail, passed on to the lazy rebuild, and set them in ArchiveChecker.check(): the validator always, so both modes resync the same way, and drop_corrupt_tail only when repairing, so a check that just diagnoses still never drops a pack tail.
…0122 Rebuilding the chunks index from the packs walks every object header. With neither a validate callable nor drop_corrupt_tail, iter_headers raises IntegrityError at a corrupt header, which reached the user as a traceback with rc 90. Catch it in build_chunkindex_from_repo and raise CorruptPack (rc 93, no traceback) instead, telling the user to run "borg check --repair". Continuing past the header is not an option there: the resulting index would be incomplete. Only borg check passes a validator (and, when repairing, drop_corrupt_tail) and gets past it.
5252918 to
5a5c889
Compare
Fixes #10122. Rebased on current master; the two commits below are what is left here.
When the
index/fragments are gone or unreadable, borg rebuilds the chunks index by walking the object headers of every pack. Hit a corrupt header anditer_headers()raisesIntegrityError, which nobody catches, so a plainborg repo-listdies with a traceback and rc 90.build_chunkindex_from_repo()now catches it and raisesCorruptPack, a newErrorsubclass with rc 93 and no traceback:It aborts instead of skipping the damaged object, because everything after that offset would be missing from the index without the user ever knowing.
borg checkis the one caller that gets past the header — it passes a validator to resync, ordrop_corrupt_tailunder--repairwhen the key is unreadable — and it still does.CorruptPackis not anIntegrityErrorsubclass. That would print a traceback, and it would get swallowed by theexcept IntegrityErrorhandlers incheck_cmd.pyandcompact_cmd.pythat handle single corrupt objects. The, run "borg check"tail is gone from theiter_headers()message now that the wrapper says something more useful.The other commit
Testing this turned up a gap in #10094.
ArchiveCheckerbuilds its own chunks index with a validator, butrepository.get()resolves chunk locations throughrepository.chunks, a separate index built lazily on first access with neither a validator nordrop_corrupt_tail. So a check aborted at a header it had already resynced past. OnceCorruptPackwas in, that turned into the command telling the user to run the command they were running.Repository.chunkindex_validateandRepository.chunkindex_drop_corrupt_tailfix it: the lazy rebuild passes both on toiter_headers(), andArchiveChecker.check()sets them — the validator always, so both modes resync the same way, anddrop_corrupt_tailonly when repairing, so a check that just diagnoses still never drops a pack tail. Handing over the checker's index object instead does not work, it stores plaintext sizes andPackWriter.add()assertssize == 0.Verification
Real repository, one header byte flipped at a known object offset,
index/deleted:borg repo-list: the message above, rc 93, no tracebackBORG_EXIT_CODES=legacy: rc 2borg compact: same message, rc 93borg check: resyncs past the header, finishes the diagnosis and reports the object the resync skipped as missing, rc 1borg check --repair: resyncs, reports the one lost chunk, rc 0test_repo_list_aborts_cleanly_on_corrupt_packcovers the exit code and the missing traceback through the real CLI, forked so the top level error handler actually runs, and then the plainborg checkrun: rc 1 and the last line of the diagnosis,Archive consistency check complete, problems found.Which object the resync skips depends on how the pack was filled — a file chunk or an item metadata chunk — so the finding itself is not asserted. The two tests from #10094 that assertedIntegrityErrorout ofbuild_chunkindex_from_repo(test_build_chunkindex_without_drop_corrupt_tail_raises_on_a_damaged_pack,test_check_without_repair_does_not_drop_a_pack_tail) now expectCorruptPack.One thing the test does not claim:
borg check --repairdoes not rewrite the damaged pack yet, so a later index-less rebuild walks into the same header again. That is a TODO in the test rather than an assertion that the repository is fine afterwards.Full suite 3018 passed, 1117 skipped. ruff and black clean. Exit code 93 added to the table in
frontends.rst, plus a note in theiter_headers()section ofpacks.rst.Changed since the review
repository.chunksrebuild unconditionally, not only under--repair. Onlydrop_corrupt_tailstays repair-only. Without this a plainborg checkstill aborted with rc 93 partway through its diagnosis, at the very header its own rebuild had just resynced past — the same gap the commit closes for--repair.check --repair:tocheck:, and the comment on the twoRepositoryattributes no longer says they are set only when repairing.test_repo_list_aborts_cleanly_on_corrupt_packgained the plainborg checkrun described above. It fails without the fix, with rc 93 instead of 1.test_check_without_repair_does_not_drop_a_pack_tailstill holds: it arranges an unreadable key, so the checker's own rebuild raises before the lazy one is ever reached, anddrop_corrupt_tail=repairkeeps "a check that only diagnoses does not drop" explicit.Two of the review's points are left for later, they are not this PR's subject: when the index fragments are unusable, the packs are walked twice with the validator (once for the checker's index, once for the lazy one), which is why the resync warning appears twice in the output above — copying the entries over would beat a second walk; and
CorruptPackis the only top-levelErrorsubclass not named...Error, while the class name doubles as the JSONmsgid.