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 4: one repository may be bounded, and a freed slot wakes the parked
8d33608319bb83725256d02196925309fe521cda · Keenan Nemetz · 17d ago · parent 379dad1e
Phase 4: one repository may be bounded, and a freed slot wakes the parked max_jobs_per_repo caps how many of one repository's jobs run at once; unset there is still no cap. Ordering and bounding answer different questions: the lease already serves the least busy repository first, which stops a burst taking the whole fleet, but ordering is not a bound — and what an operator wants when one repository's build saturates the instance is a number. A repository at its cap is skipped rather than blocking, so the other repositories an account may run are still served. That makes nil from the lease mean two things: "nothing to do", and "work exists, but not for you yet". The second meaning is why Finish now wakes the parked lease — releasing a slot is work somebody can pick up, and without it a runner parked on a cap would sit out its whole wait against a queue it could already have taken. Both are one line of the same query: `? = 0 OR COALESCE(busy.n, 0) < ?`, so "no cap" does not become a second query string that can drift. The runners page renders `4 of 4 (at the cap)` beside the depth, because a queue that is full and not draining is exactly the question that page exists to answer: "7 waiting, 4 running" is a mystery where the cap is not shown. Measured: TestPerRepoCapBoundsOneRepositoryAndResumesWhenASlotFrees puts two jobs in each of two repositories with a cap of 1, so "nothing to lease" cannot be confused with "no work" — both repositories served once, a third runner refused, a finish waking the parked lease and the freed slot's own repository becoming leasable. TestRunnersPageSaysWhenARepositoryIsAtItsCap and TestMaxJobsPerRepoLoads cover the page and the key.
internal/web/runners.go +10−1
| @@ -37,6 +37,11 @@ type queueView struct { | |||
| 37 | 37 | Name string | |
| 38 | 38 | Queued int | |
| 39 | 39 | Running int | |
| 40 | // Cap is the per-repository bound on running jobs, 0 when there is none. It is | ||
| 41 | // shown beside the depth because a queue that is full and not draining is the | ||
| 42 | // question this page exists to answer, and "running 4 of 4" answers it in one | ||
| 43 | // line where "4 running, 7 waiting" leaves the reader guessing. | ||
| 44 | Cap int | ||
| 40 | 45 | } | |
| 41 | 46 | ||
| 42 | 47 | // RunnersPage serves GET /runners: who is connected, what they are building, and how | |
| @@ -76,12 +81,16 @@ func (w *Web) RunnersPage(rw http.ResponseWriter, r *http.Request) { | |||
| 76 | 81 | if err != nil { | |
| 77 | 82 | w.Logger("runners page: queue depth: %v", err) | |
| 78 | 83 | } | |
| 84 | perRepoCap := 0 | ||
| 85 | if w.Cfg != nil { | ||
| 86 | perRepoCap = w.Cfg.MaxJobsPerRepo | ||
| 87 | } | ||
| 79 | 88 | for name, d := range depths { | |
| 80 | 89 | m, err := w.Repos.Open(name) | |
| 81 | 90 | if err != nil || !w.public(m) { | |
| 82 | 91 | continue | |
| 83 | 92 | } | |
| 84 | queue = append(queue, queueView{Name: name, Queued: d.Queued, Running: d.Running}) | ||
| 93 | queue = append(queue, queueView{Name: name, Queued: d.Queued, Running: d.Running, Cap: perRepoCap}) | ||
| 85 | 94 | } | |
| 86 | 95 | sort.Slice(queue, func(i, j int) bool { return queue[i].Name < queue[j].Name }) | |
| 87 | 96 | } | |