Git development
 help / color / mirror / Atom feed
* [PATCH] tmp-objdir: merge loose object index when migrating tmpdir
@ 2026-10-22 23:40 Justin Tobler
  2026-10-23 12:19 ` Patrick Steinhardt
  2026-10-29  0:24 ` [PATCH v2 0/4] loose: fix migrating "loose-object-idx" with batch fsync Justin Tobler
  0 siblings, 2 replies; 14+ messages in thread
From: Justin Tobler @ 2026-10-22 23:40 UTC (permalink / raw)
  To: git; +Cc: Justin Tobler

When a repository is configured with a compatibility hash, each loose
object write also records a mapping between the object and its
compatibility hash in a "loose-object-idx" file. This index is located
in the same object database the object is written to. If batch fsync of
loose objects is also enabled, the loose objects and index file are
instead written to a temporary object directory and expected to be
migrated to the primary object directory when the batch transaction
commits.

While migrating the loose objects works, the "loose-object-idx" file is
unable to be migrated if an index already exists in the primary object
directory. This occurs because `finalize_object_file_flags()` expects an
already existing file to have the same contents. Otherwise, the
following error is returned:

  error: files '.git/objects/tmp_objdir-bulk-fsync-XXXXXX/loose-object-idx' and '.git/objects/loose-object-idx' differ in contents
  error: unable to migrate temporary objects

Furthermore, it is not sufficient to just replace the preexisting index
with the new one as the new index only contains mapping entries for the
newly written objects.

Update the temporary directory migration logic to handle
"loose-object-idx" files explicitly by appending new mapping entries to
the primary index.

Signed-off-by: Justin Tobler <jltobler@gmail.com>
---

Greetings,

While working on a separate patch series to convert the "files" ODB
transaction to be eagerly created instead of lazily, I encountered this
bug that causes an ODB transaction to fail on commit when a
compatibility hash is also configured. This occurs because the temporary
directory migration code does not know how to handle the loose object
index file.

This patch addresses updating the migration logic to explicitly merge
mapping entries in the transaction by appending them to the primary
loose object index file.

Some other approaches I considered:
  - Storing the uncommitted object mapping entries in the transaction
    without writing a file and only writing on transaction commit. I
    chose to keep writing the temp index into the transaction so that
    child processes that write to the tempdir could still append entries.
  - Also writing mapping entires to the main ODB loose object index.
    This also didn't seem like a good apprach because it write entires
    that may not ultimately get committed.

Thanks for the review,
-Justin

---
 loose.c                       | 72 +++++++++++++++++++++++++++++++++++
 loose.h                       |  2 +
 t/t1016-compatObjectFormat.sh | 27 +++++++++++++
 tmp-objdir.c                  | 11 +++++-
 4 files changed, 111 insertions(+), 1 deletion(-)

diff --git a/loose.c b/loose.c
index d6a2bb5ba3..79d4c256e0 100644
--- a/loose.c
+++ b/loose.c
@@ -265,3 +265,75 @@ void loose_object_map_clear(struct loose_object_map **map)
 	free(m);
 	*map = NULL;
 }
+
+int loose_object_idx_merge(struct repository *repo, const char *src,
+			   const char *dst)
+{
+	struct lock_file lock = LOCK_INIT;
+	struct strbuf buf = STRBUF_INIT;
+	struct stat st;
+	FILE *fp;
+	int fd = -1;
+	int ret;
+
+	fp = fopen(src, "r");
+	if (!fp) {
+		ret = error_errno(_("unable to open %s"), src);
+		goto out;
+	}
+	if (strbuf_getwholeline(&buf, fp, '\n') || strcmp(buf.buf, loose_object_header)) {
+		ret = error(_("invalid loose object index %s"), src);
+		goto out;
+	}
+
+	if (repo_hold_lock_file_for_update_timeout(repo, &lock, dst, 0, -1) < 0) {
+		ret = error_errno(_("unable to lock %s"), dst);
+		goto out;
+	}
+
+	fd = open(dst, O_WRONLY | O_CREAT | O_APPEND, 0666);
+	if (fd < 0) {
+		ret = error_errno(_("unable to open %s"), dst);
+		goto out;
+	}
+	if (fstat(fd, &st) < 0) {
+		ret = error_errno(_("unable to stat %s"), dst);
+		goto out;
+	}
+
+	/* Create the loose-object-idx if one does not already exist. */
+	if (!st.st_size && write_in_full(fd, loose_object_header,
+					 strlen(loose_object_header)) < 0) {
+		ret = error_errno(_("failed to write loose object index header"));
+		goto out;
+	}
+
+	while (!strbuf_getwholeline(&buf, fp, '\n')) {
+		if (write_in_full(fd, buf.buf, buf.len) < 0) {
+			ret = error_errno(_("failed to write loose object index %s"),
+					  dst);
+			goto out;
+		}
+	}
+
+	ret = close(fd);
+	fd = -1;
+	if (ret) {
+		ret = error_errno(_("unable to close %s"), dst);
+		goto out;
+	}
+
+	adjust_shared_perm(repo, dst);
+
+	ret = 0;
+
+out:
+	rollback_lock_file(&lock);
+	strbuf_release(&buf);
+	if (fd >= 0)
+		close(fd);
+	if (fp)
+		fclose(fp);
+
+	return ret;
+}
diff --git a/loose.h b/loose.h
index ed663ac550..35b46f4be5 100644
--- a/loose.h
+++ b/loose.h
@@ -23,5 +23,7 @@ int repo_add_loose_object_map(struct odb_source_loose *loose,
 			      const struct object_id *compat_oid);
 int repo_read_loose_object_map(struct repository *repo);
 int repo_write_loose_object_map(struct repository *repo);
+int loose_object_idx_merge(struct repository *repo, const char *src,
+			   const char *dst);
 
 #endif
diff --git a/t/t1016-compatObjectFormat.sh b/t/t1016-compatObjectFormat.sh
index 9cafcee509..bebf71bc29 100755
--- a/t/t1016-compatObjectFormat.sh
+++ b/t/t1016-compatObjectFormat.sh
@@ -305,4 +305,31 @@ compare_oids GPG2 'commit' signedcommit4 "$signedcommit4_sha1_oid" "$signedcommi
 compare_oids GPG2 'tag' signedtag3 "$signedtag3_sha1_oid" "$signedtag3_sha256_oid"
 compare_oids GPG2 'tag' signedtag4 "$signedtag4_sha1_oid" "$signedtag4_sha256_oid"
 
+test_expect_success 'batched fsync migrates loose object index' '
+	test_when_finished "rm -rf batch-fsync" &&
+	git init --object-format=sha1 batch-fsync &&
+	(
+		cd batch-fsync &&
+		git config core.repositoryFormatVersion 1 &&
+		git config extensions.compatObjectFormat sha256 &&
+		git config core.fsync loose-object &&
+		git config core.fsyncMethod batch &&
+
+		# Write object to generate initial loose object index.
+		echo foo >foo &&
+		git add foo &&
+		test_grep "$(git rev-parse :foo)" .git/objects/loose-object-idx &&
+
+		# Write some additional objects and ensure the corresponding
+		# temporary loose object index entries stored in the transaction
+		# are merged into the primary ODB.
+		echo bar >bar &&
+		echo baz >baz &&
+		git add bar baz &&
+		test_grep "$(git rev-parse :foo)" .git/objects/loose-object-idx &&
+		test_grep "$(git rev-parse :bar)" .git/objects/loose-object-idx &&
+		test_grep "$(git rev-parse :baz)" .git/objects/loose-object-idx
+	)
+'
+
 test_done
diff --git a/tmp-objdir.c b/tmp-objdir.c
index 3debb60270..5307717354 100644
--- a/tmp-objdir.c
+++ b/tmp-objdir.c
@@ -4,6 +4,7 @@
 #include "chdir-notify.h"
 #include "dir.h"
 #include "environment.h"
+#include "loose.h"
 #include "object-file.h"
 #include "path.h"
 #include "string-list.h"
@@ -294,6 +295,11 @@ static int is_loose_object_shard(const char *name)
 	return strlen(name) == 2 && isxdigit(name[0]) && isxdigit(name[1]);
 }
 
+static int is_loose_object_idx(const char *name)
+{
+	return !strcmp(name, "loose-object-idx");
+}
+
 static int migrate_paths(struct tmp_objdir *t,
 			 struct strbuf *src, struct strbuf *dst,
 			 enum finalize_object_file_flags flags)
@@ -317,7 +323,10 @@ static int migrate_paths(struct tmp_objdir *t,
 		if (is_loose_object_shard(name))
 			flags_copy |= FOF_SKIP_COLLISION_CHECK;
 
-		ret |= migrate_one(t, src, dst, flags_copy);
+		if (is_loose_object_idx(name))
+			ret |= loose_object_idx_merge(t->repo, src->buf, dst->buf);
+		else
+			ret |= migrate_one(t, src, dst, flags_copy);
 
 		strbuf_setlen(src, src_len);
 		strbuf_setlen(dst, dst_len);

base-commit: 7e34d8174dcda3435d1404c9a30bef46afe6c13e
-- 
2.56.0.173.gf57c9a7388


^ permalink raw reply related	[flat|nested] 14+ messages in thread

* Re: [PATCH] tmp-objdir: merge loose object index when migrating tmpdir
  2026-10-22 23:40 [PATCH] tmp-objdir: merge loose object index when migrating tmpdir Justin Tobler
@ 2026-10-23 12:19 ` Patrick Steinhardt
  2026-10-23 21:25   ` Justin Tobler
  2026-10-29  0:24 ` [PATCH v2 0/4] loose: fix migrating "loose-object-idx" with batch fsync Justin Tobler
  1 sibling, 1 reply; 14+ messages in thread
From: Patrick Steinhardt @ 2026-10-23 12:19 UTC (permalink / raw)
  To: Justin Tobler; +Cc: git

On Thu, Oct 22, 2026 at 06:40:07PM -0500, Justin Tobler wrote:
> When a repository is configured with a compatibility hash, each loose
> object write also records a mapping between the object and its
> compatibility hash in a "loose-object-idx" file. This index is located
> in the same object database the object is written to. If batch fsync of

You probably mean "object directory", not "database"?

> loose objects is also enabled, the loose objects and index file are
> instead written to a temporary object directory and expected to be
> migrated to the primary object directory when the batch transaction
> commits.
> 
> While migrating the loose objects works, the "loose-object-idx" file is
> unable to be migrated if an index already exists in the primary object

The naming really throws me off, "index" to me is our staging area. What
this is is a map. But oh, well, cannot change that now.

> directory. This occurs because `finalize_object_file_flags()` expects an
> already existing file to have the same contents. Otherwise, the
> following error is returned:
> 
>   error: files '.git/objects/tmp_objdir-bulk-fsync-XXXXXX/loose-object-idx' and '.git/objects/loose-object-idx' differ in contents
>   error: unable to migrate temporary objects
> 
> Furthermore, it is not sufficient to just replace the preexisting index
> with the new one as the new index only contains mapping entries for the
> newly written objects.
> 
> Update the temporary directory migration logic to handle
> "loose-object-idx" files explicitly by appending new mapping entries to
> the primary index.

Sounds sensible.

> Signed-off-by: Justin Tobler <jltobler@gmail.com>
> ---
> 
> Greetings,
> 
> While working on a separate patch series to convert the "files" ODB
> transaction to be eagerly created instead of lazily, I encountered this
> bug that causes an ODB transaction to fail on commit when a
> compatibility hash is also configured. This occurs because the temporary
> directory migration code does not know how to handle the loose object
> index file.
> 
> This patch addresses updating the migration logic to explicitly merge
> mapping entries in the transaction by appending them to the primary
> loose object index file.
> 
> Some other approaches I considered:
>   - Storing the uncommitted object mapping entries in the transaction
>     without writing a file and only writing on transaction commit. I
>     chose to keep writing the temp index into the transaction so that
>     child processes that write to the tempdir could still append entries.

Is that a theoretical scenario or do we actually have cases where a
child process wants to write data into the quarantine directory, too?

>   - Also writing mapping entires to the main ODB loose object index.
>     This also didn't seem like a good apprach because it write entires
>     that may not ultimately get committed.

Agreed, we shouldn't commit this before the actual objects exist. I bet
we don't properly handle this during housekeeping either -- or at least
I never stumbled over logic that prunes mappings when pruning the
corresponding objects. But that's a problem to solve another day.

> diff --git a/loose.c b/loose.c
> index d6a2bb5ba3..79d4c256e0 100644
> --- a/loose.c
> +++ b/loose.c
> @@ -265,3 +265,75 @@ void loose_object_map_clear(struct loose_object_map **map)
>  	free(m);
>  	*map = NULL;
>  }
> +
> +int loose_object_idx_merge(struct repository *repo, const char *src,
> +			   const char *dst)
> +{
> +	struct lock_file lock = LOCK_INIT;
> +	struct strbuf buf = STRBUF_INIT;
> +	struct stat st;
> +	FILE *fp;
> +	int fd = -1;
> +	int ret;
> +
> +	fp = fopen(src, "r");
> +	if (!fp) {
> +		ret = error_errno(_("unable to open %s"), src);
> +		goto out;
> +	}
> +	if (strbuf_getwholeline(&buf, fp, '\n') || strcmp(buf.buf, loose_object_header)) {
> +		ret = error(_("invalid loose object index %s"), src);
> +		goto out;
> +	}

Curious. Why do we even expect that this file may be broken?

> +	if (repo_hold_lock_file_for_update_timeout(repo, &lock, dst, 0, -1) < 0) {
> +		ret = error_errno(_("unable to lock %s"), dst);
> +		goto out;
> +	}

With a timeout of -1 we'll block indefinitely. That doesn't feel like
the best option to me, as the lockfile may be left over from a previous
process. And Git locking up indefinitely feels suboptimal to me.

> +	fd = open(dst, O_WRONLY | O_CREAT | O_APPEND, 0666);

I don't think it's a good idea to append data under the lock. It may
easily cause us to corrupt the file in case the write succeeds
partially, only.

Our usual pattern here is to create the lockfile, write all data into
the new file and then rename it into place.

I'm also a bit surprised that the file isn't sorted. Is a reader
expected to always read _all_ mappings?

> +	if (fd < 0) {
> +		ret = error_errno(_("unable to open %s"), dst);
> +		goto out;
> +	}
> +	if (fstat(fd, &st) < 0) {
> +		ret = error_errno(_("unable to stat %s"), dst);
> +		goto out;
> +	}
> +
> +	/* Create the loose-object-idx if one does not already exist. */
> +	if (!st.st_size && write_in_full(fd, loose_object_header,
> +					 strlen(loose_object_header)) < 0) {
> +		ret = error_errno(_("failed to write loose object index header"));
> +		goto out;
> +	}
> +
> +	while (!strbuf_getwholeline(&buf, fp, '\n')) {
> +		if (write_in_full(fd, buf.buf, buf.len) < 0) {
> +			ret = error_errno(_("failed to write loose object index %s"),
> +					  dst);
> +			goto out;
> +		}
> +	}

When merging, wouldn't we have to verify that the object IDs that we're
adding don't already exist?

> diff --git a/tmp-objdir.c b/tmp-objdir.c
> index 3debb60270..5307717354 100644
> --- a/tmp-objdir.c
> +++ b/tmp-objdir.c
> @@ -317,7 +323,10 @@ static int migrate_paths(struct tmp_objdir *t,
>  		if (is_loose_object_shard(name))
>  			flags_copy |= FOF_SKIP_COLLISION_CHECK;
>  
> -		ret |= migrate_one(t, src, dst, flags_copy);
> +		if (is_loose_object_idx(name))
> +			ret |= loose_object_idx_merge(t->repo, src->buf, dst->buf);
> +		else
> +			ret |= migrate_one(t, src, dst, flags_copy);

Makes sense.

Thanks!

Patrick

^ permalink raw reply	[flat|nested] 14+ messages in thread

* Re: [PATCH] tmp-objdir: merge loose object index when migrating tmpdir
  2026-10-23 12:19 ` Patrick Steinhardt
@ 2026-10-23 21:25   ` Justin Tobler
  2026-10-26  6:39     ` Patrick Steinhardt
  0 siblings, 1 reply; 14+ messages in thread
From: Justin Tobler @ 2026-10-23 21:25 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: git

On 26/10/23 02:19PM, Patrick Steinhardt wrote:
> On Thu, Oct 22, 2026 at 06:40:07PM -0500, Justin Tobler wrote:
> > When a repository is configured with a compatibility hash, each loose
> > object write also records a mapping between the object and its
> > compatibility hash in a "loose-object-idx" file. This index is located
> > in the same object database the object is written to. If batch fsync of
> 
> You probably mean "object directory", not "database"?

Ya, "object directory" would be more correct here. Will fix.

> > loose objects is also enabled, the loose objects and index file are
> > instead written to a temporary object directory and expected to be
> > migrated to the primary object directory when the batch transaction
> > commits.
> > 
> > While migrating the loose objects works, the "loose-object-idx" file is
> > unable to be migrated if an index already exists in the primary object
> 
> The naming really throws me off, "index" to me is our staging area. What
> this is is a map. But oh, well, cannot change that now.

Agreed. I may change the commit message to call it a "mapping file" if
that helps in any way.

[snip]
> > Some other approaches I considered:
> >   - Storing the uncommitted object mapping entries in the transaction
> >     without writing a file and only writing on transaction commit. I
> >     chose to keep writing the temp index into the transaction so that
> >     child processes that write to the tempdir could still append entries.
> 
> Is that a theoretical scenario or do we actually have cases where a
> child process wants to write data into the quarantine directory, too?

I think a git-receive-pack(1) can spawn a git-unpack-objects(1) child
process and write loose objects into the quarantine directory.
Consequently, if a repository is configured with a compatibility hash,
these loose objects would also end up writing mapping entries into the
quarantine.

> >   - Also writing mapping entires to the main ODB loose object index.
> >     This also didn't seem like a good apprach because it write entires
> >     that may not ultimately get committed.
> 
> Agreed, we shouldn't commit this before the actual objects exist. I bet
> we don't properly handle this during housekeeping either -- or at least
> I never stumbled over logic that prunes mappings when pruning the
> corresponding objects. But that's a problem to solve another day.

Yep, there doesn't appear to be any existing logic to prune entries from
this mapping file. It can only grow in size. This seemed like a separate
problem altogether though, so I didn't bother tackling that here.

> > diff --git a/loose.c b/loose.c
> > index d6a2bb5ba3..79d4c256e0 100644
> > --- a/loose.c
> > +++ b/loose.c
> > @@ -265,3 +265,75 @@ void loose_object_map_clear(struct loose_object_map **map)
> >  	free(m);
> >  	*map = NULL;
> >  }
> > +
> > +int loose_object_idx_merge(struct repository *repo, const char *src,
> > +			   const char *dst)
> > +{
> > +	struct lock_file lock = LOCK_INIT;
> > +	struct strbuf buf = STRBUF_INIT;
> > +	struct stat st;
> > +	FILE *fp;
> > +	int fd = -1;
> > +	int ret;
> > +
> > +	fp = fopen(src, "r");
> > +	if (!fp) {
> > +		ret = error_errno(_("unable to open %s"), src);
> > +		goto out;
> > +	}
> > +	if (strbuf_getwholeline(&buf, fp, '\n') || strcmp(buf.buf, loose_object_header)) {
> > +		ret = error(_("invalid loose object index %s"), src);
> > +		goto out;
> > +	}
> 
> Curious. Why do we even expect that this file may be broken?

For our intended use case of reading the "loose-object-idx" file from
the quarantine directory and merging its entries to the mapping file in
the primary object directory, I wouldn't expect it to ever be broken.
That is because realistically only a "loose-object-idx" that we create
during the transaction should be written into the quarantine directory.

I chose to be more defensive here because the `loose_object_idx_merge()`
function itself isn't aware of the circumstances regarding its use case.
In theory, another caller could use any mapping file as the source.
Ultimately though I don't feel too strongly either way and am happy to
remove it if we don't like it.

> > +	if (repo_hold_lock_file_for_update_timeout(repo, &lock, dst, 0, -1) < 0) {
> > +		ret = error_errno(_("unable to lock %s"), dst);
> > +		goto out;
> > +	}
> 
> With a timeout of -1 we'll block indefinitely. That doesn't feel like
> the best option to me, as the lockfile may be left over from a previous
> process. And Git locking up indefinitely feels suboptimal to me.

That's fair. I picked this value purely because I was closely following
how `write_one_object()` implements its logic to update the
"loose-object-idx" file.

Would it make sense to make this value configurable? Or would it be fine
to just set it to 1000ms? If we change this here, maybe we should also
change it for `write_one_object()` to match?

> > +	fd = open(dst, O_WRONLY | O_CREAT | O_APPEND, 0666);
> 
> I don't think it's a good idea to append data under the lock. It may
> easily cause us to corrupt the file in case the write succeeds
> partially, only.
> 
> Our usual pattern here is to create the lockfile, write all data into
> the new file and then rename it into place.

So here I was also following how `write_one_object()` handles writing
its mapping entries. I was trying to keep the `loose_object_idx_merge()`
and `write_one_object()` approaches similar. If this really isn't the
write approach though, I can explore adapting the patch to follow our
more typical pattern.

> I'm also a bit surprised that the file isn't sorted. Is a reader
> expected to always read _all_ mappings?

I was surprised by this too. From my understanding, readers are not
expected to consult the "loose-object-idx" file directly, but instead
consult an in-memory hash map that is loaded from the file. When an
entry is not found in map, the file is reloaded to try to pick up any
missing entries.

I see this a preexisting quirk though and am not sure it is something
that needs to be addressed as part of this change.

> > +	if (fd < 0) {
> > +		ret = error_errno(_("unable to open %s"), dst);
> > +		goto out;
> > +	}
> > +	if (fstat(fd, &st) < 0) {
> > +		ret = error_errno(_("unable to stat %s"), dst);
> > +		goto out;
> > +	}
> > +
> > +	/* Create the loose-object-idx if one does not already exist. */
> > +	if (!st.st_size && write_in_full(fd, loose_object_header,
> > +					 strlen(loose_object_header)) < 0) {
> > +		ret = error_errno(_("failed to write loose object index header"));
> > +		goto out;
> > +	}
> > +
> > +	while (!strbuf_getwholeline(&buf, fp, '\n')) {
> > +		if (write_in_full(fd, buf.buf, buf.len) < 0) {
> > +			ret = error_errno(_("failed to write loose object index %s"),
> > +					  dst);
> > +			goto out;
> > +		}
> > +	}
> 
> When merging, wouldn't we have to verify that the object IDs that we're
> adding don't already exist?

So typically a loose object doesn't get written to the object directory
at all if it already exists and thus doesn't create a new mapping entry.
There is a race though where concurrent writes of the same object could
cause the same entry to be appended twice. This is already an existing
problem though as `write_one_object()` also doesn't verify whether the
object is already in the file.

With this patch, I mostly avoided many of these preexisting quirks and
just focused on addressing the underlying bug. If we feel strongly about
it though, I can change the approach.

Thanks,
-Justin

^ permalink raw reply	[flat|nested] 14+ messages in thread

* Re: [PATCH] tmp-objdir: merge loose object index when migrating tmpdir
  2026-10-23 21:25   ` Justin Tobler
@ 2026-10-26  6:39     ` Patrick Steinhardt
  2026-10-26 17:58       ` Justin Tobler
  0 siblings, 1 reply; 14+ messages in thread
From: Patrick Steinhardt @ 2026-10-26  6:39 UTC (permalink / raw)
  To: Justin Tobler; +Cc: git

On Fri, Oct 23, 2026 at 04:25:12PM -0500, Justin Tobler wrote:
> On 26/10/23 02:19PM, Patrick Steinhardt wrote:
> > On Thu, Oct 22, 2026 at 06:40:07PM -0500, Justin Tobler wrote:
[snip]
> > > diff --git a/loose.c b/loose.c
> > > index d6a2bb5ba3..79d4c256e0 100644
> > > --- a/loose.c
> > > +++ b/loose.c
> > > @@ -265,3 +265,75 @@ void loose_object_map_clear(struct loose_object_map **map)
> > >  	free(m);
> > >  	*map = NULL;
> > >  }
> > > +
> > > +int loose_object_idx_merge(struct repository *repo, const char *src,
> > > +			   const char *dst)
> > > +{
> > > +	struct lock_file lock = LOCK_INIT;
> > > +	struct strbuf buf = STRBUF_INIT;
> > > +	struct stat st;
> > > +	FILE *fp;
> > > +	int fd = -1;
> > > +	int ret;
> > > +
> > > +	fp = fopen(src, "r");
> > > +	if (!fp) {
> > > +		ret = error_errno(_("unable to open %s"), src);
> > > +		goto out;
> > > +	}
> > > +	if (strbuf_getwholeline(&buf, fp, '\n') || strcmp(buf.buf, loose_object_header)) {
> > > +		ret = error(_("invalid loose object index %s"), src);
> > > +		goto out;
> > > +	}
> > 
> > Curious. Why do we even expect that this file may be broken?
> 
> For our intended use case of reading the "loose-object-idx" file from
> the quarantine directory and merging its entries to the mapping file in
> the primary object directory, I wouldn't expect it to ever be broken.
> That is because realistically only a "loose-object-idx" that we create
> during the transaction should be written into the quarantine directory.
> 
> I chose to be more defensive here because the `loose_object_idx_merge()`
> function itself isn't aware of the circumstances regarding its use case.
> In theory, another caller could use any mapping file as the source.
> Ultimately though I don't feel too strongly either way and am happy to
> remove it if we don't like it.

Ah. You mean because we're called with arbitrary paths we want to verify
whether our callers know what they're doing, and consequently we check
whether the files look like a mapping file? Still a bit funny, but with
that context it at least doesn't feel too unreasonable.

> > > +	if (repo_hold_lock_file_for_update_timeout(repo, &lock, dst, 0, -1) < 0) {
> > > +		ret = error_errno(_("unable to lock %s"), dst);
> > > +		goto out;
> > > +	}
> > 
> > With a timeout of -1 we'll block indefinitely. That doesn't feel like
> > the best option to me, as the lockfile may be left over from a previous
> > process. And Git locking up indefinitely feels suboptimal to me.
> 
> That's fair. I picked this value purely because I was closely following
> how `write_one_object()` implements its logic to update the
> "loose-object-idx" file.
> 
> Would it make sense to make this value configurable? Or would it be fine
> to just set it to 1000ms? If we change this here, maybe we should also
> change it for `write_one_object()` to match?

Yeah, I think that'd be a good idea.

> > > +	fd = open(dst, O_WRONLY | O_CREAT | O_APPEND, 0666);
> > 
> > I don't think it's a good idea to append data under the lock. It may
> > easily cause us to corrupt the file in case the write succeeds
> > partially, only.
> > 
> > Our usual pattern here is to create the lockfile, write all data into
> > the new file and then rename it into place.
> 
> So here I was also following how `write_one_object()` handles writing
> its mapping entries. I was trying to keep the `loose_object_idx_merge()`
> and `write_one_object()` approaches similar. If this really isn't the
> write approach though, I can explore adapting the patch to follow our
> more typical pattern.

I think this is quite broken. Right now nobody would care because we
don't really use the compatibility hash algorithm yet. But once it
starts becoming useful this _will_ lead to corrupted files. So I'd
prefer to fix it rather than doubling down on the broken pattern.

> > I'm also a bit surprised that the file isn't sorted. Is a reader
> > expected to always read _all_ mappings?
> 
> I was surprised by this too. From my understanding, readers are not
> expected to consult the "loose-object-idx" file directly, but instead
> consult an in-memory hash map that is loaded from the file. When an
> entry is not found in map, the file is reloaded to try to pick up any
> missing entries.

Sure. But that'd still require a linear scan through all mappings in
here.

> I see this a preexisting quirk though and am not sure it is something
> that needs to be addressed as part of this change.

Yeah, I think it's fine to ignore this for now. This part here is
certainly outside the scope of the patch series.

> > > +	if (fd < 0) {
> > > +		ret = error_errno(_("unable to open %s"), dst);
> > > +		goto out;
> > > +	}
> > > +	if (fstat(fd, &st) < 0) {
> > > +		ret = error_errno(_("unable to stat %s"), dst);
> > > +		goto out;
> > > +	}
> > > +
> > > +	/* Create the loose-object-idx if one does not already exist. */
> > > +	if (!st.st_size && write_in_full(fd, loose_object_header,
> > > +					 strlen(loose_object_header)) < 0) {
> > > +		ret = error_errno(_("failed to write loose object index header"));
> > > +		goto out;
> > > +	}
> > > +
> > > +	while (!strbuf_getwholeline(&buf, fp, '\n')) {
> > > +		if (write_in_full(fd, buf.buf, buf.len) < 0) {
> > > +			ret = error_errno(_("failed to write loose object index %s"),
> > > +					  dst);
> > > +			goto out;
> > > +		}
> > > +	}
> > 
> > When merging, wouldn't we have to verify that the object IDs that we're
> > adding don't already exist?
> 
> So typically a loose object doesn't get written to the object directory
> at all if it already exists and thus doesn't create a new mapping entry.
> There is a race though where concurrent writes of the same object could
> cause the same entry to be appended twice. This is already an existing
> problem though as `write_one_object()` also doesn't verify whether the
> object is already in the file.
> 
> With this patch, I mostly avoided many of these preexisting quirks and
> just focused on addressing the underlying bug. If we feel strongly about
> it though, I can change the approach.

I feel strongly that this mechanism is quite broken in its current form:

  - The file is unsorted, making lookups inefficient.

  - We don't deduplicate at all.

  - We never prune entries.

  - Writes are not atomic.

The first three are conceptual issues that we'd have to fix to ever make
this feature usable. But that certainly doesn't have to happen as part
of this patch series.

The last part though shouldn't be too hard to fix, and you make the
problem worse because you add a second writer that uses the same broken
pattern. So that's a part I'd like to see fixd as part of this series,
but I won't insist on it.

Thanks!

Patrick

^ permalink raw reply	[flat|nested] 14+ messages in thread

* Re: [PATCH] tmp-objdir: merge loose object index when migrating tmpdir
  2026-10-26  6:39     ` Patrick Steinhardt
@ 2026-10-26 17:58       ` Justin Tobler
  0 siblings, 0 replies; 14+ messages in thread
From: Justin Tobler @ 2026-10-26 17:58 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: git

On 26/10/26 07:39AM, Patrick Steinhardt wrote:
> On Fri, Oct 23, 2026 at 04:25:12PM -0500, Justin Tobler wrote:
> > On 26/10/23 02:19PM, Patrick Steinhardt wrote:
> > > On Thu, Oct 22, 2026 at 06:40:07PM -0500, Justin Tobler wrote:
> > > > +	if (strbuf_getwholeline(&buf, fp, '\n') || strcmp(buf.buf, loose_object_header)) {
> > > > +		ret = error(_("invalid loose object index %s"), src);
> > > > +		goto out;
> > > > +	}
> > > 
> > > Curious. Why do we even expect that this file may be broken?
> > 
> > For our intended use case of reading the "loose-object-idx" file from
> > the quarantine directory and merging its entries to the mapping file in
> > the primary object directory, I wouldn't expect it to ever be broken.
> > That is because realistically only a "loose-object-idx" that we create
> > during the transaction should be written into the quarantine directory.
> > 
> > I chose to be more defensive here because the `loose_object_idx_merge()`
> > function itself isn't aware of the circumstances regarding its use case.
> > In theory, another caller could use any mapping file as the source.
> > Ultimately though I don't feel too strongly either way and am happy to
> > remove it if we don't like it.
> 
> Ah. You mean because we're called with arbitrary paths we want to verify
> whether our callers know what they're doing, and consequently we check
> whether the files look like a mapping file? Still a bit funny, but with
> that context it at least doesn't feel too unreasonable.

Your understanding is correct. This may be more defensive that
realistically neccessary. Probably doesn't matter too much either way
though.

> > > > +	if (repo_hold_lock_file_for_update_timeout(repo, &lock, dst, 0, -1) < 0) {
> > > > +		ret = error_errno(_("unable to lock %s"), dst);
> > > > +		goto out;
> > > > +	}
> > > 
> > > With a timeout of -1 we'll block indefinitely. That doesn't feel like
> > > the best option to me, as the lockfile may be left over from a previous
> > > process. And Git locking up indefinitely feels suboptimal to me.
> > 
> > That's fair. I picked this value purely because I was closely following
> > how `write_one_object()` implements its logic to update the
> > "loose-object-idx" file.
> > 
> > Would it make sense to make this value configurable? Or would it be fine
> > to just set it to 1000ms? If we change this here, maybe we should also
> > change it for `write_one_object()` to match?
> 
> Yeah, I think that'd be a good idea.

Ok, I'll update to use 1000ms in the next version.

[snip]
> > > When merging, wouldn't we have to verify that the object IDs that we're
> > > adding don't already exist?
> > 
> > So typically a loose object doesn't get written to the object directory
> > at all if it already exists and thus doesn't create a new mapping entry.
> > There is a race though where concurrent writes of the same object could
> > cause the same entry to be appended twice. This is already an existing
> > problem though as `write_one_object()` also doesn't verify whether the
> > object is already in the file.
> > 
> > With this patch, I mostly avoided many of these preexisting quirks and
> > just focused on addressing the underlying bug. If we feel strongly about
> > it though, I can change the approach.
> 
> I feel strongly that this mechanism is quite broken in its current form:
> 
>   - The file is unsorted, making lookups inefficient.
> 
>   - We don't deduplicate at all.
> 
>   - We never prune entries.
> 
>   - Writes are not atomic.

Ya, I completely agree. There are quite a few existing problems here.

> The first three are conceptual issues that we'd have to fix to ever make
> this feature usable. But that certainly doesn't have to happen as part
> of this patch series.
> 
> The last part though shouldn't be too hard to fix, and you make the
> problem worse because you add a second writer that uses the same broken
> pattern. So that's a part I'd like to see fixd as part of this series,
> but I won't insist on it.

That's fair. I'll update in the next version to make writing the
"loose-object-idx" file here atomic. I'm not entirely sure if I should
go ahead and update the code in `write_one_object()` to also be atomic
though.

One concern I have is operations that may write many loose objects,
would rewrite the "loose-object-idx" file for each object written. If
the mapping file becomes quite large, I suppose this could create a
significant performance regression. If these loose object writes were
batched in a transaction, we could wait to write the final mapping file
at transaction commit, but that may not always be the case.

Right now this feature isn't really usable to begin with, so maybe it
doesn't matter too much either way. For now, I'll proceed with just
updating the mapping file writer adding in this patch to be atomic.

-Justin

^ permalink raw reply	[flat|nested] 14+ messages in thread

* [PATCH v2 0/4] loose: fix migrating "loose-object-idx" with batch fsync
  2026-10-22 23:40 [PATCH] tmp-objdir: merge loose object index when migrating tmpdir Justin Tobler
  2026-10-23 12:19 ` Patrick Steinhardt
@ 2026-10-29  0:24 ` Justin Tobler
  2026-10-29  0:24   ` [PATCH v2 1/4] loose: use finite timeout when locking "loose-object-idx" file Justin Tobler
                     ` (3 more replies)
  1 sibling, 4 replies; 14+ messages in thread
From: Justin Tobler @ 2026-10-29  0:24 UTC (permalink / raw)
  To: git; +Cc: ps, Justin Tobler

Greetings,

While working on a separate patch series to convert the "files" ODB
transaction to be eagerly created instead of lazily, I encountered this
bug that causes an ODB transaction to fail on commit when a
compatibility hash is also configured. This occurs because the temporary
directory migration code does not know how to handle the loose object
index file.

This patch addresses updating the migration logic to explicitly merge
mapping entries in the transaction and atomically write a new
"loose-object-idx" file.

Some other approaches I considered:
  - Storing the uncommitted object mapping entries in the transaction
    without writing a file and only writing on transaction commit. I
    chose to keep writing the temp index into the transaction so that
    child processes that write to the tempdir could still append entries.
  - Also writing mapping entires to the main ODB loose object index.
    This also didn't seem like a good apprach because it write entries
    that may not ultimately get committed.

Changes since V1:
  - The loose object index write after the merging maps is now atomic.
  - Added a fixed timeout to all loose object index writers.
  - Duplicate entries are now dropped when writing the merged mapping file.

Thanks for the review,
-Justin

Justin Tobler (4):
  loose: use finite timeout when locking "loose-object-idx" file
  loose: lift loose object index parsing logic into helper
  loose: lift loose object index writing logic into helper
  tmp-objdir: merge loose object index when migrating tmpdir

 loose.c                       | 207 ++++++++++++++++++++++++----------
 loose.h                       |   2 +
 t/t1016-compatObjectFormat.sh |  27 +++++
 tmp-objdir.c                  |  11 +-
 4 files changed, 189 insertions(+), 58 deletions(-)

Range-diff against v1:
-:  ---------- > 1:  6f104c482e loose: use finite timeout when locking "loose-object-idx" file
-:  ---------- > 2:  4799421a74 loose: lift loose object index parsing logic into helper
-:  ---------- > 3:  4233e913ce loose: lift loose object index writing logic into helper
1:  34393e789c ! 4:  614aab27d1 tmp-objdir: merge loose object index when migrating tmpdir
    @@ Commit message
     
         When a repository is configured with a compatibility hash, each loose
         object write also records a mapping between the object and its
    -    compatibility hash in a "loose-object-idx" file. This index is located
    -    in the same object database the object is written to. If batch fsync of
    -    loose objects is also enabled, the loose objects and index file are
    -    instead written to a temporary object directory and expected to be
    -    migrated to the primary object directory when the batch transaction
    +    compatibility hash in a "loose-object-idx" file. This mapping file is
    +    located in the same object directory the object is written to. If batch
    +    fsync of loose objects is also enabled, the loose objects and mapping
    +    file are instead written to a temporary object directory and expected to
    +    be migrated to the primary object directory when the batch transaction
         commits.
     
         While migrating the loose objects works, the "loose-object-idx" file is
    -    unable to be migrated if an index already exists in the primary object
    -    directory. This occurs because `finalize_object_file_flags()` expects an
    -    already existing file to have the same contents. Otherwise, the
    -    following error is returned:
    +    unable to be migrated if a mapping file already exists in the primary
    +    object directory. This occurs because `finalize_object_file_flags()`
    +    expects an already existing file to have the same contents. Otherwise,
    +    the following error is returned:
     
           error: files '.git/objects/tmp_objdir-bulk-fsync-XXXXXX/loose-object-idx' and '.git/objects/loose-object-idx' differ in contents
           error: unable to migrate temporary objects
     
    -    Furthermore, it is not sufficient to just replace the preexisting index
    -    with the new one as the new index only contains mapping entries for the
    -    newly written objects.
    +    Furthermore, it is not sufficient to just replace the preexisting
    +    mapping file with the new one as the new file only contains mapping
    +    entries for the newly written objects.
     
         Update the temporary directory migration logic to handle
    -    "loose-object-idx" files explicitly by appending new mapping entries to
    -    the primary index.
    +    "loose-object-idx" files explicitly by merging new entries and
    +    atomically writing the resulting mapping file.
     
         Signed-off-by: Justin Tobler <jltobler@gmail.com>
     
    @@ loose.c: void loose_object_map_clear(struct loose_object_map **map)
     +			   const char *dst)
     +{
     +	struct lock_file lock = LOCK_INIT;
    -+	struct strbuf buf = STRBUF_INIT;
    -+	struct stat st;
    -+	FILE *fp;
    -+	int fd = -1;
    ++	struct loose_object_map *map;
     +	int ret;
    ++	int fd;
     +
    -+	fp = fopen(src, "r");
    -+	if (!fp) {
    -+		ret = error_errno(_("unable to open %s"), src);
    -+		goto out;
    -+	}
    -+	if (strbuf_getwholeline(&buf, fp, '\n') || strcmp(buf.buf, loose_object_header)) {
    -+		ret = error(_("invalid loose object index %s"), src);
    -+		goto out;
    -+	}
    ++	loose_object_map_init(&map);
     +
    -+	if (repo_hold_lock_file_for_update_timeout(repo, &lock, dst, 0, -1) < 0) {
    ++	fd = repo_hold_lock_file_for_update_timeout(repo, &lock, dst, 0,
    ++						    LOOSE_OBJECT_IDX_LOCK_TIMEOUT_MS);
    ++	if (fd < 0) {
     +		ret = error_errno(_("unable to lock %s"), dst);
     +		goto out;
     +	}
     +
    -+	fd = open(dst, O_WRONLY | O_CREAT | O_APPEND, 0666);
    -+	if (fd < 0) {
    -+		ret = error_errno(_("unable to open %s"), dst);
    -+		goto out;
    -+	}
    -+	if (fstat(fd, &st) < 0) {
    -+		ret = error_errno(_("unable to stat %s"), dst);
    ++	if (loose_object_map_read(repo, dst, map, NULL) < 0) {
    ++		ret = error(_("unable to read loose object index %s"), dst);
     +		goto out;
     +	}
     +
    -+	/* Create the loose-object-idx if one does not already exist. */
    -+	if (!st.st_size && write_in_full(fd, loose_object_header,
    -+					 strlen(loose_object_header)) < 0) {
    -+		ret = error_errno(_("failed to write loose object index header"));
    ++	if (loose_object_map_read(repo, src, map, NULL) < 0) {
    ++		ret = error(_("unable to read loose object index %s"), src);
     +		goto out;
     +	}
     +
    -+	while (!strbuf_getwholeline(&buf, fp, '\n')) {
    -+		if (write_in_full(fd, buf.buf, buf.len) < 0) {
    -+			ret = error_errno(_("failed to write loose object index %s"),
    -+					  dst);
    -+			goto out;
    -+		}
    ++	if (loose_object_map_write(repo, map, fd) < 0) {
    ++		ret = error_errno(_("unable to write %s"),
    ++				  get_lock_file_path(&lock));
    ++		goto out;
     +	}
     +
    -+	ret = close(fd);
    -+	fd = -1;
    -+	if (ret) {
    -+		ret = error_errno(_("unable to close %s"), dst);
    ++	if (commit_lock_file(&lock) < 0) {
    ++		ret = error_errno(_("unable to write %s"), dst);
     +		goto out;
     +	}
     +
    -+	adjust_shared_perm(repo, dst);
    -+
     +	ret = 0;
     +
     +out:
    ++	loose_object_map_clear(&map);
     +	rollback_lock_file(&lock);
    -+	strbuf_release(&buf);
    -+	if (fd >= 0)
    -+		close(fd);
    -+	if (fp)
    -+		fclose(fp);
     +
     +	return ret;
     +}
    @@ t/t1016-compatObjectFormat.sh: compare_oids GPG2 'commit' signedcommit4 "$signed
     +
     +		# Write some additional objects and ensure the corresponding
     +		# temporary loose object index entries stored in the transaction
    -+		# are merged into the primary ODB.
    ++		# are merged into the primary object directory.
     +		echo bar >bar &&
     +		echo baz >baz &&
     +		git add bar baz &&

base-commit: 7e34d8174dcda3435d1404c9a30bef46afe6c13e
-- 
2.56.0.173.gf57c9a7388


^ permalink raw reply	[flat|nested] 14+ messages in thread

* [PATCH v2 1/4] loose: use finite timeout when locking "loose-object-idx" file
  2026-10-29  0:24 ` [PATCH v2 0/4] loose: fix migrating "loose-object-idx" with batch fsync Justin Tobler
@ 2026-10-29  0:24   ` Justin Tobler
  2026-10-29  8:05     ` Patrick Steinhardt
  2026-10-29  0:24   ` [PATCH v2 2/4] loose: lift loose object index parsing logic into helper Justin Tobler
                     ` (2 subsequent siblings)
  3 siblings, 1 reply; 14+ messages in thread
From: Justin Tobler @ 2026-10-29  0:24 UTC (permalink / raw)
  To: git; +Cc: ps, Justin Tobler

Loose object index file writers first attempt to acquire a lock via
`repo_hold_lock_file_for_update_timeout()` and specify a timeout value
of -1. With a negative timeout value, the lock acquisition retries
indefinitely while the lockfile still exists. If a process dies while
holding the lock and leaves a stale lockfile behind, every subsequent
writer of the mapping file hangs forever instead of reporting an error.

Introduce LOOSE_OBJECT_IDX_LOCK_TIMEOUT_MS to set the timeout to 1000ms
and use it for all callers that lock a "loose-object-idx" file. A value
of 1000ms matches the default timeout used for "core.packedRefsTimeout"
and should provide concurrent writers sufficient time to acquire the
lock.

Signed-off-by: Justin Tobler <jltobler@gmail.com>
---
 loose.c | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/loose.c b/loose.c
index d6a2bb5ba3..c6f4ee0ec6 100644
--- a/loose.c
+++ b/loose.c
@@ -14,6 +14,8 @@
 
 static const char *loose_object_header = "# loose-object-idx\n";
 
+#define LOOSE_OBJECT_IDX_LOCK_TIMEOUT_MS 1000
+
 static inline int should_use_loose_object_map(struct repository *repo)
 {
 	return repo->compat_hash_algo && repo->gitdir;
@@ -137,7 +139,8 @@ int repo_write_loose_object_map(struct repository *repo)
 
 	repo_common_path_replace(repo, &path, "objects/loose-object-idx");
 	fd = repo_hold_lock_file_for_update_timeout(repo, &lock, path.buf,
-						    LOCK_DIE_ON_ERROR, -1);
+						    LOCK_DIE_ON_ERROR,
+						    LOOSE_OBJECT_IDX_LOCK_TIMEOUT_MS);
 	iter = kh_begin(map);
 	if (write_in_full(fd, loose_object_header, strlen(loose_object_header)) < 0)
 		goto errout;
@@ -180,7 +183,8 @@ static int write_one_object(struct odb_source_loose *loose,
 
 	strbuf_addf(&path, "%s/loose-object-idx", loose->base.path);
 	repo_hold_lock_file_for_update_timeout(loose->base.odb->repo, &lock,
-					       path.buf, LOCK_DIE_ON_ERROR, -1);
+					       path.buf, LOCK_DIE_ON_ERROR,
+					       LOOSE_OBJECT_IDX_LOCK_TIMEOUT_MS);
 
 	fd = open(path.buf, O_WRONLY | O_CREAT | O_APPEND, 0666);
 	if (fd < 0)
-- 
2.56.0.173.gf57c9a7388


^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH v2 2/4] loose: lift loose object index parsing logic into helper
  2026-10-29  0:24 ` [PATCH v2 0/4] loose: fix migrating "loose-object-idx" with batch fsync Justin Tobler
  2026-10-29  0:24   ` [PATCH v2 1/4] loose: use finite timeout when locking "loose-object-idx" file Justin Tobler
@ 2026-10-29  0:24   ` Justin Tobler
  2026-10-29  8:05     ` Patrick Steinhardt
  2026-10-29  0:24   ` [PATCH v2 3/4] loose: lift loose object index writing " Justin Tobler
  2026-10-29  0:24   ` [PATCH v2 4/4] tmp-objdir: merge loose object index when migrating tmpdir Justin Tobler
  3 siblings, 1 reply; 14+ messages in thread
From: Justin Tobler @ 2026-10-29  0:24 UTC (permalink / raw)
  To: git; +Cc: ps, Justin Tobler

Mapping entries from the "loose-object-idx" file are parsed via
`loose_object_map_load()` and inserted into the in-memory loose object
map of the corresponding object source. In a subsequent commit, a
temporary loose object index file must also be parsed and have its
contents stored in a map that is not attached to any object source.

Extract the logic for parsing a loose object index file into a separate
`loose_object_map_read()` helper function that allows callers to
explicitly specify the map to insert the entries into. The
`insert_loose_map()` helper function is also updated accordingly to
explicitly operate on the provided map and optional cache instead of the
object source.

While at it, stop treating all errors related to opening the
"loose-object-idx" file as success and instead only ignore ENOENT. It is
expected that a mapping file may not always be present, but other errors
should be signaled back to callers. Note that this slightly changes the
behavior of `repo_read_loose_object_map()`, which now stops loading the
mapping files of subsequent object sources when one is unreadable, the
same way it already does when one is malformed.

Signed-off-by: Justin Tobler <jltobler@gmail.com>
---
 loose.c | 87 +++++++++++++++++++++++++++++++++++----------------------
 1 file changed, 54 insertions(+), 33 deletions(-)

diff --git a/loose.c b/loose.c
index c6f4ee0ec6..5f72c644f9 100644
--- a/loose.c
+++ b/loose.c
@@ -48,67 +48,88 @@ static int insert_oid_pair(kh_oid_map_t *map, const struct object_id *key, const
 	return 1;
 }
 
-static int insert_loose_map(struct odb_source_loose *loose,
+static int insert_loose_map(struct loose_object_map *map,
+			    struct oidtree *cache,
 			    const struct object_id *oid,
 			    const struct object_id *compat_oid)
 {
-	struct loose_object_map *map = loose->map;
 	int inserted = 0;
 
 	inserted |= insert_oid_pair(map->to_compat, oid, compat_oid);
 	inserted |= insert_oid_pair(map->to_storage, compat_oid, oid);
-	if (inserted)
-		oidtree_insert(loose->cache, compat_oid, NULL);
+	if (inserted && cache)
+		oidtree_insert(cache, compat_oid, NULL);
 
 	return inserted;
 }
 
-int loose_object_map_load(struct odb_source_loose *loose)
+static int loose_object_map_read(struct repository *repo, const char *path,
+				 struct loose_object_map *map,
+				 struct oidtree *cache)
 {
-	struct repository *repo = loose->base.odb->repo;
 	struct strbuf buf = STRBUF_INIT;
-	char *path;
 	FILE *fp;
-	int ret = -1;
-
-	if (!should_use_loose_object_map(repo))
-		return 0;
-
-	if (!loose->map)
-		loose_object_map_init(&loose->map);
-	if (!loose->cache) {
-		ALLOC_ARRAY(loose->cache, 1);
-		oidtree_init(loose->cache);
-	}
-
-	insert_loose_map(loose, repo->hash_algo->empty_tree, repo->compat_hash_algo->empty_tree);
-	insert_loose_map(loose, repo->hash_algo->empty_blob, repo->compat_hash_algo->empty_blob);
-	insert_loose_map(loose, repo->hash_algo->null_oid, repo->compat_hash_algo->null_oid);
+	int ret;
 
-	path = xstrfmt("%s/loose-object-idx", loose->base.path);
 	fp = fopen(path, "rb");
 	if (!fp) {
-		free(path);
-		return 0;
+		if (errno == ENOENT)
+			return 0;
+		return -1;
 	}
 
-	if (strbuf_getwholeline(&buf, fp, '\n') || strcmp(buf.buf, loose_object_header))
-		goto err;
+	if (strbuf_getwholeline(&buf, fp, '\n') || strcmp(buf.buf, loose_object_header)) {
+		ret = -1;
+		goto out;
+	}
 	while (!strbuf_getline_lf(&buf, fp)) {
 		const char *p;
 		struct object_id oid, compat_oid;
 		if (parse_oid_hex_algop(buf.buf, &oid, &p, repo->hash_algo) ||
 		    *p++ != ' ' ||
 		    parse_oid_hex_algop(p, &compat_oid, &p, repo->compat_hash_algo) ||
-		    p != buf.buf + buf.len)
-			goto err;
-		insert_loose_map(loose, &oid, &compat_oid);
+		    p != buf.buf + buf.len) {
+			ret = -1;
+			goto out;
+		}
+		insert_loose_map(map, cache, &oid, &compat_oid);
 	}
 
 	ret = ferror(fp) ? -1 : 0;
-err:
-	fclose(fp);
+
+out:
 	strbuf_release(&buf);
+	fclose(fp);
+
+	return ret;
+}
+
+int loose_object_map_load(struct odb_source_loose *loose)
+{
+	struct repository *repo = loose->base.odb->repo;
+	char *path;
+	int ret;
+
+	if (!should_use_loose_object_map(repo))
+		return 0;
+
+	if (!loose->map)
+		loose_object_map_init(&loose->map);
+	if (!loose->cache) {
+		ALLOC_ARRAY(loose->cache, 1);
+		oidtree_init(loose->cache);
+	}
+
+	insert_loose_map(loose->map, loose->cache, repo->hash_algo->empty_tree,
+			 repo->compat_hash_algo->empty_tree);
+	insert_loose_map(loose->map, loose->cache, repo->hash_algo->empty_blob,
+			 repo->compat_hash_algo->empty_blob);
+	insert_loose_map(loose->map, loose->cache, repo->hash_algo->null_oid,
+			 repo->compat_hash_algo->null_oid);
+
+	path = xstrfmt("%s/loose-object-idx", loose->base.path);
+	ret = loose_object_map_read(repo, path, loose->map, loose->cache);
+
 	free(path);
 	return ret;
 }
@@ -223,7 +244,7 @@ int repo_add_loose_object_map(struct odb_source_loose *loose,
 	if (!should_use_loose_object_map(loose->base.odb->repo))
 		return 0;
 
-	inserted = insert_loose_map(loose, oid, compat_oid);
+	inserted = insert_loose_map(loose->map, loose->cache, oid, compat_oid);
 	if (inserted)
 		return write_one_object(loose, oid, compat_oid);
 	return 0;
-- 
2.56.0.173.gf57c9a7388


^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH v2 3/4] loose: lift loose object index writing logic into helper
  2026-10-29  0:24 ` [PATCH v2 0/4] loose: fix migrating "loose-object-idx" with batch fsync Justin Tobler
  2026-10-29  0:24   ` [PATCH v2 1/4] loose: use finite timeout when locking "loose-object-idx" file Justin Tobler
  2026-10-29  0:24   ` [PATCH v2 2/4] loose: lift loose object index parsing logic into helper Justin Tobler
@ 2026-10-29  0:24   ` Justin Tobler
  2026-10-29  8:05     ` Patrick Steinhardt
  2026-10-29  0:24   ` [PATCH v2 4/4] tmp-objdir: merge loose object index when migrating tmpdir Justin Tobler
  3 siblings, 1 reply; 14+ messages in thread
From: Justin Tobler @ 2026-10-29  0:24 UTC (permalink / raw)
  To: git; +Cc: ps, Justin Tobler

Writing the in-memory loose object map to a "loose-object-idx" file is
handled via `repo_write_loose_object_map()`, which always writes the map
into the primary object directory. In a subsequent commit, a separate
"loose-object-idx" file is staged prior to commit and must be
constructed from a loose object map not attached to the primary loose
object source.

Extract the logic for writing a loose object index file into a separate
`loose_object_map_write()` helper function that allows callers to
explicitly specify both the loose object map as well as the file
descriptor to write the entries to.

Signed-off-by: Justin Tobler <jltobler@gmail.com>
---
 loose.c | 69 +++++++++++++++++++++++++++++++++++++--------------------
 1 file changed, 45 insertions(+), 24 deletions(-)

diff --git a/loose.c b/loose.c
index 5f72c644f9..f2e111c25e 100644
--- a/loose.c
+++ b/loose.c
@@ -146,25 +146,17 @@ int repo_read_loose_object_map(struct repository *repo)
 	return 0;
 }
 
-int repo_write_loose_object_map(struct repository *repo)
+static int loose_object_map_write(struct repository *repo,
+				  struct loose_object_map *loose_map, int fd)
 {
-	struct odb_source_files *files = odb_source_files_downcast(repo->objects->source);
-	kh_oid_map_t *map = files->dirs->loose->map->to_compat;
-	struct lock_file lock;
-	int fd;
+	kh_oid_map_t *map = loose_map->to_compat;
+	struct strbuf buf = STRBUF_INIT;
 	khiter_t iter;
-	struct strbuf buf = STRBUF_INIT, path = STRBUF_INIT;
-
-	if (!should_use_loose_object_map(repo))
-		return 0;
+	int ret = -1;
 
-	repo_common_path_replace(repo, &path, "objects/loose-object-idx");
-	fd = repo_hold_lock_file_for_update_timeout(repo, &lock, path.buf,
-						    LOCK_DIE_ON_ERROR,
-						    LOOSE_OBJECT_IDX_LOCK_TIMEOUT_MS);
 	iter = kh_begin(map);
 	if (write_in_full(fd, loose_object_header, strlen(loose_object_header)) < 0)
-		goto errout;
+		goto out;
 
 	for (; iter != kh_end(map); iter++) {
 		if (kh_exist(map, iter)) {
@@ -173,24 +165,53 @@ int repo_write_loose_object_map(struct repository *repo)
 				continue;
 			strbuf_addf(&buf, "%s %s\n", oid_to_hex(&kh_key(map, iter)), oid_to_hex(kh_value(map, iter)));
 			if (write_in_full(fd, buf.buf, buf.len) < 0)
-				goto errout;
+				goto out;
 			strbuf_reset(&buf);
 		}
 	}
+
+	ret = 0;
+
+out:
 	strbuf_release(&buf);
+
+	return ret;
+}
+
+int repo_write_loose_object_map(struct repository *repo)
+{
+	struct odb_source_files *files = odb_source_files_downcast(repo->objects->source);
+	struct strbuf path = STRBUF_INIT;
+	struct lock_file lock;
+	int ret;
+	int fd;
+
+	if (!should_use_loose_object_map(repo))
+		return 0;
+
+	repo_common_path_replace(repo, &path, "objects/loose-object-idx");
+	fd = repo_hold_lock_file_for_update_timeout(repo, &lock, path.buf,
+						    LOCK_DIE_ON_ERROR,
+						    LOOSE_OBJECT_IDX_LOCK_TIMEOUT_MS);
+
+	if (loose_object_map_write(repo, files->dirs->loose->map, fd) < 0) {
+		ret = error_errno(_("unable to write %s"),
+				  get_lock_file_path(&lock));
+		goto out;
+	}
+
 	if (commit_lock_file(&lock) < 0) {
-		error_errno(_("could not write loose object index %s"), path.buf);
-		strbuf_release(&path);
-		return -1;
+		ret = error_errno(_("unable to write %s"), path.buf);
+		goto out;
 	}
-	strbuf_release(&path);
-	return 0;
-errout:
+
+	ret = 0;
+
+out:
 	rollback_lock_file(&lock);
-	strbuf_release(&buf);
-	error_errno(_("failed to write loose object index %s"), path.buf);
 	strbuf_release(&path);
-	return -1;
+
+	return ret;
 }
 
 static int write_one_object(struct odb_source_loose *loose,
-- 
2.56.0.173.gf57c9a7388


^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH v2 4/4] tmp-objdir: merge loose object index when migrating tmpdir
  2026-10-29  0:24 ` [PATCH v2 0/4] loose: fix migrating "loose-object-idx" with batch fsync Justin Tobler
                     ` (2 preceding siblings ...)
  2026-10-29  0:24   ` [PATCH v2 3/4] loose: lift loose object index writing " Justin Tobler
@ 2026-10-29  0:24   ` Justin Tobler
  2026-10-29  8:05     ` Patrick Steinhardt
  3 siblings, 1 reply; 14+ messages in thread
From: Justin Tobler @ 2026-10-29  0:24 UTC (permalink / raw)
  To: git; +Cc: ps, Justin Tobler

When a repository is configured with a compatibility hash, each loose
object write also records a mapping between the object and its
compatibility hash in a "loose-object-idx" file. This mapping file is
located in the same object directory the object is written to. If batch
fsync of loose objects is also enabled, the loose objects and mapping
file are instead written to a temporary object directory and expected to
be migrated to the primary object directory when the batch transaction
commits.

While migrating the loose objects works, the "loose-object-idx" file is
unable to be migrated if a mapping file already exists in the primary
object directory. This occurs because `finalize_object_file_flags()`
expects an already existing file to have the same contents. Otherwise,
the following error is returned:

  error: files '.git/objects/tmp_objdir-bulk-fsync-XXXXXX/loose-object-idx' and '.git/objects/loose-object-idx' differ in contents
  error: unable to migrate temporary objects

Furthermore, it is not sufficient to just replace the preexisting
mapping file with the new one as the new file only contains mapping
entries for the newly written objects.

Update the temporary directory migration logic to handle
"loose-object-idx" files explicitly by merging new entries and
atomically writing the resulting mapping file.

Signed-off-by: Justin Tobler <jltobler@gmail.com>
---
 loose.c                       | 47 +++++++++++++++++++++++++++++++++++
 loose.h                       |  2 ++
 t/t1016-compatObjectFormat.sh | 27 ++++++++++++++++++++
 tmp-objdir.c                  | 11 +++++++-
 4 files changed, 86 insertions(+), 1 deletion(-)

diff --git a/loose.c b/loose.c
index f2e111c25e..36da63684f 100644
--- a/loose.c
+++ b/loose.c
@@ -311,3 +311,50 @@ void loose_object_map_clear(struct loose_object_map **map)
 	free(m);
 	*map = NULL;
 }
+
+int loose_object_idx_merge(struct repository *repo, const char *src,
+			   const char *dst)
+{
+	struct lock_file lock = LOCK_INIT;
+	struct loose_object_map *map;
+	int ret;
+	int fd;
+
+	loose_object_map_init(&map);
+
+	fd = repo_hold_lock_file_for_update_timeout(repo, &lock, dst, 0,
+						    LOOSE_OBJECT_IDX_LOCK_TIMEOUT_MS);
+	if (fd < 0) {
+		ret = error_errno(_("unable to lock %s"), dst);
+		goto out;
+	}
+
+	if (loose_object_map_read(repo, dst, map, NULL) < 0) {
+		ret = error(_("unable to read loose object index %s"), dst);
+		goto out;
+	}
+
+	if (loose_object_map_read(repo, src, map, NULL) < 0) {
+		ret = error(_("unable to read loose object index %s"), src);
+		goto out;
+	}
+
+	if (loose_object_map_write(repo, map, fd) < 0) {
+		ret = error_errno(_("unable to write %s"),
+				  get_lock_file_path(&lock));
+		goto out;
+	}
+
+	if (commit_lock_file(&lock) < 0) {
+		ret = error_errno(_("unable to write %s"), dst);
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	loose_object_map_clear(&map);
+	rollback_lock_file(&lock);
+
+	return ret;
+}
diff --git a/loose.h b/loose.h
index ed663ac550..35b46f4be5 100644
--- a/loose.h
+++ b/loose.h
@@ -23,5 +23,7 @@ int repo_add_loose_object_map(struct odb_source_loose *loose,
 			      const struct object_id *compat_oid);
 int repo_read_loose_object_map(struct repository *repo);
 int repo_write_loose_object_map(struct repository *repo);
+int loose_object_idx_merge(struct repository *repo, const char *src,
+			   const char *dst);
 
 #endif
diff --git a/t/t1016-compatObjectFormat.sh b/t/t1016-compatObjectFormat.sh
index 9cafcee509..6d0f48226f 100755
--- a/t/t1016-compatObjectFormat.sh
+++ b/t/t1016-compatObjectFormat.sh
@@ -305,4 +305,31 @@ compare_oids GPG2 'commit' signedcommit4 "$signedcommit4_sha1_oid" "$signedcommi
 compare_oids GPG2 'tag' signedtag3 "$signedtag3_sha1_oid" "$signedtag3_sha256_oid"
 compare_oids GPG2 'tag' signedtag4 "$signedtag4_sha1_oid" "$signedtag4_sha256_oid"
 
+test_expect_success 'batched fsync migrates loose object index' '
+	test_when_finished "rm -rf batch-fsync" &&
+	git init --object-format=sha1 batch-fsync &&
+	(
+		cd batch-fsync &&
+		git config core.repositoryFormatVersion 1 &&
+		git config extensions.compatObjectFormat sha256 &&
+		git config core.fsync loose-object &&
+		git config core.fsyncMethod batch &&
+
+		# Write object to generate initial loose object index.
+		echo foo >foo &&
+		git add foo &&
+		test_grep "$(git rev-parse :foo)" .git/objects/loose-object-idx &&
+
+		# Write some additional objects and ensure the corresponding
+		# temporary loose object index entries stored in the transaction
+		# are merged into the primary object directory.
+		echo bar >bar &&
+		echo baz >baz &&
+		git add bar baz &&
+		test_grep "$(git rev-parse :foo)" .git/objects/loose-object-idx &&
+		test_grep "$(git rev-parse :bar)" .git/objects/loose-object-idx &&
+		test_grep "$(git rev-parse :baz)" .git/objects/loose-object-idx
+	)
+'
+
 test_done
diff --git a/tmp-objdir.c b/tmp-objdir.c
index 3debb60270..5307717354 100644
--- a/tmp-objdir.c
+++ b/tmp-objdir.c
@@ -4,6 +4,7 @@
 #include "chdir-notify.h"
 #include "dir.h"
 #include "environment.h"
+#include "loose.h"
 #include "object-file.h"
 #include "path.h"
 #include "string-list.h"
@@ -294,6 +295,11 @@ static int is_loose_object_shard(const char *name)
 	return strlen(name) == 2 && isxdigit(name[0]) && isxdigit(name[1]);
 }
 
+static int is_loose_object_idx(const char *name)
+{
+	return !strcmp(name, "loose-object-idx");
+}
+
 static int migrate_paths(struct tmp_objdir *t,
 			 struct strbuf *src, struct strbuf *dst,
 			 enum finalize_object_file_flags flags)
@@ -317,7 +323,10 @@ static int migrate_paths(struct tmp_objdir *t,
 		if (is_loose_object_shard(name))
 			flags_copy |= FOF_SKIP_COLLISION_CHECK;
 
-		ret |= migrate_one(t, src, dst, flags_copy);
+		if (is_loose_object_idx(name))
+			ret |= loose_object_idx_merge(t->repo, src->buf, dst->buf);
+		else
+			ret |= migrate_one(t, src, dst, flags_copy);
 
 		strbuf_setlen(src, src_len);
 		strbuf_setlen(dst, dst_len);
-- 
2.56.0.173.gf57c9a7388


^ permalink raw reply related	[flat|nested] 14+ messages in thread

* Re: [PATCH v2 1/4] loose: use finite timeout when locking "loose-object-idx" file
  2026-10-29  0:24   ` [PATCH v2 1/4] loose: use finite timeout when locking "loose-object-idx" file Justin Tobler
@ 2026-10-29  8:05     ` Patrick Steinhardt
  0 siblings, 0 replies; 14+ messages in thread
From: Patrick Steinhardt @ 2026-10-29  8:05 UTC (permalink / raw)
  To: Justin Tobler; +Cc: git

On Wed, Oct 28, 2026 at 07:24:10PM -0500, Justin Tobler wrote:
> Loose object index file writers first attempt to acquire a lock via
> `repo_hold_lock_file_for_update_timeout()` and specify a timeout value
> of -1. With a negative timeout value, the lock acquisition retries
> indefinitely while the lockfile still exists. If a process dies while
> holding the lock and leaves a stale lockfile behind, every subsequent
> writer of the mapping file hangs forever instead of reporting an error.
> 
> Introduce LOOSE_OBJECT_IDX_LOCK_TIMEOUT_MS to set the timeout to 1000ms
> and use it for all callers that lock a "loose-object-idx" file. A value
> of 1000ms matches the default timeout used for "core.packedRefsTimeout"
> and should provide concurrent writers sufficient time to acquire the
> lock.

Right, aborting eventually is way better compared to blocking
indefinitely. Ideally we should also make this setting configurable. But
I think it's fine to skip that for now and fix that at a later point in
time.

Patrick

^ permalink raw reply	[flat|nested] 14+ messages in thread

* Re: [PATCH v2 2/4] loose: lift loose object index parsing logic into helper
  2026-10-29  0:24   ` [PATCH v2 2/4] loose: lift loose object index parsing logic into helper Justin Tobler
@ 2026-10-29  8:05     ` Patrick Steinhardt
  0 siblings, 0 replies; 14+ messages in thread
From: Patrick Steinhardt @ 2026-10-29  8:05 UTC (permalink / raw)
  To: Justin Tobler; +Cc: git

On Wed, Oct 28, 2026 at 07:24:11PM -0500, Justin Tobler wrote:
> diff --git a/loose.c b/loose.c
> index c6f4ee0ec6..5f72c644f9 100644
> --- a/loose.c
> +++ b/loose.c
> @@ -48,67 +48,88 @@ static int insert_oid_pair(kh_oid_map_t *map, const struct object_id *key, const
>  	return 1;
>  }
>  
> -static int insert_loose_map(struct odb_source_loose *loose,
> +static int insert_loose_map(struct loose_object_map *map,
> +			    struct oidtree *cache,
>  			    const struct object_id *oid,
>  			    const struct object_id *compat_oid)
>  {
> -	struct loose_object_map *map = loose->map;
>  	int inserted = 0;
>  
>  	inserted |= insert_oid_pair(map->to_compat, oid, compat_oid);
>  	inserted |= insert_oid_pair(map->to_storage, compat_oid, oid);
> -	if (inserted)
> -		oidtree_insert(loose->cache, compat_oid, NULL);
> +	if (inserted && cache)
> +		oidtree_insert(cache, compat_oid, NULL);

Okay, so the `cache` is optional now. You mention that fact in the
commit message, but you don't explain why that is. I assume a subsequent
commit will make use of this.

>  	return inserted;
>  }
>  
> -int loose_object_map_load(struct odb_source_loose *loose)
> +static int loose_object_map_read(struct repository *repo, const char *path,
> +				 struct loose_object_map *map,
> +				 struct oidtree *cache)
>  {
> -	struct repository *repo = loose->base.odb->repo;
>  	struct strbuf buf = STRBUF_INIT;
> -	char *path;
>  	FILE *fp;
> -	int ret = -1;
> -
> -	if (!should_use_loose_object_map(repo))
> -		return 0;
> -
> -	if (!loose->map)
> -		loose_object_map_init(&loose->map);
> -	if (!loose->cache) {
> -		ALLOC_ARRAY(loose->cache, 1);
> -		oidtree_init(loose->cache);
> -	}
> -
> -	insert_loose_map(loose, repo->hash_algo->empty_tree, repo->compat_hash_algo->empty_tree);
> -	insert_loose_map(loose, repo->hash_algo->empty_blob, repo->compat_hash_algo->empty_blob);
> -	insert_loose_map(loose, repo->hash_algo->null_oid, repo->compat_hash_algo->null_oid);

Okay, all of this stays in `loose_object_map_load()` further down.

> +	int ret;
>  
> -	path = xstrfmt("%s/loose-object-idx", loose->base.path);
>  	fp = fopen(path, "rb");
>  	if (!fp) {
> -		free(path);
> -		return 0;
> +		if (errno == ENOENT)
> +			return 0;
> +		return -1;
>  	}
>  
> -	if (strbuf_getwholeline(&buf, fp, '\n') || strcmp(buf.buf, loose_object_header))
> -		goto err;
> +	if (strbuf_getwholeline(&buf, fp, '\n') || strcmp(buf.buf, loose_object_header)) {
> +		ret = -1;
> +		goto out;
> +	}

Ideally we'd of course print an error message telling the user what's
happened. But we didn't before either, so this is fine as-is.

>  	while (!strbuf_getline_lf(&buf, fp)) {
>  		const char *p;
>  		struct object_id oid, compat_oid;
>  		if (parse_oid_hex_algop(buf.buf, &oid, &p, repo->hash_algo) ||
>  		    *p++ != ' ' ||
>  		    parse_oid_hex_algop(p, &compat_oid, &p, repo->compat_hash_algo) ||
> -		    p != buf.buf + buf.len)
> -			goto err;
> -		insert_loose_map(loose, &oid, &compat_oid);
> +		    p != buf.buf + buf.len) {
> +			ret = -1;
> +			goto out;
> +		}
> +		insert_loose_map(map, cache, &oid, &compat_oid);
>  	}
>  
>  	ret = ferror(fp) ? -1 : 0;
> -err:
> -	fclose(fp);
> +
> +out:
>  	strbuf_release(&buf);
> +	fclose(fp);
> +
> +	return ret;
> +}
> +
> +int loose_object_map_load(struct odb_source_loose *loose)
> +{
> +	struct repository *repo = loose->base.odb->repo;
> +	char *path;
> +	int ret;
> +
> +	if (!should_use_loose_object_map(repo))
> +		return 0;
> +
> +	if (!loose->map)
> +		loose_object_map_init(&loose->map);
> +	if (!loose->cache) {
> +		ALLOC_ARRAY(loose->cache, 1);
> +		oidtree_init(loose->cache);
> +	}
> +
> +	insert_loose_map(loose->map, loose->cache, repo->hash_algo->empty_tree,
> +			 repo->compat_hash_algo->empty_tree);
> +	insert_loose_map(loose->map, loose->cache, repo->hash_algo->empty_blob,
> +			 repo->compat_hash_algo->empty_blob);
> +	insert_loose_map(loose->map, loose->cache, repo->hash_algo->null_oid,
> +			 repo->compat_hash_algo->null_oid);
> +
> +	path = xstrfmt("%s/loose-object-idx", loose->base.path);
> +	ret = loose_object_map_read(repo, path, loose->map, loose->cache);
> +
>  	free(path);
>  	return ret;
>  }

Right, and this here is the original function that we had with the
remaining bits that are specific to the source.

Patrick

^ permalink raw reply	[flat|nested] 14+ messages in thread

* Re: [PATCH v2 3/4] loose: lift loose object index writing logic into helper
  2026-10-29  0:24   ` [PATCH v2 3/4] loose: lift loose object index writing " Justin Tobler
@ 2026-10-29  8:05     ` Patrick Steinhardt
  0 siblings, 0 replies; 14+ messages in thread
From: Patrick Steinhardt @ 2026-10-29  8:05 UTC (permalink / raw)
  To: Justin Tobler; +Cc: git

On Wed, Oct 28, 2026 at 07:24:12PM -0500, Justin Tobler wrote:
> Writing the in-memory loose object map to a "loose-object-idx" file is
> handled via `repo_write_loose_object_map()`, which always writes the map
> into the primary object directory. In a subsequent commit, a separate
> "loose-object-idx" file is staged prior to commit and must be
> constructed from a loose object map not attached to the primary loose
> object source.
> 
> Extract the logic for writing a loose object index file into a separate
> `loose_object_map_write()` helper function that allows callers to
> explicitly specify both the loose object map as well as the file
> descriptor to write the entries to.

Okay. This commit is essentially the equivalent to the preceding one,
but for writes.

> diff --git a/loose.c b/loose.c
> index 5f72c644f9..f2e111c25e 100644
> --- a/loose.c
> +++ b/loose.c
> @@ -146,25 +146,17 @@ int repo_read_loose_object_map(struct repository *repo)
>  	return 0;
>  }
>  
> -int repo_write_loose_object_map(struct repository *repo)
> +static int loose_object_map_write(struct repository *repo,
> +				  struct loose_object_map *loose_map, int fd)

Curious that you're using a file descriptor instead of a path as you did
in the preceding commit. But I guess that's because of how we first have
to lock the file, which we of course don't have to do on the writing
side.

I guess we could've also moved locking into this function here to make
both interfaces match, but maybe the next commit will give a hint why we
don't.

Patrick

^ permalink raw reply	[flat|nested] 14+ messages in thread

* Re: [PATCH v2 4/4] tmp-objdir: merge loose object index when migrating tmpdir
  2026-10-29  0:24   ` [PATCH v2 4/4] tmp-objdir: merge loose object index when migrating tmpdir Justin Tobler
@ 2026-10-29  8:05     ` Patrick Steinhardt
  0 siblings, 0 replies; 14+ messages in thread
From: Patrick Steinhardt @ 2026-10-29  8:05 UTC (permalink / raw)
  To: Justin Tobler; +Cc: git

On Wed, Oct 28, 2026 at 07:24:13PM -0500, Justin Tobler wrote:
> diff --git a/loose.c b/loose.c
> index f2e111c25e..36da63684f 100644
> --- a/loose.c
> +++ b/loose.c
> @@ -311,3 +311,50 @@ void loose_object_map_clear(struct loose_object_map **map)
>  	free(m);
>  	*map = NULL;
>  }
> +
> +int loose_object_idx_merge(struct repository *repo, const char *src,
> +			   const char *dst)
> +{
> +	struct lock_file lock = LOCK_INIT;
> +	struct loose_object_map *map;
> +	int ret;
> +	int fd;
> +
> +	loose_object_map_init(&map);
> +
> +	fd = repo_hold_lock_file_for_update_timeout(repo, &lock, dst, 0,
> +						    LOOSE_OBJECT_IDX_LOCK_TIMEOUT_MS);
> +	if (fd < 0) {
> +		ret = error_errno(_("unable to lock %s"), dst);
> +		goto out;
> +	}

Okay, so this explains why you didn't want to pass a path to
`loose_object_map_write()`: we need the lock to span across multiple
other operations. Makes sense.

> +	if (loose_object_map_read(repo, dst, map, NULL) < 0) {
> +		ret = error(_("unable to read loose object index %s"), dst);
> +		goto out;
> +	}
> +
> +	if (loose_object_map_read(repo, src, map, NULL) < 0) {
> +		ret = error(_("unable to read loose object index %s"), src);
> +		goto out;
> +	}

It makes sense that we read `dst` under the lock so that we don't risk
losing data that a concurrent process writes into it. But to minimize
the critical section, can't we move `src` out of it and read it before
we lock?

> diff --git a/loose.h b/loose.h
> index ed663ac550..35b46f4be5 100644
> --- a/loose.h
> +++ b/loose.h
> @@ -23,5 +23,7 @@ int repo_add_loose_object_map(struct odb_source_loose *loose,
>  			      const struct object_id *compat_oid);
>  int repo_read_loose_object_map(struct repository *repo);
>  int repo_write_loose_object_map(struct repository *repo);
> +int loose_object_idx_merge(struct repository *repo, const char *src,
> +			   const char *dst);

Do we maybe want to add a comment to this new function?

Thanks!

Patrick

^ permalink raw reply	[flat|nested] 14+ messages in thread

end of thread, other threads:[~2026-10-29  8:06 UTC | newest]

Thread overview: 14+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-10-22 23:40 [PATCH] tmp-objdir: merge loose object index when migrating tmpdir Justin Tobler
2026-10-23 12:19 ` Patrick Steinhardt
2026-10-23 21:25   ` Justin Tobler
2026-10-26  6:39     ` Patrick Steinhardt
2026-10-26 17:58       ` Justin Tobler
2026-10-29  0:24 ` [PATCH v2 0/4] loose: fix migrating "loose-object-idx" with batch fsync Justin Tobler
2026-10-29  0:24   ` [PATCH v2 1/4] loose: use finite timeout when locking "loose-object-idx" file Justin Tobler
2026-10-29  8:05     ` Patrick Steinhardt
2026-10-29  0:24   ` [PATCH v2 2/4] loose: lift loose object index parsing logic into helper Justin Tobler
2026-10-29  8:05     ` Patrick Steinhardt
2026-10-29  0:24   ` [PATCH v2 3/4] loose: lift loose object index writing " Justin Tobler
2026-10-29  8:05     ` Patrick Steinhardt
2026-10-29  0:24   ` [PATCH v2 4/4] tmp-objdir: merge loose object index when migrating tmpdir Justin Tobler
2026-10-29  8:05     ` Patrick Steinhardt

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox