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 |
| 402 | 402 | be starved. That is the rule, decided in krz/gitbay#207: a runner |
| 403 | 403 | serving several repositories takes them oldest-first, and an operator |
| 404 | 404 | who wants one repository never to wait on another runs a second |
| 405 | | runner attached to it alone. Nothing caps what an account queues: builds pending at |
| 406 | | once and schedule intervals down to a minute are unbounded, and a |
| 407 | | build nothing claims stays pending. Since a build runs only on a |
| 408 | | runner 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. |
| 405 | runner attached to it alone. Nothing caps what an account queues, |
| 406 | and nothing needs to (krz/gitbay#206): a schedule tick queues nothing |
| 407 | while the job's last build is pending or running, so a repository |
| 408 | with no runner holds one row per scheduled job rather than one per |
| 409 | tick, and a build runs only on a runner its owner attaches, so a busy |
| 410 | schedule spends the owner's compute. Pushes are bounded by what an |
| 411 | account can push. |
| 410 | 412 | |
| 411 | 413 | #+begin_src sh |
| 412 | 414 | gitbay-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 |
| 28 | 28 | takes the oldest pending build, across every repository it serves; |
| 29 | 29 | a repository that must never wait on another gets a runner of its own |
| 30 | 30 | (decided in krz/gitbay#207). There is no cap on how many |
| 31 | | builds an account queues or how often a schedule fires; that is |
| 32 | | krz/gitbay#206. |
| 31 | builds an account queues or how often a schedule fires, and none is |
| 32 | planned (krz/gitbay#206): a tick queues nothing while the job's last |
| 33 | build is pending or running, so a repository with no runner holds one |
| 34 | row per scheduled job, and a schedule with a runner attached spends |
| 35 | its owner's compute, not the instance's. |
| 33 | 36 | |
| 34 | 37 | Scheduled jobs run on their cron against the default branch, never on |
| 35 | 38 | push; 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: |
| 84 | 87 | | merge request head from a fork | queued, untrusted | queued, untrusted | queued, untrusted | — | — | — | |
| 85 | 88 | | tag push | — | — | — | — | queued | — | |
| 86 | 89 | | schedule tick on the default branch | — | — | — | queued | — | — | |
| 90 | | schedule tick while the last scheduled build is still pending | — | — | — | — | — | — | |
| 87 | 91 | | claimed builds whose runner vanished | abandoned | abandoned | — | — | — | — | |
| 88 | 92 | | push to the default branch with an old sha that cannot be diffed | queued | queued | queued | registered | — | — | |
| 89 | 93 | | push with a broken ci.yml | — | — | — | — | — | failure | |
internal/ci/sched.go
+10
| @@ -100,6 +100,16 @@ func (s *Scheduler) RunDue(now time.Time) { |
| 100 | 100 | s.St.RemoveSchedule(e.RepoID, e.Job) |
| 101 | 101 | continue |
| 102 | 102 | } |
| 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 | } |
| 103 | 113 | steps, _ := json.Marshal(job.Steps) |
| 104 | 114 | n, err := s.St.CreateBuild(repo.ID, job.Name, sha, repo.DefaultBranch, string(steps), job.Image, "", true) |
| 105 | 115 | if err != nil { |
internal/ci/sched_test.go
+34
| @@ -101,4 +101,38 @@ func TestSchedulerRunDue(t *testing.T) { |
| 101 | 101 | if builds, _ = st.ListBuilds(repoID, 10); len(builds) != 1 { |
| 102 | 102 | t.Fatalf("second pass queued extra builds: %+v", builds) |
| 103 | 103 | } |
| 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 | } |
| 104 | 138 | } |
internal/hookd/pushshapes_test.go
+7
| @@ -363,6 +363,13 @@ var pushShapes = []pushShape{ |
| 363 | 363 | f.sched.RunDue(time.Now().AddDate(1, 0, 0)) |
| 364 | 364 | }, []string{"—", "—", "—", "queued", "—", "—"}}, |
| 365 | 365 | |
| 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 | |
| 366 | 373 | {"claimed builds whose runner vanished", func(f *shapeFixture) { |
| 367 | 374 | f.git(f.src, "checkout", "-q", "-b", "feat") |
| 368 | 375 | c1 := f.appCommit("more") |