connected: add incremental connectivity check - #2211
Open
spkrka wants to merge 1 commit into
Open
Conversation
spkrka
marked this pull request as ready for review
August 28, 2026 10:15
spkrka
force-pushed
the
tree-diff-connectivity-v1-clean
branch
10 times, most recently
from
September 1, 2026 18:03
326b1e4 to
452d6b9
Compare
spkrka
force-pushed
the
tree-diff-connectivity-v1-clean
branch
3 times, most recently
from
September 10, 2026 09:10
a361b72 to
f4a0fab
Compare
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
force-pushed
the
tree-diff-connectivity-v1-clean
branch
from
September 10, 2026 18:31
f4a0fab to
d2a8f4c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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:
connected: extract get_self_contained_pack() helper
Pure refactor for reuse by the incremental path.
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.
connected: enable incremental check with shallow boundaries
Adds shallow boundary handling and removes the shallow guard.
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