Skip to content

connected: add incremental connectivity check - #2211

Open
spkrka wants to merge 1 commit into
gitgitgadget:masterfrom
spkrka:tree-diff-connectivity-v1-clean
Open

connected: add incremental connectivity check#2211
spkrka wants to merge 1 commit into
gitgitgadget:masterfrom
spkrka:tree-diff-connectivity-v1-clean

Conversation

@spkrka

@spkrka spkrka commented Aug 28, 2026

Copy link
Copy Markdown

This series adds an incremental connectivity check to
check_connected(), gated behind transfer.connectivityCheck=incremental
(so no expected changes unless you opt-in to it).

It relates to the RFC I sent out earlier:

[RFC] check_connected: toward incoming-proportional cost
https://lore.kernel.org/git/CAL71e4Nf=-zCrfN7ghEVGq11irajJhtdxYZgKe0Ycux0qs1ZvQ@mail.gmail.com/

The current connectivity check delegates to rev-list, which walks
the full object closure at the connectivity boundary. On a large
private repository with ~600K reachable trees and blobs, fetching
a single new commit takes ~1.8 seconds -- almost all of it spent
walking objects unrelated to the incoming change.

I think this is worth optimizing, since the connectivity check
is on the critical path in normal fetch and receive-pack flows.

The core idea here is to compare each incoming commit's tree
against its parent trees, descending only into entries that
differ. This shifts much of the tree-verification cost toward
changed paths rather than the full reachable closure. On the
same repository, the same fetch drops to ~140 ms (12.9x faster).
On linux.git it drops from 226 ms to 97 ms (2.3x).

Synthetic benchmarks (p5412) show 2.5x-19.3x speedups for
single-commit checks across tree sizes from 50K to 800K
files, with breakeven around 500 commits in the 200K-file
fixture and an observed regression of up to ~1.7x in the long
linear-chain cases tested. The tradeoff comes from rereading
parent trees as comparison bases. (Patch 2 contains the full
table and analysis.)

The series:

  1. connected: extract get_self_contained_pack() helper

    Pure refactor for reuse by the incremental path.

  2. connected: add incremental connectivity check

    Core implementation, tests, and performance tests. Falls back
    to rev-list for shallow boundaries, partial clones, replacement
    objects, and deepening fetches.

  3. connected: enable incremental check with shallow boundaries

    Adds shallow boundary handling and removes the shallow guard.

  4. connected: enable incremental check for partial clones

    Adds promisor object handling and removes the promisor guard.
    Missing promisor objects are accepted without triggering lazy
    fetches.

The series includes a test-tool helper (test-check-connected) that
exercises check_connected() directly, making it easier to test
specific object graph shapes and failure modes without going through
the full fetch/push machinery. The functional tests cover happy
paths, corrupt objects, missing objects, shallow boundaries, and
partial clones. There is also a perf test (p5412), though it may
be too specific for this series -- happy to drop it if a more
general regression test would be preferred.

Open questions

Some questions surfaced while building this:

Type confusion: the existing rev-list connectivity check does not
always detect a tree entry whose mode disagrees with the actual
object type. The incremental verifier retains this same
behavior -- it checks existence but not type correctness for
blobs. Should we tighten type checking in a follow-up covering
both the rev-list and incremental paths, or is the current
behavior sufficient as-is?

Replacement objects: the existing rev-list path follows replacement
refs, while git-prune explicitly disables them. The incremental
path falls back to rev-list when replacement objects are active.
Should connectivity checking operate on the underlying object graph
rather than the replaced one? Even the rev-list behavior here may
be worth reconsidering.

The tree verifier introduces private _nofetch variants of tree
reading and tag peeling to avoid triggering lazy promisor fetches
during verification. This felt a bit awkward and introduces some
code duplication, but trying to make the existing functions support
nofetch started to become a can of worms so this at least keeps
the change smaller than it otherwise would have been. Is this
something that should be refactored first or is keeping the
duplication acceptable?

I am not sure how to reason about the regression in some edge-cases.
Perhaps it's not a problem since it's gated behind a config flag,
but that might mean the flag needs to remain permanently?
Alternatively, it would be possible to build some type of heuristic
to decide which algorithm to use based on how many commits need to
be verified, or how many files exist in the repository.
Or is a constant factor regression in some unfavorable cases an
acceptable price to pay for helping the happy cases?

Next steps

I don't want to digress too much here, but I think it's
useful to view this as one part of making connectivity-check
cost less dependent on total repository size. This series
addresses tree verification; the other substantial part is
finding the commit boundary.

In the same single-commit case on my large private repository, a
separate prototype using an in-process boundary walk reduces the
total incremental connectivity check from ~140 ms to ~14 ms.
That work is not included here; boundary discovery has its own
tradeoffs and seems better reviewed separately.

Thanks,
Kristofer

@spkrka
spkrka marked this pull request as ready for review August 28, 2026 10:15
@spkrka
spkrka force-pushed the tree-diff-connectivity-v1-clean branch 10 times, most recently from 326b1e4 to 452d6b9 Compare September 1, 2026 18:03
@spkrka
spkrka force-pushed the tree-diff-connectivity-v1-clean branch 3 times, most recently from a361b72 to f4a0fab Compare September 10, 2026 09:10
The connectivity check uses rev-list to find commits reachable
from the incoming tips but not from any local ref and then walks
their object closure.  Commit traversal stops at the connectivity
boundary, but trees and blobs reachable from that boundary still
need to be walked so they can be marked uninteresting, allocating
a struct object for each one.  On repositories where the boundary
commits have large trees, the connectivity check for small
incoming changes visits and tracks more objects than needed.

Add an alternative connectivity check that verifies incoming
commits incrementally against their parents.

Instead of traversing the full boundary closure, the new check
compares each new commit's tree with its parent trees.  Already
trusted entries are skipped, changed subtrees are descended into
recursively, and blobs are checked for existence.  This approach
thus avoids descending into untouched subtrees.

For example, consider a commit that changes one file under lib/
and also moves an unchanged subtree from src/ to dev/:

    Parent tree              New tree
    +-- src/   (aaa)         +-- dev/   (aaa)
    +-- lib/   (bbb)         +-- lib/   (ccc)
         +-- foo.c (ddd)          +-- foo.c (ddd)
         +-- bar.c (eee)          +-- bar.c (fff)

The verifier first scans the new root and collects aaa and ccc as
work items.  It then scans the parent root, publishing aaa and bbb
into the trusted sets and recording bbb as the comparison base for
ccc.

When the work list is revisited, aaa is now trusted and skipped
even though it appears at a different path.  The verifier descends
into ccc using bbb as its parent base.  Scanning bbb similarly
makes ddd and eee trusted, leaving only the new fff blob to be
checked for existence.

Thus neither the moved subtree nor any other unchanged subtree is
recursively explored; only the changed lib/ subtree is descended
into, and only the new bar.c blob needs an existence check.  The
root trees still need to be read and scanned as comparison bases.

New commits are processed with ancestors before descendants.
Parents outside the incoming commit set are on the already-connected
side of the boundary and provide the initial trusted bases.  Once
an incoming commit has been verified, its tree can in turn be used
as a trusted base for descendant commits.

The verifier distinguishes trusted trees from expanded trees.  A
trusted tree can be accepted without further verification.  An
expanded tree has additionally published its direct non-gitlink
entries into the trusted sets.  Expanded parent trees therefore
need not be read again for blob-only work, but may still be reread
when recursive verification needs same-path parent subtrees.

The implementation adds a --verify-trees-incremental flag to
rev-list, following the same pattern as --exclude-promisor-objects:
a pre-setup_revisions() scan sets the flag, the
post-setup_revisions() option loop skips it, and the main
traversal short-circuits into the incremental verifier after
collecting commits from the revision walk.

Running the verifier inside the rev-list subprocess avoids several
problems that an in-process implementation would face:

 - die() in the subprocess simply terminates it with a non-zero
   exit code, which connected.c already handles.

 - Error output routing is handled by subprocess stderr
   redirection.

The incremental verifier replaces both mark_edges_uninteresting()
and the commit-tree portion of traverse_commit_list_filtered().
After get_revision() exhausts the commit list, the subsequent
traverse_commit_list_filtered() call becomes a no-op for commits
and only processes any non-commit tips (trees, blobs) left in
revs.pending.

For partial clones, missing promisor objects (trees and blobs
promised by a promisor remote) are silently accepted during
verification instead of triggering errors.  The revision walk
receives --exclude-promisor-objects so promisor commits do not
enter the verification set.

Gate the new algorithm behind transfer.connectivityCheck=incremental,
keeping full as the default.  Fall back to the full algorithm for
deepening fetches to keep this commit easy to reason about, though
incremental mode could potentially handle them as well.

p5412 results (median of 3), scaling one dimension at a time.
Each modified file is in a different directory, with
directories chosen round-robin.

Scaling tree size (10 commits, 10 files/commit):

          files    full  incr.  full/incr
    (a)     5K    0.01s  0.01s    1.1x
    (b)    50K    0.04s  0.01s    2.2x
    (c)   200K    0.14s  0.02s    5.7x
    (d)   800K    0.60s  0.04s   14.8x

The full mode must traverse the boundary tree closure, which
grows with overall tree size.  Incremental still scans the
root trees, but avoids descending into unchanged subtrees,
so it grows much more slowly with repository size.

Scaling commit count (200K files, 10 files/commit):

        commits    full  incr.  full/incr
    (e)       1    0.14s  0.01s    7.5x
    (f)      10    0.15s  0.02s    6.8x
    (g)     100    0.16s  0.07s    2.2x
    (h)     500    0.29s  0.29s    0.9x
    (i)    3000    1.26s  1.67s    0.7x
    (j)    5000    1.95s  2.71s    0.7x
    (k)   10000    3.74s  6.02s    0.6x

With many commits the per-commit overhead of scanning both
the new and parent root trees accumulates and incremental
becomes slower.  Breakeven is around 500 commits and the
ratio stabilizes near 0.6x for this fixture.

Scaling files per commit (200K files, 10 commits):

        files/commit    full  incr.  full/incr
    (l)            1    0.15s  0.02s    7.5x
    (m)           10    0.15s  0.02s    6.8x
    (n)          100    0.15s  0.04s    3.1x
    (o)          500    0.26s  0.16s    1.6x
    (p)         1000    0.36s  0.35s    1.0x
    (q)         2000    0.66s  0.79s    0.8x

Breakeven is around 1000 files/commit.  At 2000 files/commit
(every directory touched), incremental is about 1.2x slower.

At the 5K-file point (a), both modes complete in well under
25 ms and the difference is negligible.

Signed-off-by: Kristofer Karlsson <krka@spotify.com>
@spkrka
spkrka force-pushed the tree-diff-connectivity-v1-clean branch from f4a0fab to d2a8f4c Compare September 10, 2026 18:31
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.

1 participant