Repack chunks index into entry-count-bounded fragments#9846
Open
ThomasWaldmann wants to merge 4 commits into
Open
Repack chunks index into entry-count-bounded fragments#9846ThomasWaldmann wants to merge 4 commits into
ThomasWaldmann wants to merge 4 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #9846 +/- ##
==========================================
+ Coverage 85.11% 85.14% +0.03%
==========================================
Files 93 93
Lines 15408 15483 +75
Branches 2326 2338 +12
==========================================
+ Hits 13114 13183 +69
- Misses 1596 1600 +4
- Partials 698 700 +2 ☔ View full report in Codecov by Harness. |
25d1f2d to
264bc11
Compare
Member
Author
|
@mr-raj12 please review ^^^ |
Contributor
Network outage, give me EOD's time |
Replace the unconditional "collapse all index/* fragments into one all-in-one index" behavior with an entry-count-based repacking policy that keeps each fragment within [CHUNKINDEX_FRAGMENT_ENTRIES_MIN, CHUNKINDEX_FRAGMENT_ENTRIES_MAX] entries where possible. - write_chunkindex_to_repo now always splits its output into fragments of at most MAX entries (streamed, so memory stays bounded), collecting all new hashes so delete_other/delete_these never delete a fragment just written. - repack_chunkindex() merges small (< MIN) fragments, deferring until they can seal a full fragment (sum >= MIN) or too many piled up (> SMALL_FRAGMENT_CAP); fragments already in range are left untouched and immutable. - Wire repack into AdHocWithFilesCache.close() and the build_chunkindex_from_repo merge>1 branch (which no longer collapses-and-deletes everything). - list_chunkindex_fragments() estimates a fragment's entry count from its stored byte size, so fragments can be classified without loading them. This bounds both fragment size and count, and keeps large fragments stable (groundwork for a future writethrough cache on the index/ namespace, which an all-in-one consolidation would invalidate for every client of the repo). Also: Make chunk index fragment partitioning deterministic Sort the selected keys before partitioning them into fragments, so an identical set of entries always produces an identical fragment set (identical content hashes), no matter in which order the entries were inserted into the hash table. This makes writing/repacking idempotent and convergent across clients: a fragment that already exists in the repo is not stored again, and no differently-partitioned duplicates of the same entries can pile up (previously, two clients repacking the same small fragments could each produce a different - possibly sealed, thus never cleaned up - merged fragment). Sorting cost is negligible on the hot paths: incremental writes and repacks sort ~100k-200k keys (~0.02s). Only full rewrites (compact, slow rebuild) sort the whole index, where the sort is dwarfed by the rest of the operation. Also: Don't fail an already-committed backup on repack error The repack in ChunksMixin.close() runs after the archive and its incremental chunk-index fragment are durably stored, so it is optional maintenance. Catch a transient repack failure and warn instead of letting it propagate out of close() and fail the whole command; the next run repacks again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
write_chunkindex_to_repo gated deletion of superseded index fragments on whether it actually uploaded the replacement (stored_anything). Combined with repack's force_write=True, every repack re-uploaded byte-identical merged fragments; and had repack simply dropped force_write, a crash between store and delete would leave the sources undeleted forever (the re-derived fragments dedupe-skip, stored_anything stays False, deletion never runs) -- small fragments piling up and being re-merged on every run. Gate deletion on new_hashes (the fragment set making up the index just written) instead. A dedupe-skipped fragment means its content is verifiably already in the repo, so deleting the superseded fragments is safe; an empty write (new_hashes empty) still skips deletion, so we never leave the repo without an index. repack now passes force_write=False, gaining idempotence with no redundant uploads. Add a regression test covering the crash/concurrent-repack recovery case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The files cache stores each file's chunk list "compressed" to chunk-index hash-table positions, valid only while the index is unmutated. When a backup runs out of space and aborts, pending chunks are rolled back out of the index; a files-cache entry memorized earlier still points at a now-stale position, so decompress_entry's self.chunks[id] raises KeyError. This crashed close() while it was cleaning up the already-failed backup, masking the real ENOSPC error with a "Key not found" traceback. Guard the two call sites that resolve cached entries against the index, both mirroring the existing compress_entry KeyError handling in _read_files_cache: - _write_files_cache drops the unresolvable entry (the file is re-chunked next backup) instead of raising. - file_known_and_unchanged treats the file as unknown so it gets re-chunked. Reproduced on a space-limited ramdisk (borg create -> ENOSPC): the crash is gone and the genuine "No space left on device" error surfaces instead; the repo remains consistent (check ok) and recoverable (compact + backup ok). Add a regression test that memorizes a file, deletes its chunk from the index, and asserts neither a cache lookup nor the files-cache save raises. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
264bc11 to
d8ab5db
Compare
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
Replace the unconditional "collapse all
index/*fragments into one all-in-one index" behavior with an entry-count-based repacking policy that keeps each chunks-index fragment within[CHUNKINDEX_FRAGMENT_ENTRIES_MIN, CHUNKINDEX_FRAGMENT_ENTRIES_MAX]entries where possible.Why
The chunks index is stored in the repo as immutable, content-addressed
index/<sha256>fragments. Each backup appends a small incremental fragment. Several paths previously consolidated all fragments into a single object and deleted the rest, which is problematic:index/: it would invalidate the cached fragments of every client of the repo, forcing a full re-download.Bounding fragment size (upper bound) and count (lower bound), and keeping large fragments immutable/stable, is groundwork for that future caching while also making the current behavior cheaper.
How
write_chunkindex_to_repo()now always splits its output into fragments of at mostMAXentries (streamed, so memory stays bounded), collecting all new hashes sodelete_other/delete_thesenever delete a fragment it just wrote.repack_chunkindex()merges small (< MIN) fragments, using deferred merging: it only acts when the small fragments can seal a full fragment (sum>= MIN) or too many have piled up (> CHUNKINDEX_SMALL_FRAGMENT_CAP). Fragments already in range are left untouched (immutable).AdHocWithFilesCache.close()and thebuild_chunkindex_from_repomerge>1 branch (which no longer collapses-and-deletes everything).list_chunkindex_fragments()estimates a fragment's entry count from its stored byte size (~CHUNKINDEX_ENTRY_SIZEbytes/entry), so fragments are classified without loading them.repack_chunkindex()usingforce_write=False, this makes repack idempotent: a merged fragment that already exists is dedupe-skipped rather than re-uploaded, and the small sources are still deleted even if a previous repack crashed after storing but before deleting.close()also treats repack as best-effort — a transient failure warns instead of failing an already-committed backup.Note:
write_chunkindex_to_reponow splits the incremental path too (not just full rewrites), so even a huge initial backup's single incremental fragment stays bounded byMAX.Not in scope
Enabling the
index/writethrough cache — deferred.Tests
New unit tests in
cache_test.pycover: full-write and incremental-write splitting, the deferred-merge triggers (defer / seal-at-MIN / cap-forced), sealed-fragment survival across a repack, identical reconstruction viabuild_chunkindex_from_repo, an end-to-end multi-session test confirmingclose()consolidates fragments across runs, and an idempotent-repack test asserting a repack whose merged fragment already exists (dedupe-skipped) still deletes the small sources. Existing repository / compact / check / create / delete / prune suites pass.🤖 Generated with Claude Code