create: do not wrap repository writes in backup_io("read") (silent data loss on ENOSPC) - #9853
Merged
ThomasWaldmann merged 1 commit intoJul 3, 2026
Conversation
process_file() ran process_file_chunks() inside `with backup_io("read")`.
That block was meant to guard reading the *source* file, but the source
reads are already guarded individually by backup_io_iter(). The outer
wrapper additionally caught add_chunk()'s *repository* writes, so a critical
repository IO failure -- e.g. the repo running out of space during a pack
flush -- was wrapped into a per-file BackupOSError. Borg then only warned,
skipped the file, and continued, and create_inner() still committed the
archive via archive.save().
The result: `borg create` on an out-of-space repo printed a normal success
summary ("Error files: 0"), exited 0, and committed an archive that
references chunks which were never durably stored. `borg check` afterwards
reports "Missing file chunk detected" and `borg compact` reports "Repository
has N missing objects!" -- silent, unrestorable-backup data loss.
Drop the outer backup_io("read") wrapper. Source reads stay per-file
warnings (backup_io_iter is unchanged); repository OSErrors are now left
unwrapped and therefore critical, aborting create before archive.save()
runs, exactly as the BackupOSError docstring prescribes.
Reproduced on a space-limited macOS ramdisk (source > free space): before,
create exited 0 with a corrupt archive; after, create fails and commits
nothing, and the repository stays consistent across a create/delete/compact
churn matrix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ThomasWaldmann
force-pushed
the
fix-create-enospc-atomicity
branch
from
July 3, 2026 17:50
eb82083 to
5aaa2dd
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #9853 +/- ##
==========================================
- Coverage 85.11% 85.11% -0.01%
==========================================
Files 93 93
Lines 15409 15408 -1
Branches 2326 2326
==========================================
- Hits 13115 13114 -1
Misses 1596 1596
Partials 698 698 ☔ View full report in Codecov by Harness. |
This was referenced Aug 1, 2026
ThomasWaldmann
added a commit
that referenced
this pull request
Aug 2, 2026
PackWriter now hands a full pack to a background store-thread (at most one in flight): the pack bytes are joined, sha256-hashed (the pack_id) and stored in the store-thread, while the caller goes on assembling the next pack. hashlib and the store I/O release the GIL, so there is real overlap even on CPython. Throughput becomes max(assembly, store) instead of assembly + store. The ChunkIndex is only ever touched by the calling thread: the store-thread's results (or error) are applied when it is joined, at the next pack boundary or flush(). Consequences: - put()/add() return the *previous* pack's results while the current pack's store is in flight; update_pack_info() keys by chunk_id, so callers do not care which pack the results belong to. - flush() is a barrier: it joins an in-flight store and writes the current buffer synchronously, so afterwards nothing is F_PENDING anymore (needed by the periodic chunk index persist (#9900) and by close()). - a store error (e.g. ENOSPC) surfaces one pack later, from whichever add()/flush() call joins the store-thread - still before anything gets finalized, since the final flush is a barrier (no #9853-style regression). the failed pack's index entries are dropped and the buffered pieces die with the aborting command, so the close()-time index persist stays clean. - get()/get_many() of a chunk whose pack store is still in flight join the store-thread first (read barrier), then read normally. - close() joins a still-in-flight store (normally a no-op, flush ran before): a stored pack gets recorded, a failed one rolled back (not raising, to not mask the error being unwound). Sharing the Store between the store-thread and the main thread (lock refresh, reads of already stored packs) requires borgstore >= 0.6.0, which serializes all Store operations internally (borgstore #206 / #207). The borgstore dependency is bumped accordingly and now also pulls the blake3 extra, so borgstore's hash/defrag blake3 support is available server-side too. BORG_PACK_ASYNC=no disables the store-thread (debugging aid). The pre-existing synchronous-contract unit tests run with async_store=False; new tests cover deferred results, the combined flush barrier, deferred error surfacing with rollback, and the get() read barrier.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
borg createon an out-of-space repository could silently commit a corrupt, unrestorable archive and exit0with a normal success summary.Symptom
On a repository whose backend runs out of space during
borg create:createprints a normal summary —Added files: N,Error files: 0— and exits 0.borg check→Missing file chunk detected(exit 1)borg compact→Repository has N missing objects!(exit 2)So a backup reports success but is not restorable.
Root cause
process_file()ranprocess_file_chunks()insidewith backup_io("read"):That block is meant to guard reading the source file, but the source reads are already guarded individually by
backup_io_iter(). The outer wrapper additionally caughtadd_chunk()'s repository writes. So a critical repository IO failure (e.g. ENOSPC during a pack flush) was wrapped into a per-fileBackupOSError, tagged"read"— indistinguishable from "this source file couldn't be read". Borg then only warned, skipped the file, continued the walk, andcreate_inner()committed the archive viaarchive.save(). Because pack flushing is deferred, chunks of earlier, already-emitted items were lost too, so the whole archive ends up referencing missing chunks.This directly contradicts the
BackupOSErrordocstring:Fix
Drop the outer
backup_io("read")wrapper. Source reads stay per-file warnings (backup_io_iteris unchanged); repositoryOSErrors are now left unwrapped and therefore critical, abortingcreatebeforearchive.save()runs — as the docstring prescribes.Audited all
with backup_ioblocks acrosssrc/borg: this regular-file path was the only one wrapping a repository operation. The stdin/pipe andimport-tarprocess_file_chunkscall sites were already unwrapped (correct).Testing
archive_test.py(40) andcreate_cmd_test.py(61) pass.compactthen reportedRepository has N missing objects!andcheckreported missing chunks in every run.check: no problems found) in every run. Normal backups and unreadable-source-file handling (per-file warning) are unchanged.Notes / follow-ups (not in this PR)
Error: OSError: [Errno 28] …) plus a secondaryFileNotFoundErrorduring unwind, rather than a cleanError: No space left on device. Worth polishing (catch the repoOSError→ cleanError, make PackWriter/close()teardown ENOSPC-clean).🤖 Generated with Claude Code