From: "Elijah Newren via GitGitGadget" <gitgitgadget@gmail.com>
To: git@vger.kernel.org
Cc: Patrick Steinhardt <ps@pks.im>, Elijah Newren <newren@gmail.com>,
Jeff King <peff@peff.net>, Derrick Stolee <stolee@gmail.com>,
Elijah Newren <newren@gmail.com>,
Elijah Newren <newren@gmail.com>
Subject: [PATCH v2 4/4] packfile: recover when a multi-pack-index names a removed pack
Date: Tue, 25 Aug 2026 19:00:29 +0000 [thread overview]
Message-ID: <eacf6ba4b11e366466da18b7b668e65793c532a9.1787684429.git.gitgitgadget@gmail.com> (raw)
In-Reply-To: <pull.2207.v2.git.1787684429.gitgitgadget@gmail.com>
From: Elijah Newren <newren@gmail.com>
A geometric repack writes a new pack and multi-pack-index and then
deletes the packs the new one subsumes. A process still using the
previous MIDX keeps seeing a removed pack listed as the owner of some
objects. Since a MIDX attributes each object to exactly one pack, such
an object is served only through its recorded owner; if that owner was
just removed, find_pack_entry() cannot serve it -- fill_midx_entry()
routes to the missing pack, and the regular pack fallback deliberately
skips every MIDX-covered pack, so a surviving copy in another covered
pack (e.g. a kept base pack) is never consulted.
Unlike the ordinary "a pack's .idx is mapped but its .pack is gone"
race, the second read does not rescue us -- and not only for
OBJECT_INFO_QUICK callers. Reloading the on-disk pack set does not
reload the borrowed, cached MIDX (freeing it under the code that caches
the "struct multi_pack_index *" would be a use-after-free), so the stale
MIDX keeps routing to the removed pack and the surviving copy stays
hidden behind the covered-pack skip. cat-file, rev-list and pack-objects
can thus all spuriously fail with "unable to read object".
Teach find_pack_entry() to recover. fill_midx_entry() now returns a
tri-state, distinguishing "absent from the MIDX" from "present but the
owning pack is unavailable"; in the latter case, once the regular
fallback has also missed, scan the MIDX's packs directly for a surviving
copy.
Do the scan only on the second read (OBJECT_INFO_SECOND_READ): by then
the cheaper on-disk reload has run, so an object merely relocated into a
new (non-covered) pack has already been found by the regular fallback,
and only a genuine hidden duplicate reaches the rescan. QUICK callers
that would skip the second read are steered into it by the preceding
commit's stale_packs_detected flag, which prepare_midx_pack() sets when
it cannot open the owning pack.
Reloading the stale MIDX would be a more complete fix but is much more
involved (the borrowers above need proper invalidation), so leave that
for later.
Assisted-by: Claude Opus 4.8 & GPT-5.6 Sol
Helped-by: Jeff King <peff@peff.net>
Signed-off-by: Elijah Newren <newren@gmail.com>
---
builtin/pack-objects.c | 2 +-
midx.c | 38 ++++++++++--------
midx.h | 21 +++++++++-
odb/source-packed.c | 42 ++++++++++++++++---
t/t5319-multi-pack-index.sh | 80 +++++++++++++++++++++++++++++++++++++
5 files changed, 158 insertions(+), 25 deletions(-)
diff --git a/builtin/pack-objects.c b/builtin/pack-objects.c
index 399acd0f22..30ad7d822c 100644
--- a/builtin/pack-objects.c
+++ b/builtin/pack-objects.c
@@ -1786,7 +1786,7 @@ static int want_object_in_pack_mtime(const struct object_id *oid,
struct multi_pack_index *m = get_multi_pack_index(files->packed);
struct pack_entry e;
- if (m && fill_midx_entry(m, oid, &e, NULL)) {
+ if (m && fill_midx_entry(m, oid, &e, NULL) == MIDX_FILL_HIT) {
want = want_object_in_pack_one(e.p, oid, exclude, found_pack, found_offset, found_mtime);
if (want != -1)
return want;
diff --git a/midx.c b/midx.c
index 942505ac41..6b585f3c1a 100644
--- a/midx.c
+++ b/midx.c
@@ -595,46 +595,50 @@ uint32_t nth_midxed_pack_int_id(struct multi_pack_index *m, uint32_t pos)
(off_t)pos * MIDX_CHUNK_OFFSET_WIDTH);
}
-int fill_midx_entry(struct multi_pack_index *m,
- const struct object_id *oid,
- struct pack_entry *e,
- struct packed_git **bad_pack)
+enum midx_fill_result fill_midx_entry(struct multi_pack_index *m,
+ const struct object_id *oid,
+ struct pack_entry *e,
+ struct packed_git **bad_pack)
{
uint32_t pos;
uint32_t pack_int_id;
struct packed_git *p;
if (!bsearch_midx(oid, m, &pos))
- return 0;
+ return MIDX_FILL_MISS;
midx_for_object(&m, pos);
pack_int_id = nth_midxed_pack_int_id(m, pos);
if (prepare_midx_pack(m, pack_int_id))
- return 0;
+ goto owner_unavailable;
p = m->packs[pack_int_id - m->num_packs_in_base];
- /*
- * We are about to tell the caller where they can locate the
- * requested object. We better make sure the packfile is
- * still here and can be accessed before supplying that
- * answer, as it may have been deleted since the MIDX was
- * loaded!
- */
+ /* Make sure the pack is still present before pointing at it. */
if (!is_pack_valid(p))
- return 0;
+ goto owner_unavailable;
if (oidset_size(&p->bad_objects) &&
oidset_contains(&p->bad_objects, oid)) {
if (bad_pack && !*bad_pack)
*bad_pack = p;
- return 0;
+ return MIDX_FILL_MISS;
}
e->offset = nth_midxed_offset(m, pos);
e->p = p;
- return 1;
+ return MIDX_FILL_HIT;
+
+owner_unavailable:
+ /*
+ * Re-arm stale_packs_detected on every such lookup, not just the
+ * first: prepare_midx_pack() caches the failure, so without this a
+ * later lookup of the same vanished pack would leave the flag clear
+ * and a QUICK reader would skip its recovering second read.
+ */
+ m->source->base.odb->stale_packs_detected = 1;
+ return MIDX_FILL_OWNER_UNAVAILABLE;
}
/* Match "foo.idx" against either "foo.pack" _or_ "foo.idx". */
@@ -1038,7 +1042,7 @@ int verify_midx_file(struct odb_source_packed *source, unsigned flags)
nth_midxed_object_oid(&oid, m, pairs[i].pos);
- if (!fill_midx_entry(m, &oid, &e, NULL)) {
+ if (fill_midx_entry(m, &oid, &e, NULL) != MIDX_FILL_HIT) {
midx_report(_("failed to load pack entry for oid[%d] = %s"),
pairs[i].pos, oid_to_hex(&oid));
continue;
diff --git a/midx.h b/midx.h
index 1f2f2d5321..52fe9c81e9 100644
--- a/midx.h
+++ b/midx.h
@@ -117,8 +117,25 @@ uint32_t nth_midxed_pack_int_id(struct multi_pack_index *m, uint32_t pos);
struct object_id *nth_midxed_object_oid(struct object_id *oid,
struct multi_pack_index *m,
uint32_t n);
-int fill_midx_entry(struct multi_pack_index *m, const struct object_id *oid,
- struct pack_entry *e, struct packed_git **bad_pack);
+/*
+ * Result of looking an object up in a multi-pack-index. MIDX_FILL_HIT means
+ * "e was filled in"; the two miss variants distinguish an object the midx does
+ * not know about (MIDX_FILL_MISS) from one it does know about but whose owning
+ * pack we can no longer open (MIDX_FILL_OWNER_UNAVAILABLE -- the signature of a
+ * concurrent repack having removed that pack). A known-bad (corrupt) object
+ * reports MIDX_FILL_MISS but also sets *bad_pack, if provided, to the owning
+ * pack so the caller can tell "corrupt" apart from "absent".
+ */
+enum midx_fill_result {
+ MIDX_FILL_MISS = 0,
+ MIDX_FILL_HIT,
+ MIDX_FILL_OWNER_UNAVAILABLE,
+};
+
+enum midx_fill_result fill_midx_entry(struct multi_pack_index *m,
+ const struct object_id *oid,
+ struct pack_entry *e,
+ struct packed_git **bad_pack);
int midx_contains_pack(struct multi_pack_index *m,
const char *idx_or_pack_name);
int midx_layer_contains_pack(struct multi_pack_index *m,
diff --git a/odb/source-packed.c b/odb/source-packed.c
index b6c1d8fdf4..ae4c4bac40 100644
--- a/odb/source-packed.c
+++ b/odb/source-packed.c
@@ -17,13 +17,18 @@
static int find_pack_entry(struct odb_source_packed *store,
const struct object_id *oid,
struct pack_entry *e,
+ enum object_info_flags flags,
struct packed_git **bad_pack)
{
struct packfile_list_entry *l;
+ enum midx_fill_result midx_result = MIDX_FILL_MISS;
odb_source_prepare(&store->base, 0);
- if (store->midx && fill_midx_entry(store->midx, oid, e, bad_pack))
- return 1;
+ if (store->midx) {
+ midx_result = fill_midx_entry(store->midx, oid, e, bad_pack);
+ if (midx_result == MIDX_FILL_HIT)
+ return 1;
+ }
for (l = store->packs.head; l; l = l->next) {
struct packed_git *p = l->pack;
@@ -35,6 +40,33 @@ static int find_pack_entry(struct odb_source_packed *store,
}
}
+ /*
+ * Recovery for a concurrent-repack race: a stale MIDX may still name a
+ * vanished owning pack even though the object survives in another pack
+ * the same MIDX covers. The regular fallback above skips MIDX-covered
+ * packs, and repreparing the on-disk pack set does not reload the
+ * borrowed, cached MIDX, so scan its packs directly for the survivor.
+ *
+ * Do this only on the second read, by which point repreparing packs has
+ * already had a chance to find an object merely relocated into a new,
+ * uncovered pack; only a genuine hidden duplicate reaches here.
+ */
+ if (midx_result == MIDX_FILL_OWNER_UNAVAILABLE &&
+ (flags & OBJECT_INFO_SECOND_READ)) {
+ struct multi_pack_index *m = store->midx;
+ uint32_t i;
+
+ for (i = 0; i < m->num_packs + m->num_packs_in_base; i++) {
+ struct packed_git *p;
+
+ if (prepare_midx_pack(m, i))
+ continue;
+ p = nth_midxed_pack(m, i);
+ if (p && packfile_fill_entry(p, oid, e, bad_pack))
+ return 1;
+ }
+ }
+
return 0;
}
@@ -57,7 +89,7 @@ static enum odb_read_status odb_source_packed_read_object_info(struct odb_source
if (flags & OBJECT_INFO_SECOND_READ)
odb_source_prepare(source, ODB_PREPARE_FLUSH_CACHES);
- if (!find_pack_entry(packed, oid, &e, &bad_pack)) {
+ if (!find_pack_entry(packed, oid, &e, flags, &bad_pack)) {
/*
* The lookup may have failed because the object is known to be
* corrupt in one of the packfiles. Report the object as
@@ -105,7 +137,7 @@ static int odb_source_packed_read_object_stream(struct odb_read_stream **out,
struct odb_source_packed *packed = odb_source_packed_downcast(source);
struct pack_entry e;
- if (!find_pack_entry(packed, oid, &e, NULL))
+ if (!find_pack_entry(packed, oid, &e, 0, NULL))
return -1;
return packfile_read_object_stream(out, oid, e.p, e.offset);
@@ -611,7 +643,7 @@ static int odb_source_packed_freshen_object(struct odb_source *source,
timesp = ×
}
- if (!find_pack_entry(packed, oid, &e, NULL))
+ if (!find_pack_entry(packed, oid, &e, 0, NULL))
return 0;
if (e.p->is_cruft)
return 0;
diff --git a/t/t5319-multi-pack-index.sh b/t/t5319-multi-pack-index.sh
index 68143cb5b7..4041805807 100755
--- a/t/t5319-multi-pack-index.sh
+++ b/t/t5319-multi-pack-index.sh
@@ -1393,4 +1393,84 @@ test_expect_success 'pack.preferBitmapTips interprets patterns as hierarchy' '
)
'
+test_expect_success 'lookup recovers object whose midx-owning pack was removed' '
+ test_when_finished "rm -fr repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ # "keep" ends up only in the big pack; "dup" is deliberately
+ # placed in two packs so the midx has to choose an owner.
+ test_commit keep &&
+ echo duplicated-content >dup &&
+ git add dup &&
+ git commit -m dup &&
+ dup_oid=$(git rev-parse HEAD:dup) &&
+
+ # Roll every object, including dup, into a single big pack.
+ git repack -adq &&
+
+ # Build a second, "moderate" pack that also contains dup, so dup
+ # now lives in two packs that the midx will cover.
+ moderate=$(echo "$dup_oid" |
+ git pack-objects --quiet $objdir/pack/pack) &&
+
+ # Attribute dup to the moderate pack in the midx.
+ git multi-pack-index write \
+ --preferred-pack="pack-$moderate.idx" &&
+
+ # Simulate a concurrent "git repack" retiring the moderate pack:
+ # its files disappear, but the now-stale midx still names it as
+ # the owner of dup. A valid copy of dup survives in the big pack.
+ rm -f $objdir/pack/pack-$moderate.* &&
+
+ # The midx routes the lookup to the deleted pack, and the regular
+ # pack fallback skips midx-covered packs, so without recovery dup
+ # would appear missing even though it is physically present.
+ echo blob >expect &&
+ git cat-file -t "$dup_oid" >actual &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'repeated QUICK lookups recover after owning pack removed' '
+ test_when_finished "rm -fr repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ # Two blobs, each duplicated across packs so the midx must pick
+ # an owning pack, and each attributed to the same moderate pack.
+ echo one >f1 &&
+ echo two >f2 &&
+ git add f1 f2 &&
+ git commit -m dups &&
+ d1=$(git rev-parse HEAD:f1) &&
+ d2=$(git rev-parse HEAD:f2) &&
+
+ # Roll every object, including d1 and d2, into one big pack,
+ # then build a moderate pack that also holds both blobs.
+ git repack -adq &&
+ moderate=$(printf "%s\n%s\n" "$d1" "$d2" |
+ git pack-objects --quiet $objdir/pack/pack) &&
+
+ git multi-pack-index write \
+ --preferred-pack="pack-$moderate.idx" &&
+
+ # Retire the moderate pack; the stale midx still names it as the
+ # owner of both blobs, each of which survives in the big pack.
+ rm -f $objdir/pack/pack-$moderate.* &&
+
+ # One resident QUICK reader ("git mktree --batch") resolves both
+ # blobs. The first lookup recovers d1 and caches the owning
+ # packs failure; unless that failure keeps re-arming the second
+ # read, the lookup of d2 skips its recovering read and the reader
+ # dies reporting d2 as missing.
+ printf "100644 blob %s\tf1\n\n100644 blob %s\tf2\n\n" \
+ "$d1" "$d2" |
+ git mktree --batch >trees &&
+ test_line_count = 2 trees
+ )
+'
+
test_done
--
gitgitgadget
prev parent reply other threads:[~2026-08-25 19:00 UTC|newest]
Thread overview: 27+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-08-18 22:34 [PATCH 0/2] Objects treated as missing despite being present, due to race with geometric repacking Elijah Newren via GitGitGadget
2026-08-18 22:34 ` [PATCH 1/2] replay: fail gracefully when a merge input is unreadable Elijah Newren via GitGitGadget
2026-08-19 18:09 ` Junio C Hamano
2026-08-21 1:44 ` Elijah Newren
2026-08-21 3:37 ` Junio C Hamano
2026-08-18 22:34 ` [PATCH 2/2] packfile: recover when a multi-pack-index names a removed pack Elijah Newren via GitGitGadget
2026-08-19 18:21 ` Junio C Hamano
2026-08-20 7:54 ` Patrick Steinhardt
2026-08-21 1:36 ` Elijah Newren
2026-08-24 4:48 ` Jeff King
2026-08-24 5:13 ` Patrick Steinhardt
2026-08-24 6:55 ` Jeff King
2026-08-24 7:06 ` Jeff King
2026-08-24 7:23 ` Jeff King
2026-08-25 7:38 ` Elijah Newren
2026-08-24 4:55 ` Jeff King
2026-08-24 5:40 ` Patrick Steinhardt
2026-08-24 7:03 ` Jeff King
2026-08-25 7:19 ` Elijah Newren
2026-08-24 14:45 ` Derrick Stolee
2026-08-25 7:38 ` Elijah Newren
2026-08-24 14:46 ` Derrick Stolee
2026-08-25 19:00 ` [PATCH v2 0/4] Objects treated as missing despite being present, due to race with geometric repacking Elijah Newren via GitGitGadget
2026-08-25 19:00 ` [PATCH v2 1/4] replay: fail gracefully when a merge input is unreadable Elijah Newren via GitGitGadget
2026-08-25 19:00 ` [PATCH v2 2/4] mktree: plug per-tree leak in --batch mode Elijah Newren via GitGitGadget
2026-08-25 19:00 ` [PATCH v2 3/4] packfile: recover object lookups racing a concurrent repack Elijah Newren via GitGitGadget
2026-08-25 19:00 ` Elijah Newren via GitGitGadget [this message]
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=eacf6ba4b11e366466da18b7b668e65793c532a9.1787684429.git.gitgitgadget@gmail.com \
--to=gitgitgadget@gmail.com \
--cc=git@vger.kernel.org \
--cc=newren@gmail.com \
--cc=peff@peff.net \
--cc=ps@pks.im \
--cc=stolee@gmail.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox