Commit aa224ebf3b

aa224ebf3b824b965c3dd0da19c4bc202bb78396

parent: bc93d6cbca

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-08 06:01 UTC

store, control, wiki: admin runners reports the build queue

`admin runners` now heads its output with the queue: builds pending
now, and over the last 24 hours the builds claimed, the average and
worst wait from creation to claim, and the builds the reaper ended
instead of a runner reporting them. Migration 0049 adds
`builds.reaped_at`, set by ReapStaleBuilds, so the last number can be
counted. The JSON is `{queue, runners}` where it was a bare list.

Ref #184
.gitbay/wiki/Admin.org +4 −1
@@ -403,7 +403,10 @@ necessity, so the boundary is you choosing how to start it.
403403
404404=gitbay dashboard= and =ssh git@<host> admin runners= list every account
405405that has polled as a runner: when it last polled, the =-repos= scope it
406asked for, and the build it holds. A build a runner claimed and never
406asked for, and the build it holds. =admin runners= also heads the list
407with the queue: builds pending now, and over the last day how many were
408claimed, how long they waited to be claimed (average and worst), and
409how many the reaper ended instead of a runner reporting them. A build a runner claimed and never
407410reported is failed by the scheduler's minute tick, whether or not any
408411runner is still alive: within about two minutes of its log stream ending
409412with no outcome reported — the runner reports right after closing the
cmd/gitbay/main.go +1 −1
@@ -111,7 +111,7 @@ func newRoot() *cobra.Command {
111111 ),
112112 pass("invite", "issue a registration invite and mail its code: --email <address>", passOpts{server: []string{"admin", "invite"}}),
113113 pass("stats", "instance statistics: counts and per-repository disk usage", passOpts{server: []string{"admin", "stats"}}),
114 pass("runners", "runner accounts: last poll, scope, the build each holds", passOpts{server: []string{"admin", "runners"}}),
114 pass("runners", "the build queue and runner accounts: last poll, scope, the build each holds", passOpts{server: []string{"admin", "runners"}}),
115115 group("repo", "any repository, for moderation (audited)",
116116 pass("list", "every repository with size and last push: [--owner o] [--visibility v] [--limit n] [--cursor c]", passOpts{server: []string{"admin", "repo", "list"}}),
117117 pass("archive", "archive a repository: <owner/name>", passOpts{server: []string{"admin", "repo", "archive"}}),
e2e/reap_test.go +11 −3
@@ -80,6 +80,11 @@ func TestStaleBuildReapedWithoutRunner(t *testing.T) {
8080 if out, _, _ := inst.ssh(t, aliceKey, "", "build", "log", "alice/app", "1"); !strings.Contains(out, "build abandoned") {
8181 t.Fatalf("log lacks the abandonment note:\n%s", out)
8282 }
83 // The reaper's work is counted, apart from builds runners reported.
84 if out, _, _ := inst.ssh(t, runnerKey, "", "admin", "runners", "--json"); !strings.Contains(out, `"reaped_24h":1`) ||
85 !strings.Contains(out, `"claimed_24h":1`) {
86 t.Fatalf("queue after reap:\n%s", out)
87 }
8388}
8489
8590func TestAdminRunners(t *testing.T) {
@@ -93,7 +98,8 @@ func TestAdminRunners(t *testing.T) {
9398 if _, _, code := inst.ssh(t, aliceKey, "", "admin", "runners"); code != 4 {
9499 t.Fatal("non-admin listed runners")
95100 }
96 if out, _, code := inst.ssh(t, rootKey, "", "admin", "runners", "--json"); code != 0 || strings.TrimSpace(out) != `{"protocol_version":1,"data":[]}` {
101 if out, _, code := inst.ssh(t, rootKey, "", "admin", "runners", "--json"); code != 0 ||
102 strings.TrimSpace(out) != `{"protocol_version":1,"data":{"queue":{"pending":0,"claimed_24h":0,"claim_wait_avg_s":0,"claim_wait_max_s":0,"reaped_24h":0},"runners":[]}}` {
97103 t.Fatalf("no runners yet: exit %d %s", code, out)
98104 }
99105 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
@@ -104,7 +110,8 @@ func TestAdminRunners(t *testing.T) {
104110 t.Fatal("runner next failed")
105111 }
106112 out, _, _ := inst.ssh(t, rootKey, "", "admin", "runners")
107 if !strings.HasPrefix(out, "ci\t") || !strings.Contains(out, "\talice/app\tidle") {
113 if !strings.HasPrefix(out, "queue: 0 pending; last 24h: 0 claimed") || !strings.Contains(out, "\nci\t") ||
114 !strings.Contains(out, "\talice/app\tidle") {
108115 t.Fatalf("idle runner row:\n%s", out)
109116 }
110117 work := t.TempDir()
@@ -133,7 +140,8 @@ func TestAdminRunners(t *testing.T) {
133140 if _, _, code := inst.ssh(t, runnerKey, "", "runner", "done", fmt.Sprint(claim.Data.ID), "success"); code != 0 {
134141 t.Fatal("runner done failed")
135142 }
136 if out, _, _ := inst.ssh(t, rootKey, "", "admin", "runners"); !strings.Contains(out, "\tany\tidle") {
143 if out, _, _ := inst.ssh(t, rootKey, "", "admin", "runners"); !strings.Contains(out, "\tany\tidle") ||
144 !strings.HasPrefix(out, "queue: 0 pending; last 24h: 1 claimed, wait avg ") {
137145 t.Fatalf("runner still holds a build after done:\n%s", out)
138146 }
139147 // Host-local, the same read.
internal/control/admin.go +12 −2
@@ -30,7 +30,7 @@ func init() {
3030 Usage: "admin user demote <username>",
3131 SSHOnly: true, Run: runAdminUserDemote})
3232 register(Command{Path: []string{"admin", "runners"},
33 Summary: "runner accounts: last poll, scope, the build each holds (instance admins)",
33 Summary: "the build queue and runner accounts: last poll, scope, the build each holds (instance admins)",
3434 Usage: "admin runners",
3535 ReadOnly: true, SSHOnly: true, Run: runAdminRunners})
3636 register(Command{Path: []string{"admin", "repo", "list"},
@@ -452,7 +452,17 @@ func runAdminRunners(c *Ctx, args []string) int {
452452 if err != nil {
453453 return c.fail(protocol.ExitFailure, "%v", err)
454454 }
455 return c.emit(runners, func(w io.Writer) {
455 queue, err := c.Store.QueueStats()
456 if err != nil {
457 return c.fail(protocol.ExitFailure, "%v", err)
458 }
459 if runners == nil {
460 runners = []store.Runner{}
461 }
462 d := map[string]any{"queue": queue, "runners": runners}
463 return c.emit(d, func(w io.Writer) {
464 fmt.Fprintf(w, "queue: %d pending; last 24h: %d claimed, wait avg %ds max %ds, %d reaped\n",
465 queue.Pending, queue.Claimed24h, queue.ClaimWaitAvgS, queue.ClaimWaitMaxS, queue.Reaped24h)
456466 for _, r := range runners {
457467 scope := r.Scope
458468 if scope == "" {
internal/store/builds.go +28
@@ -189,10 +189,38 @@ func (s *Store) ReapStaleBuilds() ([]Build, error) {
189189 if err := s.FinishBuild(b.ID, "failure"); err != nil {
190190 return nil, err
191191 }
192 if _, err := s.DB.Exec(`UPDATE builds SET reaped_at = finished_at WHERE id = ?`, b.ID); err != nil {
193 return nil, err
194 }
192195 }
193196 return stale, nil
194197}
195198
199// QueueStats is the state of the build queue: what waits now, and over
200// the last day how long a build waited to be claimed and how many were
201// ended by the reaper rather than by a runner's report (#184).
202type QueueStats struct {
203 Pending int64 `json:"pending"`
204 Claimed24h int64 `json:"claimed_24h"`
205 ClaimWaitAvgS int64 `json:"claim_wait_avg_s"`
206 ClaimWaitMaxS int64 `json:"claim_wait_max_s"`
207 Reaped24h int64 `json:"reaped_24h"`
208}
209
210func (s *Store) QueueStats() (QueueStats, error) {
211 var q QueueStats
212 since := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02T15:04:05Z")
213 err := s.DB.QueryRow(`SELECT
214 (SELECT COUNT(*) FROM builds WHERE status = 'pending'),
215 COUNT(*),
216 COALESCE(AVG(strftime('%s', started_at) - strftime('%s', created_at)), 0),
217 COALESCE(MAX(strftime('%s', started_at) - strftime('%s', created_at)), 0),
218 (SELECT COUNT(*) FROM builds WHERE reaped_at >= ?)
219 FROM builds WHERE started_at >= ?`, since, since).
220 Scan(&q.Pending, &q.Claimed24h, &q.ClaimWaitAvgS, &q.ClaimWaitMaxS, &q.Reaped24h)
221 return q, err
222}
223
196224// AppendBuildLog adds a chunk to the build's log, dropping bytes past the cap.
197225func (s *Store) AppendBuildLog(id int64, chunk []byte) error {
198226 res, err := s.DB.Exec(`
internal/store/migrations/0049_build_reaped.down.sql added +1
@@ -0,0 +1 @@
1ALTER TABLE builds DROP COLUMN reaped_at;
internal/store/migrations/0049_build_reaped.up.sql added +3
@@ -0,0 +1,3 @@
1-- When the scheduler failed a build instead of its runner reporting it,
2-- so builds ended by the reaper can be counted (#184).
3ALTER TABLE builds ADD COLUMN reaped_at TEXT NOT NULL DEFAULT '';