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: an idle runner parks on the queue instead of polling it
34afc5691bf2d6c808f905935cc95ffba34f09a7 · Keenan Nemetz · 17d ago · parent a945ae55
Phase 4: an idle runner parks on the queue instead of polling it Two costs made a runner pool more expensive than it needed to be. Every lease request re-derived the account's eligible repositories by opening every repository and reading its rules — O(repos) per idle runner, every two seconds, to be told there was nothing to do. And the "long poll" was the client's own timer: the queue answered at once and the runner slept. The lease now takes a `wait`: the session holds the request on a wake-up channel that a committed enqueue closes (and RequeueExpired closes when it puts a job back), wakes and asks once more, because the wake-up may belong to a job another runner took first. The channel is taken *before* the lease attempt, so a run queued between the two cannot be missed. It is in-process on purpose: one `vole serve` owns this database, and the wait is a courtesy, never a durability mechanism — a lost wake-up costs one poll interval and nothing else. The eligibility list is now reused for the roster's five-second window instead of being re-derived per request, so an allowlist change still takes effect without a reconnect but a quiet pool makes no queries at all. `--once` sends no wait: a one-shot runner is here for the job it was started for. Measured: TestALeaseWaitsForWorkInsteadOfPolling parks a lease for 30s and gets the job inside 5s of it being queued; TestALeaseWithoutAWaitAnswersAtOnce returns from an empty queue immediately; TestQueuingWorkWakesTheParked and TestADuplicateEnqueueDoesNotWakeTheParked pin that a queued run wakes waiters and a duplicate does not. Also corrected: `vole runner --help` claimed jobs come from repositories the account administers, which stopped being true when `acl runners` became the allowlist.
internal/sshserver/lease_wait_test.go +101
| @@ -0,0 +1,101 @@ | |||
| 1 | package sshserver | ||
| 2 | |||
| 3 | // A resident runner should cost the queue nothing while it is idle. These tests pin | ||
| 4 | // the two halves of that: a lease with a wait parks until work is queued, and a lease | ||
| 5 | // without one answers immediately. The wait is a hint — the lease still decides — so | ||
| 6 | // what is measured here is latency, not permission. | ||
| 7 | |||
| 8 | import ( | ||
| 9 | "strings" | ||
| 10 | "testing" | ||
| 11 | "time" | ||
| 12 | |||
| 13 | "github.com/rodent-software/vole/internal/id" | ||
| 14 | "github.com/rodent-software/vole/internal/runs" | ||
| 15 | ) | ||
| 16 | |||
| 17 | func (h *harness) enqueueJob(t *testing.T, jobName string) { | ||
| 18 | t.Helper() | ||
| 19 | runID, err := id.New(time.Now().UTC()) | ||
| 20 | if err != nil { | ||
| 21 | t.Fatal(err) | ||
| 22 | } | ||
| 23 | if _, err := h.runs.Enqueue(runs.Run{ | ||
| 24 | ID: runID, | ||
| 25 | Repo: h.repoName, | ||
| 26 | Ref: "refs/heads/main", | ||
| 27 | Branch: "main", | ||
| 28 | SHA: strings.Repeat("ab", 20), | ||
| 29 | Workflow: ".vole/workflows/ci.yaml", | ||
| 30 | WorkflowSHA: strings.Repeat("cd", 20), | ||
| 31 | Job: jobName, | ||
| 32 | Steps: []runs.Step{{Name: "test", Run: "true"}}, | ||
| 33 | Created: time.Now().UTC(), | ||
| 34 | }); err != nil { | ||
| 35 | t.Fatal(err) | ||
| 36 | } | ||
| 37 | } | ||
| 38 | |||
| 39 | // The reply to a parked lease arrives because a job was queued, not because the wait | ||
| 40 | // ran out: the wait is 30 seconds and the assertion is five, so a poll would fail it. | ||
| 41 | func TestALeaseWaitsForWorkInsteadOfPolling(t *testing.T) { | ||
| 42 | h := newHarness(t) | ||
| 43 | c := h.runner(t, h.keyPath) | ||
| 44 | if rep := c.call(runnerRequest{Op: "ping"}); rep.Error != "" { | ||
| 45 | t.Fatal(rep.Error) | ||
| 46 | } | ||
| 47 | |||
| 48 | type result struct { | ||
| 49 | rep runnerReply | ||
| 50 | err error | ||
| 51 | } | ||
| 52 | got := make(chan result, 1) | ||
| 53 | go func() { | ||
| 54 | if err := c.enc.Encode(runnerRequest{Op: "lease", Wait: 30}); err != nil { | ||
| 55 | got <- result{err: err} | ||
| 56 | return | ||
| 57 | } | ||
| 58 | var rep runnerReply | ||
| 59 | err := c.dec.Decode(&rep) | ||
| 60 | got <- result{rep: rep, err: err} | ||
| 61 | }() | ||
| 62 | |||
| 63 | // Let the session park before queueing, so the wake-up is what answers it rather | ||
| 64 | // than the initial lease attempt. | ||
| 65 | time.Sleep(300 * time.Millisecond) | ||
| 66 | h.enqueueJob(t, "waited") | ||
| 67 | |||
| 68 | select { | ||
| 69 | case r := <-got: | ||
| 70 | if r.err != nil { | ||
| 71 | t.Fatalf("reading the parked lease: %v", r.err) | ||
| 72 | } | ||
| 73 | if r.rep.Run == nil { | ||
| 74 | t.Fatalf("the parked lease answered with no job: %+v", r.rep) | ||
| 75 | } | ||
| 76 | if r.rep.Run.Job != "waited" { | ||
| 77 | t.Fatalf("leased %q, want the job that was queued", r.rep.Run.Job) | ||
| 78 | } | ||
| 79 | case <-time.After(5 * time.Second): | ||
| 80 | t.Fatal("a lease parked for 30s did not return when a job was queued") | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | // A one-shot runner is here for the job it was started for, so it must never park: an | ||
| 85 | // empty queue answers at once. | ||
| 86 | func TestALeaseWithoutAWaitAnswersAtOnce(t *testing.T) { | ||
| 87 | h := newHarness(t) | ||
| 88 | c := h.runner(t, h.keyPath) | ||
| 89 | |||
| 90 | start := time.Now() | ||
| 91 | rep := c.call(runnerRequest{Op: "lease"}) | ||
| 92 | if rep.Error != "" { | ||
| 93 | t.Fatal(rep.Error) | ||
| 94 | } | ||
| 95 | if rep.Run != nil { | ||
| 96 | t.Fatalf("leased %q from an empty queue", rep.Run.Job) | ||
| 97 | } | ||
| 98 | if waited := time.Since(start); waited > time.Second { | ||
| 99 | t.Fatalf("a lease with no wait blocked for %s", waited) | ||
| 100 | } | ||
| 101 | } | ||