ci: a schedule tick queues nothing while the last build is pending !360

merged merged by cmc on 2026-09-10 02:10 UTC · krz/gitbay:sched-dedupe into main

5 files changed, +64 −7

Layout: unified · split

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