Skip to content

packs: add an encrypted object directory at the end of each pack, for a fast index rebuild #10487

Description

@ThomasWaldmann

Problem

Rebuilding the chunk index from the packs (build_chunkindex_from_repo in src/borg/cache.py, used when there are no index/ fragments, when cache/chunkindex-invalid is present, by borg check --repair, borg compact, borg repo-compress on a corrupt fragment, ...) walks every pack with PackReader.iter_headers(). That does one store range request per object: a 49-byte header read, or a META_READ_SIZE read with a validator.

With the default 50 MB packs that is:

  • ~25 requests per pack for ~2 MiB chunks (default chunker params),
  • thousands of requests per pack for small-file data (one chunk per small file).

All of them are sequential. On a high latency backend (sftp, s3/b2, rest over ssh) a rebuild of a 1 TB repository means hundreds of thousands to millions of round trips, i.e. hours, just to read headers.

Reading packs in windows instead of per object (the TODO in iter_headers) would help without any format change, but it still reads a large part of every pack when the objects are small.

Proposal: an encrypted object directory at the end of each pack

Append to every pack:

... objects ... | encrypted_directory | directory_size (uint32le) | DIR_MAGIC (8 bytes)
  • encrypted_directory: a list of (chunk_id, obj_offset, obj_size) for all objects in the pack (optionally also the plaintext size, so a rebuilt index does not need size=0 there), stored in the key's store object envelope like the index/ fragments (Repository.key, with its own AAD), so it is encrypted and authenticated.
  • directory_size + DIR_MAGIC: a fixed-size trailer, so a reader can find the directory from the end of the pack.

The pack id stays the store hash of all pack bytes, the directory included.

Rebuilding then needs only the pack tails: store_list("packs") already returns every pack's size, so the last N bytes (e.g. 64 KiB, enough for the trailer and most directories) of up to 1000 packs can be read with one store.gather call, plus a second read only for packs with a larger directory. That is a few round trips per thousand packs instead of tens to thousands per pack.

The per-object headers stay as they are. They remain the fallback: a pack whose trailer or directory is missing, damaged or fails authentication is walked as today (including the validating resync of borg check). The directory also defines the objects of a pack exactly, which makes the gap handling in compact_pack / transform_pack simpler.

Writers that need changes

  • PackWriter._store_pieces: build the directory from the pieces it already lays out, append it.
  • transform_pack (borg repo-compress) and salvage_pack (borg check --repair): build their packs client-side, so they can use the same helper.
  • compact_pack and merge_packs (borg compact): build the new pack server side with store.defrag() from byte ranges of the old packs. They must no longer copy the old trailers (merge_packs currently copies whole pack files (pack, 0, pack_size)), and the new pack needs a recomputed directory (offsets rebased, dropped objects removed). merge_packs would build it from the source packs' directories rather than from the chunk index, so the gap objects it carries along stay listed, as a header walk would find them.

Needs: an enhanced Store.defrag in borgstore

Store.defrag(sources, ...) can only concatenate ranges of existing items. borg needs to append the client-computed, encrypted directory, its size and the magic to the defragmented content, so defrag must accept literal bytes, e.g. as a source entry or as a suffix= argument. The target name (algorithm=...) must be computed over the whole new content, suffix included.

  • The default backend implementation (gather, then store) can just append the bytes.
  • posixfs and the REST backend implement defrag themselves; the REST protocol sends the sources as JSON in the request body, so it needs a way to carry the extra bytes too (e.g. base64 in the JSON, or a binary body part).

Without this, borg compact would have to download and re-upload every pack it rewrites or merges.

Notes

  • This changes the pack format, so it needs a new repository version. borg 2 is still in beta, so no compatibility code for packs without a directory is needed.
  • This is only about rebuild speed. Moving chunk ids and per-object sizes out of the plaintext headers (they are visible to anyone who can read the storage) is a separate discussion; the directory would be the natural place for them, but that would also need a different resync strategy.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions