Skip to content

Commit 7133066

Browse files
Roll back chunks also on read errors (avoid bad refcounts)
The changed-file retry path already rolls back the chunks it added before re-reading. Do the same when a read error (BackupOSError) interrupts reading a file: the chunks added since the last checkpoint (item.chunks[from_chunk:]) are not referenced by any committed part item, so without rolling them back they leak as an inflated chunk refcount (or, if content also shifted, an orphan chunk). The rollback lives in process_file_chunks, where from_chunk is known: chunks before from_chunk are referenced by part files we already wrote and must be kept; only the uncommitted tail is decref'd. Adds test_create_erroneous_file_read_retry_rolls_back_chunks: the re-read chunks dedup to the same ids, so the leak shows up as a too-high refcount (not an orphan id) - the test checks refcounts directly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ff0eb87 commit 7133066

2 files changed

Lines changed: 55 additions & 18 deletions

File tree

src/borg/archive.py

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1324,24 +1324,34 @@ def chunk_processor(chunk):
13241324
del item.chunks_healthy
13251325
from_chunk = 0
13261326
part_number = 1
1327-
for chunk in chunk_iter:
1328-
item.chunks.append(chunk_processor(chunk))
1329-
if show_progress:
1330-
stats.show_progress(item=item, dt=0.2)
1331-
from_chunk, part_number = self.maybe_checkpoint(item, from_chunk, part_number, forced=False)
1332-
else:
1333-
if part_number > 1:
1334-
if item.chunks[from_chunk:]:
1335-
# if we already have created a part item inside this file, we want to put the final
1336-
# chunks (if any) into a part item also (so all parts can be concatenated to get
1337-
# the complete file):
1338-
from_chunk, part_number = self.maybe_checkpoint(item, from_chunk, part_number, forced=True)
1339-
1340-
# if we created part files, we have referenced all chunks from the part files,
1341-
# but we also will reference the same chunks also from the final, complete file:
1342-
for chunk in item.chunks:
1343-
cache.chunk_incref(chunk.id, stats, size=chunk.size, part=True)
1344-
stats.nfiles_parts += part_number - 1
1327+
try:
1328+
for chunk in chunk_iter:
1329+
item.chunks.append(chunk_processor(chunk))
1330+
if show_progress:
1331+
stats.show_progress(item=item, dt=0.2)
1332+
from_chunk, part_number = self.maybe_checkpoint(item, from_chunk, part_number, forced=False)
1333+
else:
1334+
if part_number > 1:
1335+
if item.chunks[from_chunk:]:
1336+
# if we already have created a part item inside this file, we want to put the final
1337+
# chunks (if any) into a part item also (so all parts can be concatenated to get
1338+
# the complete file):
1339+
from_chunk, part_number = self.maybe_checkpoint(item, from_chunk, part_number, forced=True)
1340+
1341+
# if we created part files, we have referenced all chunks from the part files,
1342+
# but we also will reference the same chunks also from the final, complete file:
1343+
for chunk in item.chunks:
1344+
cache.chunk_incref(chunk.id, stats, size=chunk.size, part=True)
1345+
stats.nfiles_parts += part_number - 1
1346+
except BackupOSError:
1347+
# a read error happened after we already read (and added to the repo/cache) some chunks.
1348+
# the chunks we added since the last checkpoint (item.chunks[from_chunk:]) are not referenced
1349+
# by any (committed) part item, so they would leak (bad refcount / orphan chunk) - roll them
1350+
# back. the chunks before from_chunk are referenced by part items we already wrote, keep them.
1351+
for chunk in item.chunks[from_chunk:]:
1352+
cache.chunk_decref(chunk.id, stats)
1353+
item.chunks = item.chunks[:from_chunk]
1354+
raise
13451355
# part_number > 1 means we wrote part files (checkpoints) for this file:
13461356
return part_number
13471357

src/borg/testsuite/archiver.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1298,6 +1298,33 @@ def change_file_on_post_read_fstat(fd):
12981298
cached_ids = {id for id, entry in cache.chunks.iteritems()}
12991299
assert cached_ids == referenced_ids
13001300

1301+
def test_create_erroneous_file_read_retry_rolls_back_chunks(self):
1302+
# a read error after we already read+added some chunks must roll those chunks back, so we do
1303+
# not end up with bad (too high) chunk refcounts. note: the re-read chunks dedup to the same
1304+
# ids, so a leak shows up as an inflated refcount (not as an orphan id), hence we check refcounts.
1305+
from collections import Counter
1306+
chunk_size = 1000
1307+
# distinct content per block, so each block maps to its own (unique) chunk id:
1308+
self.create_regular_file('file', contents=b'A' * chunk_size + b'B' * chunk_size)
1309+
self.cmd('init', '--encryption=repokey', self.repository_location)
1310+
# rErr: read 1st block ok, 2nd block fails -> retry; on the retry both blocks read ok.
1311+
out = self.cmd('create', f'--chunker-params=fail,{chunk_size},rErr', '--list',
1312+
self.repository_location + '::test', 'input')
1313+
assert 'retry: 1 of ' in out
1314+
assert 'E input/file' not in out # it was backed up ok on the retry
1315+
1316+
with Repository(self.repository_path, exclusive=True) as repository:
1317+
manifest, key = Manifest.load(repository, Manifest.NO_OPERATION_CHECK)
1318+
archive = Archive(repository, key, manifest, 'test')
1319+
item = [item for item in archive.iter_items() if item.path == 'input/file'][0]
1320+
item_chunk_refs = Counter(chunk.id for chunk in item.chunks)
1321+
with Cache(repository, key, manifest) as cache:
1322+
refcounts = {id: entry.refcount for id, entry in cache.chunks.iteritems()}
1323+
# each data chunk of the file must be referenced exactly as often as it occurs in the item -
1324+
# a leaked chunk from the failed read attempt would show up here as a too high refcount:
1325+
for chunk_id, refs in item_chunk_refs.items():
1326+
assert refcounts[chunk_id] == refs
1327+
13011328
def test_create_erroneous_file_with_part_files(self):
13021329
# if we have already written part files (checkpoints) for a file, a later read error must
13031330
# NOT trigger a retry: re-reading the file from the start would create duplicate / inconsistent

0 commit comments

Comments
 (0)