Skip to content

refdb_fs: refresh packed refs after loose during iteration#4981

Closed
peff wants to merge 2 commits intomasterfrom
packed-refs-race
Closed

refdb_fs: refresh packed refs after loose during iteration#4981
peff wants to merge 2 commits intomasterfrom
packed-refs-race

Conversation

@peff
Copy link
Member

@peff peff commented Feb 14, 2019

There's a possible race between loose and packed refs when iterating over refs. It works like this:

  1. A loose ref X exists.

  2. libgit2 reads the packed-refs file, which does not mention X.

  3. A simultaneously-running git pack-refs commands packs X and prunes the loose version.

  4. libgit2 reads the loose directory, which no longer has X.

At this point, libgit2 claims that X does not exist, even though it does. We can solve this by reading loose refs before packed. This works because refs always migrate from loose to packed, but not the other way around (technically an update to a packed ref creates a new loose file, but it doesn't delete the packed one, so we're guaranteed to always see either the old or the new version).

See also git/git@98eeb09, which fixed this same race in upstream Git, and discusses the race in more detail.

The commit here unfortunately breaks a bunch of tests! The problem seems to be that one cannot naively swap the order of the loose and packed loads. I think the code does not actually understand the precedence between "loose" and "packed", but rather just loads the packed refs and then overlays the loose ones on top in a last-one-wins fashion. By flipping the order, we need a first-one-wins strategy. I don't see a way to easily do that with the git_sortedcache data structure.

So until that is sorted, this PR is garbage; but I wanted to open it to identify the problem and show what the general form of the solution should look like.

[I've force-pushed up a non-broken version].

@peff
Copy link
Member Author

peff commented Feb 14, 2019

One note: if you add back in the packed_reload call from early in the function, and keep the one after the loose as a "refresh", then the tests pass. But this isn't right! The second call is still buggy, but it does nothing if there's no actual race, because it checks the timestamp of the file and bails early.

@peff
Copy link
Member Author

peff commented Feb 14, 2019

I poked at a few related areas, wondering if there were other instances:

  • refdb_fs_backend__exists seems to check the packed and then loose, so could report "does not exist" for this same race

  • refdb_fs_backend__lookup gets it right; it only looks in packed-refs if the loose lookup failed

  • refdb_fs_backend__delete_tail doesn't have this bug, but I think creates its own race. It deletes the loose ref first, before touching packed-refs. During the moment between those two steps, a reader would see the packed-refs value as the authoritative one, but it's garbage at that point (it was some previous value that the ref had, but it could be very old, and possibly not even a reachable object). This was fixed in upstream Git by git/git@dc39e09.

@ethomson
Copy link
Member

It seems like this just swaps one sort of race for another, though, doesn't it?

This works because refs always migrate from loose to packed, but not the other way around (technically an update to a packed ref creates a new loose file, but it doesn't delete the packed one, so we're guaranteed to always see either the old or the new version).

IOW, there's no guarantee that you ever see anything like an atomic snapshot of the state of references... but this new race is superior because you at least see that a reference with a given name exists, even if you're seeing that it's outdated.

I guess this is better? And I'm guessing that this is solving an actual problem that you're having in production, since you're opening this issue here.

Since I missed the Git Contributor Summit this year, I would be remiss if I didn't mention that it's disappointing to spend a lot of time in libgit2 to bring the loose/packed reference storage system up to parity with git, when that parity already has so many obvious deficiencies.

@ethomson
Copy link
Member

With my ObComplaint out of the way, thanks for investigating and opening this issue. I'm sure I'm not alone in appreciating you coming over to the dark side that is libgit2 to bring us fixes. 😀

@peff
Copy link
Member Author

peff commented Feb 14, 2019

One final piece of information to dump: if you want to recreate the race, you can run examples/for-each-ref and git pack-refs --all --prune in a loop. But an easier way to generate it reliably is to instrument libgit2 like this:

diff --git a/src/refdb_fs.c b/src/refdb_fs.c
index d582cb8a2..1e006f439 100644
--- a/src/refdb_fs.c
+++ b/src/refdb_fs.c
@@ -678,6 +678,13 @@ static int refdb_fs_backend__iterator(
        if ((error = packed_reload(backend)) < 0)
                return error;
 
+       /*
+        * This is obviously contrived, but imagine that we are scheduled out
+        * by the OS and this pack-refs is run simultaneously by some other
+        * session.
+        */
+       system("git pack-refs --all --prune");
+
        iter = git__calloc(1, sizeof(refdb_fs_iter));
        GIT_ERROR_CHECK_ALLOC(iter);
 

@peff
Copy link
Member Author

peff commented Feb 14, 2019

IOW, there's no guarantee that you ever see anything like an atomic snapshot of the state of references... but this new race is superior because you at least see that a reference with a given name exists, even if you're seeing that it's outdated.

Yes, it's impossible to have an atomic view of the whole ref namespace given the file formats. And yes, it may be outdated, but for a pretty narrow definition of outdated. If you read simultaneously with somebody updating a ref or packing a ref, you are guaranteed to see either the old value or the new value, atomically (which is the best we can ever do, since there's no locking on the reading side!). But across multiple refs (e.g., say renaming X to Y) it's possible that you may see "old" from one and "new" from another (e.g., not see either X or Y, depending on the ordering). There's nothing we can do to fix that.

So that's strictly better than the current behavior (which may say "does not exist", or may even report an outdated value from a stale packed-refs entry that was just replaced).

I guess this is better? And I'm guessing that this is solving an actual problem that you're having in production, since you're opening this issue here.

Yes, we've actually seen this in production, though it seems to be fairly rare (OTOH, we only detect it when it causes some catastrophic failure, and since it's a problem on the read side, we might just be silently serving bad results without realizing it). And calling this "solving" is generous, because it does not (yet) actually work. 😉

Since I missed the Git Contributor Summit this year, I would be remiss if I didn't mention that it's disappointing to spend a lot of time in libgit2 to bring the loose/packed reference storage system up to parity with git, when that parity already has so many obvious deficiencies.

Yeah, knowing first hand how much work has gone into performance and race-fixing on the git.git side, I'm sad at the thought of having to do it all again (especially since I convinced somebody much smarter than me to do most of the heavy lifting in git.git!).

The ref code in git.git is actually pretty clean these days. I wonder to what degree it would be possible to lift it (especially the low-level packed/loose iterator bits) more or less wholesale. Most of that code is from @mhagger, who I suspect would be amenable to including it in libgit2. It may be a giant rabbit hole, though.

@mhagger
Copy link
Contributor

mhagger commented Feb 14, 2019

As far as licenses are concerned, I've already agreed to allow my code from Git to be incorporated into libgit2. Unfortunately my reference-related code in Git is mixed with lots of other people's code, and I haven't checked whether those people have also agreed to cross-licensing.

As far as actually doing the work is concerned, I'm afraid that I don't have any spare bandwidth. I'd be happy to offer tips and encouragement, but not much more than that.

Sharing more reference code between the two projects would be good for another reason: we could implement the reftable format for storing references once and have it in both projects. It has significantly better performance, uses less disk space, and gives you atomic reading and updates of references without the need for a read lock.

@ethomson
Copy link
Member

Sharing more reference code between the two projects would be good for another reason: we could implement the reftable format for storing references once and have it in both projects. It has significantly better performance, uses less disk space, and gives you atomic reading and updates of references without the need for a read lock.

I'm very much here for this. The sooner we can add a portable ref storage format, the sooner we can start the incredibly long and painful process of deprecating the loose/packed ref storage mechanisms.

It sounds like you're busy with other things, @mhagger -- as am I -- but if you wanted to pair up so that we had a common implementation between libgit2 and git.git, then I'd be willing to find some time for this very noble project.

@ethomson ethomson added the 1.0 label Feb 14, 2019
peff added 2 commits February 15, 2019 04:36
There's a possible race between loose and packed refs when iterating
over refs. It works like this:

  0. A loose ref X exists.

  1. libgit2 reads the packed-refs file, which does not mention X.

  2. A simultaneously-running `git pack-refs` commands packs X and
     prunes the loose version.

  3. libgit2 reads the loose directory, which no longer has X.

At this point, libgit2 claims that X does not exist, even though it
does. We can solve this by reading loose refs before packed. This works
because refs always migrate from loose to packed, but not the other way
around (technically an update to a packed ref creates a new loose file,
but it doesn't _delete_ the packed one, so we're guaranteed to always
see either the old or the new version).

See also git/git@98eeb09, which fixed
this same race in upstream Git, and discusses the race in more detail.

Note that just naively flipping the order of packed_load() and
iter_load_loose_paths() doesn't quite work. The latter expects the
packed refs to already be loaded, so that it can mark them with
PACKREF_SHADOWED. When doing it in reverse order, we have two options:

  1. packed_reload() should take the "loose" vector and check to see
     if refs it loads are shadowed. Doing this efficiently requires the
     loose vector to be sorted, which might impact the output.

  2. After loading both, take an additional pass over the loose vector
     and mark shadowed packrefs. This is slightly less efficient than
     (1) because of the extra pass, but it's big-O identical.

This patch does (2), since it seems less the risky route, and the
efficiency difference should be minimal. If we really care about
efficiency, then we should scrap the current PACKREF_SHADOWED bit
entirely, and instead do a sorted merge of the two lists.

Note that in this patch we do leave the existing PACKREF_SHADOWED
code in iter_load_loose_paths(). If the packed-refs file has already
been loaded, then that code would kick in (and our subsequent
packed-load would be a noop, as we only reload the file if the timestamp
indicates it has changed).
When checking if a ref exists, we load the packed-refs file and then
return true if the ref is found either loose or in the packed-refs
cache.

This is subject to the same race described in the previous commit, where
a simultaneous pack-refs may migrate a ref from loose to packed and
cause us to see neither. We can fix it by making sure we load
packed-refs only after checking the loose store.

As a bonus, this is a minor optimization: we'll avoid even looking at
the packed-refs file if we see a loose version.
@peff
Copy link
Member Author

peff commented Feb 15, 2019

Noble projects aside, I think it's worth fixing this in the short term if we can. And I think I've got it working, and this is ready for real review.

I think the code does not actually understand the precedence between "loose" and "packed", but rather just loads the packed refs and then overlays the loose ones on top in a last-one-wins fashion.

That's not exactly right. It does keep two separate lists, but when creating the "loose" list, it consults the "packed" list to mark any refs there as "shadowed". When flipping the order of load, we have to do the reverse (or take a separate pass to resolve the shadowing -- see the discussion in 83d4060).

refdb_fs_backend__exists seems to check the packed and then loose, so could report "does not exist" for this same race

This one was easy to fix, and is another instance of the same race, so I handled it in 5df823f.

refdb_fs_backend__delete_tail doesn't have this bug, but I think creates its own race

I didn't fix this one. It's really orthogonal to this PR. And while in theory the fix is simple (flip the order of the packed-refs rewrite and unlink()), I think there may be some subtlety around when you hold the packed-refs lock. So I punted on that for now.

@peff peff changed the title WIP: refdb_fs: refresh packed refs after loose during iteration refdb_fs: refresh packed refs after loose during iteration Feb 15, 2019
@pks-t
Copy link
Member

pks-t commented Feb 15, 2019

The commit here unfortunately breaks a bunch of tests! The problem seems to be that one cannot naively swap the order of the loose and packed loads. I think the code does not actually understand the precedence between "loose" and "packed", but rather just loads the packed refs and then overlays the loose ones on top in a last-one-wins fashion. By flipping the order, we need a first-one-wins strategy. I don't see a way to easily do that with the git_sortedcache data structure.

The problem here is not due to our git_sortedcache. The FS refdb uses two different structures to store loose and packed refs: the loose refs are stored in the sorted vector refdb->loose and the packed refs are stored in refdb->cache. As you notice, we still fail tests though based on how which refs we load first.

Correct would be iter_load_loose_paths followed by packed_reload. This cannot work though, because iter_load_loose_paths has the side-effect of setting PACKREF_SHADOWED if the same ref exists in the packed sortedcache to effectively say "Do not output this packed ref, we already have a loose ref shadowing it." If you now reload the packed cache after calling iter_load_loose_paths, you loose the PACKREF_SHADOWED flag and output any such reference twice.

It should be easily possible to reverse this, though: instead of setting the flag when loading loose refs, we should be able to set it when loading packed refs by searching through the sorted loose refs vector.

@pks-t
Copy link
Member

pks-t commented Feb 15, 2019

Please see #4984 for a different approach, fixing the above ordering dependency. I didn't yet create proper commit messages, so please regard it as RFC

@tiennou
Copy link
Contributor

tiennou commented Feb 15, 2019

refdb_fs_backend__delete_tail doesn't have this bug, but I think creates its own race

I have a PR around renaming/deleting which might fix it already (known as #4301, #4292, PR at #4316).

I'd be interested in doing the "noble project" thing, since I've already looked pretty hard at that code, and having a shared implementation would indeed be ❤️ (especially the reftable thing).

@peff
Copy link
Member Author

peff commented Feb 21, 2019

@pks-t Yeah, I think we crossed comments/pushes. Looking at your solution in #4984, I think it's roughly the same approach as what I have here, with the exception that I "resolve" the shadow flag with a loop immediately after loading the loose/packed refs into memory. Whereas you do it progressively as the caller actually iterates. They're big-O equivalent, but yours may be slightly faster in practice due to piggy-backing on the existing iteration and using the lockless copy of the cache.

I'm happy if either solution gets merged.

@peff
Copy link
Member Author

peff commented Feb 21, 2019

@tiennou If I'm reading your #4316 right, I don't think it solves the particular problem in __delete_tail. Even if there is a transaction that provides in-process atomicity, we still may be racing with an external process. So the order of packed/loose deletion we do at the lowest level needs to be addressed. I.e., I think teh code in __delete_tail has to be flipped.

@pks-t
Copy link
Member

pks-t commented Feb 21, 2019

Yeah, I think we crossed comments/pushes.

Seems so, sorry for the duplicated work.

I'm happy if either solution gets merged.

There are some additional cleanups in #4984 to prepare the actual fix, which is why I'd personally prefer if that one got merged.

@peff
Copy link
Member Author

peff commented Feb 21, 2019

There are some additional cleanups in #4984 to prepare the actual fix, which is why I'd personally prefer if that one got merged.

OK. I'll close this then, and will assume you can shepherd your PR through to getting merge (FWIW, it looks fine to me, but I am also pretty far from an expert on libgit2 😉).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants