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.

Code

395 commit(s) on main

update readme Keenan Nemetz · 24m ago 32290114
history blame raw 11.5 KiB
1package agent
2
3// The runner keeps a trusted clone of every repository it builds at <dir>/<repo>,
4// so a later job clones from disk and only the new commits cross the network. This
5// file bounds that cache.
6//
7// Two operations, because they cost different things. Repacking one clone drops
8// what no job can reach any more — the temporary refs a killed runner left behind,
9// the reflogs that keep force-pushed history alive, and the objects only those
10// held — and costs CPU. Deleting a clone costs the next job a full download, so it
11// is the last resort, and whole clones are the only unit: half a clone is a job
12// that fails in the middle of a fetch.
13//
14// Both take the repository's lock. A clone is a shared object store: a repack that
15// prunes while another process is cloning from it, or a deletion that lands under a
16// running job's feet, is data loss rather than a slow build. The lock is per
17// repository, so two runners sharing a --dir still work in parallel on different
18// repositories, and the only thing that waits is the same repository's next job.
19
20import (
21 "fmt"
22 "io/fs"
23 "os"
24 "os/exec"
25 "path/filepath"
26 "sort"
27 "strings"
28 "syscall"
29 "time"
30
31 "rodent.software/vole/internal/format"
32)
33
34// DefaultGitCacheBytes caps the saved checkouts when a runner's operator says
35// nothing. It is larger than the job cache because a clone only grows by history:
36// a runner serving a handful of repositories should never reach it, and the cost
37// of being wrong is one download.
38const DefaultGitCacheBytes int64 = 10 << 30
39
40const (
41 // lastUsedStamp records when a job last built from a clone. The directory's
42 // mtime cannot stand in: git writes into .git, and a job that fetched nothing
43 // still used the cache.
44 lastUsedStamp = ".vole-last-used"
45 // repackStamp records when a clone was last repacked, so a repository under
46 // pressure is not repacked after every job.
47 repackStamp = ".vole-last-repack"
48 // repackInterval is the shortest gap between two repacks of the same clone.
49 repackInterval = 6 * time.Hour
50 // repackShare is the fraction of the cap one repository may hold before
51 // repacking it is worth the CPU: below it there is no pressure to relieve.
52 repackShare = 4
53)
54
55// lockCache takes the repository's exclusive lock and returns the release
56// function. A process that dies holding it releases it; the file itself is never
57// removed, because removing a lock file races with the next process opening it.
58func lockCache(cache, repo string) (func(), error) {
59 f, err := openCacheLock(cache, repo)
60 if err != nil {
61 return nil, err
62 }
63 if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
64 _ = f.Close()
65 return nil, fmt.Errorf("lock the saved checkout of %s: %w", repo, err)
66 }
67 return func() {
68 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
69 _ = f.Close()
70 }, nil
71}
72
73// tryLockCache is lockCache for a caller that can leave the work for later: a
74// clone another process is using must not be deleted or repacked underneath it,
75// and that process's job is more important than this one's tidying.
76func tryLockCache(cache, repo string) (func(), bool) {
77 f, err := openCacheLock(cache, repo)
78 if err != nil {
79 return nil, false
80 }
81 if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
82 _ = f.Close()
83 return nil, false
84 }
85 return func() {
86 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
87 _ = f.Close()
88 }, true
89}
90
91func openCacheLock(cache, repo string) (*os.File, error) {
92 dir := filepath.Join(cache, ".locks")
93 if err := os.MkdirAll(dir, 0o755); err != nil {
94 return nil, err
95 }
96 f, err := os.OpenFile(filepath.Join(dir, repo+".lock"), os.O_CREATE|os.O_RDWR, 0o644)
97 if err != nil {
98 return nil, fmt.Errorf("open the lock for the saved checkout of %s: %w", repo, err)
99 }
100 return f, nil
101}
102
103// trustCache marks a clone that prepareWorktree finished, and records the job that
104// used it. The marker is what makes a clone trusted; the stamp is what makes it
105// evictable in the right order.
106func touchCache(cacheDir string) error {
107 if err := os.WriteFile(filepath.Join(cacheDir, cacheMarker), []byte("1\n"), 0o644); err != nil {
108 return fmt.Errorf("mark the trusted cache: %w", err)
109 }
110 if err := os.WriteFile(filepath.Join(cacheDir, lastUsedStamp), []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644); err != nil {
111 return fmt.Errorf("record the use of the trusted cache: %w", err)
112 }
113 return nil
114}
115
116// savedCheckout is one repository's clone under --dir.
117type savedCheckout struct {
118 repo string
119 path string
120 size int64
121 used time.Time
122}
123
124// savedCheckouts lists the clones a runner may reclaim. A directory without the
125// marker is not trusted and not this file's business: prepareWorktree rebuilds it
126// on the next job.
127func savedCheckouts(cache string) []savedCheckout {
128 entries, err := os.ReadDir(cache)
129 if err != nil {
130 return nil
131 }
132 var out []savedCheckout
133 for _, e := range entries {
134 if !e.IsDir() {
135 continue
136 }
137 // The job cache, the job trees and the locks live in the same directory and
138 // are bounded or removed elsewhere.
139 switch e.Name() {
140 case "cache", "jobs", ".locks":
141 continue
142 }
143 dir := filepath.Join(cache, e.Name())
144 if !exists(filepath.Join(dir, ".git")) || !exists(filepath.Join(dir, cacheMarker)) {
145 continue
146 }
147 out = append(out, savedCheckout{
148 repo: e.Name(),
149 path: dir,
150 size: dirSize(dir),
151 used: stampTime(dir, lastUsedStamp),
152 })
153 }
154 return out
155}
156
157// maintainTrustedCaches keeps the saved checkouts under cache within maxBytes. The
158// active repository is the one the next job will build from, so it is never
159// deleted. A maxBytes of zero keeps every clone: the operator asked for disk to be
160// spent on downloads saved.
161//
162// It runs between jobs, and a failure is reported and ignored: a runner that
163// cannot tidy its disk is still a runner that can build.
164func maintainTrustedCaches(cache string, maxBytes int64, active string) {
165 if maxBytes <= 0 {
166 return
167 }
168 all := savedCheckouts(cache)
169 if len(all) == 0 {
170 return
171 }
172 var total int64
173 for _, c := range all {
174 total += c.size
175 }
176 share := maxBytes / repackShare
177 if share < 1 {
178 share = 1
179 }
180
181 // Repack under pressure first: it is the only way to free space without
182 // costing the next job a download. Biggest first, so the clone that is most of
183 // the cache is dealt with before smaller ones are deleted to make room for it.
184 bySize := append([]savedCheckout(nil), all...)
185 sort.Slice(bySize, func(i, j int) bool { return bySize[i].size > bySize[j].size })
186 for _, c := range bySize {
187 overCap := total > maxBytes
188 if !overCap && c.size <= share {
189 continue
190 }
191 // A repository over its share is repacked at most every repackInterval. When
192 // the whole cache is over the cap it is worth doing now: the alternative is
193 // deleting a clone a repack might have kept.
194 if !overCap && !dueForRepack(c.path) {
195 continue
196 }
197 freed, err := repackSavedCheckout(cache, c.repo)
198 if err != nil {
199 fmt.Fprintf(os.Stderr, "vole runner: could not repack the saved checkout of %s: %v\n", c.repo, err)
200 continue
201 }
202 total -= freed
203 if freed > 0 {
204 fmt.Fprintf(os.Stderr, "vole runner: repacked the saved checkout of %s, freeing %s\n",
205 c.repo, format.Bytes(freed))
206 }
207 }
208 if total <= maxBytes {
209 return
210 }
211
212 // Still over the cap. Delete the clones nobody has built from in the longest
213 // time, oldest first, and pass over one another process is using: its job is
214 // the one that would suffer.
215 sort.Slice(all, func(i, j int) bool { return all[i].used.Before(all[j].used) })
216 for _, c := range all {
217 if total <= maxBytes {
218 return
219 }
220 if c.repo == active {
221 continue
222 }
223 freed, ok := deleteSavedCheckout(cache, c.repo)
224 if !ok {
225 continue
226 }
227 total -= freed
228 fmt.Fprintf(os.Stderr, "vole runner: deleted the saved checkout of %s to stay within the cap, freeing %s\n",
229 c.repo, format.Bytes(freed))
230 }
231}
232
233// repackSavedCheckout drops everything in one clone that no branch or tag can
234// reach. Branch tips and tags stay, because they are what turns the next job into
235// a delta fetch.
236func repackSavedCheckout(cache, repo string) (int64, error) {
237 unlock, ok := tryLockCache(cache, repo)
238 if !ok {
239 // Another process is using the clone right now. The space can wait for the
240 // next job rather than stalling this one.
241 return 0, nil
242 }
243 defer unlock()
244
245 dir := filepath.Join(cache, repo)
246 before := dirSize(dir)
247 if err := clearJobRefs(dir); err != nil {
248 return 0, err
249 }
250 // The cache is not a safety net: nobody recovers work from it, and a reflog for
251 // refs/remotes/origin/* is exactly what keeps a force-pushed-away history
252 // alive. Expire both kinds at once and let gc prune what that leaves.
253 for _, c := range [][2]string{
254 {"gc.reflogExpire", "now"},
255 {"gc.reflogExpireUnreachable", "now"},
256 {"gc.pruneExpire", "now"},
257 } {
258 if out, err := gitCache(dir, "config", c[0], c[1]); err != nil {
259 return 0, fmt.Errorf("git config %s: %v: %s", c[0], err, strings.TrimSpace(out))
260 }
261 }
262 if out, err := gitCache(dir, "gc", "--quiet", "--prune=now"); err != nil {
263 return 0, fmt.Errorf("git gc: %v: %s", err, strings.TrimSpace(out))
264 }
265 if err := os.WriteFile(filepath.Join(dir, repackStamp), []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644); err != nil {
266 return 0, fmt.Errorf("record the repack: %w", err)
267 }
268 // A repack packs what is left, and on a small clone the new pack is bigger than
269 // the loose objects it removed. Report only what was actually freed, so the
270 // caller does not subtract a saving that did not happen.
271 after := dirSize(dir)
272 if after > before {
273 return 0, nil
274 }
275 return before - after, nil
276}
277
278// clearJobRefs removes the temporary refs prepareWorktree adds for a commit that is
279// not on a branch. A killed runner leaves them, and they pin that commit forever.
280// The lock is what makes this safe: a live job's ref is added under the same lock,
281// so a running job's commit is never the one deleted here.
282func clearJobRefs(dir string) error {
283 out, err := gitCache(dir, "for-each-ref", "--format=%(refname)", "refs/vole/jobs")
284 if err != nil {
285 return fmt.Errorf("git for-each-ref: %v: %s", err, strings.TrimSpace(out))
286 }
287 for _, ref := range strings.Fields(out) {
288 if out, err := gitCache(dir, "update-ref", "-d", ref); err != nil {
289 return fmt.Errorf("git update-ref -d %s: %v: %s", ref, err, strings.TrimSpace(out))
290 }
291 }
292 return nil
293}
294
295// deleteSavedCheckout removes one clone and reports what it freed. It declines when
296// another process holds the lock, because that process is building from it.
297func deleteSavedCheckout(cache, repo string) (int64, bool) {
298 unlock, ok := tryLockCache(cache, repo)
299 if !ok {
300 return 0, false
301 }
302 defer unlock()
303 dir := filepath.Join(cache, repo)
304 freed := dirSize(dir)
305 if err := os.RemoveAll(dir); err != nil {
306 fmt.Fprintf(os.Stderr, "vole runner: could not delete the saved checkout of %s: %v\n", repo, err)
307 return 0, false
308 }
309 return freed, true
310}
311
312func dueForRepack(dir string) bool {
313 return time.Since(stampTime(dir, repackStamp)) >= repackInterval
314}
315
316func stampTime(dir, name string) time.Time {
317 info, err := os.Stat(filepath.Join(dir, name))
318 if err != nil {
319 return time.Time{}
320 }
321 return info.ModTime()
322}
323
324func exists(path string) bool {
325 _, err := os.Stat(path)
326 return err == nil
327}
328
329func dirSize(dir string) int64 {
330 var total int64
331 _ = filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
332 if err != nil || d.IsDir() {
333 return nil
334 }
335 if info, err := d.Info(); err == nil {
336 total += info.Size()
337 }
338 return nil
339 })
340 return total
341}
342
343func gitCache(dir string, args ...string) (string, error) {
344 cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
345 out, err := cmd.CombinedOutput()
346 return string(out), err
347}
348