Repository: don't mask the original exception when unwinding with buffered chunks - #10013
Repository: don't mask the original exception when unwinding with buffered chunks#10013ThomasWaldmann wants to merge 4 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #10013 +/- ##
==========================================
+ Coverage 87.86% 88.08% +0.22%
==========================================
Files 103 103
Lines 18876 18942 +66
Branches 2915 2923 +8
==========================================
+ Hits 16586 16686 +100
+ Misses 1589 1565 -24
+ Partials 701 691 -10 ☔ View full report in Codecov by Harness. |
f3ca1d1 to
7bd4296
Compare
7bd4296 to
f533d6c
Compare
888bdc2 to
c15f87f
Compare
…fered chunks When a command aborts with chunks still buffered in the PackWriter, the "with repository:" unwind called close(), whose "PackWriter has unflushed chunks" assertion raised AssertionError and masked the original exception. Buffered chunks also left F_PENDING entries in the chunk index, which the close()-time index persist asserts on. This affects the paths that put chunks without a Cache: ArchiveChecker (borg check --repair) and borg debug put-obj. Commands that use a Cache are unaffected, because Cache.close() unwinds first and flushes the pack writer. ArchiveChecker.finish() flushes too, but only on the success path, so an abort before that still reaches close() with a non-empty buffer. Fix: on exception unwind, Repository.__exit__ drops the buffered pieces and their still-pending index entries via PackWriter._drop_buffered(), so the original exception propagates unmasked and no F_PENDING entries are persisted. The never-stored chunks die with the aborted operation. On a clean close, the assertion still catches a forgotten flush(). _drop_buffered() only ever runs while aborting, so it must not build the chunk index from the repo: that I/O can fail and mask the error being unwound. It now empties the buffer before it touches the index and skips the index cleanup when no index is loaded, where there is nothing to delete anyway. invalidate_chunk_index() is what leaves that state behind; its callers all flush first or never buffer, so this keeps the helper safe either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…raise from close() teardown Review follow-ups on the drop-on-unwind fix: Repository.__exit__ now calls PackWriter.discard(), the abort-side counterpart to flush(): it joins a still in-flight pack store first, so a pack that was already stored gets recorded in the index - and dropping buffered entries can no longer break update_pack_info() for a chunk id sitting in that pack and in the buffer (dropping first deleted the shared index entry, update_pack_info() then raised KeyError mid-pack and left F_PENDING leftovers for the close()-time persist to assert on, masking the original exception through a different door). Store errors from the join are logged, not raised. All abort-time index cleanup goes through PackWriter._drop_index_entries(): it never builds the chunk index from the repo (that I/O can fail and mask the error being unwound; with no in-memory index there is nothing to delete, since add() installs a chunk's entry before buffering its piece) and it only deletes entries that are still pending - a resolved entry means the chunk is in a stored pack, only the aborted duplicate piece dies. _apply_outcome() gets the same no-index guard, so joining a pack store while aborting cannot trigger a rebuild either. close() could still mask the original error a few lines further down: the close()-time chunk index persist and the lock release both do store I/O, which fails again exactly when the abort was caused by a failing store. Both are now logged instead of raised (the persisted index is only a cache; an unreleasable lock goes stale eventually), and the lock release and store close run in a finally block, so a close()-time error - e.g. the unflushed-chunks assertion - cannot leak the exclusive lock anymore. Also: log dropped buffer pieces (debug level), document the abort semantics in docs/internals/packs.rst, add ChunkIndex.is_pending/F_PENDING to the .pyi stub, new tests for the join-before-drop ordering and the guarded persist (both fail without the fixes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
c15f87f to
87d687d
Compare
There was a problem hiding this comment.
I tested this on the PR head merged with current master. I found two blockers.
1. Abort after a re-put drops a chunk that is already stored
ChunkIndex.add() resets a resolved entry to F_PENDING. On abort, _drop_index_entries() deletes every pending entry, and close() then saves the index without that chunk.
def test_reput_stored_chunk_survives_unwind(tmp_path):
location = os.fspath(tmp_path / "repo")
with pytest.raises(ValueError, match="original error"):
with Repository(location, exclusive=True, create=True) as repository:
repository.put(H(0), fchunk(b"DATA"))
repository.flush()
repository.put(H(0), fchunk(b"DATA"))
raise ValueError("original error")
with Repository(location, exclusive=True) as repository:
assert pdchunk(repository.get(H(0))) == b"DATA"This test fails with ObjectNotFound, but the pack is present in the store. It passes if H(0) was flushed in an earlier session, so only chunks flushed in the same session are affected. Please keep the resolved location in add(), or restore the overwritten entries on drop, and add this test.
2. close() hides errors on a clean exit also
The new except Exception blocks around write_chunkindex_to_repo() and lock.release() run even when no exception is unwinding. I checked a clean with Repository(...) block:
- If the index write fails, master raises
OSError. This branch only logs a warning. - If the lock release fails, master raises
OSError. This branch only logs a warning and leaves the exclusive lock behind.
Both are plain logger.warning calls, so the command still exits 0. Please swallow these errors only in the unwind path from __exit__.
Minor
- The no-index guard in
_apply_outcome()also runs on the normal path. If I put 3 chunks, callinvalidate_chunk_index()and thenflush(), master raisesKeyError. This branch returns normally, andget(H(0))then givesObjectNotFound. Please limit the guard to the abort path or document it. - The tests don't cover the no-index return in
_apply_outcome(), theexceptindiscard(), or theexceptaroundlock.release().close()setsself.lock = Noneeither way, soassert repository.lock is Nonedoesn't test the release.
Re-adding a chunk reset its entry to UNKNOWN/pending, dropping any prior location. For a re-put of an already stored chunk that meant: the chunk was unreadable until the next flush(), and when the operation aborted instead, the drop-on-unwind deleted the pending entry, so the close()-time persist saved a chunk index lacking a chunk that is in the store (review finding on this PR: put(x), flush(), put(x) again, abort -> x gone). Now add() keeps an existing resolved location: the chunk stays readable, an abort keeps its entry (the drop only deletes entries that are still pending), and a successful flush() overwrites the entry with the re-added copy's location via update_pack_info(), as before. Entries without a resolved location (new chunks, or re-adds while still pending) go to UNKNOWN/pending exactly as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eption close() wrapped the chunk index persist and the lock release in try/except unconditionally, so on a *clean* close a failing persist or lock release was only logged and the command still succeeded (review finding on this PR); on master both raise. close() takes an aborting=True keyword now, passed by __exit__ (and by __enter__'s failure path) when an exception is unwinding: only then are persist/release errors logged instead of raised, so they cannot mask the error being unwound. On a clean close they raise again, like on master -- but the lock release and store close still run in the finally block, so even a raising close does not leak the exclusive lock. A failing lock release is also only logged while the persist (or the unflushed- chunks assertion) is already raising, so teardown never masks an error. Also make the _apply_outcome() no-index skip unreachable on the normal path: flush() now asserts that the chunk index is loaded while chunks are buffered or in flight (invalidate_chunk_index() callers must flush first), so that caller bug fails loudly instead of silently leaving a stored pack unrecorded. Tests: cover the re-put-then-abort case, the failing in-flight store in discard(), a failing persist/lock release on both the clean and the unwinding path, an in-flight pack joined with a dropped index, and the new flush() assertion. The lock checks reopen the repository with an exclusive lock instead of asserting on self.lock, which close() used to reset either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem
Repository.close()asserts that the PackWriter has no unflushed chunks. When a command aborts with chunks still buffered, thewith repository:unwind callsclose()and that assertion raisesAssertionError, masking the original exception. Buffered chunks also leaveF_PENDINGentries in the chunk index, which theclose()-time index persist asserts on (chunk ... has no pack location yet).Reproduce: open a
Repository,put()one small chunk (below the pack size limit), raise inside the with-block — theAssertionErrorreplaces the real error.This affects the paths that put chunks without a Cache:
ArchiveChecker.add_reference()—borg check --repairborg debug put-objCommands that use a
Cacheare not affected:Cacheis the innerwith, soCache.close()unwinds first and flushes the pack writer via_maybe_write_chunks_index(force=True).ArchiveChecker.finish()flushes as well since #10072, but only on the success path, so an abort before that still reachesclose()with a non-empty buffer.Fix
On exception unwind,
Repository.__exit__callsPackWriter.discard()— the abort-side counterpart toflush():update_pack_info()for a chunk id that sits in the in-flight pack and in the buffer (dropping first would delete the shared index entry,update_pack_info()would thenKeyErrormid-pack and leaveF_PENDINGleftovers for the persist to trip over — masking the original error again through a different door).close()'s assertion is unchanged: on a clean close it still catches a genuinely forgottenflush().__exit__is the only place that knows whether we are unwinding, so the discard decision lives there.All abort-time index cleanup goes through one helper,
PackWriter._drop_index_entries():add()installs a chunk's entry before buffering its piece, so pending entries never outlive a dropped index. (flush()asserts the index is loaded while chunks are outstanding, so on the normal path that caller bug fails loudly instead of silently leaving a stored pack unrecorded.)ChunkIndex.add()now keeps an existing resolved pack location instead of resetting the entry to pending: a re-put of an already stored chunk stays readable, an abort cannot lose it, and a successfulflush()overwrites the entry with the new copy's location viaupdate_pack_info(), as before._apply_outcome()gets the same no-index guard, so joining a pack store while aborting cannot trigger an index rebuild either.close()itself could still mask the original error a few lines further down: the close-time chunk index persist and the lock release both do store I/O, which fails again exactly when the abort was a store failure.close()now takesaborting=Truefrom__exit__(and from__enter__'s failure path) when an exception is unwinding: only then are these errors logged instead of raised (the persisted index is only a cache and gets rebuilt; an unreleasable lock goes stale eventually). On a clean close a failing persist or lock release raises, as on master, so it is not silently ignored. The lock release and store close run in afinallyblock, so even a raising close — including the unflushed-chunks assertion — cannot leak the exclusive lock anymore.Also: dropped buffer pieces are logged (debug level), the abort semantics are documented in
docs/internals/packs.rst, andChunkIndex.is_pending/F_PENDINGwere added to the.pyistub.Tests
test_exception_unwind_drops_buffered_chunks— the base repro: asserts the originalValueError(notAssertionError) propagates, and that after reopening the buffered chunk is neither in the chunk index nor readable.test_exception_unwind_records_inflight_pack_drops_buffer— unwind with one pack in flight and more chunks buffered, including a chunk id that is in both: the stored pack's chunks stay recorded and readable, only the never-stored chunk dies. Fails without the join-before-drop ordering.test_reput_stored_chunk_survives_unwind— re-put of an already stored chunk, then abort: the chunk stays in the index and readable. Fails without theadd()change.test_exception_unwind_survives_failing_index_persist/test_exception_unwind_survives_failing_lock_release/test_exception_unwind_survives_failing_inflight_store— the store dies mid-operation: the close-time persist / lock release / in-flight pack store fails, is logged, the original exception propagates, and the lock is still released (checked by reopening with an exclusive lock).test_clean_close_raises_on_failing_index_persist/test_clean_close_raises_on_failing_lock_release— on a clean close the same failures raise; the lock is still released before the persist error propagates.test_exception_unwind_does_not_rebuild_dropped_chunk_index/test_exception_unwind_with_inflight_pack_and_dropped_index— buffered chunks or an in-flight pack plus a dropped index: no chunk-index rebuild happens during unwind and the original exception survives.test_flush_after_invalidate_asserts— flush() with outstanding chunks afterinvalidate_chunk_index()is a caller bug and fails loudly.test_close_with_unflushed_chunks_asserts— documents that a clean exit with buffered chunks still trips the assertion, and that even that failing close releases the lock and closes the store.test_chunkindex_add_keeps_resolved_location— pins the newadd()semantics at the ChunkIndex level.Full test suite passes, ruff clean.
🤖 Generated with Claude Code