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 | } | |