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.

Phase 4: a runners page, built from the queue's own presence

ac43353160989ae56b6a92ae8f1df1017c2c20c4 · Keenan Nemetz · 16d ago · parent 760b523a

Phase 4: a runners page, built from the queue's own presence

"Nobody is connected and eleven jobs are waiting" is the sentence somebody needs when
a build does not start, and until now nothing could say it: a runner existed only as
an SSH channel, invisible to both the web and the queue. `runs.db` could already
answer "who holds this lease" (lease_owner, lease_until) and "who ran this" (the
attestation), but not "who is connected and idle".

Presence is now a row per channel in the queue — `runner_sessions` — written by events
a runner already sends: connect, lease (which records the run it holds), heartbeat (a
long build must not look gone), and finish (which clears it). A clean disconnect drops
the row, and `watchLeases` forgets rows silent for ten minutes, which is the only trace
a killed process leaves. Nothing here is authoritative: presence is a derived fact
about a live channel, which is why the page applies its own two-minute window rather
than trusting a stored "online" flag.

GET /runners shows the connected runners (account, the job it is building and its
repository, or "waiting for work") and the queue depth per repository. It follows the
clone rule like every other page: a session building a private repository is not shown
at all, and a private repository's queue is not counted, because naming either would
confirm the repository exists. The header now links to it from every page.

Measured: TestARunnerSessionIsVisibleWhileItIsConnected (a connected runner is one row,
idle, then "building <run>" once it leases), TestRunnersPageShowsTheRunnerHoldingAJob
AndTheQueueDepth (the page names the account, links the run it is building and counts
one waiting and one running), TestRunnersPageKeepsAPrivateRepositoryOut (no repository
name, account, job or run id from a private repository appears).

internal/runs/runs.go +126−1

@@ -178,7 +178,20 @@ CREATE TABLE IF NOT EXISTS ref_cursors (
178 178 sha TEXT NOT NULL,
179 179 seen INTEGER NOT NULL,
180 180 PRIMARY KEY (repo, ref)
181 );`); err != nil {
181 );
182 -- runner_sessions is presence, not history: one row per connected runner, dropped
183 -- when its channel closes. It is written by events a runner already sends (connect,
184 -- lease, heartbeat, finish), so showing who is connected and what they are building
185 -- needs no new traffic and no reach into the SSH server from the web.
186 CREATE TABLE IF NOT EXISTS runner_sessions (
187 session TEXT PRIMARY KEY,
188 account TEXT NOT NULL,
189 repo TEXT NOT NULL DEFAULT '',
190 run_id TEXT NOT NULL DEFAULT '',
191 connected INTEGER NOT NULL,
192 last_seen INTEGER NOT NULL
193 );
194 CREATE INDEX IF NOT EXISTS runner_sessions_seen ON runner_sessions(last_seen);`); err != nil {
182 195 db.Close()
183 196 return nil, err
184 197 }
@@ -283,6 +296,118 @@ func (s *Store) CancelSuperseded(repo, ref, keep string, now time.Time) (int, er
283 296 return int(n), err
284 297 }
285 298
299 // Session is one connected runner as the queue sees it: who it authenticated as,
300 // what it is building (if anything), and when it was last heard from. Presence is a
301 // derived fact about live channels, so a row that stops being refreshed is a runner
302 // that went away without closing cleanly — readers apply their own freshness window.
303 type Session struct {
304 ID string
305 Account string
306 Repo string
307 RunID string
308 Connected time.Time
309 LastSeen time.Time
310 }
311
312 // TouchSession records that a runner is connected and alive. One row per channel, so
313 // a pool of three runners on one account shows as three rows rather than one.
314 func (s *Store) TouchSession(session, account string, now time.Time) error {
315 _, err := s.db.Exec(`INSERT INTO runner_sessions(session, account, connected, last_seen)
316 VALUES (?,?,?,?)
317 ON CONFLICT(session) DO UPDATE SET account = excluded.account, last_seen = excluded.last_seen`,
318 session, account, now.Unix(), now.Unix())
319 return err
320 }
321
322 // SessionWorking marks the run a session is building, so the page can link to it
323 // instead of saying "busy" and leaving the reader to guess.
324 func (s *Store) SessionWorking(session, repo, runID string, now time.Time) error {
325 _, err := s.db.Exec(`UPDATE runner_sessions SET repo = ?, run_id = ?, last_seen = ? WHERE session = ?`,
326 repo, runID, now.Unix(), session)
327 return err
328 }
329
330 // SessionIdle clears the run: the lease is closed and the runner is waiting again.
331 func (s *Store) SessionIdle(session string, now time.Time) error {
332 _, err := s.db.Exec(`UPDATE runner_sessions SET repo = '', run_id = '', last_seen = ? WHERE session = ?`,
333 now.Unix(), session)
334 return err
335 }
336
337 // DropSession forgets a channel that closed. It is presence, so a clean disconnect
338 // leaves nothing behind; a crash is aged out by PruneSessions instead.
339 func (s *Store) DropSession(session string) error {
340 _, err := s.db.Exec(`DELETE FROM runner_sessions WHERE session = ?`, session)
341 return err
342 }
343
344 // Sessions lists the runners last heard from since the given time, busiest first —
345 // somebody looking for "what is my CI doing" wants the working runners at the top.
346 func (s *Store) Sessions(since time.Time) ([]Session, error) {
347 rows, err := s.db.Query(`SELECT session, account, repo, run_id, connected, last_seen
348 FROM runner_sessions WHERE last_seen >= ?
349 ORDER BY (run_id = '') , last_seen DESC`, since.Unix())
350 if err != nil {
351 return nil, err
352 }
353 defer rows.Close()
354 var out []Session
355 for rows.Next() {
356 var ses Session
357 var connected, seen int64
358 if err := rows.Scan(&ses.ID, &ses.Account, &ses.Repo, &ses.RunID, &connected, &seen); err != nil {
359 return nil, err
360 }
361 ses.Connected = unix(connected)
362 ses.LastSeen = unix(seen)
363 out = append(out, ses)
364 }
365 return out, rows.Err()
366 }
367
368 // PruneSessions forgets runners that have not been heard from in a long time, which
369 // is the only trace a killed process leaves.
370 func (s *Store) PruneSessions(before time.Time) (int, error) {
371 res, err := s.db.Exec(`DELETE FROM runner_sessions WHERE last_seen < ?`, before.Unix())
372 if err != nil {
373 return 0, err
374 }
375 n, _ := res.RowsAffected()
376 return int(n), nil
377 }
378
379 // Depth is how much work one repository has waiting and running.
380 type Depth struct {
381 Queued int
382 Running int
383 }
384
385 // QueueByRepo counts the work in flight per repository. It is what a runner page
386 // shows beside the runners themselves: "nobody is connected and eleven jobs are
387 // waiting" is the sentence somebody needs when a build does not start.
388 func (s *Store) QueueByRepo() (map[string]Depth, error) {
389 rows, err := s.db.Query(`SELECT repo,
390 SUM(CASE WHEN status = ? THEN 1 ELSE 0 END),
391 SUM(CASE WHEN status = ? THEN 1 ELSE 0 END)
392 FROM runs WHERE status IN (?, ?)
393 GROUP BY repo`,
394 StatusQueued, StatusRunning, StatusQueued, StatusRunning)
395 if err != nil {
396 return nil, err
397 }
398 defer rows.Close()
399 out := map[string]Depth{}
400 for rows.Next() {
401 var repo string
402 var d Depth
403 if err := rows.Scan(&repo, &d.Queued, &d.Running); err != nil {
404 return nil, err
405 }
406 out[repo] = d
407 }
408 return out, rows.Err()
409 }
410
286 411 // Cursors returns the tip each of a repository's refs had when its work was last
287 412 // queued. A ref that is missing has never been reconciled, which is treated as work
288 413 // owed rather than as "nothing to do": the refs are the only durable statement that