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/gc/gc.go +135−44
| @@ -1,22 +1,28 @@ | |||
| 1 | 1 | // Package gc sweeps the LFS store. | |
| 2 | 2 | // | |
| 3 | // The rule the sweep must never break: an object reachable from a live ref is | ||
| 4 | // kept. That is why nothing here trusts a counter — counters drift after a crash, | ||
| 5 | // a failed upload or an interrupted push, and a counter that is one too low | ||
| 6 | // deletes a release artifact. Instead the sweep marks what it can prove is | ||
| 7 | // reachable, and deletes only what remains. | ||
| 3 | // The rule the sweep must never break: an object reachable from a live ref is kept. That | ||
| 4 | // is why nothing here trusts a counter — counters drift after a crash, a failed upload or | ||
| 5 | // an interrupted push, and a counter that is one too low deletes a release artifact. | ||
| 6 | // Instead the sweep marks what it can prove is reachable, and deletes only what remains. | ||
| 8 | 7 | // | |
| 9 | 8 | // Two kinds of root: | |
| 10 | 9 | // | |
| 11 | // - Pointer files in any tree reachable from any ref, in any repository. A | ||
| 12 | // pointer names one object by sha256; that is the whole reference. | ||
| 13 | // - Objects named by the run queue: archived CI logs and published run | ||
| 14 | // artifacts. No git ref reaches them, so a sweep that only marked git would | ||
| 15 | // delete every finished log and every build output a run published. | ||
| 10 | // - Pointer files in any tree reachable from any ref, in any repository. A pointer | ||
| 11 | // names one object by sha256; that is the whole reference. | ||
| 12 | // - Objects named by the run queue: archived CI logs and published run artifacts. No | ||
| 13 | // git ref reaches them, so a sweep that only marked git would delete every finished | ||
| 14 | // log and every build output a run published. | ||
| 16 | 15 | // | |
| 17 | // Anything unmarked and younger than the grace period is kept: an upload that | ||
| 18 | // lands while the sweep is marking is not reachable yet, but is not garbage | ||
| 19 | // either. | ||
| 16 | // Reachability is per repository, because an object is served through the repository that | ||
| 17 | // owns it: the same bytes may be linked into two namespaces, and one repository dropping | ||
| 18 | // its pointer must not remove the other's link. So the sweep does three things in order — | ||
| 19 | // link what is reachable into the repository that reaches it, unlink what that repository | ||
| 20 | // no longer reaches, and finally drop pool objects that no namespace refers to any more. | ||
| 21 | // The middle step is what makes this pass the repair for a store that predates scoping, | ||
| 22 | // or one whose namespace was deleted: nothing else creates the links. | ||
| 23 | // | ||
| 24 | // Anything unmarked and younger than the grace period is kept: an upload that lands while | ||
| 25 | // the sweep is marking is not reachable yet, but is not garbage either. | ||
| 20 | 26 | package gc | |
| 21 | 27 | ||
| 22 | 28 | import ( | |
| @@ -27,23 +33,34 @@ import ( | |||
| 27 | 33 | "github.com/rodent-software/vole/internal/lfs" | |
| 28 | 34 | ) | |
| 29 | 35 | ||
| 36 | // Repo is one repository to scan: the name its objects are served under, and the bare | ||
| 37 | // directory to read refs from. | ||
| 38 | type Repo struct { | ||
| 39 | Name string | ||
| 40 | Dir string | ||
| 41 | } | ||
| 42 | |||
| 30 | 43 | type Options struct { | |
| 31 | // Repos are bare repository directories. | ||
| 32 | Repos []string | ||
| 44 | Repos []Repo | ||
| 33 | 45 | Store *lfs.Store | |
| 34 | // RunOIDs are objects named by the run queue: archived logs and published run | ||
| 35 | // artifacts. Neither is reachable from a git ref, so a sweep that only marked | ||
| 36 | // git would delete every finished log and every build output a run published. | ||
| 37 | RunOIDs []string | ||
| 38 | // Grace protects recent objects that a concurrent upload has not made | ||
| 39 | // reachable yet. | ||
| 46 | // RunOIDs are objects named by the run queue, per repository: archived logs and | ||
| 47 | // published run artifacts. Neither is reachable from a git ref, so a sweep that only | ||
| 48 | // marked git would delete every finished log and every build output a run published. | ||
| 49 | RunOIDs map[string][]string | ||
| 50 | // Grace protects recent objects that a concurrent upload has not made reachable yet. | ||
| 40 | 51 | Grace time.Duration | |
| 41 | 52 | DryRun bool | |
| 42 | Now time.Time | ||
| 53 | // LinkOnly creates the missing links and deletes nothing. It is what an instance | ||
| 54 | // upgrading onto repository-scoped storage runs once, and it is the safe half of | ||
| 55 | // this package: it can only make more objects reachable. | ||
| 56 | LinkOnly bool | ||
| 57 | Now time.Time | ||
| 43 | 58 | } | |
| 44 | 59 | ||
| 45 | 60 | type Result struct { | |
| 46 | 61 | Marked int | |
| 62 | Linked int // links created, which is repair work when it is not zero | ||
| 63 | Unlinked int | ||
| 47 | 64 | Swept int | |
| 48 | 65 | SweptB int64 | |
| 49 | 66 | Within int // unmarked but inside the grace period | |
| @@ -52,8 +69,8 @@ type Result struct { | |||
| 52 | 69 | Problems []string | |
| 53 | 70 | } | |
| 54 | 71 | ||
| 55 | // Run marks, then sweeps. A repository that cannot be scanned aborts the sweep: | ||
| 56 | // an unreadable ref means "I do not know what is reachable", and guessing costs | ||
| 72 | // Run marks, links, unlinks, then sweeps. A repository that cannot be scanned aborts the | ||
| 73 | // sweep: an unreadable ref means "I do not know what is reachable", and guessing costs | ||
| 57 | 74 | // somebody's data. | |
| 58 | 75 | func Run(opts Options) (Result, error) { | |
| 59 | 76 | var res Result | |
| @@ -63,16 +80,20 @@ func Run(opts Options) (Result, error) { | |||
| 63 | 80 | if opts.Now.IsZero() { | |
| 64 | 81 | opts.Now = time.Now() | |
| 65 | 82 | } | |
| 66 | marked := map[string]bool{} | ||
| 67 | for _, oid := range opts.RunOIDs { | ||
| 68 | marked[oid] = true | ||
| 69 | } | ||
| 70 | 83 | ||
| 71 | for _, dir := range opts.Repos { | ||
| 72 | g := gitx.Open(dir) | ||
| 84 | // Two sets: one per repository, for the links that make an object fetchable, and the | ||
| 85 | // union, which is what decides whether the pool may forget the bytes. | ||
| 86 | reachable := make(map[string]map[string]bool, len(opts.Repos)) | ||
| 87 | all := map[string]bool{} | ||
| 88 | for _, repo := range opts.Repos { | ||
| 89 | g := gitx.Open(repo.Dir) | ||
| 73 | 90 | blobs, err := g.AllBlobs() | |
| 74 | 91 | if err != nil { | |
| 75 | return res, fmt.Errorf("gc: scan %s: %w", dir, err) | ||
| 92 | return res, fmt.Errorf("gc: scan %s: %w", repo.Dir, err) | ||
| 93 | } | ||
| 94 | set := map[string]bool{} | ||
| 95 | for _, oid := range opts.RunOIDs[repo.Name] { | ||
| 96 | set[oid] = true | ||
| 76 | 97 | } | |
| 77 | 98 | for sha, size := range blobs { | |
| 78 | 99 | if size > lfs.MaxPointerBytes { | |
| @@ -80,24 +101,88 @@ func Run(opts Options) (Result, error) { | |||
| 80 | 101 | } | |
| 81 | 102 | head, err := g.BlobPrefix(sha, int(lfs.MaxPointerBytes)) | |
| 82 | 103 | if err != nil { | |
| 83 | return res, fmt.Errorf("gc: read %s in %s: %w", sha, dir, err) | ||
| 104 | return res, fmt.Errorf("gc: read %s in %s: %w", sha, repo.Dir, err) | ||
| 84 | 105 | } | |
| 85 | 106 | p, ok := lfs.ParsePointer(head) | |
| 86 | 107 | if !ok { | |
| 87 | 108 | continue | |
| 88 | 109 | } | |
| 89 | marked[p.OID] = true | ||
| 110 | set[p.OID] = true | ||
| 111 | } | ||
| 112 | reachable[repo.Name] = set | ||
| 113 | for oid := range set { | ||
| 114 | all[oid] = true | ||
| 115 | } | ||
| 116 | } | ||
| 117 | // A run's objects belong to a repository that may not exist any more; those are | ||
| 118 | // still marked, so the sweep does not delete bytes a row still names. | ||
| 119 | for _, oids := range opts.RunOIDs { | ||
| 120 | for _, oid := range oids { | ||
| 121 | all[oid] = true | ||
| 122 | } | ||
| 123 | } | ||
| 124 | res.Marked = len(all) | ||
| 125 | |||
| 126 | // Link what each repository can reach, and (unless this is a repair-only pass) | ||
| 127 | // unlink what it cannot. Grace applies to the unlink exactly as it does to the sweep: | ||
| 128 | // a link created by an upload whose push has not landed yet is not garbage. | ||
| 129 | for _, repo := range opts.Repos { | ||
| 130 | set := reachable[repo.Name] | ||
| 131 | for oid := range set { | ||
| 132 | if opts.Store.Has(repo.Name, oid) { | ||
| 133 | continue | ||
| 134 | } | ||
| 135 | res.Linked++ | ||
| 136 | if opts.DryRun { | ||
| 137 | continue | ||
| 138 | } | ||
| 139 | if err := opts.Store.Link(repo.Name, oid); err != nil { | ||
| 140 | res.Problems = append(res.Problems, fmt.Sprintf("%s: link %s: %v", repo.Name, oid, err)) | ||
| 141 | } | ||
| 142 | } | ||
| 143 | if opts.LinkOnly { | ||
| 144 | continue | ||
| 145 | } | ||
| 146 | err := opts.Store.RepoWalk(repo.Name, func(oid string, size int64) error { | ||
| 147 | if set[oid] { | ||
| 148 | return nil | ||
| 149 | } | ||
| 150 | age, err := opts.Store.StoredAt(repo.Name, oid) | ||
| 151 | if err != nil { | ||
| 152 | res.Problems = append(res.Problems, fmt.Sprintf("%s: %s: %v", repo.Name, oid, err)) | ||
| 153 | return nil | ||
| 154 | } | ||
| 155 | if opts.Now.Sub(age) < opts.Grace { | ||
| 156 | // Counted once, by the pool sweep: an object kept by grace here is kept | ||
| 157 | // there too unless another repository still reaches it. | ||
| 158 | return nil | ||
| 159 | } | ||
| 160 | res.Unlinked++ | ||
| 161 | if opts.DryRun { | ||
| 162 | return nil | ||
| 163 | } | ||
| 164 | if err := opts.Store.Unlink(repo.Name, oid); err != nil { | ||
| 165 | res.Problems = append(res.Problems, fmt.Sprintf("%s: unlink %s: %v", repo.Name, oid, err)) | ||
| 166 | } | ||
| 167 | return nil | ||
| 168 | }) | ||
| 169 | if err != nil { | ||
| 170 | return res, fmt.Errorf("gc: reconcile links for %s: %w", repo.Name, err) | ||
| 90 | 171 | } | |
| 91 | 172 | } | |
| 92 | res.Marked = len(marked) | ||
| 173 | if opts.LinkOnly { | ||
| 174 | return res, nil | ||
| 175 | } | ||
| 93 | 176 | ||
| 177 | // Finally the pool: an object no repository reaches, and that nothing refers to any | ||
| 178 | // more, is the only thing that may be deleted. | ||
| 94 | 179 | err := opts.Store.Walk(func(oid string, size int64) error { | |
| 95 | 180 | res.Scanned++ | |
| 96 | 181 | res.Left += size | |
| 97 | if marked[oid] { | ||
| 182 | if all[oid] { | ||
| 98 | 183 | return nil | |
| 99 | 184 | } | |
| 100 | age, err := opts.Store.StoredAt(oid) | ||
| 185 | age, err := opts.Store.PoolStoredAt(oid) | ||
| 101 | 186 | if err != nil { | |
| 102 | 187 | res.Problems = append(res.Problems, fmt.Sprintf("%s: %v", oid, err)) | |
| 103 | 188 | return nil | |
| @@ -106,14 +191,11 @@ func Run(opts Options) (Result, error) { | |||
| 106 | 191 | res.Within++ | |
| 107 | 192 | return nil | |
| 108 | 193 | } | |
| 109 | if opts.DryRun { | ||
| 110 | res.Swept++ | ||
| 111 | res.SweptB += size | ||
| 112 | return nil | ||
| 113 | } | ||
| 114 | if err := opts.Store.Remove(oid); err != nil { | ||
| 115 | res.Problems = append(res.Problems, fmt.Sprintf("%s: %v", oid, err)) | ||
| 116 | return nil | ||
| 194 | if !opts.DryRun { | ||
| 195 | if err := opts.Store.Remove(oid); err != nil { | ||
| 196 | res.Problems = append(res.Problems, fmt.Sprintf("%s: %v", oid, err)) | ||
| 197 | return nil | ||
| 198 | } | ||
| 117 | 199 | } | |
| 118 | 200 | res.Swept++ | |
| 119 | 201 | res.SweptB += size | |
| @@ -124,3 +206,12 @@ func Run(opts Options) (Result, error) { | |||
| 124 | 206 | } | |
| 125 | 207 | return res, nil | |
| 126 | 208 | } | |
| 209 | |||
| 210 | // Link is the repair pass: it creates the links a repository is missing and deletes | ||
| 211 | // nothing. An instance that predates repository-scoped storage runs it once so its | ||
| 212 | // existing objects become fetchable again; so does a namespace somebody deleted. | ||
| 213 | func Link(opts Options) (Result, error) { | ||
| 214 | opts.LinkOnly = true | ||
| 215 | opts.DryRun = false | ||
| 216 | return Run(opts) | ||
| 217 | } | ||