Skip to content

connected: add incremental connectivity check - #2211

Open
spkrka wants to merge 2 commits into
gitgitgadget:masterfrom
spkrka:tree-diff-connectivity-v1-clean
Open

connected: add incremental connectivity check#2211
spkrka wants to merge 2 commits 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 mode for the connectivity
check, gated behind transfer.connectivityCheck=incremental
(no expected changes unless you opt in).

The intent is to solve the problem of the connectivity check
slowing down as the number of reachable objects from the
boundary grows.

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/

Design

The verifier runs inside the same rev-list subprocess that
check_connected() already spawns, triggered by a new internal
flag --verify-trees-incremental. After get_revision() collects
the incoming commits, the verifier processes them in topological
order (ancestors before descendants).

The idea is to keep a set of trusted objects, shared across the
incoming commits, that grows over time. We visit the new/untrusted
commit trees and do a comparison walk over the trees of their
parents. Entries discovered on the trusted parent side are
remembered as trusted, which lets later verification skip
matching objects and avoid descending into unchanged subtrees.

More algorithmic details are in
Documentation/technical/connectivity-check.adoc.

Benchmarks

I'll just mention a short summary here, to avoid repeating
what's already in the commit message. The incremental mode is
faster than the full mode when there are few commits to verify
and when the active object tree is large. In the happy case,
the work tracks the changed paths and their comparison trees
rather than the full reachable object closure, which
substantially reduces the dependence on total repository size.
I've seen speedups up to around 20x for the synthetic perf
tests.

Running against a large real-world repo (3.4 GB boundary
closure), the numbers are more dramatic. All timings use
rev-list directly with --not HEAD
N, isolating the tree
verification cost from the boundary-finding cost:

commits    full  incr.  speedup   full RSS  incr. RSS
      1    1.9s  0.01s    190x      3.4 GB     14 MB
     10    1.9s  0.04s     48x      3.4 GB    125 MB
    100    1.9s  0.37s      5x      3.4 GB    1.1 GB

The full mode takes ~1.9s regardless of commit count because
it is dominated by walking the boundary closure. Incremental
scales with the number of incoming commits and the paths they
touch. Memory follows the same pattern: incremental uses a
fraction of the full mode's RSS for small pushes, converging
only when many commits are verified.

There are also regression cases in the synthetic fixtures.
With long incoming histories, the extra parent-tree scans
accumulate; in the synthetic fixture incremental is about
1.5x slower at 10000 commits. Per-commit changes have less
impact than expected: even when every directory is touched,
incremental remains competitive; bypassing the object cache
for tree reads likely helps here.

I cannot establish how common these regression cases are. In
the cases I have tested, however, the regression has remained
modest; I have not been able to provoke a substantially larger
slowdown. My feeling is that this is an acceptable tradeoff
behind the opt-in config, since the target case (small pushes
to large repos) sees the largest speedup, while the regression
appears with long incoming histories.

A safety net for this regression could be to dynamically disable
the incremental mode if the number of incoming commits is too
large, but this is left out of the initial version to avoid
overly speculative code.

Deepening fetches currently fall back to the full check because
the full check omits --not --all for deepening -- there is no
existing-reference boundary at which the walk can stop. An
incremental approach is possible here too -- using the old
shallow roots as the trusted boundary and walking the deepened ancestry forward -- but that
is a separate change and left for future work. Deepening is
also less common than regular fetch and receive-pack, where the
speedup matters most.

Test coverage

Most correctness cases in t5412-connectivity-check.sh are run
in both full and incremental modes to check semantic equivalence.
Selected cases additionally assert trace2 tree/blob counts for
the incremental mode, to verify that unchanged portions of the
object graph are actually skipped. It covers:

  • Corruption detection: missing blobs, missing trees, type
    mismatches, malformed trees (unparseable, mid-tree
    corruption)
  • Tree optimization: trace2 assertions confirm unchanged
    subtrees are skipped, subtree moves, merge parent boundaries
  • Root commits (no parents -- verifies full tree closure)
  • Partial clones: missing promised blobs, missing promised
    trees, verification of local commits
  • Replacement objects (with and without GIT_NO_REPLACE_OBJECTS)
  • Shallow boundaries
  • Deepening fetches (falls back to full check)
  • Integration: real push, fetch, and clone

Most tests call git rev-list directly with the appropriate
flags; integration tests exercise the full check_connected()
path through push, fetch, and clone.

Alternatives considered

My first prototype ran the verifier in-process inside
connected.c. This required a second rev-list subprocess just
for boundary finding, _nofetch variants of several
object-reading functions to prevent lazy fetches in partial
clones, explicit shallow-file plumbing, and careful avoidance
of die() in all code paths reachable from the verifier. The
result worked but was fragile and touched many files.

Moving the verifier into the rev-list subprocess eliminated
all of those problems: in partial clones the existing
--exclude-promisor-objects handling already disables lazy
fetching, die() is isolated by the process boundary, shallow
and replacement semantics are established before the verifier
runs, and error routing comes for free via stderr.

So while I liked the idea of being less reliant on checking within
a subprocess, making that work ended up being a lot more complex.

Next steps

This series only addresses tree verification; the other
significant cost is finding the commit boundary, especially for
repos with many refs. I already have some prototypes for
optimizing that too, and if this ends up landing, that would be
something I would start polishing up.

Thanks,
Kristofer

cc: Kristofer Karlsson krka@spotify.com

@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 9 times, most recently from 0d19d97 to 3f4473e Compare September 11, 2026 12:35
@spkrka

spkrka commented Sep 11, 2026

Copy link
Copy Markdown
Author

/cc Patrick Steinhardt ps@pks.im

@spkrka

spkrka commented Sep 11, 2026

Copy link
Copy Markdown
Author

/cc Jeff King <peff@peff.net

@gitgitgadget

gitgitgadget Bot commented Sep 11, 2026

Copy link
Copy Markdown

User Patrick Steinhardt <ps@pks.im> has been added to the cc: list.

@spkrka

spkrka commented Sep 11, 2026

Copy link
Copy Markdown
Author

/cc Elijah Newren newren@gmail.com

@gitgitgadget

gitgitgadget Bot commented Sep 11, 2026

Copy link
Copy Markdown

User Jeff King <peff@peff.net> has been added to the cc: list.

@gitgitgadget

gitgitgadget Bot commented Sep 11, 2026

Copy link
Copy Markdown

User Elijah Newren <newren@gmail.com> has been added to the cc: list.

Add Documentation/technical/connectivity-check.adoc describing
the connectivity invariant and the full connectivity check.

Signed-off-by: Kristofer Karlsson <krka@spotify.com>
The full connectivity check uses rev-list to find commits
reachable from the incoming tips but not from the
already-connected side, 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.

The verifier processes incoming commits with ancestors first and
compares each new tree against its trusted parent trees.  Entries
already seen on the trusted side are skipped by OID, so unchanged
subtrees need not remain at the same path to be recognized.
Changed subtrees are recursively compared against same-path parent
subtrees, and new subtrees without a comparison base are verified
from scratch.

See Documentation/technical/connectivity-check.adoc for the trust
invariants and detailed algorithm.

The incremental check runs as an internal
--verify-trees-incremental mode in rev-list.  After the normal
revision walk identifies the incoming commits, the verifier
consumes them before the usual object traversal; any pending
non-commit tips are still handled by the existing traversal.

Partial-clone semantics are preserved: objects promised by a
promisor remote are accepted as connected, and on-demand fetching
is prevented by excluding promisor objects from traversal.  The
new mode is selected by transfer.connectivityCheck=incremental,
with full remaining the default.  Deepening fetches fall back to
the full check because they do not have the normal
existing-reference boundary, and traversal instead follows the
effective shallow boundary.

p5412 results (median of 3), scaling one dimension at a time.
Each fixture is a repository with a flat tree of many
directories containing 100 files each.  The incoming set is a
chain of commits on top of the existing history, with each
commit changing files in different directories round-robin.
The three axes vary the total repository tree size, the number
of incoming commits, and the number of files changed per
incoming commit.

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

    files    full  incr.  full/incr
      5K    0.01s  0.01s    1.0x
     50K    0.04s  0.01s    4.0x
    200K    0.15s  0.01s   15.0x
    800K    0.61s  0.03s   20.3x

The full mode must walk the trees of boundary commits, 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 incoming commit count (200K files, 10 files/commit):

    commits    full  incr.  full/incr
          1    0.14s  0.01s   14.0x
         10    0.15s  0.01s   15.0x
        100    0.19s  0.05s    3.8x
        500    0.32s  0.27s    1.2x
       3000    1.23s  1.52s    0.8x
       5000    1.88s  2.43s    0.8x
      10000    3.72s  5.36s    0.7x

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

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

    files/commit    full  incr.  full/incr
               1    0.14s  0.01s   14.0x
              10    0.15s  0.01s   15.0x
             100    0.16s  0.04s    4.0x
             500    0.23s  0.14s    1.6x
            1000    0.34s  0.28s    1.2x
            2000    0.70s  0.63s    1.1x

Breakeven is around 1000 files/commit.  At 2000 files/commit
(every directory touched), incremental remains slightly faster;
bypassing the object cache for tree reads likely helps here.

For small repositories both modes are fast enough that the
difference is difficult to measure reliably.

Selected peak RSS measurements from the same fixtures:

    case                        full    incr.  ratio
    200K files, 10 commits      31 MB    12 MB   0.4x
    800K files, 10 commits     110 MB    26 MB   0.2x
    200K files, 5000 commits   155 MB   151 MB   1.0x

Incremental uses substantially less memory when it can prune
most of the boundary tree walk.  In the long-history case the
two modes visit similar object sets and memory converges.

The regression cases in the CPU benchmarks are in wall-clock
time rather than memory, primarily from scanning some trees
more than once.

Signed-off-by: Kristofer Karlsson <krka@spotify.com>
@spkrka
spkrka force-pushed the tree-diff-connectivity-v1-clean branch from 3f4473e to ebe6c90 Compare September 14, 2026 08:55
@spkrka

spkrka commented Sep 14, 2026

Copy link
Copy Markdown
Author

/submit

@gitgitgadget

gitgitgadget Bot commented Sep 14, 2026

Copy link
Copy Markdown

Submitted as pull.2211.git.1789379276.gitgitgadget@gmail.com

To fetch this version into FETCH_HEAD:

git fetch https://github.com/gitgitgadget/git/ pr-2211/spkrka/tree-diff-connectivity-v1-clean-v1

To fetch this version to local tag pr-2211/spkrka/tree-diff-connectivity-v1-clean-v1:

git fetch --no-tags https://github.com/gitgitgadget/git/ tag pr-2211/spkrka/tree-diff-connectivity-v1-clean-v1

@gitgitgadget

gitgitgadget Bot commented Sep 14, 2026

Copy link
Copy Markdown

Junio C Hamano wrote on the Git mailing list (how to reply to this email):

"Kristofer Karlsson via GitGitGadget" <gitgitgadget@gmail.com>
writes:

> This series adds an incremental mode for the connectivity check, gated
> behind transfer.connectivityCheck=incremental (no expected changes unless
> you opt in).
>
> The intent is to solve the problem of the connectivity check slowing down as
> the number of reachable objects from the boundary grows.

Exciting benchmarks.

@@ -1,3 +1,23 @@
transfer.connectivityCheck::

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Junio C Hamano wrote on the Git mailing list (how to reply to this email):

"Kristofer Karlsson via GitGitGadget" <gitgitgadget@gmail.com>
writes:

> +static void verify_blob(struct repository *repo,
> +			const struct object_id *oid,
> +			struct verify_state *vs)
> +{
> +	int type;
> +
> +	if (oidset_contains(&vs->trusted_blobs, oid))
> +		return;
> +
> +	vs->blobs_checked++;
> +	type = odb_read_object_info(repo->objects, oid, NULL);
> +	if (type == OBJ_BLOB) {
> +		oidset_insert(&vs->trusted_blobs, oid);
> +		return;
> +	}
> +	if (type >= 0)
> +		die(_("object %s is a %s, not a blob"),
> +		    oid_to_hex(oid), type_name(type));
> +	if (vs->exclude_promisor_objects &&
> +	    is_promisor_object(repo, oid))
> +		return;
> +	die(_("missing blob object '%s'"), oid_to_hex(oid));
> +}

I wonder if this is_promisor_object() call comes a bit too late, as
we earlier already have called odb_read_object_info() which may have
fetched it lazily from the promisor remote?  Or do we globally
disable promisor_remote_get_direct() call somehow without having to
pass OBJECT_INFO_SKIP_FETCH_OBJECT flag?

> +static void verify_commit_tree(struct repository *repo,
> +			       struct commit *commit,
> +			       struct verify_state *vs)
> +{
> +	struct oid_array base_trees = OID_ARRAY_INIT;
> +	struct commit_list *p;
> +
> +	/*
> +	 * Parent trees are trusted: boundary parents are already
> +	 * connected, and earlier incoming parents were verified
> +	 * first due to the topological processing order.
> +	 */
> +	for (p = commit->parents; p; p = p->next) {
> +		const struct object_id *tree_oid;
> +		parse_commit_or_die(p->item);
> +		tree_oid = get_commit_tree_oid(p->item);
> +		tree_map_add(vs->trees, tree_oid, TREE_TRUSTED);
> +		oid_array_append(&base_trees, tree_oid);
> +	}
> +
> +	verify_tree(repo, get_commit_tree_oid(commit),
> +		    &base_trees, vs, 0);
> +	oid_array_clear(&base_trees);
> +}

Do we assume that we do not have to deal with repository corruption
in any graceful way?  I am just wondering what happens when
get_commit_tree_oid() yields NULL after parse_commit_or_die() finds
p->item is a valid-looking commit object but the tree within it is
not, and we end up passing NULL to tree_map_add(), perhaps?

The same potential issue may exist in the get_commit_tree_oid() call
outside the look at the end on the incoming commit's tree.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kristofer Karlsson wrote on the Git mailing list (how to reply to this email):

On Mon, 14 Sept 2026 at 17:26, Junio C Hamano <gitster@pobox.com> wrote:
>
> "Kristofer Karlsson via GitGitGadget" <gitgitgadget@gmail.com>
> writes:
>
> I wonder if this is_promisor_object() call comes a bit too late, as
> we earlier already have called odb_read_object_info() which may have
> fetched it lazily from the promisor remote?  Or do we globally
> disable promisor_remote_get_direct() call somehow without having to
> pass OBJECT_INFO_SKIP_FETCH_OBJECT flag?

Yes, I think it's safe due to the following mechanism:

1. If promisors exist, the connectivity-check will invoke
   rev-list with --exclude-promisor-objects.
2. rev-list in turn sets repo->fetch_if_missing = 0 on startup.
3. Then the odb read goes down into do_oid_object_info_extended()
   which respects that flag.

However, my paranoia kicked in so I re-ran my test for this,
after adding some temporary code inside
verify_commits_incremental():

    repo->fetch_if_missing = 1;

And fortunately, one of the tests failed as expected.

    Exactly 1 failure out of 62 tests: test 53
      "incremental: verifies new subtree when parent subtree is
       promised".

And the relevant assertion is this one:

    test_must_fail env GIT_NO_LAZY_FETCH=1 \
        git cat-file -e "$parent_subtree"

which ensures that the object was never fetched.

However, the test only catches this scenario for trees,
not blobs -- that's an oversight, I will add a matching
test for blobs too.

I think the code technically works as-is, but I could also try
to rewrite the code to stop depending on odb_read_object_info()
and instead use odb_read_object_info_extended() which allows
me to pass the flags.  That gives us belts and suspenders, which
may be nicer here.

> Do we assume that we do not have to deal with repository corruption
> in any graceful way?  I am just wondering what happens when
> get_commit_tree_oid() yields NULL after parse_commit_or_die() finds
> p->item is a valid-looking commit object but the tree within it is
> not, and we end up passing NULL to tree_map_add(), perhaps?
>
> The same potential issue may exist in the get_commit_tree_oid() call
> outside the look at the end on the incoming commit's tree.

You're right, this is an oversight.
I think I incorrectly assumed that parse_commit_or_die()
would catch any malformed commit.

I will add a NULL check and a die()-exit at the two call sites
in verify_commit_tree()

    die(_("unable to load root tree for commit %s"),
        oid_to_hex(&commit->object.oid));

Thanks for spotting these errors,
Kristofer

@gitgitgadget

gitgitgadget Bot commented Sep 14, 2026

Copy link
Copy Markdown

User Kristofer Karlsson <krka@spotify.com> has been added to the cc: list.

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