Skip to content

Commit 84a6388

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 f8f1464 commit 84a6388

4 files changed

Lines changed: 462 additions & 55 deletions

File tree

src/borg/archive.py

Lines changed: 119 additions & 32 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
56+
from .hashindex import ChunkIndex, ChunkIndexEntry
57+
from .repository import Repository, PackReader
5758
from .repoobj import RepoObj, object_validator
5859

5960
# macOS: SF_DATALESS marks dataless placeholder files (e.g. cloud files not materialized locally).
@@ -2212,9 +2213,24 @@ class ArchiveChecker:
22122213
def __init__(self):
22132214
self.error_found = False
22142215
self.key = None
2215-
# True once repair drops a defect chunk or writes a new one, i.e. once the chunks index no
2216-
# longer matches the packs.
2216+
# True once repair wrote a pack: it stored a chunk or deleted a defect chunk.
22172217
self.chunks_modified = False
2218+
# ids of the existing packs repair stored (put(), flush()) or wrote by rewriting a pack (delete()).
2219+
self.written_packs = set()
2220+
2221+
def record_stored(self, results):
2222+
"""Add the pack ids in results to written_packs.
2223+
2224+
results: the (chunk_id, pack_id, obj_offset, obj_size) tuples Repository.put() or .flush() returns
2225+
for the packs it stored, or None if it stored no pack.
2226+
"""
2227+
if results:
2228+
self.written_packs.update(pack_id for _, pack_id, _, _ in results)
2229+
2230+
def create_archive_entry(self, name, id, ts):
2231+
"""Store the pack writer buffer, record the packs it wrote, create the archives directory entry."""
2232+
self.record_stored(self.repository.flush())
2233+
self.manifest.archives.create(name, id, ts)
22182234

22192235
def note_dropped_objects(self):
22202236
# The chunk index rebuild skipped repository content to get past a corrupt object header.
@@ -2273,6 +2289,7 @@ def check(
22732289
validate = object_validator(self.repo_objs)
22742290
else:
22752291
validate = None
2292+
assert not repair or validate is not None # a repair validates every object it indexes
22762293
# store the chunks buffered in the pack writer, so the index below has their pack locations
22772294
# (pack id, offset and size in the pack).
22782295
self.repository.flush()
@@ -2405,9 +2422,14 @@ def verify_data(self):
24052422
# failed twice -> remove this defect chunk. delete rewrites its pack without it,
24062423
# keeping the other chunks, and removes it from self.chunks, so rebuild_archives
24072424
# reports the file it belongs to. update_index=False: finish() stores the index
2408-
# rebuilt from the packs and clears the invalid marker delete() writes.
2409-
self.repository.delete(defect_chunk, update_index=False, validate=validate)
2425+
# and clears the invalid marker delete() writes.
2426+
# new_pack_id holds the other objects of the old pack, None if there were none.
2427+
old_pack_id = self.chunks[defect_chunk].pack_id
2428+
new_pack_id, _ = self.repository.delete(defect_chunk, update_index=False, validate=validate)
24102429
self.chunks_modified = True
2430+
self.written_packs.discard(old_pack_id)
2431+
if new_pack_id is not None:
2432+
self.written_packs.add(new_pack_id)
24112433
else:
24122434
logger.warning("chunk %s not deleted, did not consistently fail.", bin_to_hex(defect_chunk))
24132435
else:
@@ -2495,7 +2517,7 @@ def valid_archive(obj):
24952517
self.error_found = True
24962518
if self.repair:
24972519
logger.warning(f"Creating archives directory entry for {name} {archive_id_hex}.")
2498-
self.manifest.archives.create(name, archive_id, archive.time)
2520+
self.create_archive_entry(name, archive_id, archive.time)
24992521
else:
25002522
logger.warning(f"Would create archives directory entry for {name} {archive_id_hex}.")
25012523

@@ -2551,7 +2573,7 @@ def add_reference(id_, size, cdata):
25512573
# with --repair, store a chunk the repository does not have; put() adds it to self.chunks.
25522574
if self.repair and id_ not in self.chunks:
25532575
assert cdata is not None
2554-
self.repository.put(id_, cdata)
2576+
self.record_stored(self.repository.put(id_, cdata))
25552577
self.chunks_modified = True
25562578

25572579
def verify_file_chunks(archive_name, item):
@@ -2761,43 +2783,108 @@ def valid_item(obj):
27612783
logger.debug(f"archive id new: {bin_to_hex(new_archive_id)}")
27622784
cdata = self.repo_objs.format(new_archive_id, {}, data, ro_type=ROBJ_ARCHIVE_META)
27632785
add_reference(new_archive_id, len(data), cdata)
2764-
self.manifest.archives.create(info.name, new_archive_id, info.ts)
2786+
self.create_archive_entry(info.name, new_archive_id, info.ts)
27652787
if archive_id != new_archive_id:
27662788
self.manifest.archives.delete_by_id(archive_id)
27672789
finally:
27682790
pi.finish()
27692791
report_missing_chunks()
27702792

2793+
def verify_written_packs(self):
2794+
"""Read the object headers of the packs in written_packs and make the chunks index match them.
2795+
2796+
put() and delete() compute the index entries of the packs they write without reading the packs.
2797+
This compares the (chunk_id, obj_offset, obj_size) of each object header in a written pack, read
2798+
with a validator, with the index entries that name the pack. Each difference is a check finding,
2799+
logged and fixed in the index:
2800+
2801+
- an index entry names an object the pack does not hold: the entry is removed.
2802+
- the pack holds an object whose chunk id is not indexed: the object is indexed.
2803+
- the pack does not exist: its index entries are removed.
2804+
2805+
An object whose chunk id is indexed at another location is a superseded duplicate, not a finding: a
2806+
pack delete() wrote can hold one, in a byte range compact_pack copied with no index entry covering it.
2807+
"""
2808+
pack_ids = sorted(self.written_packs)
2809+
if not pack_ids:
2810+
return
2811+
logger.info(f"Re-reading the packs written by the repair: {len(pack_ids)}.")
2812+
# (chunk_id, obj_offset, obj_size) of the index entries, per written pack.
2813+
indexed = {pack_id: set() for pack_id in pack_ids}
2814+
for chunk_id, entry in self.chunks.iteritems():
2815+
entries = indexed.get(entry.pack_id)
2816+
if entries is not None:
2817+
entries.add((chunk_id, entry.obj_offset, entry.obj_size))
2818+
validate = object_validator(self.repo_objs)
2819+
for pack_id in pack_ids:
2820+
# PackReader reads from the store, which does not refresh the repository lock.
2821+
self.repository._lock_refresh()
2822+
pack_hex = bin_to_hex(pack_id)
2823+
expected = indexed.pop(pack_id)
2824+
reader = PackReader(self.repository.store, pack_id)
2825+
# iter_headers() yields nothing for a missing pack: the store reports size 0 for it.
2826+
if not self.repository.store.info(reader.key).exists:
2827+
self.error_found = True
2828+
logger.error(f"pack {pack_hex}: written by the repair, but it is missing. Removing its index entries.")
2829+
for chunk_id, _, _ in expected:
2830+
del self.chunks[chunk_id]
2831+
continue
2832+
found = list(reader.iter_headers(validate=validate, on_drop=self.note_dropped_objects))
2833+
not_found = sorted(expected.difference(found))
2834+
for chunk_id, _, _ in not_found:
2835+
del self.chunks[chunk_id]
2836+
# the loop indexes each unindexed object, so of several unindexed copies of a chunk, the first
2837+
# is indexed and the others are superseded duplicates.
2838+
unindexed = []
2839+
for obj in found:
2840+
chunk_id, obj_offset, obj_size = obj
2841+
if obj in expected:
2842+
continue
2843+
if chunk_id in self.chunks:
2844+
logger.debug(
2845+
f"pack {pack_hex}: {bin_to_hex(chunk_id)} at offset {obj_offset}, {obj_size} bytes: "
2846+
"superseded duplicate"
2847+
)
2848+
continue
2849+
unindexed.append(obj)
2850+
# size=0: the object header does not hold the plaintext size.
2851+
self.chunks[chunk_id] = ChunkIndexEntry(
2852+
flags=ChunkIndex.F_USED, size=0, pack_id=pack_id, obj_offset=obj_offset, obj_size=obj_size
2853+
)
2854+
if not (not_found or unindexed):
2855+
continue
2856+
self.error_found = True
2857+
logger.error(
2858+
f"pack {pack_hex}: the chunks index does not match the pack. Indexed objects not in the pack: "
2859+
f"{len(not_found)}, objects in the pack with an unindexed chunk id: {len(unindexed)}. "
2860+
"Fixed the index."
2861+
)
2862+
for chunk_id, obj_offset, obj_size in not_found:
2863+
logger.debug(
2864+
f"pack {pack_hex}: {bin_to_hex(chunk_id)} at offset {obj_offset}, {obj_size} bytes: not in pack"
2865+
)
2866+
for chunk_id, obj_offset, obj_size in unindexed:
2867+
logger.debug(
2868+
f"pack {pack_hex}: {bin_to_hex(chunk_id)} at offset {obj_offset}, {obj_size} bytes: not indexed"
2869+
)
2870+
27712871
def finish(self):
27722872
if self.repair:
2773-
# flush chunks re-added during repair so their packs are on the store and out of the pack
2774-
# writer buffer (close() requires an empty buffer, #10055) before we (re)build the index.
2775-
self.repository.flush()
2873+
# store the pack writer buffer before the index is written (close() requires an empty buffer, #10055).
2874+
self.record_stored(self.repository.flush())
27762875
if self.chunks_modified:
2777-
# the packs changed: rebuild the index from them and store it. The index/ fragments lack
2778-
# the chunks this repair stored, so the index is invalid until the rebuilt one is stored.
2779-
# Free the current index first, so only one index is in memory.
2876+
# the index/ fragments do not have the chunks this repair stored.
27802877
write_chunkindex_invalid(self.repository)
2781-
self.repository.invalidate_chunk_index()
2782-
self.chunks = None
2783-
logger.info("Rebuilding and writing the repository chunks index.")
2784-
build_chunkindex_from_repo(
2785-
self.repository,
2786-
slow_rebuild=True,
2787-
validate=object_validator(self.repo_objs),
2788-
on_drop=self.note_dropped_objects,
2789-
write_immediately=True,
2790-
)
2791-
else:
2792-
# the packs are unchanged, so the index still matches them: persist it as is.
2793-
logger.info("Writing the rebuilt repository chunks index.")
2794-
write_chunkindex_to_repo(
2795-
self.repository, self.chunks, incremental=False, clear=False, force_write=True, delete_other=True
2796-
)
2878+
self.verify_written_packs()
2879+
logger.info("Writing the rebuilt repository chunks index.")
2880+
write_chunkindex_to_repo(
2881+
self.repository, self.chunks, incremental=False, clear=False, force_write=True, delete_other=True
2882+
)
2883+
# close() persists the in-memory index: drop it, the stored one is current.
2884+
self.repository.invalidate_chunk_index()
2885+
self.chunks = None
27972886
# the stored index matches the packs: clear the invalid marker.
27982887
delete_chunkindex_invalid(self.repository)
2799-
# drop the in-memory index so close() does not persist it over the index just written.
2800-
self.repository.invalidate_chunk_index()
28012888

28022889

28032890
class ArchiveRecreater:

src/borg/repository.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1290,10 +1290,15 @@ def is_chunk_index_loaded(self):
12901290
return self._chunks is not None
12911291

12921292
def flush(self):
1293-
"""Flush any buffered pack writer chunks."""
1293+
"""Store the pack writer buffer as a pack, after waiting for the pack the background store-thread is storing.
1294+
1295+
Returns the (chunk_id, pack_id, obj_offset, obj_size) tuples of the objects in the packs this call
1296+
stored or waited for, or None if there were none.
1297+
"""
12941298
if self._pack_writer is not None:
12951299
self._lock_refresh()
1296-
self._pack_writer.flush() # PackWriter updates _chunks internally
1300+
return self._pack_writer.flush() # PackWriter updates _chunks internally
1301+
return None
12971302

12981303
def close(self, *, aborting=False):
12991304
"""Close the repository: join an in-flight pack store, persist the chunk index, tear down.
@@ -1376,12 +1381,10 @@ def check(self, repair=False, max_duration=0, max_age=0, repo_only=False, valida
13761381
continuing. A read-only check never rebuilds the index: reading every pack to do so would be
13771382
far too slow and expensive for a routine (e.g. cron) check. With repair=True and a corrupt
13781383
index, and if every pack is intact, the index is rebuilt from the packs' object headers and
1379-
persisted; on a full check the archives phase rebuilds and re-persists it afterwards, see
1380-
ArchiveChecker.finish. Packs are verified by the store hash, which is content-addressing rather
1381-
than a MAC, so that check detects accidental corruption but not tampering; the rebuild therefore
1382-
checks every object with validate, see below, refs #9901, #10026. If any pack is corrupt the index
1383-
is left unchanged, refs #8572, #10026. Pack ids found corrupt are kept in cache/checked-packs,
1384-
refs #9696.
1384+
persisted. Packs are verified by the store hash, which is content-addressing rather than a MAC, so
1385+
that check detects accidental corruption but not tampering; the rebuild therefore checks every
1386+
object with validate, see below, refs #9901, #10026. If any pack is corrupt the index is left
1387+
unchanged, refs #8572, #10026. Pack ids found corrupt are kept in cache/checked-packs, refs #9696.
13851388
13861389
A pack recorded corrupt fails the check, also on a partial run that stops before re-reaching
13871390
it. The record clears at the check that finds the pack intact again or gone (removed by
@@ -1809,9 +1812,12 @@ def delete(self, id, *, validate, update_index=True):
18091812
Raises PermissionDenied before any store change unless the repo permissions grant write and delete
18101813
on packs/ and index/ (see assert_writable).
18111814
1815+
validate: passed to compact_pack.
18121816
update_index: True: store the full chunk index and delete the invalid marker. False: update the
18131817
in-memory index only; the marker stays until the index is stored and the marker deleted.
1814-
validate: passed to compact_pack.
1818+
1819+
Returns compact_pack's (new_pack_id, dropped_bytes): the id of the pack holding the other objects
1820+
of the old pack (None if there were none), and the number of bytes the rewrite dropped.
18151821
"""
18161822
from .cache import write_chunkindex_to_repo, write_chunkindex_invalid, delete_chunkindex_invalid
18171823

@@ -1824,7 +1830,7 @@ def delete(self, id, *, validate, update_index=True):
18241830
# keep every object the chunk index lists for this pack, except the one being deleted.
18251831
keep_ids = {cid for cid, e in self.chunks.iteritems() if e.pack_id == pack_id}
18261832
keep_ids.discard(id)
1827-
self.compact_pack(
1833+
result = self.compact_pack(
18281834
pack_id,
18291835
keep_ids=keep_ids,
18301836
drop_ids={id},
@@ -1836,6 +1842,7 @@ def delete(self, id, *, validate, update_index=True):
18361842
# the removal for the next borg process.
18371843
write_chunkindex_to_repo(self, self.chunks, incremental=False, force_write=True, delete_other=True)
18381844
delete_chunkindex_invalid(self)
1845+
return result
18391846

18401847
def compact_pack(
18411848
self, pack_id, *, keep_ids: set, drop_ids: set, validate, chunks=None, before_old_pack_delete=None

0 commit comments

Comments
 (0)