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 inside rev-list, selected by the
internal --connectivity-check option. 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.
A new transfer.connectivityCheck value "incremental" selects the
new mode, 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.
Clones and pushes into an empty repository also use the full
check. Without negative revisions there is no trusted boundary,
and the incremental check would have to verify all of history
from scratch like the full check does, with extra per-commit
overhead on top. On git.git, checking 70K commits without a
boundary takes 5.3s with the full check and 9.2s with the
incremental one.
p5413 results (best 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.13s 0.01s 13.0x
800K 0.54s 0.03s 18.0x
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.13s 0.01s 13.0x
10 0.13s 0.01s 13.0x
100 0.16s 0.06s 2.7x
500 0.29s 0.25s 1.2x
3000 1.14s 1.44s 0.8x
5000 1.87s 2.39s 0.8x
10000 3.45s 5.13s 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.13s 0.01s 13.0x
10 0.13s 0.01s 13.0x
100 0.15s 0.03s 5.0x
500 0.23s 0.13s 1.8x
1000 0.34s 0.27s 1.3x
2000 0.65s 0.56s 1.2x
The advantage shrinks as more files change per commit, but
incremental stays slightly faster even at 2000 files/commit,
where every directory is touched.
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 less memory when it can prune enough 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: a commit's tree is read as a candidate when the
commit is verified, and again as a trusted parent tree when its
children are verified.
Signed-off-by: Kristofer Karlsson <krka@spotify.com>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
260 lines
9.7 KiB
Plaintext
260 lines
9.7 KiB
Plaintext
Connectivity checking
|
|
=====================
|
|
|
|
After receiving new objects via fetch, push (receive-pack), clone,
|
|
or bundle, Git verifies that the new reference tips do not leave
|
|
the repository in a state where reachable objects are missing.
|
|
This verification is called the connectivity check.
|
|
|
|
Connectivity invariant
|
|
----------------------
|
|
|
|
A repository is connected when every object reachable from its
|
|
references is available locally (with exceptions noted below).
|
|
The reverse does not hold: objects that exist in the object
|
|
database but are not reachable from any reference may be
|
|
incomplete or lack a connected closure.
|
|
|
|
The connectivity check maintains this invariant when references
|
|
are updated. It trusts that all objects reachable from existing
|
|
reference tips are already fully connected and verifies that the
|
|
new reference tips do not introduce references to unavailable
|
|
objects. Verification is permitted to stop when it reaches
|
|
objects already reachable from existing references, since their
|
|
closure is already connected. These trusted references include
|
|
local references and references from alternate object stores.
|
|
|
|
Without this check, a truncated or corrupted transfer could leave
|
|
a repository in a state where later history walks encounter
|
|
missing objects.
|
|
|
|
Exceptions
|
|
~~~~~~~~~~
|
|
|
|
Gitlink entries (submodule references) are excluded from
|
|
connectivity checking. Their target objects belong to a separate
|
|
repository.
|
|
|
|
In partial clones, objects promised by a promisor remote are
|
|
accepted as connected without requiring local existence. The
|
|
check excludes promisor objects from traversal so that it does
|
|
not trigger on-demand fetches for them.
|
|
|
|
Full connectivity check
|
|
-----------------------
|
|
|
|
The full connectivity check runs a `rev-list` subprocess, feeding
|
|
the new reference tips via stdin. A normal invocation is roughly:
|
|
|
|
git rev-list --objects --stdin --not --all --quiet
|
|
--alternate-refs [--exclude-promisor-objects]
|
|
|
|
In a partial clone, objects received from a promisor remote do
|
|
not need to be checked: anything they reference is promised by
|
|
that remote and can be fetched on demand. So if every new tip is
|
|
found in a promisor packfile, `rev-list` is not run at all.
|
|
Otherwise it runs with `--exclude-promisor-objects`, so that the
|
|
traversal stops at promisor objects instead of fetching them.
|
|
|
|
Consider the following graph after a fetch, where all reference
|
|
tips point directly to commits. For simplicity, only local
|
|
references appear on the already-connected side; alternate refs
|
|
play the same role. N3 is a merge commit:
|
|
|
|
/-------------L2
|
|
/
|
|
C1---B1---C2---B2-----L1
|
|
\ \
|
|
N1 N3---T2
|
|
\ /
|
|
N2-----------T1
|
|
|
|
L1, L2: local refs
|
|
T1, T2: incoming tips (new refs)
|
|
N1, N2, N3: incoming commits (N3 is a merge)
|
|
B1, B2: boundary commits (already connected)
|
|
C1, C2: already connected (but not boundary)
|
|
|
|
The incoming set is the commits reachable from the incoming
|
|
tips but not from the already-connected side. Boundary commits
|
|
are the already-connected commits at the edge of that set. Here
|
|
B1 is an ancestor of B2, which happens when incoming branches
|
|
fork at different depths in the existing history.
|
|
|
|
The `--not --all` arguments establish the trust boundary: the
|
|
incoming set is the commits reachable from the new tips but not
|
|
from any existing reference.
|
|
|
|
The traversal then walks the trees of the boundary commits and
|
|
marks those objects uninteresting. Trees of incoming commits
|
|
are then walked, stopping at objects already marked as
|
|
uninteresting. The cost of marking boundary trees uninteresting
|
|
dominates when the boundary commits have large trees, even when
|
|
the incoming changes are small.
|
|
|
|
Deepening fetches
|
|
~~~~~~~~~~~~~~~~~
|
|
|
|
For deepening fetches (where the shallow boundary moves), the
|
|
full check omits `--not --all`. There is no existing-reference
|
|
boundary at which the walk can stop. Instead, traversal follows
|
|
the effective shallow boundary supplied for the deepened
|
|
repository. The new content may be below the old shallow
|
|
boundary even when the tips themselves have not changed.
|
|
|
|
Non-commit tips
|
|
~~~~~~~~~~~~~~~
|
|
|
|
When a new reference points to a non-commit object, such as a
|
|
tag, tree, or blob, that object is not part of the commit walk.
|
|
These non-commit tips are verified during the tree and blob
|
|
traversal that follows the commit walk within the same rev-list
|
|
invocation.
|
|
|
|
Incremental connectivity check
|
|
------------------------------
|
|
|
|
The incremental mode, selected by
|
|
`transfer.connectivityCheck=incremental`, avoids traversing the
|
|
full tree walk of the boundary commits. Instead, it verifies
|
|
each incoming commit's tree against the already-trusted trees of
|
|
its parent commits.
|
|
|
|
The same `--not --all` boundary as the full check identifies the
|
|
incoming commits. The incremental mode reuses this boundary but
|
|
replaces what happens afterwards: rather than walking boundary
|
|
trees to mark objects uninteresting, it compares each incoming
|
|
commit's tree against its parents' trees, processing commits in
|
|
topological order (ancestors first). This all happens inside
|
|
the same `rev-list` invocation, and the promisor fast path above
|
|
still runs first.
|
|
|
|
Trust model
|
|
~~~~~~~~~~~
|
|
|
|
A tree is trusted when its transitive object closure is known to
|
|
be connected.
|
|
|
|
Every parent of an incoming commit is either on the
|
|
already-connected side of the `--not --all` boundary (a boundary
|
|
parent whose tree is trusted by definition) or is an earlier
|
|
incoming commit that was already verified due to the topological
|
|
processing order. In both cases the parent's tree is trusted, so
|
|
all parent trees can be unconditionally used as comparison bases.
|
|
|
|
Once an incoming commit's tree has been verified, it is trusted
|
|
and can serve as a comparison base for later descendants. This
|
|
gives an inductive correctness argument: the base case is the
|
|
trust boundary established by `--not --all`, and the inductive
|
|
step is that verifying a commit extends the trusted set.
|
|
|
|
Tree states
|
|
~~~~~~~~~~~
|
|
|
|
The verifier tracks tree OIDs in three states:
|
|
|
|
untrusted::
|
|
The tree has not yet been established as connected. This
|
|
is the implicit state of an OID not present in the state
|
|
map.
|
|
|
|
trusted::
|
|
The tree is known to have a connected transitive closure,
|
|
but its direct entries have not yet been published into the
|
|
verifier's trusted object sets.
|
|
|
|
expanded::
|
|
The tree is trusted and its direct non-gitlink entries
|
|
have also been published into the trusted object sets.
|
|
|
|
State transitions are monotonic: a tree may move from untrusted
|
|
to trusted to expanded, but never backwards. An untrusted tree
|
|
that is successfully verified goes directly to expanded.
|
|
|
|
Blobs require only trusted/untrusted state: a blob becomes
|
|
trusted when it is found in a trusted tree or when its existence
|
|
and type have been verified directly.
|
|
|
|
The trusted/expanded distinction is an optimization. An expanded
|
|
parent does not need to be reread merely to publish entries that
|
|
are already trusted, though it may still be read when same-path
|
|
subtree bases are needed for recursive comparison.
|
|
|
|
Algorithm
|
|
~~~~~~~~~
|
|
|
|
At a high level:
|
|
|
|
verify(commits):
|
|
sort topologically (ancestors first)
|
|
for each commit:
|
|
mark parent root trees as trusted
|
|
verify_tree(commit.tree, parent root trees)
|
|
|
|
verify_tree(tree, base_trees):
|
|
if tree already trusted: return
|
|
read tree, collect entries not already trusted
|
|
for each available base tree:
|
|
publish its entries as trusted
|
|
record same-path subtrees as bases
|
|
for each collected entry:
|
|
if now trusted: skip
|
|
if blob: verify blob connectivity and type
|
|
if tree: verify_tree(entry, its recorded bases)
|
|
mark tree as expanded
|
|
|
|
The important ordering within `verify_tree` is that entries from
|
|
the new tree are collected before the base trees are scanned, but
|
|
are processed only afterwards. Trust learned from any base can
|
|
therefore eliminate work before recursive verification begins.
|
|
|
|
Same-path parent subtrees are passed down as comparison bases
|
|
during recursive descent. If no comparison base is available,
|
|
the new subtree is verified from scratch.
|
|
|
|
Example
|
|
~~~~~~~
|
|
|
|
Consider a commit that changes one file under `lib/` and 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)
|
|
|
|
Scanning the parent makes `aaa` trusted even though it moved from
|
|
`src/` to `dev/`, so that subtree is skipped. The changed `ccc`
|
|
subtree is compared against its same-path parent `bbb`; scanning
|
|
`bbb` makes `ddd` and `eee` trusted, leaving only the new `fff`
|
|
blob to be checked.
|
|
|
|
This illustrates two properties:
|
|
|
|
* Trust is OID-based rather than path-based. An unchanged subtree
|
|
is recognized after a move.
|
|
|
|
* Same-path parent subtrees provide recursive comparison bases.
|
|
These bases improve pruning efficiency but are not required for
|
|
correctness; a new subtree can always be verified from scratch.
|
|
|
|
Multiple parents
|
|
~~~~~~~~~~~~~~~~
|
|
|
|
For a merge commit, root trees from all parents are comparison
|
|
bases. When several parents contain a same-path subtree, each
|
|
matching subtree is collected as a recursive comparison base.
|
|
|
|
Entries published from any trusted parent become globally trusted,
|
|
so a matching tree or blob entry present in any parent can be
|
|
skipped while verifying the merge tree.
|
|
|
|
Missing comparison bases
|
|
~~~~~~~~~~~~~~~~~~~~~~~~
|
|
|
|
A promised base tree may not be locally available for comparison.
|
|
In that case the verifier skips that base and verifies the new
|
|
subtree without it. The missing comparison can reduce pruning but
|
|
does not affect correctness.
|