Skip to content

Commit f7ebbb5

Browse files
authored
odb: scan all sources' packfiles before loose objects (#975)
Switching branches in a VFS for Git or Scalar enlistment became drastically slower in Git v2.55. The cause is the interaction of two independent changes, not a single one. Commit 062b914 (treewide: convert users of repo_has_object_file() to has_object()), first released in v2.50, accidentally inverted the object existence check in cache_tree_fully_valid(), so that function bailed out at the root instead of validating the cache tree recursively. Commit 5217312 (cache-tree: fix inverted object existence check in cache_tree_fully_valid), first released in v2.55, correctly restored the recursion. In between, v2.54's per-source object database refactor changed the cross-source lookup order: instead of scanning all packfiles before any loose object store, it scans packed then loose per source. That change caused no observable checkout regression in v2.54 precisely because the inverted check prevented recursion and thereby masked the per-object cost. Conversely, v2.49 did recurse, yet stayed fast because its global pack-first lookup found the objects in an alternate's packs before attempting any loose lookup. v2.55 is therefore the first version combining recursive cache-tree validation with per-source packed-then-loose lookup, at a cost of roughly one wasted primary loose-object lstat() per cache-tree node. In enlistments that use an alternate object cache, that cost is severe. cache_tree_fully_valid() calls odb_has_object() hundreds of thousands of times, and ODB_HAS_OBJECT_RECHECK_PACKED clears OBJECT_INFO_QUICK, so each call performs a real lstat() in the primary loose object store before the object is found in the alternate's packfile. On a measured index with about 2.4M entries: 381,006 cache-tree nodes, 380,944 wasted lstat() calls, not a single miss; cache-tree validation took about 32 seconds and switching to a branch pointing at the same commit about 36 seconds. This merge restores the all-sources-packed-before-all-sources-loose order whenever more than one source is present. The wasted stats are gone, validation drops to about 2 seconds and branch switching to about 6-7 seconds. Presence semantics are unchanged. The trade-off needs to be stated plainly: the mitigation lives in the shared object-info lookup, which is a slightly incorrect representation layer for a problem specific to presence-only queries via odb_has_object(). When an object is loose in the primary and packed in an alternate, metadata callers now observe the alternate's packed representation. Observable differences include reported on-disk size, mtime, delta base, corruption handling, and promisor-pack classification. Object content, type and logical size, as well as presence, remain correct because objects are content-addressed, which bounds the fallout to the representation level. We accept those behavior changes deliberately in exchange for fixing an intolerable regression in this fork now. The architecturally correct fix is upstream's plan to move alternate handling into the files backend. That backend would own both the primary and the alternates and could therefore scan all relevant packs before any loose-object lookup without violating the abstraction. The upstream contributor estimates that work at "three to four patch years", i.e. likely months in Git project time, and probably not before Git 2.56. microsoft/git cannot wait that long. This merge is consequently an explicitly temporary mitigation specific to Microsoft Git. Once upstream's fix lands, this implementation should be replaced and the regression test reassessed. That test currently asserts packed-versus-loose selection through %(deltabase), which necessarily pins representation ordering and may not survive the upstream architecture. A future replacement could instead verify the absence of the unwanted lstat() calls directly, possibly as a Linux-only strace test, since the behavior itself is platform-independent.
2 parents ffdc0a5 + dc305fc commit f7ebbb5

4 files changed

Lines changed: 94 additions & 3 deletions

File tree

odb.c

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,19 @@ static int register_all_submodule_sources(struct object_database *odb)
756756
return ret;
757757
}
758758

759+
static int read_object_info_from_sources(struct object_database *odb,
760+
const struct object_id *oid,
761+
struct object_info *oi,
762+
enum object_info_flags flags)
763+
{
764+
struct odb_source *source;
765+
766+
for (source = odb->sources; source; source = source->next)
767+
if (!odb_source_read_object_info(source, oid, oi, flags))
768+
return 0;
769+
return -1;
770+
}
771+
759772
static int do_oid_object_info_extended(struct object_database *odb,
760773
const struct object_id *oid,
761774
struct object_info *oi, unsigned flags)
@@ -781,9 +794,28 @@ static int do_oid_object_info_extended(struct object_database *odb,
781794
extern int core_use_gvfs_helper;
782795
struct odb_source *source;
783796

784-
for (source = odb->sources; source; source = source->next)
785-
if (!odb_source_read_object_info(source, real, oi, flags))
797+
/*
798+
* With one or more alternates, scan the packfiles of
799+
* every source before consulting any source's loose
800+
* object store. Otherwise a primary-source loose lookup
801+
* -- a filesystem stat that, without OBJECT_INFO_QUICK,
802+
* bypasses the cached loose index -- runs for every
803+
* object that resides in an alternate's packfile.
804+
* cache_tree_fully_valid() checks many tree objects that
805+
* live in an alternate, so this avoids a stat() per
806+
* object.
807+
*/
808+
if (odb->sources && odb->sources->next) {
809+
if (!read_object_info_from_sources(odb, real, oi,
810+
flags | OBJECT_INFO_SKIP_LOOSE))
786811
return 0;
812+
if (!read_object_info_from_sources(odb, real, oi,
813+
flags | OBJECT_INFO_SKIP_PACKED))
814+
return 0;
815+
} else if (!read_object_info_from_sources(odb, real, oi,
816+
flags)) {
817+
return 0;
818+
}
787819

788820
if (core_use_gvfs_helper && !tried_gvfs_helper) {
789821
enum gh_client__created ghc;

odb.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,18 @@ enum object_info_flags {
332332
*/
333333
OBJECT_INFO_SECOND_READ = (1 << 4),
334334

335+
/*
336+
* Only consult the packed object store of a source, skipping its loose
337+
* object store (OBJECT_INFO_SKIP_LOOSE), or vice versa
338+
* (OBJECT_INFO_SKIP_PACKED). These are used by
339+
* odb_read_object_info_extended() to scan the packfiles of all sources
340+
* before consulting any source's loose object store, so that an object
341+
* that resides in an alternate's packfile is not preceded by a spurious
342+
* loose-object lookup on an earlier source.
343+
*/
344+
OBJECT_INFO_SKIP_LOOSE = (1 << 5),
345+
OBJECT_INFO_SKIP_PACKED = (1 << 6),
346+
335347
/*
336348
* This is meant for bulk prefetching of missing blobs in a partial
337349
* clone. Implies OBJECT_INFO_SKIP_FETCH_OBJECT and OBJECT_INFO_QUICK.

odb/source-files.c

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,11 @@ static int odb_source_files_read_object_info(struct odb_source *source,
5555
{
5656
struct odb_source_files *files = odb_source_files_downcast(source);
5757

58-
if (!packfile_store_read_object_info(files->packed, oid, oi, flags) ||
58+
if (!(flags & OBJECT_INFO_SKIP_PACKED) &&
59+
!packfile_store_read_object_info(files->packed, oid, oi, flags))
60+
return 0;
61+
62+
if (!(flags & OBJECT_INFO_SKIP_LOOSE) &&
5963
!odb_source_read_object_info(&files->loose->base, oid, oi, flags))
6064
return 0;
6165

t/t5615-alternate-env.sh

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,47 @@ test_expect_success !MINGW 'broken quoting falls back to interpreting raw' '
8888
EOF
8989
'
9090

91+
test_expect_success 'packs across sources are checked before loose objects' '
92+
# Regression test for a performance issue in which an object that
93+
# resides in an alternate as a packed object caused a spurious loose
94+
# object lookup (a filesystem stat) on the main object store before the
95+
# alternate packfile was consulted. Reading such an object must resolve
96+
# to the alternate packfile, never to a loose copy in the main store.
97+
#
98+
# Build an alternate whose object "B" is stored as a delta in a
99+
# packfile. git deltifies successive versions of a tracked file, so the
100+
# older, shorter blob "B" becomes a delta against the newer, longer
101+
# blob "O". A loose object has no delta base, so %(deltabase) tells us
102+
# which store answered the read: the alternate pack (O) or a loose copy
103+
# (the zero oid).
104+
git init alt-src &&
105+
test_seq 1 200 >alt-src/file &&
106+
git -C alt-src add file &&
107+
git -C alt-src commit -q -m base &&
108+
B=$(git -C alt-src rev-parse HEAD:file) &&
109+
git -C alt-src cat-file blob "$B" >b-content &&
110+
test_seq 1 210 >alt-src/file &&
111+
git -C alt-src add file &&
112+
git -C alt-src commit -q -m more &&
113+
O=$(git -C alt-src rev-parse HEAD:file) &&
114+
git -C alt-src repack -adf --window=10 --depth=50 &&
115+
116+
# Precondition: in the alternate, B is a delta based on O.
117+
echo "$B" >in &&
118+
echo "$O" >expect &&
119+
git -C alt-src cat-file --batch-check="%(deltabase)" <in >actual &&
120+
test_cmp expect actual &&
121+
122+
# Main repo: write B as a loose object, before any alternate is active.
123+
git init main &&
124+
git -C main hash-object -w --stdin <b-content >/dev/null &&
125+
test -e "main/.git/objects/$(test_oid_to_path "$B")" &&
126+
127+
# With the alternate active, B must resolve to the alternate packfile
128+
# (deltabase O), not to the main store loose copy (deltabase zero oid).
129+
GIT_ALTERNATE_OBJECT_DIRECTORIES="$PWD/alt-src/.git/objects" \
130+
git -C main cat-file --batch-check="%(deltabase)" <in >actual &&
131+
test_cmp expect actual
132+
'
133+
91134
test_done

0 commit comments

Comments
 (0)