Skip to content

Repack chunks index into entry-count-bounded fragments#9846

Open
ThomasWaldmann wants to merge 4 commits into
borgbackup:masterfrom
ThomasWaldmann:medium-sized-index
Open

Repack chunks index into entry-count-bounded fragments#9846
ThomasWaldmann wants to merge 4 commits into
borgbackup:masterfrom
ThomasWaldmann:medium-sized-index

Conversation

@ThomasWaldmann

@ThomasWaldmann ThomasWaldmann commented Jul 2, 2026

Copy link
Copy Markdown
Member

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:

  • a single index for a large repo can be huge; writing/reading it is all-or-nothing;
  • deleting every fragment and replacing it with one object is hostile to a future borgstore writethrough cache on 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 most MAX entries (streamed, so memory stays bounded), collecting all new hashes so delete_other/delete_these never 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).
  • Wired 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 (~CHUNKINDEX_ENTRY_SIZE bytes/entry), so fragments are classified without loading them.
  • Deletion of superseded fragments is gated on the replacement fragments being present in the repo (their content hashes), not on a fresh upload having happened. Combined with repack_chunkindex() using force_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_repo now splits the incremental path too (not just full rewrites), so even a huge initial backup's single incremental fragment stays bounded by MAX.

Not in scope

Enabling the index/ writethrough cache — deferred.

Tests

New unit tests in cache_test.py cover: full-write and incremental-write splitting, the deferred-merge triggers (defer / seal-at-MIN / cap-forced), sealed-fragment survival across a repack, identical reconstruction via build_chunkindex_from_repo, an end-to-end multi-session test confirming close() 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

@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.91919% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.14%. Comparing base (5aaa2dd) to head (c4a62b4).
⚠️ Report is 9 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/borg/cache.py 91.57% 5 Missing and 3 partials ⚠️
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.
📢 Have feedback on the report? Share it here.

@ThomasWaldmann

Copy link
Copy Markdown
Member Author

@mr-raj12 please review ^^^

@mr-raj12

mr-raj12 commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

@mr-raj12 please review ^^^

Network outage, give me EOD's time

ThomasWaldmann and others added 3 commits July 5, 2026 01:11
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants