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.

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 }