Commit 4effb29e57

4effb29e572777a68b56b21da1b1733fb6219e89

parent: 935a8ada01

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

cmc <hello@cleberg.net> · 2026-09-10 02:00 UTC

ci, hookd, wiki: a schedule tick queues nothing while the job's last build is pending

The push path already skips a job whose build for the commit is
pending or running; the scheduler did not, so a repository no runner
serves gained one row per tick. A finished build does not suppress
the tick. New push-shape row and test. Per-account caps and a
schedule floor are recorded as not planned.

Closes #206
.gitbay/wiki/Admin.org +7 −5
@@ -402,11 +402,13 @@ stacks from one repository. A runner attached to one repository cannot
402402be starved. That is the rule, decided in krz/gitbay#207: a runner
403403serving several repositories takes them oldest-first, and an operator
404404who wants one repository never to wait on another runs a second
405runner attached to it alone. Nothing caps what an account queues: builds pending at
406once and schedule intervals down to a minute are unbounded, and a
407build nothing claims stays pending. Since a build runs only on a
408runner its owner attaches, the cost is rows and the queue numbers on
409=admin runners=, not compute; a per-account cap is krz/gitbay#206.
405runner attached to it alone. Nothing caps what an account queues,
406and nothing needs to (krz/gitbay#206): a schedule tick queues nothing
407while the job's last build is pending or running, so a repository
408with no runner holds one row per scheduled job rather than one per
409tick, and a build runs only on a runner its owner attaches, so a busy
410schedule spends the owner's compute. Pushes are bounded by what an
411account can push.
410412
411413#+begin_src sh
412414gitbay-runner -remote git@gitbay.org -repos krz/site,krz/docs \
.gitbay/wiki/CI.org +6 −2
@@ -28,8 +28,11 @@ nothing claims. See the Users page. Among what a runner may claim it
2828takes the oldest pending build, across every repository it serves;
2929a repository that must never wait on another gets a runner of its own
3030(decided in krz/gitbay#207). There is no cap on how many
31builds an account queues or how often a schedule fires; that is
32krz/gitbay#206.
31builds an account queues or how often a schedule fires, and none is
32planned (krz/gitbay#206): a tick queues nothing while the job's last
33build is pending or running, so a repository with no runner holds one
34row per scheduled job, and a schedule with a runner attached spends
35its owner's compute, not the instance's.
3336
3437Scheduled jobs run on their cron against the default branch, never on
3538push; a default-branch push registers or updates them. Tag jobs run on
@@ -84,6 +87,7 @@ push produced for that job; the commit's =ci/<job>= status follows:
8487| merge request head from a fork | queued, untrusted | queued, untrusted | queued, untrusted | — | — | — |
8588| tag push | — | — | — | — | queued | — |
8689| schedule tick on the default branch | — | — | — | queued | — | — |
90| schedule tick while the last scheduled build is still pending | — | — | — | — | — | — |
8791| claimed builds whose runner vanished | abandoned | abandoned | — | — | — | — |
8892| push to the default branch with an old sha that cannot be diffed | queued | queued | queued | registered | — | — |
8993| push with a broken ci.yml | — | — | — | — | — | failure |
internal/ci/sched.go +10
@@ -100,6 +100,16 @@ func (s *Scheduler) RunDue(now time.Time) {
100100 s.St.RemoveSchedule(e.RepoID, e.Job)
101101 continue
102102 }
103 // The push path skips a job whose build for the commit is still
104 // pending or running; so does a tick. Without this a repository
105 // no runner serves gained one row per tick forever (#206). A
106 // finished build does not suppress the tick: a schedule re-runs
107 // an unchanged commit on purpose.
108 if built, err := s.St.BuildsForCommit(repo.ID, sha); err == nil {
109 if b, ok := built[job.Name]; ok && (b.Status == "pending" || b.Status == "running") {
110 continue
111 }
112 }
103113 steps, _ := json.Marshal(job.Steps)
104114 n, err := s.St.CreateBuild(repo.ID, job.Name, sha, repo.DefaultBranch, string(steps), job.Image, "", true)
105115 if err != nil {
internal/ci/sched_test.go +34
@@ -101,4 +101,38 @@ func TestSchedulerRunDue(t *testing.T) {
101101 if builds, _ = st.ListBuilds(repoID, 10); len(builds) != 1 {
102102 t.Fatalf("second pass queued extra builds: %+v", builds)
103103 }
104
105 // Due again while the first build is still pending: nothing is
106 // queued, so a repository no runner serves holds one row per
107 // scheduled job, not one per tick (#206). Once that build finishes,
108 // the next tick queues again — a schedule re-runs an unchanged
109 // commit on purpose.
110 if err := st.SetScheduleNext(repoID, "nightly", past); err != nil {
111 t.Fatal(err)
112 }
113 s.RunDue(now)
114 if builds, _ = st.ListBuilds(repoID, 10); len(builds) != 1 {
115 t.Fatalf("tick with the last build pending queued another: %+v", builds)
116 }
117 b, ok, err := st.ClaimBuild(nil, false)
118 if err != nil || !ok {
119 t.Fatalf("claim: %v ok=%v", err, ok)
120 }
121 if err := st.SetScheduleNext(repoID, "nightly", past); err != nil {
122 t.Fatal(err)
123 }
124 s.RunDue(now)
125 if builds, _ = st.ListBuilds(repoID, 10); len(builds) != 1 {
126 t.Fatalf("tick with the last build running queued another: %+v", builds)
127 }
128 if err := st.FinishBuild(b.ID, "success"); err != nil {
129 t.Fatal(err)
130 }
131 if err := st.SetScheduleNext(repoID, "nightly", past); err != nil {
132 t.Fatal(err)
133 }
134 s.RunDue(now)
135 if builds, _ = st.ListBuilds(repoID, 10); len(builds) != 2 {
136 t.Fatalf("tick after the last build finished did not queue: %+v", builds)
137 }
104138}
internal/hookd/pushshapes_test.go +7
@@ -363,6 +363,13 @@ var pushShapes = []pushShape{
363363 f.sched.RunDue(time.Now().AddDate(1, 0, 0))
364364 }, []string{"—", "—", "—", "queued", "—", "—"}},
365365
366 {"schedule tick while the last scheduled build is still pending", func(f *shapeFixture) {
367 f.push("main", zeroSHA40, f.base)
368 f.sched.RunDue(time.Now().AddDate(1, 0, 0))
369 f.mark(f.base)
370 f.sched.RunDue(time.Now().AddDate(2, 0, 0))
371 }, []string{"—", "—", "—", "—", "—", "—"}},
372
366373 {"claimed builds whose runner vanished", func(f *shapeFixture) {
367374 f.git(f.src, "checkout", "-q", "-b", "feat")
368375 c1 := f.appCommit("more")