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 | |