Skip to content

borg2 check: overview / tracking ticket #9998

Open

Description

@ThomasWaldmann

This ticket collects all cmd: check tickets into one place: a summary of how borg2 check
works, plus a checkbox list of the concrete work items. Ticked items are in master (PR or commit
linked); unticked items are still to do (open PR linked where one exists).

Status as of master ab0e8e9ce (2026-10-05).

Open tickets: #9825 #2397 #10479 #10410 #10397, #6584 (b27).
Closed tickets: #9696 #9898 #9901 #9218 #9443 #8009 #7893 #8466 #8476 #8478 #10026 #10042 #10434 #10364
#10122 #9897 #9411 #8559 #8297 #1931 #7062 #8517 #8518 #2398.

How borg2 check works

What changed vs. borg 1.x

borg 1.x check had a repository part (crc32-check all segment entries, always rebuild the
repository index, run server-side for ssh repos) and an archives part (read all archives, check
chunk presence, optionally --verify-data). Much of that design no longer applies:

  • A borg2 repository is a borgstore store. There is no borg process on the server side
    (except for ssh:///rest: served by borg serve --rest), so "run the check on the server"
    is replaced by content-addressed verification.
  • Pack files (packs/<id>) and chunk index fragments (index/<id>) are named by the store hash
    (blake3, name content-addressed objects by blake3 instead of sha256 #10363) of their content. So "is this object intact?" is simply
    Store.hash(name) == name. The REST backend computes the hash server-side, so a full
    repository check of a remote repo transfers no pack data. The store hash detects accidental
    corruption, not tampering — that is what the AEAD authentication (key needed) is for.
  • The chunks index (chunk id → pack id, offset, size) lives in the repository and is the
    authoritative record of which chunks exist and where.
  • There is no chunks_healthy anymore (borg2: reconsider chunks_healthy approach #8559): an item's chunks list always holds the
    correct chunk ids, even when chunks are missing. Missing chunks are handled dynamically at
    read time (zeros or error), and if a later borg create re-produces a missing chunk, all
    old archives referencing it are healed instantly — no repeated check --repair runs.

Phase 1: repository check (cheap, read-only)

  1. Verify all index/ fragments via Store.hash (small, always checked in full).
  2. If (and only if) the index is intact, cross-check that every pack referenced by the index is
    present in packs/ (borg2 check: vanished packs? #9898), then verify all packs/ via Store.hash.
  3. Pack results (intact with a timestamp, or corrupt) are persisted in cache/checked-packs
    (borg2 check: move from marker to set-of-checked-packs #9897, check: keep pack check results, add --max-age to reuse them #9925). Partial checks (--max-duration) resume from that set, with periodic
    checkpoints; --max-age skips packs with a fresh intact result. Packs recorded as corrupt
    are always re-verified, never skipped, and keep failing the check until repaired or gone.
  4. The read-only check never rebuilds the index: reading every pack is far too slow and
    expensive for a routine check. A corrupt index is reported and left for repair.

cache/checked-packs and the index fragments are stored in the key's envelope, so borg check
always needs the key (#10385); the passphrase is asked up front (#1931). This phase is designed
to be a routine, cron-able job: bounded time, meaningful exit code, and its findings (corrupt
pack ids) are consumed by a later repair instead of re-scanning everything.

Phase 2: archives check (client-side)

  1. Check the manifest (rebuild if missing/corrupt), the archives directory, each selected
    archive's metadata object and item metadata stream (robust resync on damage), and each
    item's file-content chunk presence against the (verified) chunks index.
  2. --verify-data: additionally read, decrypt (AEAD-authenticate) and decompress every
    object — full cryptographic verification, detects malicious tampering too (for encrypted
    repos, against attackers without the key). Reads pack by pack, each pack's chunks in offset
    order (ChunkIndex.iter_packs()), so each pack is fetched once.
  3. --find-lost-archives: scan for archive metadata without an archives directory entry; with
    --repair, recreate the entry (possible until borg compact removes the data). Combined
    with --verify-data, it reuses the archive metadata ids found by the verify pass.

A normal (non-repair) archives check trusts the hash-verified stored index and shares one
chunks index with the repository phase; it does not rebuild it (#8476, #10368).

Repair (borg check --repair)

The command split was decided in #10026: repair stays under borg check --repair.

Design goals

Work items

Repository check (read-only)

Archives check

Repair

Accepted for now: check --repair rebuilds the index from all packs once per run, even if the
stored index is intact — the full walk is what finds archives that reference chunks in missing
packs (#8476).

Reporting & UX

Done / resolved by the borg2 design (no action needed)

Ticket overview

Ticket Topic State
#9696 check redesign umbrella done (closed); remaining items tracked here
#10026 repository repair (index + pack) done
#8466 check --repair improvements done
#8476 avoid unnecessary index rebuild done
#8478 --verify-data + --find-lost-archives done (PR #10447)
#9898 vanished packs done (presence only, PR #10069)
#9901 index rebuild trusts pack headers done (PR #10369)
#9218 missing-chunk reporting done (PR #9965)
#7893 Ctrl-C done (PRs #9966, #10382)
#9443 progress done (PRs #9794, #10458)
#8009 progress counters borg1 wontfix; borg2 covered by #9443
#9897 set-of-checked-packs done (bc8b3c07)
#9411 check --format done (d7ffe748)
#2397 summarize findings/repairs open, PR #9891
#9825 dealing with repo corruption open (docs + acknowledge mechanism)
#10479 unauthenticatable corrupt pack never removed open
#10410 compact makes a corrupt pack pass check open
#10397 corrupt store cache entry treated as repo corruption open, PR #10467
#6584 repair from related repos open (b27)

made by Claude Fable 5, status updated 2026-10-05 by Claude Opus 5.5

Activity

  1. added this to the 2.0.0b23 milestone on Aug 1, 2026
  2. ThomasWaldmann commented on Aug 1, 2026

    @ThomasWaldmann
    MemberAuthor

    @mr-raj12 Please check this manually, maybe it is useful for your ongoing work.

  3. ThomasWaldmann commented on Aug 14, 2026

    @ThomasWaldmann
    MemberAuthor

    --verify-data needs checking: does it process data in pack order? it must not process chunks in random index order.

  4. mr-raj12 commented on Sep 16, 2026

    @mr-raj12
    Contributor

    @ThomasWaldmann went through the work items against current master (d7095f9).

    Can be ticked:

    Stale text:

    In progress:

    --verify-data order: no, it reads in chunk id (hash table) order, one repository.get() per chunk:

    • verify_data() iterates self.chunks.iteritems() (archive.py:2381), which is borghash bucket order (HashTable.pyx:273), i.e. random relative to pack placement.
    • get() does a range read and doesn't cache the pack (repository.py:1485-1487); only get_many(read_data=True) fills the 3-entry pack cache (repository.py:814, 1525).

    On a 3218 pack / 51218 chunk repo that is 51218 range reads instead of 3218 pack reads. Just switching to get_many() would be worse (whole pack load per chunk with a 3-entry LRU), so it needs: group by pack_id, sort by offset, then get_many(). The loop doesn't modify the index, so reordering is safe.

    Question is memory: a global sort needs ~68 bytes/chunk extra (~6.8 GB at 100M chunks). Using iteritems(prefix_bits=k, ...) and sorting per partition divides that by 2k, but each pack is then read up to 2k times. Which way do you prefer? Not measured yet, I can time both.

  5. ThomasWaldmann commented on Sep 16, 2026

    @ThomasWaldmann
    MemberAuthor

    About the ordered scan:

    Guess this can be done efficiently by:

    • using the flags in the index to mark what is done
    • scan the index for any entry that is not done yet and also for all other entries referring to same pack_id
    • verifiy the found chunks, then flag them as done
    • repeat until all is flagged done

    One scan per pack is cheap (it is all in memory), no additional memory needs.

    Note: this pack-ordered scan should be a generally available helper, we might need that for other purposes also.

  6. mr-raj12 commented on Sep 17, 2026

    @mr-raj12
    Contributor

    Measured the per-pack rescan before building it: HashTable.items() can only filter on the key prefix, so matching pack_id visits every entry in Python on every pass — 3218 packs / 51218 chunks costs 5.1 min CPU (that whole --repair --verify-data run is 18.4 s today), and 20k packs / 500k chunks (~1 TB) about 3 h.

    One pass per batch of B packs keeps your memory property and makes it a knob: B=256 measures 1.2 s / 43 s with ~1 MB of ids in flight regardless of repo size, against 0.09 s / 0.8 s but 177 bytes/chunk (17.7 GB at 100M chunks) for the global sort I asked about.

    The flags need no decision: write_chunkindex_to_repo zeroes flags before serializing and analyze_cmd already treats M_USER bits 3..23 as ephemeral scratch on the shared index, so a done-bit cannot survive an abort — I'll take a bit there.

    For the helper I'd add ChunkIndex.iter_packs(batch=...) in hashindex.pyx, yielding offset-sorted ids per pack, with compact's two group-by-pack scans (compact_cmd.py:300, :400) as the second consumer. OK with the batch knob, and a preference for its default?

  7. ThomasWaldmann commented on Sep 17, 2026

    @ThomasWaldmann
    MemberAuthor

    First paragraph doesn't sound right. Also, in practice, performance will depend a lot on store latency and throughput, caching, etc.

    Checking for a batch of pack IDs when doing a pass over the index sounds good. After implementing that, measurements can be done for misc batch sizes.

    Decide: give a specific set of pack ids as batch to the cython code or just give the target batch size and do all in the cython code.

  8. ThomasWaldmann commented on Sep 17, 2026

    @ThomasWaldmann
    MemberAuthor

    Check again:

  9. mr-raj12 commented on Sep 22, 2026

    @mr-raj12
    Contributor

    right, the first paragraph was wrong. I timed a Python loop over iteritems(), so the numbers are mostly per-entry object overhead, not the scan itself. The 3 h was extrapolated, and the 177 bytes/chunk were Python objects too. Please ignore them.

    On your question: I'd pass only the batch size and do the rest in Cython. Each pass over the index collects the entries of the current batch of packs and also picks the packs for the next batch, so it is one pass per batch. Entries are sorted by offset within each pack and flagged done after they are yielded. The done flag gets its own constant in ChunkIndex (analyze already uses bits 3 and 4) and is cleared at the end.

    Compact is a weaker fit than I said: only its second scan (compact_cmd.py:400) could use it.

    The scan needs borghash internals through its .pxd. Is that OK in hashindex.pyx, or would you rather have it in borghash?

    Once it's implemented I'll measure a few batch sizes, also against a store with latency.

    #7893: the remaining part is in #10382.

  10. ThomasWaldmann commented on Sep 22, 2026

    @ThomasWaldmann
    MemberAuthor

    I don't think it should use the pxd or access internals. Also, this is quite borg-specific while borghash should be general purpose. What internals does it need?

  11. mr-raj12 commented on Sep 22, 2026

    @mr-raj12
    Contributor

    The internals were HashTableNT.inner plus table, capacity, values and vsize from HashTable.pxd. Per entry the scan only wants the flags (value offset 0) and the pack id (value offset 8), and items() builds a key bytes, a value bytes, a tuple and a namedtuple for every entry to hand them over. That was the only reason for it. Dropping it.

    k_to_idx() / idx_to_k() do the same job with the public API: hold one uint64 per chunk, obj_offset << 32 | kv_idx, instead of the 32 byte id. 15 bytes per chunk in flight instead of 176.

    At that size the done flags are not needed either. Partition the packs by the leading bits of the pack id and make one pass per partition. A pack falls into exactly one partition, so it is still read once, the pass count is known before the first pass, and the index is not written at all: nothing to clear on Ctrl-C or on an exception, no collision with the scratch bits analyze uses, and it does not matter that the checker and the repository share the index since #10368. It is what write_chunkindex_to_repo() does on the key space (#9886), on the pack id instead. Pack ids are store hashes, so the partitions come out about even.

    Measured with a synthetic index of 500k chunks in 20k packs, complete pack-ordered iteration including the per-pack sort by offset. Index side only, nothing read from a store:

    passes time peak in flight
    1 0.74 s 7.1 MiB
    2 1.03 s 3.6 MiB
    4 1.67 s 1.8 MiB
    16 5.28 s 0.5 MiB

    2M chunks in 80k packs: 3.24 s and 28.8 MiB in one pass, 6.89 s and 7.2 MiB in four. Linear in both.

    Script and full output: https://gist.github.com/mr-raj12/4404d89f09b5d5135ae2c90111d91032

    Each extra pass costs one scan of the index, 600 ns per entry over ht.items(). Over ChunkIndex.iteritems() it is 1340 ns, because hide_system_flags() builds a second namedtuple per entry. The helper does not need the flags, so it reads ht.items(). That factor of two is there for every iteritems() caller, may be worth its own look.

    So the argument is a memory budget rather than a pack count: ChunkIndex.iter_packs(max_entries=...) derives the pass count from it, the way CHUNKINDEX_FRAGMENT_ENTRIES_MAX derives prefix_bits. Still your second option, one number in and the helper does the rest, only without the flag walk. compact's second scan wants the other form, the entries of a given set of pack ids in one pass, so iter_packs(pack_ids=...) next to it.

    One thing to document: the index must not be modified while iterating, because a delete zeroes the key and idx_to_k() would then return zeros instead of raising.

    OK with the budget as the argument? Then I implement it with a default around 1M entries in flight (~16 MiB) and measure real repos afterwards, local and with store latency.

  12. ThomasWaldmann commented on Sep 22, 2026

    @ThomasWaldmann
    MemberAuthor

    Yes, giving it a memory budget and doing the partitioning as proposed is fine. We already do a similar partitioning when saving the index fragments.

  13. mr-raj12 commented on Sep 22, 2026

    @mr-raj12
    Contributor

    Side finding from that comparison, not part of this issue: items() builds the 32 byte key and the namedtuple for every entry, also for callers that read one field. new_count walks the whole index only for flags and pays for both, and iteritems(only_new=True) does the same for the entries it skips.

    An iterator that yields only the fields the caller asks for would take that cost off them.

    Shall I raise a separate issue for it?

  14. ThomasWaldmann commented on Sep 22, 2026

    @ThomasWaldmann
    MemberAuthor

    You can file a borgstore issue for it, but I think we'll delay optimizing on that level until we really would need it. The api right now is rather simple, that is also worth something.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions