Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Phase 5: an object is served only through a repository that holds it

65b88b3b1940623b6f7fbc686b19e986497c9ff2 · Keenan Nemetz · 17d ago · parent bf1e027c

Phase 5: an object is served only through a repository that holds it

The store was instance-wide and content-addressed, so an object was fetchable
through any repository whose read rule you passed, provided you knew its
sha256. That is now impossible, and the fix is structural rather than a check:
the pool still holds the bytes once, and each repository has a hard link into
a namespace of its own (data/lfs/repos/<repo>/…). A fetch reads only that
namespace, so "which repository owns these bytes" stops being a database
question that can go stale and becomes a fact about the filesystem — and gc
reconciles the links from the refs, which keeps the promise that derived state
can always be thrown away and rebuilt.

The alternatives were worse. A (repo, oid) table would have made the read path
depend on the index, so deleting data/index.db — which the roadmap promises
loses nothing — would have stopped LFS fetches until a rebuild. Namespacing
the storage itself would have cost the deduplication the store exists for.
Hard links give both: one copy of the bytes, one directory entry per
repository, and removing one repository's link leaves the other's data alone.

Three consequences that had to be handled rather than assumed:

- pre-receive checks pointers against the pushing repository, not the pool. A
  pointer to an object that exists only in another repository would otherwise
  pass the push and then 404 on clone. The client re-uploads, the pool
  deduplicates and the link lands, which is the transfer cost isolation
  implies.
- The sweep walks per repository, because reachability is per repository: an
  object reachable in one and not another keeps the first link and loses the
  second while the bytes stay. So `vole gc` is also the repair — it links what
  a repository reaches and unlinks what it no longer does.
- An instance upgrading from the shared pool has no namespaces at all, so
  every fetch would 404 until somebody read an upgrade note. server.New
  notices a store with no namespace and runs the link pass in the background
  once, using gc.Link — the half of gc that creates links and deletes nothing,
  so an interrupted pass leaves the store more reachable than it found it.
  A repository rename moves its namespace with it.

Reference counting stays rejected, and this makes the argument stronger: the
thing that repairs the store is the same walk that cleans it, and it cannot
invent a reference that does not exist.

Measured: TestAnObjectIsInvisibleToARepositoryThatDoesNotHoldIt and the server
level TestAnObjectIsNotFetchableThroughAnotherRepository (404 over the LFS
endpoint for an object the shared store demonstrably has);
TestTwoRepositoriesShareOneCopyOfTheBytes proves os.SameFile, so isolation
costs no deduplication; TestOneRepositoryLosingAPointerKeepsTheOthersLink and
TestLinkRepairsAMissingNamespace pin the per-repository sweep and the repair;
TestAnUnscopedStoreIsLinkedOnStartup covers the upgrade path.

Phase 5 is now complete: locking and object scoping both landed.

internal/lfs/transfer.go +5−5

@@ -172,7 +172,7 @@ func (t *Transfer) batch(op string, pr *pktReader, pw *pktWriter) error {
172 172 size, _ := strconv.ParseInt(f[1], 10, 64)
173 173 switch op {
174 174 case "upload":
175 if t.Store.Has(oid) {
175 if t.Store.Has(t.Repo, oid) {
176 176 out = append(out, oid+" "+strconv.FormatInt(size, 10)+" noop")
177 177 } else {
178 178 out = append(out, oid+" "+strconv.FormatInt(size, 10)+" upload")
@@ -180,7 +180,7 @@ func (t *Transfer) batch(op string, pr *pktReader, pw *pktWriter) error {
180 180 case "download":
181 181 // An object we do not have gets no action; the client then reports
182 182 // the missing object by oid, which is the only thing it can say.
183 if n, err := t.Store.Stat(oid); err == nil {
183 if n, err := t.Store.Stat(t.Repo, oid); err == nil {
184 184 out = append(out, oid+" "+strconv.FormatInt(n, 10)+" download")
185 185 }
186 186 }
@@ -215,7 +215,7 @@ func (t *Transfer) put(op, oid string, pr *pktReader, pw *pktWriter) error {
215 215 size, _ = strconv.ParseInt(v, 10, 64)
216 216 }
217 217 }
218 n, created, err := t.Store.Put(oid, size, data)
218 n, created, err := t.Store.Put(t.Repo, oid, size, data)
219 219 // Drain whatever the client still has queued so the session stays in sync.
220 220 if _, derr := io.Copy(io.Discard, data); derr != nil && err == nil {
221 221 err = derr
@@ -246,7 +246,7 @@ func (t *Transfer) get(oid string, pr *pktReader, pw *pktWriter) error {
246 246 if !t.CanRead {
247 247 return pw.statusLines(403, []string{t.denied()}, []string{t.denied()})
248 248 }
249 f, size, err := t.Store.Open(oid)
249 f, size, err := t.Store.Open(t.Repo, oid)
250 250 if err != nil {
251 251 code := 404
252 252 if !errors.Is(err, ErrNotFound) {
@@ -281,7 +281,7 @@ func (t *Transfer) verify(oid string, pr *pktReader, pw *pktWriter) error {
281 281 want, _ = strconv.ParseInt(v, 10, 64)
282 282 }
283 283 }
284 n, err := t.Store.Stat(oid)
284 n, err := t.Store.Stat(t.Repo, oid)
285 285 if err != nil {
286 286 return pw.statusLines(404, nil, []string{err.Error()})
287 287 }