Skip to content

Commit 3ecd788

Browse files
committed
connected: add incremental connectivity check via rev-list
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. p5413 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>
1 parent 72380a4 commit 3ecd788

9 files changed

Lines changed: 1185 additions & 4 deletions

File tree

‎Documentation/technical/connectivity-check.adoc‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,148 @@ tag, tree, or blob, that object is not part of the commit walk.
108108
These non-commit tips are verified during the tree and blob
109109
traversal that follows the commit walk within the same rev-list
110110
invocation.
111+
112+
Incremental connectivity check
113+
------------------------------
114+
115+
The incremental mode, selected by
116+
`transfer.connectivityCheck=incremental`, avoids traversing the
117+
full tree walk of the boundary commits. Instead, it verifies
118+
each incoming commit's tree against the already-trusted trees of
119+
its parent commits.
120+
121+
The same `--not --all` boundary as the full check identifies the
122+
incoming commits. The incremental mode reuses this boundary but
123+
replaces what happens afterwards: rather than walking boundary
124+
trees to mark objects uninteresting, it compares each incoming
125+
commit's tree against its parents' trees, processing commits in
126+
topological order (ancestors first).
127+
128+
Trust model
129+
~~~~~~~~~~~
130+
131+
A tree is trusted when its transitive object closure is known to
132+
be connected.
133+
134+
Every parent of an incoming commit is either on the
135+
already-connected side of the `--not --all` boundary (a boundary
136+
parent whose tree is trusted by definition) or is an earlier
137+
incoming commit that was already verified due to the topological
138+
processing order. In both cases the parent's tree is trusted, so
139+
all parent trees can be unconditionally used as comparison bases.
140+
141+
Once an incoming commit's tree has been verified, it is trusted
142+
and can serve as a comparison base for later descendants. This
143+
gives an inductive correctness argument: the base case is the
144+
trust boundary established by `--not --all`, and the inductive
145+
step is that verifying a commit extends the trusted set.
146+
147+
Tree states
148+
~~~~~~~~~~~
149+
150+
The verifier tracks tree OIDs in three states:
151+
152+
untrusted::
153+
The tree has not yet been established as connected. This
154+
is the implicit state of an OID not present in the state
155+
map.
156+
157+
trusted::
158+
The tree is known to have a connected transitive closure,
159+
but its direct entries have not yet been published into the
160+
verifier's trusted object sets.
161+
162+
expanded::
163+
The tree is trusted and its direct non-gitlink entries
164+
have also been published into the trusted object sets.
165+
166+
State transitions are monotonic: a tree may move from untrusted
167+
to trusted to expanded, but never backwards. An untrusted tree
168+
that is successfully verified goes directly to expanded.
169+
170+
Blobs require only trusted/untrusted state: a blob becomes
171+
trusted when it is found in a trusted tree or when its existence
172+
and type have been verified directly.
173+
174+
The trusted/expanded distinction is an optimization. An expanded
175+
parent does not need to be reread merely to publish entries that
176+
are already trusted, though it may still be read when same-path
177+
subtree bases are needed for recursive comparison.
178+
179+
Algorithm
180+
~~~~~~~~~
181+
182+
At a high level:
183+
184+
verify(commits):
185+
sort topologically (ancestors first)
186+
for each commit:
187+
mark parent root trees as trusted
188+
verify_tree(commit.tree, parent root trees)
189+
190+
verify_tree(tree, base_trees):
191+
if tree already trusted: return
192+
read tree, collect entries not already trusted
193+
for each available base tree:
194+
publish its entries as trusted
195+
record same-path subtrees as bases
196+
for each collected entry:
197+
if now trusted: skip
198+
if blob: verify blob connectivity and type
199+
if tree: verify_tree(entry, its recorded bases)
200+
mark tree as expanded
201+
202+
The important ordering within `verify_tree` is that entries from
203+
the new tree are collected before the base trees are scanned, but
204+
are processed only afterwards. Trust learned from any base can
205+
therefore eliminate work before recursive verification begins.
206+
207+
Same-path parent subtrees are passed down as comparison bases
208+
during recursive descent. If no comparison base is available,
209+
the new subtree is verified from scratch.
210+
211+
Example
212+
~~~~~~~
213+
214+
Consider a commit that changes one file under `lib/` and moves an
215+
unchanged subtree from `src/` to `dev/`:
216+
217+
Parent tree New tree
218+
+-- src/ (aaa) +-- dev/ (aaa)
219+
+-- lib/ (bbb) +-- lib/ (ccc)
220+
+-- foo.c (ddd) +-- foo.c (ddd)
221+
+-- bar.c (eee) +-- bar.c (fff)
222+
223+
Scanning the parent makes `aaa` trusted even though it moved from
224+
`src/` to `dev/`, so that subtree is skipped. The changed `ccc`
225+
subtree is compared against its same-path parent `bbb`; scanning
226+
`bbb` makes `ddd` and `eee` trusted, leaving only the new `fff`
227+
blob to be checked.
228+
229+
This illustrates two properties:
230+
231+
* Trust is OID-based rather than path-based. An unchanged subtree
232+
is recognized after a move.
233+
234+
* Same-path parent subtrees provide recursive comparison bases.
235+
These bases improve pruning efficiency but are not required for
236+
correctness; a new subtree can always be verified from scratch.
237+
238+
Multiple parents
239+
~~~~~~~~~~~~~~~~
240+
241+
For a merge commit, root trees from all parents are comparison
242+
bases. When several parents contain a same-path subtree, each
243+
matching subtree is collected as a recursive comparison base.
244+
245+
Entries published from any trusted parent become globally trusted,
246+
so a matching tree or blob entry present in any parent can be
247+
skipped while verifying the merge tree.
248+
249+
Missing comparison bases
250+
~~~~~~~~~~~~~~~~~~~~~~~~
251+
252+
A promised base tree may not be locally available for comparison.
253+
In that case the verifier skips that base and verifies the new
254+
subtree without it. The missing comparison can reduce pruning but
255+
does not affect correctness.

‎Makefile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1339,6 +1339,7 @@ LIB_OBJS += trailer.o
13391339
LIB_OBJS += transport-helper.o
13401340
LIB_OBJS += transport.o
13411341
LIB_OBJS += tree-diff.o
1342+
LIB_OBJS += tree-verify.o
13421343
LIB_OBJS += tree-walk.o
13431344
LIB_OBJS += tree.o
13441345
LIB_OBJS += unpack-trees.o

‎builtin/rev-list.c‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
#include "commit-reach.h"
2929
#include "quote.h"
3030
#include "strbuf.h"
31+
#include "tree-verify.h"
3132

3233
struct rev_list_info {
3334
struct rev_info *revs;
@@ -727,6 +728,7 @@ int cmd_rev_list(int argc,
727728
int bisect_find_all = 0;
728729
int use_bitmap_index = 0;
729730
int filter_provided_objects = 0;
731+
int verify_trees_incremental = 0;
730732
const char *show_progress = NULL;
731733
int ret = 0;
732734

@@ -770,6 +772,7 @@ int cmd_rev_list(int argc,
770772
repo->fetch_if_missing = 0;
771773
revs.exclude_promisor_objects = 1;
772774
} else if (!strcmp(arg, "--verify-trees-incremental")) {
775+
verify_trees_incremental = 1;
773776
repo->fetch_if_missing = 0;
774777
} else if (skip_prefix(arg, "--missing=", &arg)) {
775778
parse_missing_action_value(repo, arg);
@@ -973,6 +976,18 @@ int cmd_rev_list(int argc,
973976

974977
prepare_maximal_independent(&revs);
975978

979+
if (verify_trees_incremental) {
980+
struct commit *commit;
981+
struct commit_list *new_commits = NULL;
982+
983+
while ((commit = get_revision(&revs)) != NULL)
984+
commit_list_insert(commit, &new_commits);
985+
986+
verify_commits_incremental(repo, &new_commits,
987+
revs.exclude_promisor_objects);
988+
commit_list_free(new_commits);
989+
}
990+
976991
if (revs.tree_objects)
977992
mark_edges_uninteresting(&revs, show_edge, 0);
978993

‎meson.build‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,7 @@ libgit_sources = [
562562
'transport-helper.c',
563563
'transport.c',
564564
'tree-diff.c',
565+
'tree-verify.c',
565566
'tree-walk.c',
566567
'tree.c',
567568
'unpack-trees.c',

‎t/meson.build‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,7 @@ integration_tests = [
654654
't5410-receive-pack.sh',
655655
't5411-proc-receive-hook.sh',
656656
't5412-receive-report-hook.sh',
657+
't5413-connectivity-check.sh',
657658
't5500-fetch-pack.sh',
658659
't5501-fetch-push-alternates.sh',
659660
't5502-quickfetch.sh',

‎t/perf/p5413-connectivity-check.sh‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
#!/bin/sh
22

3-
test_description='performance of connectivity check
3+
test_description='performance of connectivity check modes
44
5-
Benchmark the rev-list connectivity check, avoiding pack transfer
6-
noise.
5+
Compare the default and incremental rev-list connectivity modes
6+
directly, avoiding pack transfer noise.
77
88
Each repository has a flat tree of many directories with 100 files
99
in each. Three axes are scaled independently: tree size, commit
@@ -51,12 +51,19 @@ test_perf_conn () {
5151
fi
5252
'
5353

54-
test_perf "$label" '
54+
test_perf "$label (full)" '
5555
cat '"${name}"'_new |
5656
git -C '"$name"' rev-list \
5757
--objects --stdin --not --all --quiet \
5858
--exclude-promisor-objects
5959
'
60+
61+
test_perf "$label (incremental)" '
62+
cat '"${name}"'_new |
63+
git -C '"$name"' rev-list --verify-trees-incremental \
64+
--objects --stdin --not --all --quiet \
65+
--exclude-promisor-objects
66+
'
6067
}
6168

6269
# Scaling tree size (10 commits, 10 files/commit).

0 commit comments

Comments
 (0)