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 3/4] packfile: recover object lookups racing a concurrent repack
Date: Tue, 25 Aug 2026 19:00:28 +0000 [thread overview]
Message-ID: <fc98f48ddb4d46cad66a40ecdd96c139e1397784.1787684429.git.gitgitgadget@gmail.com> (raw)
In-Reply-To: <pull.2207.v2.git.1787684429.gitgitgadget@gmail.com>
From: Elijah Newren <newren@gmail.com>
When a reader opens a pack it discovered on disk, open_packed_git_1()
first mmaps the pack's `.idx`. A `git repack` running alongside us
consolidates existing packs into a new one and then removes the
redundant packs, deleting each pack's `.idx` before its `.pack` (see the
ordering in unlink_pack_path()). A reader that had just enumerated one
of those packs -- most easily through a multi-pack-index -- can race with
the removal and find the pack gone.
Two things go wrong in that window:
1. open_pack_index() fails, so we print
error: packfile <path> index unavailable
and report the pack as unusable, even though the object still lives
in the replacement pack.
2. A normal lookup recovers: odb_read_object_info_extended() issues a
second read that reloads the on-disk pack state and finds the object
in its new home, making the message above mere noise. But an
OBJECT_INFO_QUICK lookup deliberately skips that second read to stay
fast on a genuine miss, so it does *not* recover: it reports the
object as absent even though it still lives in the replacement pack.
A resident reader that resolves objects with a QUICK lookup -- such
as the `git mktree --batch` process the tests below drive -- then
produces wrong results. Even where a spurious miss is not fatal it
is not harmless: `git upload-pack` checks a client's "have" lines
with a QUICK lookup, and a dropped "have" removes a common object
from the negotiation, so the client is sent more than it needs.
Recovering without giving up that speed is the trick: we keep QUICK's
fast path for a genuine miss and force the extra read only when a pack
we were already using has provably vanished.
Fix both. Record that a pack disappeared out from under us by setting
object_database.stale_packs_detected at the three points where a reader
can notice a pack vanish beneath it:
- In open_packed_git_1(), when open_pack_index() fails because the
index simply vanished (its open fails with ENOENT). Here we also
stay silent instead of printing "index unavailable"; a genuinely
unreadable index that is still present keeps the error, since that is
a real problem worth surfacing.
- In open_packed_git_1() again, from the other side of the race: when
the `.idx` was already mapped -- so open_pack_index() returns without
touching the filesystem -- yet opening the `.pack` fails with ENOENT.
A reader that prepared its pack list before the repack only trips
over the removal when it finally opens the pack file.
- In prepare_midx_pack(), when packfile_store_load_pack() cannot open a
pack the midx still references at all. If both the `.idx` and the
`.pack` are already gone -- as happens when the redundant pack is
removed outright rather than index-first -- we never reach
open_pack_index(), so this is the only place the vanished pack is
observed.
Then, in odb_read_object_info_extended(), issue the second read -- which
asks the sources to reload their on-disk state (for packs, a reprepare)
and retry -- not only for non-QUICK lookups but also whenever
stale_packs_detected is set, even under OBJECT_INFO_QUICK. An ordinary
QUICK miss, with no vanished pack, still skips the second read and stays
fast; we pay for the rescan only when we have positive evidence that the
on-disk pack set changed beneath us. The flag is reset when the
packfiles are reprepared, in odb_source_packed_prepare().
Add t5336, regression tests that reproduce the race deterministically:
they drive a resident `git mktree --batch` reader -- which resolves each
tree entry with OBJECT_INFO_QUICK -- across both removal windows, one
removing a pack's `.idx` first while a midx routes the lookup to the
doomed pack, the other removing a pack's `.pack` after its `.idx` was
already mapped. Each confirms the reader recovers the relocated object
instead of dying.
Assisted-by: Claude Opus 4.8 & GPT-5.6 Sol
Signed-off-by: Elijah Newren <newren@gmail.com>
---
midx.c | 6 ++
odb.c | 8 +-
odb.h | 16 +++-
odb/source-packed.c | 9 ++-
packfile.c | 39 ++++++++-
t/meson.build | 1 +
t/t5336-repack-reader-race.sh | 148 ++++++++++++++++++++++++++++++++++
7 files changed, 221 insertions(+), 6 deletions(-)
create mode 100755 t/t5336-repack-reader-race.sh
diff --git a/midx.c b/midx.c
index 37f082dbdd..942505ac41 100644
--- a/midx.c
+++ b/midx.c
@@ -475,6 +475,12 @@ int prepare_midx_pack(struct multi_pack_index *m,
if (!p) {
m->packs[pack_int_id] = MIDX_PACK_ERROR;
+ /*
+ * The midx names a pack we can no longer open (its files
+ * vanished, e.g. a concurrent repack replaced it). Record the
+ * stale pack set (see stale_packs_detected).
+ */
+ packed->base.odb->stale_packs_detected = 1;
return 1;
}
diff --git a/odb.c b/odb.c
index 6bbea64033..4bb9662c65 100644
--- a/odb.c
+++ b/odb.c
@@ -583,8 +583,14 @@ static enum odb_read_status do_oid_object_info_extended(struct object_database *
* When the object hasn't been found we try a second read and
* tell the sources so. This may cause them to invalidate
* caches or reload on-disk state.
+ *
+ * A QUICK lookup normally skips this second read to stay fast
+ * on a genuine miss, but retry anyway when a pack vanished
+ * mid-lookup (stale_packs_detected): the object likely just
+ * moved into its replacement pack.
*/
- if (!(flags & OBJECT_INFO_QUICK)) {
+ if (!(flags & OBJECT_INFO_QUICK) ||
+ odb->stale_packs_detected) {
for (source = odb->sources; source; source = source->next) {
ret = odb_source_read_object_info(source, real, oi,
flags | OBJECT_INFO_SECOND_READ,
diff --git a/odb.h b/odb.h
index 1264d4ce7d..8b91e6f8ba 100644
--- a/odb.h
+++ b/odb.h
@@ -93,6 +93,17 @@ struct object_database {
unsigned object_count_flags;
unsigned object_count_valid : 1;
+ /*
+ * Set when a lookup finds that a pack we already know about has
+ * vanished -- its ".idx" or ".pack" removed out from under us, the
+ * signature of a concurrent "git repack". It tells
+ * odb_read_object_info_extended() to reprepare and retry even for an
+ * OBJECT_INFO_QUICK lookup, which normally skips that rescan to stay
+ * fast on a genuine miss. Reset when the packfiles are reprepared
+ * (see odb_source_packed_prepare()).
+ */
+ unsigned stale_packs_detected : 1;
+
/*
* Submodule source paths that will be added as additional sources to
* allow lookup of submodule objects via the main object database.
@@ -423,8 +434,9 @@ enum object_info_flags {
* whether any on-disk state may have changed that may have caused the
* object to appear.
*
- * This flag is for internal use, only. The second read only occurs
- * when `OBJECT_INFO_QUICK` was not passed.
+ * This flag is for internal use, only. The second read occurs when
+ * OBJECT_INFO_QUICK was not passed, or when a vanished pack was
+ * detected (see stale_packs_detected).
*/
OBJECT_INFO_SECOND_READ = (1 << 4),
diff --git a/odb/source-packed.c b/odb/source-packed.c
index 1a12a605db..b6c1d8fdf4 100644
--- a/odb/source-packed.c
+++ b/odb/source-packed.c
@@ -798,8 +798,15 @@ static void odb_source_packed_prepare(struct odb_source *source,
{
struct odb_source_packed *packed = odb_source_packed_downcast(source);
- if (flags & ODB_PREPARE_FLUSH_CACHES)
+ if (flags & ODB_PREPARE_FLUSH_CACHES) {
packed->initialized = false;
+ /*
+ * A reprepare re-scans the on-disk pack set, so any pack we
+ * previously noticed had vanished is accounted for now; clear
+ * the flag that forced this rescan (see stale_packs_detected).
+ */
+ packed->base.odb->stale_packs_detected = 0;
+ }
if (packed->initialized)
return;
diff --git a/packfile.c b/packfile.c
index cd38be088d..bc8587d185 100644
--- a/packfile.c
+++ b/packfile.c
@@ -522,6 +522,21 @@ const char *pack_basename(struct packed_git *p)
return ret;
}
+/* Did the pack's ".idx" vanish from disk (ENOENT), e.g. via a repack? */
+static int pack_index_is_missing(struct packed_git *p)
+{
+ char *idx_name;
+ size_t len;
+ int missing;
+
+ if (!strip_suffix(p->pack_name, ".pack", &len))
+ return 0;
+ idx_name = xstrfmt("%.*s.idx", (int)len, p->pack_name);
+ missing = access(idx_name, F_OK) < 0 && errno == ENOENT;
+ free(idx_name);
+ return missing;
+}
+
/*
* Do not call this directly as this leaks p->pack_fd on error return;
* call open_packed_git() instead.
@@ -535,8 +550,20 @@ static int open_packed_git_1(struct packed_git *p)
ssize_t read_result;
const unsigned hashsz = p->repo->hash_algo->rawsz;
- if (open_pack_index(p))
+ if (open_pack_index(p)) {
+ /*
+ * A concurrent repack may have removed this pack, deleting its
+ * ".idx" before its ".pack" (see unlink_pack_path()). If the
+ * index simply vanished, note the stale pack set and stay
+ * quiet; the pack is still reported unusable. Only a
+ * still-present but unreadable index is worth an error.
+ */
+ if (pack_index_is_missing(p)) {
+ p->repo->objects->stale_packs_detected = 1;
+ return -1;
+ }
return error("packfile %s index unavailable", p->pack_name);
+ }
if (!pack_max_fds) {
unsigned int max_fds = get_max_fd_limit();
@@ -552,8 +579,16 @@ static int open_packed_git_1(struct packed_git *p)
; /* nothing */
p->pack_fd = git_open(p->pack_name);
- if (p->pack_fd < 0 || fstat(p->pack_fd, &st))
+ if (p->pack_fd < 0 || fstat(p->pack_fd, &st)) {
+ /*
+ * A concurrent repack removed this pack, but its ".idx" was
+ * already mapped (so open_pack_index() above succeeded); the
+ * removal surfaces only now, when the ".pack" cannot be opened.
+ */
+ if (p->pack_fd < 0 && errno == ENOENT)
+ p->repo->objects->stale_packs_detected = 1;
return -1;
+ }
pack_open_fds++;
/* If we created the struct before we had the pack we lack size. */
diff --git a/t/meson.build b/t/meson.build
index 2133c840da..28b63c486c 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -639,6 +639,7 @@ integration_tests = [
't5333-pseudo-merge-bitmaps.sh',
't5334-incremental-multi-pack-index.sh',
't5335-compact-multi-pack-index.sh',
+ 't5336-repack-reader-race.sh',
't5351-unpack-large-objects.sh',
't5400-send-pack.sh',
't5401-update-hooks.sh',
diff --git a/t/t5336-repack-reader-race.sh b/t/t5336-repack-reader-race.sh
new file mode 100755
index 0000000000..63dad5521a
--- /dev/null
+++ b/t/t5336-repack-reader-race.sh
@@ -0,0 +1,148 @@
+#!/bin/sh
+
+test_description='reader recovery when a concurrent repack retires a pack
+
+"git repack" consolidates existing packs into a replacement pack and then
+removes the redundant packs, deleting each pack.idx before its pack.pack (see
+the ordering in unlink_pack_path()). A reader that discovered one of those
+packs -- most easily through a multi-pack-index -- can look the pack up in the
+window where its .idx is gone but its .pack is not.
+
+For an OBJECT_INFO_QUICK lookup this is not recovered automatically: QUICK
+skips the reprepare-and-retry that a normal lookup performs, so a persistent
+reader whose pack list predates the replacement pack reports the object as
+missing even though it still lives in the replacement pack. "git mktree
+--batch" is such a persistent QUICK reader: it stays resident across multiple
+trees and resolves each entry with OBJECT_INFO_QUICK, so before this fix it
+produced wrong output in this window.
+
+The removal can also be observed one step later, from the other side: a reader
+that already mmapped a pack.idx (so open_pack_index() succeeds without touching
+the filesystem) but has not yet opened its pack.pack. If the pack.pack is gone
+by the time the reader opens it, the same QUICK false-negative results unless we
+notice the vanished .pack and reprepare.
+'
+
+. ./test-lib.sh
+
+test_expect_success 'setup repo with a multi-pack-index over per-object packs' '
+ test_commit seed &&
+ a=$(echo A | git hash-object -w --stdin) &&
+ b=$(echo B | git hash-object -w --stdin) &&
+ echo "$a" | git pack-objects .git/objects/pack/pack >pack-a &&
+ echo "$b" | git pack-objects .git/objects/pack/pack >pack-b &&
+
+ # Drop the loose copies so the blobs resolve only through the packs the
+ # multi-pack-index references; otherwise the loose object would satisfy
+ # the lookup and the pack-removal race could never be observed.
+ git prune-packed &&
+ git multi-pack-index write &&
+
+ printf "100644 blob %s\ta\n" "$a" >tree-a-input &&
+ printf "100644 blob %s\tb\n" "$b" >tree-b-input
+'
+
+test_expect_success PIPE 'QUICK reader recovers an object whose pack was retired mid-lookup' '
+ victim=".git/objects/pack/pack-$(cat pack-b)" &&
+ mkfifo in out &&
+ test_when_finished "rm -f in out" &&
+
+ # "git mktree --batch" is a resident OBJECT_INFO_QUICK reader; start it
+ # now so its in-memory pack list / midx predates the replacement pack.
+ (git mktree --batch <in >out 2>err &) &&
+ exec 9>in &&
+ exec 8<out &&
+ test_when_finished "exec 9>&- || :" &&
+ test_when_finished "exec 8<&- || :" &&
+
+ # The first tree forces the reader to prepare its (soon stale) pack view
+ # and gives us a synchronization point.
+ cat tree-a-input >&9 &&
+ echo >&9 &&
+ read tree_a <&8 &&
+
+ # Reproduce the transient state a concurrent repack creates: a
+ # replacement pack holding every object, plus the original pack for b
+ # with its .idx removed but its .pack still present.
+ git cat-file --batch-all-objects --batch-check="%(objectname)" >all-oids &&
+ git pack-objects .git/objects/pack/pack <all-oids >/dev/null &&
+ rm -f "$victim.idx" &&
+ test_path_is_file "$victim.pack" &&
+
+ # The reader (stale pack list) now resolves b. Without the recovery its
+ # QUICK lookup reports b missing and mktree dies; with it, b is found in
+ # the replacement pack and the misleading "index unavailable" error is
+ # not printed.
+ cat tree-b-input >&9 &&
+ echo >&9 &&
+ read tree_b <&8 &&
+ exec 9>&- &&
+
+ test -n "$tree_b" &&
+ test_grep ! "index unavailable" err
+'
+
+test_expect_success 'setup a second repo with plain (non-midx) packs' '
+ git init nomidx &&
+ (
+ cd nomidx &&
+ test_commit seed &&
+ a=$(echo A | git hash-object -w --stdin) &&
+ b=$(echo B | git hash-object -w --stdin) &&
+ echo "$a" | git pack-objects .git/objects/pack/pack >pack-a &&
+ echo "$b" | git pack-objects .git/objects/pack/pack >pack-b &&
+ git prune-packed &&
+
+ printf "100644 blob %s\ta\n" "$a" >tree-a-input &&
+ printf "100644 blob %s\tb\n" "$b" >tree-b-input
+ )
+'
+
+test_expect_success PIPE 'QUICK reader recovers when a mapped pack loses its .pack mid-lookup' '
+ (
+ cd nomidx &&
+ victim=".git/objects/pack/pack-$(cat pack-b)" &&
+ mkfifo in out &&
+
+ # We run in a subshell, so leaving the fifos and the reader
+ # descriptors open is harmless: they are cleaned up when the
+ # subshell exits (which also lets "git mktree --batch" see EOF
+ # and quit).
+ (git mktree --batch <in >out 2>err &) &&
+ exec 9>in &&
+ exec 8<out &&
+
+ # Resolving the first tree makes the reader prepare its pack
+ # list. With no multi-pack-index, that scan mmaps every
+ # pack.idx -- including the one for b -- but only opens the
+ # pack.pack it actually reads (the one for a). b is now in the
+ # exact state we want: its .idx is mapped while its .pack is
+ # still unopened.
+ cat tree-a-input >&9 &&
+ echo >&9 &&
+ read tree_a <&8 &&
+
+ # A concurrent repack writes a replacement pack holding every
+ # object and removes the now-redundant pack for b. Delete only
+ # its .pack: the reader keeps the mapped .idx for b, so
+ # open_pack_index() still succeeds and the failure surfaces when
+ # we open the vanished .pack.
+ git cat-file --batch-all-objects --batch-check="%(objectname)" >all-oids &&
+ git pack-objects .git/objects/pack/pack <all-oids >/dev/null &&
+ rm -f "$victim.pack" &&
+ test_path_is_file "$victim.idx" &&
+
+ # The reader (stale pack list) now resolves b. Without the
+ # recovery its QUICK lookup opens the missing .pack, gives up,
+ # and mktree dies; with it, the vanished .pack forces a reprepare
+ # and b is found in the replacement pack.
+ cat tree-b-input >&9 &&
+ echo >&9 &&
+ read tree_b <&8 &&
+ exec 9>&- &&
+
+ test -n "$tree_b"
+ )
+'
+
+test_done
--
gitgitgadget
next 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 ` Elijah Newren via GitGitGadget [this message]
2026-08-25 19:00 ` [PATCH v2 4/4] packfile: recover when a multi-pack-index names a removed pack Elijah Newren via GitGitGadget
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=fc98f48ddb4d46cad66a40ecdd96c139e1397784.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