Files
git/tree-verify.c
T
Kristofer Karlsson 43157175b5 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 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>
2026-10-23 08:32:30 -07:00

434 lines
12 KiB
C

#include "git-compat-util.h"
#include "commit.h"
#include "gettext.h"
#include "hex.h"
#include "khash.h"
#include "object.h"
#include "odb.h"
#include "oid-array.h"
#include "oidset.h"
#include "tree.h"
#include "tree-walk.h"
#include "tree-verify.h"
#include "packfile.h"
#include "trace2.h"
enum tree_state {
TREE_UNTRUSTED = 0,
TREE_TRUSTED = 1,
TREE_EXPANDED = 2,
};
/* Values are enum tree_state, stored as a byte to keep the map small. */
KHASH_INIT(oid_tree, struct object_id, unsigned char, 1,
oidhash_by_value, oideq_by_value)
static enum tree_state tree_map_get(kh_oid_tree_t *m,
const struct object_id *oid)
{
khint_t pos = kh_get_oid_tree(m, *oid);
if (pos == kh_end(m))
return TREE_UNTRUSTED;
return kh_val(m, pos);
}
static void tree_map_add(kh_oid_tree_t *m, const struct object_id *oid,
enum tree_state state)
{
int added;
khint_t pos = kh_put_oid_tree(m, *oid, &added);
if (added)
kh_val(m, pos) = state;
else if (state > kh_val(m, pos))
kh_val(m, pos) = state;
}
struct work_item {
struct name_entry entry;
struct oid_array parent_trees;
};
/* The entries of a tree that still need to be verified. */
struct work_list {
struct work_item *items;
size_t nr, alloc;
bool has_subtrees;
};
static void work_list_clear(struct work_list *list)
{
for (size_t i = 0; i < list->nr; i++)
oid_array_clear(&list->items[i].parent_trees);
free(list->items);
}
struct verify_state {
kh_oid_tree_t *trees;
struct oidset trusted_blobs;
size_t trees_loaded;
size_t blobs_checked;
int exclude_promisor_objects;
/*
* Scratch space for reading one tree at a time. It lives here
* rather than on the stack to keep the frame of the recursive
* verify_tree() small.
*/
struct tree_desc desc;
struct name_entry entry;
};
/*
* Merge-walk the work list against one base tree entry, recording
* same-path parent subtrees as recursive comparison bases.
* Returns the updated work-list cursor.
*/
static size_t collect_subtree_bases(struct work_list *list, size_t wi,
const struct name_entry *entry)
{
while (wi < list->nr) {
struct work_item *item = &list->items[wi];
int cmp = base_name_compare(item->entry.path,
item->entry.pathlen,
item->entry.mode,
entry->path, entry->pathlen,
entry->mode);
if (cmp > 0)
break;
if (cmp < 0) {
wi++;
continue;
}
if (S_ISDIR(item->entry.mode) && S_ISDIR(entry->mode))
oid_array_append(&item->parent_trees, &entry->oid);
return wi + 1;
}
return wi;
}
/*
* Report an object that could not be read, telling a missing object
* apart from one that exists but cannot be read, e.g. because it is
* corrupt.
*/
static int unreadable_object_error(struct repository *repo,
const struct object_id *oid,
const char *type)
{
if (!odb_has_object(repo->objects, oid, 0))
return error(_("missing %s object %s"), type, oid_to_hex(oid));
return error(_("unable to read %s object %s"), type, oid_to_hex(oid));
}
static int verify_blob(struct repository *repo,
const struct object_id *oid,
struct verify_state *vs)
{
struct object_info oi = OBJECT_INFO_INIT;
enum object_type type;
if (oidset_contains(&vs->trusted_blobs, oid))
return 0;
vs->blobs_checked++;
oi.typep = &type;
if (odb_read_object_info_extended(repo->objects, oid, &oi,
OBJECT_INFO_LOOKUP_REPLACE |
OBJECT_INFO_SKIP_FETCH_OBJECT)) {
if (vs->exclude_promisor_objects &&
is_promisor_object(repo, oid))
return 0;
return unreadable_object_error(repo, oid, "blob");
}
if (type != OBJ_BLOB)
return error(_("object %s is a %s, not a %s"),
oid_to_hex(oid), type_name(type), "blob");
oidset_insert(&vs->trusted_blobs, oid);
return 0;
}
/*
* Read a tree object into *bufp. Returns -1 if the object exists
* but is not a tree. Otherwise returns 0, leaving *bufp NULL if the
* object could not be read.
*/
static int read_tree_object(struct object_database *odb,
const struct object_id *oid,
void **bufp, size_t *sizep)
{
struct object_info oi = OBJECT_INFO_INIT;
enum object_type type;
oi.typep = &type;
oi.sizep = sizep;
oi.contentp = bufp;
if (odb_read_object_info_extended(odb, oid, &oi,
OBJECT_INFO_LOOKUP_REPLACE |
OBJECT_INFO_SKIP_FETCH_OBJECT))
*bufp = NULL;
if (*bufp && type != OBJ_TREE) {
FREE_AND_NULL(*bufp);
return error(_("object %s is a %s, not a %s"),
oid_to_hex(oid), type_name(type), "tree");
}
return 0;
}
/* List the entries of a tree that are not yet known to be trusted. */
static void list_untrusted_entries(struct verify_state *vs,
const struct object_id *tree_oid,
void *tree_buf, size_t tree_size,
struct work_list *list)
{
struct name_entry *entry = &vs->entry;
init_tree_desc(&vs->desc, tree_oid, tree_buf, tree_size);
while (tree_entry(&vs->desc, entry)) {
if (S_ISGITLINK(entry->mode))
continue;
if (S_ISDIR(entry->mode)) {
if (tree_map_get(vs->trees, &entry->oid) >=
TREE_TRUSTED)
continue;
list->has_subtrees = true;
} else {
if (oidset_contains(&vs->trusted_blobs, &entry->oid))
continue;
}
ALLOC_GROW(list->items, list->nr + 1, list->alloc);
memset(&list->items[list->nr], 0, sizeof(list->items[0]));
list->items[list->nr++].entry = *entry;
}
}
/*
* Scan one trusted base tree: expand it if that has not happened yet,
* and collect its same-path subtrees as bases for untrusted subtrees.
*/
static int scan_base_tree(struct repository *repo, struct verify_state *vs,
const struct object_id *base_oid,
struct work_list *list)
{
int expanded = tree_map_get(vs->trees, base_oid) >= TREE_EXPANDED;
struct name_entry *entry = &vs->entry;
size_t wi = 0, base_size;
void *base_buf;
if (expanded && !list->has_subtrees)
return 0;
if (read_tree_object(repo->objects, base_oid, &base_buf, &base_size))
return -1;
if (!base_buf) {
if (vs->exclude_promisor_objects &&
is_promisor_object(repo, base_oid))
return 0;
return unreadable_object_error(repo, base_oid, "tree");
}
vs->trees_loaded++;
init_tree_desc(&vs->desc, base_oid, base_buf, base_size);
while (tree_entry(&vs->desc, entry)) {
if (S_ISGITLINK(entry->mode))
continue;
/* Same-path subtree as a base for an untrusted one. */
if (list->has_subtrees)
wi = collect_subtree_bases(list, wi, entry);
/* Expand: the base is trusted, so are its entries. */
if (!expanded) {
if (S_ISDIR(entry->mode))
tree_map_add(vs->trees, &entry->oid,
TREE_TRUSTED);
else
oidset_insert(&vs->trusted_blobs,
&entry->oid);
}
}
if (!expanded)
tree_map_add(vs->trees, base_oid, TREE_EXPANDED);
free(base_buf);
return 0;
}
/*
* Verify a tree against the trusted base trees, loading as little as
* possible:
*
* First list the entries that are not yet known to be trusted. When
* the parent trees have been expanded before, only the changed entries
* remain.
*
* Then scan the trusted base trees (the same-path trees of the parent
* commits). A base that has not been expanded yet is read so that its
* entries can be marked trusted; this often shows that listed entries
* are trusted after all. An already expanded base is still read when
* there are untrusted subtrees, because its subtree at the same path is
* what lets the untrusted subtree skip its own unchanged entries in
* turn.
*
* Finally check that the remaining blobs exist, and recurse into the
* remaining subtrees with the same-path subtrees as their bases.
*/
static int verify_tree(struct repository *repo,
const struct object_id *tree_oid,
const struct oid_array *trusted_bases,
struct verify_state *vs, int depth)
{
struct work_list list = { 0 };
size_t i, tree_size;
void *tree_buf;
int ret = 0;
/*
* Enforce core.maxTreeDepth like process_tree() in list-objects.c,
* so that the full and incremental checks reject the same trees.
*/
if (depth > repo->settings.max_allowed_tree_depth)
return error(_("exceeded maximum allowed tree depth"));
if (tree_map_get(vs->trees, tree_oid) >= TREE_TRUSTED)
return 0;
if (read_tree_object(repo->objects, tree_oid, &tree_buf, &tree_size))
return -1;
if (!tree_buf) {
/*
* A missing tree that is promised can be fetched on demand,
* so it is not an error, as in the full check.
*/
if (vs->exclude_promisor_objects &&
is_promisor_object(repo, tree_oid))
return 0;
return unreadable_object_error(repo, tree_oid, "tree");
}
vs->trees_loaded++;
list_untrusted_entries(vs, tree_oid, tree_buf, tree_size, &list);
/* Scan the base trees, unless nothing is left to verify. */
for (i = 0; list.nr && i < trusted_bases->nr; i++) {
ret = scan_base_tree(repo, vs, &trusted_bases->oid[i], &list);
if (ret)
goto done;
}
/*
* Check the remaining entries in tree order. Verifying a subtree
* can show that later entries are trusted, which then makes them
* cheap to skip.
*/
for (i = 0; i < list.nr && !ret; i++) {
struct work_item *item = &list.items[i];
if (S_ISDIR(item->entry.mode))
ret = verify_tree(repo, &item->entry.oid,
&item->parent_trees, vs, depth + 1);
else
ret = verify_blob(repo, &item->entry.oid, vs);
}
if (!ret)
tree_map_add(vs->trees, tree_oid, TREE_EXPANDED);
done:
free(tree_buf);
work_list_clear(&list);
return ret;
}
static int verify_commit_tree(struct repository *repo,
struct commit *commit,
struct verify_state *vs)
{
struct oid_array parent_trees = OID_ARRAY_INIT;
const struct object_id *tree_oid;
struct commit_list *p;
int ret;
/*
* Parent trees are trusted: boundary parents are already
* connected, and earlier incoming parents were verified
* first due to the topological processing order.
*
* The revision walk skips missing parents that are promised
* (see process_parents()), so do the same here. Such a parent
* contributes no base, which only means less of the new tree
* can be skipped.
*/
for (p = commit->parents; p; p = p->next) {
if (repo_parse_commit_gently(repo, p->item, 1) < 0) {
if (vs->exclude_promisor_objects &&
is_promisor_object(repo, &p->item->object.oid))
continue;
ret = error(_("unable to parse commit %s"),
oid_to_hex(&p->item->object.oid));
goto out;
}
tree_oid = get_commit_tree_oid(p->item);
if (!tree_oid) {
ret = error(_("unable to load root tree for commit %s"),
oid_to_hex(&p->item->object.oid));
goto out;
}
tree_map_add(vs->trees, tree_oid, TREE_TRUSTED);
oid_array_append(&parent_trees, tree_oid);
}
tree_oid = get_commit_tree_oid(commit);
if (!tree_oid) {
ret = error(_("unable to load root tree for commit %s"),
oid_to_hex(&commit->object.oid));
goto out;
}
ret = verify_tree(repo, tree_oid, &parent_trees, vs, 0);
out:
oid_array_clear(&parent_trees);
return ret;
}
int verify_commits_incremental(struct repository *repo,
struct commit_list **commits,
int exclude_promisor_objects)
{
struct verify_state vs = { 0 };
struct commit_list *iter;
unsigned nr_before;
int ret = 0;
vs.trees = kh_init_oid_tree();
vs.exclude_promisor_objects = exclude_promisor_objects;
/*
* Ancestors must be verified before descendants so that parent
* trees can be trusted without re-verification. Sort explicitly
* rather than relying on the caller's ordering.
*
* sort_in_topological_order() silently drops cycle members,
* so explicitly check if the size has changed.
*/
nr_before = commit_list_count(*commits);
sort_in_topological_order(commits, REV_SORT_IN_GRAPH_ORDER);
if (commit_list_count(*commits) < nr_before) {
ret = error(_("cycle detected in incoming commit graph"));
goto out;
}
*commits = commit_list_reverse(*commits);
for (iter = *commits; iter && !ret; iter = iter->next)
ret = verify_commit_tree(repo, iter->item, &vs);
out:
kh_destroy_oid_tree(vs.trees);
oidset_clear(&vs.trusted_blobs);
trace2_data_intmax("connectivity", repo,
"trees_loaded", (intmax_t)vs.trees_loaded);
trace2_data_intmax("connectivity", repo,
"blobs_checked", (intmax_t)vs.blobs_checked);
return ret;
}