Skip to content

Commit 74fcb2b

Browse files
committed
check --repair: re-read only the packs the repair wrote, refs #8466
finish() validates the written packs against the shared index instead of rebuilding it from all packs.
1 parent 27e5985 commit 74fcb2b

4 files changed

Lines changed: 526 additions & 48 deletions

File tree

src/borg/archive.py

Lines changed: 137 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@
5353
from .patterns import PathPrefixPattern, FnmatchPattern, IECommand
5454
from .item import Item, ArchiveItem, ItemDiff
5555
from .platform import acl_get, acl_set, set_flags, get_flags, set_times, swidth
56-
from .repository import Repository, NoManifestError
56+
from .hashindex import ChunkIndex, ChunkIndexEntry
57+
from .repository import Repository, NoManifestError, PackReader
5758
from .repoobj import RepoObj, object_validator
5859

5960
# macOS: SF_DATALESS marks dataless placeholder files (e.g. cloud files not materialized locally).
@@ -2213,9 +2214,31 @@ class ArchiveChecker:
22132214
def __init__(self):
22142215
self.error_found = False
22152216
self.key = None
2216-
# True once repair drops a defect chunk or writes a new one, i.e. once the chunks index no
2217-
# longer matches the packs.
2217+
# True once repair wrote a pack: it stored a chunk or deleted a defect chunk.
22182218
self.chunks_modified = False
2219+
# True if check() built the chunks index from the packs with a validator, so every indexed object
2220+
# passed it. A validator (repoobj.object_validator) checks an object header against the object's
2221+
# tagged metadata.
2222+
self.rebuild_validated = False
2223+
# ids of the existing packs repair stored (put(), flush()) or wrote by rewriting a pack (delete()).
2224+
self.written_packs = set()
2225+
2226+
def record_stored(self, results):
2227+
"""Add the pack ids in results to written_packs.
2228+
2229+
results: the (chunk_id, pack_id, obj_offset, obj_size) tuples Repository.put() or .flush() returns
2230+
for the packs it stored, or None if it stored no pack.
2231+
"""
2232+
if results:
2233+
self.written_packs.update(pack_id for _, pack_id, _, _ in results)
2234+
2235+
def create_archive_entry(self, name, id, ts):
2236+
"""Store the pack writer buffer, add the stored packs to written_packs, create the archives entry.
2237+
2238+
Archives.create() also stores the pack writer buffer, but does not return the stored packs.
2239+
"""
2240+
self.record_stored(self.repository.flush())
2241+
self.manifest.archives.create(name, id, ts)
22192242

22202243
def note_dropped_objects(self):
22212244
# The chunk index rebuild skipped repository content to get past a corrupt object header.
@@ -2305,6 +2328,7 @@ def check(
23052328
drop_corrupt_tail=repair,
23062329
write_immediately=False,
23072330
)
2331+
self.rebuild_validated = repair and validate is not None
23082332
# a rebuild from the packs sets F_NEW (entry not stored in the index/ fragments yet) on every
23092333
# entry. Clear it, so Repository.close() does not store this index as a new fragment: only
23102334
# finish() stores the index, and only with --repair.
@@ -2439,20 +2463,23 @@ def verify_data(self):
24392463
assert_id_place="verify_data",
24402464
)
24412465
except IntegrityErrorBase:
2442-
# failed twice -> remove this defect chunk. delete rewrites its pack without it,
2443-
# keeping the other chunks. update_index=False: finish() rebuilds the index from
2444-
# the rewritten packs anyway, so a per-chunk full index write would be wasted.
2445-
# delete() also removes the chunk from self.chunks, so rebuild_archives reports
2446-
# the file it belongs to.
2466+
# failed twice -> remove this defect chunk. delete() removes it from self.chunks,
2467+
# so rebuild_archives reports the file it belongs to. update_index=False: finish()
2468+
# writes the index.
24472469
if not index_marked_invalid:
24482470
# the index/ fragments point the other chunks of a rewritten pack at the
24492471
# deleted pack until finish() stores the new index. Mark them invalid before
24502472
# the first delete, so if the check stops before finish(), the next use
24512473
# rebuilds the index from the packs.
24522474
write_chunkindex_invalid(self.repository)
24532475
index_marked_invalid = True
2454-
self.repository.delete(defect_chunk, update_index=False, validate=validate)
2476+
old_pack_id = self.chunks[defect_chunk].pack_id
2477+
new_pack_id, _ = self.repository.delete(defect_chunk, update_index=False, validate=validate)
24552478
self.chunks_modified = True
2479+
# delete() replaced old_pack_id by new_pack_id, None if no object was left.
2480+
self.written_packs.discard(old_pack_id)
2481+
if new_pack_id is not None:
2482+
self.written_packs.add(new_pack_id)
24562483
else:
24572484
logger.warning("chunk %s not deleted, did not consistently fail.", bin_to_hex(defect_chunk))
24582485
else:
@@ -2546,7 +2573,7 @@ def valid_archive(obj):
25462573
self.error_found = True
25472574
if self.repair:
25482575
logger.warning(f"Creating archives directory entry for {name} {archive_id_hex}.")
2549-
self.manifest.archives.create(name, archive_id, archive.time)
2576+
self.create_archive_entry(name, archive_id, archive.time)
25502577
else:
25512578
logger.warning(f"Would create archives directory entry for {name} {archive_id_hex}.")
25522579

@@ -2602,7 +2629,7 @@ def add_reference(id_, size, cdata):
26022629
# --repair: store a chunk the repository does not have. put() adds it to self.chunks.
26032630
if self.repair and id_ not in self.chunks:
26042631
assert cdata is not None
2605-
self.repository.put(id_, cdata)
2632+
self.record_stored(self.repository.put(id_, cdata))
26062633
self.chunks_modified = True
26072634

26082635
def verify_file_chunks(archive_name, item):
@@ -2812,23 +2839,113 @@ def valid_item(obj):
28122839
logger.debug(f"archive id new: {bin_to_hex(new_archive_id)}")
28132840
cdata = self.repo_objs.format(new_archive_id, {}, data, ro_type=ROBJ_ARCHIVE_META)
28142841
add_reference(new_archive_id, len(data), cdata)
2815-
self.manifest.archives.create(info.name, new_archive_id, info.ts)
2842+
self.create_archive_entry(info.name, new_archive_id, info.ts)
28162843
if archive_id != new_archive_id:
28172844
self.manifest.archives.delete_by_id(archive_id)
28182845
finally:
28192846
pi.finish()
28202847
report_missing_chunks()
28212848

2849+
def verify_written_packs(self):
2850+
"""Read the object headers of the packs in written_packs and make the chunks index match them.
2851+
2852+
put() and delete() compute the index entries of the packs they write without reading the packs.
2853+
This compares the (chunk_id, obj_offset, obj_size) of each object header in a written pack, read
2854+
with a validator, with the index entries that name the pack. Each difference is a check finding,
2855+
logged and fixed in the index:
2856+
2857+
- an index entry names an object the pack does not hold: the entry is removed.
2858+
- the pack holds an object whose chunk id is not indexed: the object is indexed.
2859+
- the pack does not exist: its index entries are removed.
2860+
2861+
An object whose chunk id is indexed at another location is a superseded duplicate, not a finding.
2862+
A pack delete() wrote can hold one: compact_pack copies the byte ranges no index entry covers into
2863+
the new pack, except the superseded duplicates it finds there, and its search in such a range stops
2864+
at a corrupt object header.
2865+
"""
2866+
pack_ids = sorted(self.written_packs)
2867+
if not pack_ids:
2868+
return
2869+
logger.info(f"Re-reading the packs written by the repair: {len(pack_ids)}.")
2870+
# (chunk_id, obj_offset, obj_size) of the index entries, per written pack.
2871+
indexed = {pack_id: set() for pack_id in pack_ids}
2872+
for chunk_id, entry in self.chunks.iteritems():
2873+
entries = indexed.get(entry.pack_id)
2874+
if entries is not None:
2875+
entries.add((chunk_id, entry.obj_offset, entry.obj_size))
2876+
validate = object_validator(self.repo_objs)
2877+
for pack_id in pack_ids:
2878+
# PackReader reads from the store, which does not refresh the repository lock.
2879+
self.repository._lock_refresh()
2880+
pack_hex = bin_to_hex(pack_id)
2881+
expected = indexed.pop(pack_id)
2882+
reader = PackReader(self.repository.store, pack_id)
2883+
# iter_headers() yields nothing for a missing pack: the store reports size 0 for it.
2884+
if not self.repository.store.info(reader.key).exists:
2885+
self.error_found = True
2886+
logger.error(f"pack {pack_hex}: written by the repair, but it is missing. Removing its index entries.")
2887+
for chunk_id, _, _ in expected:
2888+
del self.chunks[chunk_id]
2889+
continue
2890+
found = list(reader.iter_headers(validate=validate, on_drop=self.note_dropped_objects))
2891+
not_found = sorted(expected.difference(found))
2892+
for chunk_id, _, _ in not_found:
2893+
del self.chunks[chunk_id]
2894+
# the loop indexes each unindexed object, so of several unindexed copies of a chunk, the first
2895+
# is indexed and the others are superseded duplicates.
2896+
unindexed = []
2897+
for obj in found:
2898+
chunk_id, obj_offset, obj_size = obj
2899+
if obj in expected:
2900+
continue
2901+
if chunk_id in self.chunks:
2902+
logger.debug(
2903+
f"pack {pack_hex}: {bin_to_hex(chunk_id)} at offset {obj_offset}, {obj_size} bytes: "
2904+
"superseded duplicate"
2905+
)
2906+
continue
2907+
unindexed.append(obj)
2908+
# size=0: the object header does not hold the plaintext size.
2909+
self.chunks[chunk_id] = ChunkIndexEntry(
2910+
flags=ChunkIndex.F_USED, size=0, pack_id=pack_id, obj_offset=obj_offset, obj_size=obj_size
2911+
)
2912+
if not (not_found or unindexed):
2913+
continue
2914+
self.error_found = True
2915+
logger.error(
2916+
f"pack {pack_hex}: the chunks index does not match the pack. Indexed objects not in the pack: "
2917+
f"{len(not_found)}, objects in the pack with an unindexed chunk id: {len(unindexed)}. "
2918+
"Fixed the index."
2919+
)
2920+
for chunk_id, obj_offset, obj_size in not_found:
2921+
logger.debug(
2922+
f"pack {pack_hex}: {bin_to_hex(chunk_id)} at offset {obj_offset}, {obj_size} bytes: not in pack"
2923+
)
2924+
for chunk_id, obj_offset, obj_size in unindexed:
2925+
logger.debug(
2926+
f"pack {pack_hex}: {bin_to_hex(chunk_id)} at offset {obj_offset}, {obj_size} bytes: not indexed"
2927+
)
2928+
28222929
def finish(self):
28232930
if self.repair:
2824-
# flush chunks re-added during repair so their packs are on the store and out of the pack
2825-
# writer buffer (close() requires an empty buffer, #10055) before we (re)build the index.
2826-
self.repository.flush()
2827-
if self.chunks_modified:
2828-
# the packs changed: rebuild the index from them, validating every object header, and
2829-
# store it. Free the current index first, so only one is in memory.
2830-
self.repository.invalidate_chunk_index()
2831-
self.chunks = None
2931+
# store the pack writer buffer before the index is written (close() requires an empty buffer, #10055).
2932+
self.record_stored(self.repository.flush())
2933+
# without a validator (the key was not readable), check() indexed objects no validator
2934+
# checked, and skipped the rest of a pack after a corrupt object header. If repair wrote
2935+
# packs, rebuild the index from all packs with a validator.
2936+
full_rebuild = self.chunks_modified and not self.rebuild_validated
2937+
if not full_rebuild:
2938+
if self.chunks_modified:
2939+
# the other packs did not change since check() indexed them with a validator.
2940+
self.verify_written_packs()
2941+
logger.info("Writing the rebuilt repository chunks index.")
2942+
write_chunkindex_to_repo(
2943+
self.repository, self.chunks, incremental=False, clear=False, force_write=True, delete_other=True
2944+
)
2945+
# free the in-memory index: close() must not store it, and the full rebuild builds its own.
2946+
self.repository.invalidate_chunk_index()
2947+
self.chunks = None
2948+
if full_rebuild:
28322949
logger.info("Rebuilding and writing the repository chunks index.")
28332950
build_chunkindex_from_repo(
28342951
self.repository,
@@ -2837,17 +2954,9 @@ def finish(self):
28372954
on_drop=self.note_dropped_objects,
28382955
write_immediately=True,
28392956
)
2840-
else:
2841-
# the packs are unchanged, so the index still matches them: persist it as is.
2842-
logger.info("Writing the rebuilt repository chunks index.")
2843-
write_chunkindex_to_repo(
2844-
self.repository, self.chunks, incremental=False, clear=False, force_write=True, delete_other=True
2845-
)
28462957
# the index just written matches the packs: clear the invalid marker, set by verify_data() or left
28472958
# by an interrupted operation.
28482959
delete_chunkindex_invalid(self.repository)
2849-
# drop the in-memory index so close() does not persist it over the index just written.
2850-
self.repository.invalidate_chunk_index()
28512960
self.manifest.write()
28522961

28532962

src/borg/repository.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,10 +1115,15 @@ def is_chunk_index_loaded(self):
11151115
return self._chunks is not None
11161116

11171117
def flush(self):
1118-
"""Flush any buffered pack writer chunks."""
1118+
"""Store the pack writer buffer as a pack, after waiting for the pack the background store-thread is storing.
1119+
1120+
Returns the (chunk_id, pack_id, obj_offset, obj_size) tuples of the objects in the packs this call
1121+
stored or waited for, or None if there were none.
1122+
"""
11191123
if self._pack_writer is not None:
11201124
self._lock_refresh()
1121-
self._pack_writer.flush() # PackWriter updates _chunks internally
1125+
return self._pack_writer.flush() # PackWriter updates _chunks internally
1126+
return None
11221127

11231128
def close(self):
11241129
if self._pack_writer is not None:
@@ -1173,11 +1178,10 @@ def check(self, repair=False, max_duration=0, max_age=0, repo_only=False):
11731178
continuing. A read-only check never rebuilds the index: reading every pack to do so would be
11741179
far too slow and expensive for a routine (e.g. cron) check. With repair=True and a corrupt
11751180
index, and if every pack is intact, the index is rebuilt from the packs' object headers and
1176-
persisted; on a full check the archives phase rebuilds and re-persists it afterwards, see
1177-
ArchiveChecker.finish. Packs are verified by the store hash, which is content-addressing rather than a
1178-
MAC, so this rebuild detects accidental corruption but not tampering, refs #9901, #10026. If any
1179-
pack is corrupt the index is left unchanged, refs #8572, #10026. Pack ids found corrupt are kept
1180-
in cache/checked-packs, refs #9696.
1181+
persisted. Packs are verified by the store hash, which is content-addressing rather than a MAC,
1182+
so this rebuild detects accidental corruption but not tampering, refs #9901, #10026. If any pack
1183+
is corrupt the index is left unchanged, refs #8572, #10026. Pack ids found corrupt are kept in
1184+
cache/checked-packs, refs #9696.
11811185
11821186
A pack recorded corrupt fails the check, also on a partial run that stops before re-reaching
11831187
it. The record clears at the check that finds the pack intact again or gone (removed by
@@ -1573,11 +1577,12 @@ def put(self, id, data):
15731577
def delete(self, id, *, validate, update_index=True):
15741578
"""Delete a single repo object by rewriting its pack without it (via compact_pack).
15751579
1576-
With update_index=True the full chunk index is written back so the next borg process sees the
1577-
deletion; callers that rebuild the index themselves (check --repair) pass update_index=False to
1578-
skip the per-object index rewrite.
1579-
15801580
validate: passed to compact_pack.
1581+
update_index: if True, write the full chunk index to the repository after the delete, so the next
1582+
borg process sees it. If False, only the in-memory index is updated.
1583+
1584+
Returns compact_pack's (new_pack_id, dropped_bytes): the id of the pack holding the other objects
1585+
of the old pack (None if there were none), and the number of bytes the rewrite dropped.
15811586
"""
15821587
self._lock_refresh()
15831588
entry = self.chunks.get(id)
@@ -1587,13 +1592,14 @@ def delete(self, id, *, validate, update_index=True):
15871592
# keep every object the chunk index lists for this pack, except the one being deleted.
15881593
keep_ids = {cid for cid, e in self.chunks.iteritems() if e.pack_id == pack_id}
15891594
keep_ids.discard(id)
1590-
self.compact_pack(pack_id, keep_ids=keep_ids, drop_ids={id}, validate=validate)
1595+
result = self.compact_pack(pack_id, keep_ids=keep_ids, drop_ids={id}, validate=validate)
15911596
if update_index:
15921597
# close() only persists new entries incrementally, so write the full index here to record
15931598
# the removal for the next borg process.
15941599
from .cache import write_chunkindex_to_repo
15951600

15961601
write_chunkindex_to_repo(self, self.chunks, incremental=False, force_write=True, delete_other=True)
1602+
return result
15971603

15981604
def compact_pack(self, pack_id, *, keep_ids: set, drop_ids: set, validate, chunks=None):
15991605
"""Rewrite pack <pack_id>, keeping <keep_ids> and dropping <drop_ids>, then delete the old pack.

0 commit comments

Comments
 (0)