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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

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

Bound and reclaim the runner's saved checkouts

f594f1a16f6f8a8a3ee2867a44249b72a2c76c96 · Keenan Nemetz · 5d ago · parent de84c32b

Bound and reclaim the runner's saved checkouts

The runner already downloaded each repository once into --dir and cloned every
job from that copy, but nothing kept the copies bounded: they grew with every
tag, every branch, and every commit a job pinned by id, and a force-push left the
old history reachable through the remote-tracking reflogs. A long-lived runner
filled its disk and then could not check anything out at all.

Repack a copy that is most of the cap, dropping the objects no branch or tag can
reach and the temporary refs a killed runner leaves behind; then delete the least
recently used copy when the cap is still reached. --git-cache-size sets the cap
(10G by default, 0 keeps every copy). The repository this runner is about to
build is never the one deleted, and a copy another process holds is passed over.

Reading and writing a saved checkout now takes a per-repository lock, so a repack
or a deletion in one runner process cannot run underneath another's clone, or
underneath the site and release pushes that are written from the same cache.
The first clone skips the working-tree checkout, which nothing ever read.

ci/agent/gitcache.go +347

@@ -0,0 +1,347 @@
1 package 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
20 import (
21 "fmt"
22 "io/fs"
23 "os"
24 "os/exec"
25 "path/filepath"
26 "sort"
27 "strings"
28 "syscall"
29 "time"
30
31 "github.com/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.
38 const DefaultGitCacheBytes int64 = 10 << 30
39
40 const (
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.
58 func 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.
76 func 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
91 func 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.
106 func 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.
117 type 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.
127 func 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.
164 func 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.
236 func 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.
282 func 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.
297 func 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
312 func dueForRepack(dir string) bool {
313 return time.Since(stampTime(dir, repackStamp)) >= repackInterval
314 }
315
316 func 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
324 func exists(path string) bool {
325 _, err := os.Stat(path)
326 return err == nil
327 }
328
329 func 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
343 func 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 }