Skip to content

create: overlap pack store and build of next pack, fixes #9988 - #10012

Draft
ThomasWaldmann wants to merge 2 commits into
masterfrom
pack-overlap-9988
Draft

create: overlap pack store and build of next pack, fixes #9988#10012
ThomasWaldmann wants to merge 2 commits into
masterfrom
pack-overlap-9988

Conversation

@ThomasWaldmann

Copy link
Copy Markdown
Member

Fixes #9988.

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 — with a simulated slow store (0.2s/pack) and balanced assembly work, the pipelining measured 1.75x end to end.

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 (borg2: does borg persist UNKNOWN pack ids in periodic index writes? #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 create: do not wrap repository writes in backup_io("read") (silent data loss on ENOSPC) #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 (borgbackup/borgstore#206 / borgbackup/borgstore#207). The dependency is bumped accordingly and now also pulls the blake3 extra.

BORG_PACK_ASYNC=no disables the store-thread (debugging aid, undocumented like the other BORG_PACK_* tuning knobs).

Tests: 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. Full local suite green (repository/cache/archiver), plus end-to-end create/check/extract/compact runs in both modes with tiny packs.

Note (pre-existing, not addressed here): on master, an abort with chunks still buffered makes close()'s "PackWriter has unflushed chunks" assert fire during unwind, masking the original exception — that happens with and without this PR and deserves its own fix.

(implemented by Claude, reviewed by TW)

🤖 Generated with Claude Code

Comment thread src/borg/repository.py Dismissed
@ThomasWaldmann
ThomasWaldmann marked this pull request as draft August 2, 2026 14:17
@ThomasWaldmann

Copy link
Copy Markdown
Member Author

Real-world benchmark of the store-thread overlap: borg create of the same data in sync (BORG_PACK_ASYNC=no) vs async mode, one run at a time on an otherwise idle machine (M-series Mac, local repo), slow storage emulated via borgstore's BORGSTORE_LATENCY / BORGSTORE_BANDWIDTH (borgstore 0.6.0 holds its op lock during the emulated delay, so it behaves like a genuinely slow backend toward the store-thread).

Data: unique random bytes interleaved 1:1 with zeros (≈2:1 lz4-compressible, no dedup effects), fresh repo per run, aes256-ocb, default 50 MB packs. "Assembly A" is the wall time of the identical create with no emulation (chunking, id-hashing, compressing, encrypting, item processing).

input data stored emulated store assembly A (no emulation) sync async speedup
1 GiB, 262,144 x 4 KiB files 577 MiB 3 MB/s + 100 ms 40.9 s 254.2 s 220.9 s 1.15x
10 GiB, 160 x 64 MiB files 5142 MiB 100 MB/s + 30 ms 26.6 s 95.7 s 64.0 s 1.50x

Why these numbers: the pipeline turns assembly + store into max(assembly, store) -- async wall time lands on whichever side is larger, hiding the smaller one.

  • Row 1 (many tiny files, slow uplink): store dominates -- S = 577 MiB / 3 MB/s + per-op latency, roughly 205 s. Sync pays A + S (measured 254 s); async pays max(A, S) plus a small remainder (measured 221 s), i.e. it hid ~33 s of the 41 s of per-file assembly overhead behind the upload. The ~15 s above the ideal floor is main-thread store touches (periodic lock refresh, chunks-index write) queueing behind an in-flight pack store that holds the store for ~17 s at this bandwidth -- realistic for any genuinely slow backend.
  • Row 2 (bulk data, fast storage): the two sides are close to balanced -- S is about 5142 MiB / 100 MB/s + ~104 pack stores x 30 ms, roughly 60 s, vs A = 26.6 s. Async lands almost exactly on the store floor (64 s): assembly is hidden completely. That the measured speedup (1.50x) even exceeds the naive (A+S)/max(A,S) = 1.44x is because the sync run pays some extra interleaving overhead per pack boundary that the pipelined run avoids.

Upper bound for reference: a synthetic micro-benchmark with perfectly balanced simulated stages (0.2 s store per 1 MB pack vs matching per-chunk assembly cost) measures 1.75x, close to the theoretical 2x for one pack in flight.

Summary: the gain ranges from ~nothing (very slow uplink + borg's fast assembly path, where there is simply little to hide) up to ~1.5x when assembly and store are comparable -- and async never measured slower than sync in any run.

(benchmarks by Claude, reviewed by TW)

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.
windows: 1.0.9 is broken
py315: 1.0.8 is broken
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.

borg2 create: overlap pack store and build of next pack?

1 participant