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.
Phase 4: an idle runner parks on the queue instead of polling it
34afc5691bf2d6c808f905935cc95ffba34f09a7 · Keenan Nemetz · 16d 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/runner.go +53−8
| @@ -33,9 +33,23 @@ import ( | |||
| 33 | 33 | // "ok":false means the lease is gone — another runner has the job — and the runner | |
| 34 | 34 | // must stop rather than report a result nobody will record. | |
| 35 | 35 | ||
| 36 | const ( | ||
| 37 | // eligibleFor is how long a session reuses its repository list. Access rules live | ||
| 38 | // in the instance database and only change when an admin edits them, so a runner | ||
| 39 | // re-derives its list on the roster's five-second beat instead of on every poll. | ||
| 40 | eligibleFor = 5 * time.Second | ||
| 41 | // maxLeaseWait caps what a runner may ask for. The wait is a hint — the lease | ||
| 42 | // remains the database's decision — so all a long wait holds is one SSH channel. | ||
| 43 | maxLeaseWait = 60 * time.Second | ||
| 44 | ) | ||
| 45 | |||
| 36 | 46 | type runnerRequest struct { | |
| 37 | 47 | Op string `json:"op"` | |
| 38 | 48 | Run string `json:"run"` | |
| 49 | // Wait is how many seconds an idle lease may park before answering "nothing to | ||
| 50 | // do". Zero answers at once, which is what a one-shot runner wants: it is here to | ||
| 51 | // run the job it was started for, not to wait for the next one. | ||
| 52 | Wait int `json:"wait,omitempty"` | ||
| 39 | 53 | } | |
| 40 | 54 | ||
| 41 | 55 | type runnerStep struct { | |
| @@ -108,6 +122,12 @@ func (s *Server) serveRunner(ch ssh.Channel, user, accountID string) { | |||
| 108 | 122 | dec := json.NewDecoder(ch) | |
| 109 | 123 | enc := json.NewEncoder(ch) | |
| 110 | 124 | owner := accountID | |
| 125 | // repos is this session's eligibility list, reused for one poll window. Reading | ||
| 126 | // every repository's rules on every lease request is O(repos) per idle runner, and | ||
| 127 | // the answer changes only when an admin edits an allowlist — so it is refreshed on | ||
| 128 | // the same five-second beat the roster is, and not once per request. | ||
| 129 | var repos []string | ||
| 130 | var reposAt time.Time | ||
| 111 | 131 | cfg.Log.Info("runner connected", "user", user) | |
| 112 | 132 | ||
| 113 | 133 | for { | |
| @@ -117,19 +137,44 @@ func (s *Server) serveRunner(ch ssh.Channel, user, accountID string) { | |||
| 117 | 137 | } | |
| 118 | 138 | switch req.Op { | |
| 119 | 139 | case "lease": | |
| 120 | // A runner may only run jobs for repositories it administers: running a | ||
| 121 | // job executes that repository's code, which is a bigger power than | ||
| 122 | // writing to it. | ||
| 123 | repos, err := s.runnableRepos(accountID) | ||
| 124 | if err != nil { | ||
| 125 | _ = enc.Encode(runnerReply{Op: "lease", Error: err.Error()}) | ||
| 126 | continue | ||
| 140 | now := time.Now().UTC() | ||
| 141 | // A runner may only run jobs for repositories whose runner allowlist it is | ||
| 142 | // on: running a job executes that repository's code, which is a bigger | ||
| 143 | // power than writing to it. | ||
| 144 | if reposAt.IsZero() || now.Sub(reposAt) > eligibleFor { | ||
| 145 | list, err := s.runnableRepos(accountID) | ||
| 146 | if err != nil { | ||
| 147 | _ = enc.Encode(runnerReply{Op: "lease", Error: err.Error()}) | ||
| 148 | continue | ||
| 149 | } | ||
| 150 | repos, reposAt = list, now | ||
| 127 | 151 | } | |
| 128 | run, err := cfg.Runs.Lease(owner, repos, cfg.Lease, time.Now().UTC()) | ||
| 152 | // Take the wake-up before asking, so a run queued between the lease and the | ||
| 153 | // wait closes a channel somebody is holding. Asking first and waiting second | ||
| 154 | // loses exactly that race. | ||
| 155 | wake := cfg.Runs.Work() | ||
| 156 | run, err := cfg.Runs.Lease(owner, repos, cfg.Lease, now) | ||
| 129 | 157 | if err != nil { | |
| 130 | 158 | _ = enc.Encode(runnerReply{Op: "lease", Error: err.Error()}) | |
| 131 | 159 | continue | |
| 132 | 160 | } | |
| 161 | if run == nil && req.Wait > 0 { | ||
| 162 | wait := time.Duration(req.Wait) * time.Second | ||
| 163 | if wait > maxLeaseWait { | ||
| 164 | wait = maxLeaseWait | ||
| 165 | } | ||
| 166 | select { | ||
| 167 | case <-wake: | ||
| 168 | case <-time.After(wait): | ||
| 169 | } | ||
| 170 | // Ask again rather than assume: the wake-up may belong to a job another | ||
| 171 | // runner took first, and a lease is the only thing that hands work out. | ||
| 172 | run, err = cfg.Runs.Lease(owner, repos, cfg.Lease, time.Now().UTC()) | ||
| 173 | if err != nil { | ||
| 174 | _ = enc.Encode(runnerReply{Op: "lease", Error: err.Error()}) | ||
| 175 | continue | ||
| 176 | } | ||
| 177 | } | ||
| 133 | 178 | if run == nil { | |
| 134 | 179 | _ = enc.Encode(runnerReply{Op: "lease", OK: true}) | |
| 135 | 180 | continue | |