v0.25.1 7fabed9

ci_schedule.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"runtime/debug"
10	"slices"
11	"strings"
12	"sync"
13	"time"
14
15	"github.com/bnema/zerowrap"
16
17	"bnema.dev/ermite/internal/domain"
18)
19
20// The schedule event (CI-3, D5). A scheduler goroutine looks, once per minute of
21// its clock, at the "cron" entries of the workflows of every repository's default
22// branch and starts the ones that match that minute (UTC; "timezone" is ignored).
23//
24//   - The run is on the latest commit of the default branch. Its actor and its
25//     trust are those of whoever pushed that commit (ci_tips.go), as on GitHub:
26//     the default branch is not necessarily the owner's work.
27//   - A minute is looked at once, when the clock first reaches it. Ticks missed
28//     while ermite was down or busy are not made up: a pass that is still going
29//     when the next minute starts is dropped, as GitHub drops late runs.
30//   - The schedules of a repository are read once and cached with the commit
31//     they were read at. A push (of any principal, a job token's included, which
32//     starts no run but may change the file) marks the repository, and the
33//     cache is dropped when its default branch moved. A repository that no
34//     longer exists is forgotten at the next pass.
35//   - The work is bounded: at most ScheduleBudget repositories are looked at per
36//     tick, so a pass over many repositories spreads over several ticks (and
37//     never starts a run late for a minute that is over). A pass dropped
38//     before its end leaves a cursor: the next pass starts after the last
39//     repository handled, so that no repository is starved.
40//
41// The cron entries of one workflow that match the same minute start one run of
42// it (the first in file order); the event payload is that entry's text.
43
44const (
45	// DefaultCIScheduleTick is how often the scheduler looks at its clock.
46	DefaultCIScheduleTick = time.Second
47	// DefaultCIScheduleBudget is the repositories the scheduler handles at most
48	// per tick.
49	DefaultCIScheduleBudget = 64
50)
51
52// scheduledCron is one cron entry of a workflow.
53type scheduledCron struct {
54	spec  string
55	sched domain.CronSchedule
56}
57
58// scheduledWorkflow is a workflow with at least one valid cron entry.
59type scheduledWorkflow struct {
60	path  string
61	yaml  []byte
62	crons []scheduledCron
63}
64
65// scheduleEntry is what was read from one repository at tip.
66type scheduleEntry struct {
67	tip   domain.ObjectID
68	files []scheduledWorkflow
69}
70
71// schedulePass is the look at one minute over the repositories.
72type schedulePass struct {
73	minute time.Time
74	repos  []domain.RepoName
75	next   int
76}
77
78type scheduler struct {
79	mu    sync.Mutex
80	dirty map[domain.RepoName]struct{}
81	cache map[domain.RepoName]*scheduleEntry
82	// passed is the minute of the last pass that went through every repository.
83	passed time.Time
84
85	// The fields below belong to the scheduler goroutine.
86	last time.Time // the latest minute a pass was started for
87	pass *schedulePass
88	// cursor is the last repository a pass handled when it was dropped before
89	// the end; the next pass starts after it, so that repositories beyond the
90	// budget of a tick are not starved by the ones that come first. Zero when
91	// the last pass went through every repository.
92	cursor domain.RepoName
93}
94
95func newScheduler(boot time.Time) *scheduler {
96	return &scheduler{
97		dirty: map[domain.RepoName]struct{}{}, cache: map[domain.RepoName]*scheduleEntry{},
98		// The minute ermite starts in belongs to the process before it.
99		last: boot.UTC().Truncate(time.Minute),
100	}
101}
102
103func (s *scheduler) get(repo domain.RepoName) *scheduleEntry {
104	s.mu.Lock()
105	defer s.mu.Unlock()
106	return s.cache[repo]
107}
108
109func (s *scheduler) put(repo domain.RepoName, e *scheduleEntry) {
110	s.mu.Lock()
111	defer s.mu.Unlock()
112	s.cache[repo] = e
113}
114
115func (s *scheduler) drop(repo domain.RepoName) {
116	s.mu.Lock()
117	defer s.mu.Unlock()
118	delete(s.cache, repo)
119}
120
121// markDirty remembers that the default branch of a repository may have moved.
122func (s *scheduler) markDirty(repo domain.RepoName) {
123	s.mu.Lock()
124	defer s.mu.Unlock()
125	if _, cached := s.cache[repo]; cached {
126		s.dirty[repo] = struct{}{}
127	}
128}
129
130func (s *scheduler) forgetDirty(repo domain.RepoName) {
131	s.mu.Lock()
132	defer s.mu.Unlock()
133	delete(s.dirty, repo)
134}
135
136// takeDirty removes and returns at most n marked repositories.
137func (s *scheduler) takeDirty(n int) []domain.RepoName {
138	s.mu.Lock()
139	defer s.mu.Unlock()
140	var out []domain.RepoName
141	for r := range s.dirty {
142		if len(out) == n {
143			break
144		}
145		out = append(out, r)
146		delete(s.dirty, r)
147	}
148	return out
149}
150
151// prune forgets the repositories that are not in keep.
152func (s *scheduler) prune(keep []domain.RepoName) {
153	live := make(map[domain.RepoName]struct{}, len(keep))
154	for _, r := range keep {
155		live[r] = struct{}{}
156	}
157	s.mu.Lock()
158	defer s.mu.Unlock()
159	for r := range s.cache {
160		if _, ok := live[r]; !ok {
161			delete(s.cache, r)
162		}
163	}
164	for r := range s.dirty {
165		if _, ok := live[r]; !ok {
166			delete(s.dirty, r)
167		}
168	}
169}
170
171// CachedSchedules is the number of scheduled workflows the scheduler holds for a
172// repository, -1 when it holds nothing for it. For tests and diagnostics.
173func (c *CI) CachedSchedules(repo domain.RepoName) int {
174	if e := c.sched.get(repo); e != nil {
175		return len(e.files)
176	}
177	return -1
178}
179
180// SchedulePassed is the minute of the last pass of the scheduler that went
181// through every repository (zero before the first). For tests and diagnostics.
182func (c *CI) SchedulePassed() time.Time {
183	c.sched.mu.Lock()
184	defer c.sched.mu.Unlock()
185	return c.sched.passed
186}
187
188// markSchedulesDirty tells the scheduler that these updates may have moved the
189// default branch of the repository.
190func (c *CI) markSchedulesDirty(repo domain.RepoName, updates []domain.RefUpdate) {
191	for _, u := range updates {
192		if strings.HasPrefix(u.Ref, "refs/heads/") {
193			c.sched.markDirty(repo)
194			return
195		}
196	}
197}
198
199// ScheduleNow does one tick of the scheduler on the calling goroutine, with the
200// clock of the configuration. It is for tests (with a ScheduleTick too long to
201// fire) and operators' tooling; the server never needs to call it. It must not
202// run at the same time as the scheduler goroutine.
203func (c *CI) ScheduleNow(ctx context.Context) {
204	c.guardSchedule(func(context.Context) { c.scheduleTick(ctx) })
205}
206
207func (c *CI) scheduleLoop() {
208	defer c.wg.Done()
209	t := time.NewTicker(c.cfg.ScheduleTick)
210	defer t.Stop()
211	for {
212		select {
213		case <-c.ctx.Done():
214			return
215		case <-t.C:
216		}
217		c.guardSchedule(c.scheduleTick)
218	}
219}
220
221func (c *CI) guardSchedule(fn func(context.Context)) {
222	log := zerowrap.FromCtx(c.ctx)
223	defer func() {
224		if r := recover(); r != nil {
225			log.Error().Interface("panic", r).Bytes("stack", debug.Stack()).Msg("ci scheduler panicked")
226		}
227	}()
228	fn(c.ctx)
229}
230
231// scheduleTick does one tick of work: it checks the repositories whose default
232// branch may have moved, starts a pass when the clock reached a new minute, and
233// carries on the pass within the budget.
234func (c *CI) scheduleTick(ctx context.Context) {
235	s := c.sched
236	budget := c.cfg.ScheduleBudget
237	for _, repo := range s.takeDirty(budget) {
238		budget--
239		if s.get(repo) != nil && !c.scheduleCurrent(ctx, repo) {
240			s.drop(repo)
241		}
242	}
243	if minute := c.now().Truncate(time.Minute); minute.After(s.last) {
244		// A pass still going belongs to a minute that is over: it is dropped, and
245		// so is every minute between.
246		s.last = minute
247		s.pass = c.startPass(ctx, minute)
248	}
249	p := s.pass
250	for p != nil && p.next < len(p.repos) && budget > 0 && ctx.Err() == nil {
251		repo := p.repos[p.next]
252		p.next++
253		s.cursor = repo
254		budget--
255		if c.cfg.ScheduleVisit != nil {
256			c.cfg.ScheduleVisit(repo)
257		}
258		if err := c.scheduleRepo(ctx, repo, p.minute); err != nil && ctx.Err() == nil {
259			ctxLog(ctx).Error().Err(err).Str("repo", repo.String()).Msg("ci schedule failed")
260		}
261	}
262	if p != nil && p.next >= len(p.repos) {
263		s.mu.Lock()
264		s.passed = p.minute
265		s.mu.Unlock()
266		s.pass, s.cursor = nil, domain.RepoName{}
267	}
268}
269
270// startPass lists the repositories to look at for a minute and forgets the
271// ones that are gone. Without a runner CI is off and nothing is looked at.
272func (c *CI) startPass(ctx context.Context, minute time.Time) *schedulePass {
273	p := &schedulePass{minute: minute}
274	repos, err := c.cfg.Meta.List(ctx)
275	if err != nil {
276		ctxLog(ctx).Error().Err(err).Msg("ci schedule: list the repositories")
277		return p
278	}
279	names := make([]domain.RepoName, 0, len(repos))
280	for _, r := range repos {
281		names = append(names, r.Name)
282	}
283	c.sched.prune(names)
284	if runners, err := c.cfg.Store.ListRunners(ctx); err != nil || len(runners) == 0 {
285		return p
286	}
287	// Start after the repository the dropped pass stopped at (the list is in
288	// name order), and wrap around.
289	start := 0
290	if !c.sched.cursor.IsZero() {
291		for start < len(names) && names[start].String() <= c.sched.cursor.String() {
292			start++
293		}
294	}
295	p.repos = append(slices.Clone(names[start:]), names[:start]...)
296	return p
297}
298
299// scheduleCurrent reports whether the cached schedules of a repository were
300// read at the tip of its default branch as it is now.
301func (c *CI) scheduleCurrent(ctx context.Context, repo domain.RepoName) bool {
302	e := c.sched.get(repo)
303	if e == nil {
304		return false
305	}
306	tip, ok := c.defaultTip(ctx, repo)
307	return ok && tip == e.tip
308}
309
310// defaultTip is the latest commit of the default branch.
311func (c *CI) defaultTip(ctx context.Context, repo domain.RepoName) (domain.ObjectID, bool) {
312	def, exists, err := c.cfg.Repos.DefaultBranch(ctx, repo)
313	if err != nil || !exists {
314		return domain.ObjectID{}, false
315	}
316	tip, err := c.cfg.Git.BranchHead(ctx, repo, def)
317	if err != nil {
318		return domain.ObjectID{}, false
319	}
320	return tip, true
321}
322
323// loadSchedules reads the cron entries of the workflows at the tip of the
324// default branch. A workflow that does not parse is skipped (the push of the
325// file reported it).
326func (c *CI) loadSchedules(ctx context.Context, repo domain.RepoName) (*scheduleEntry, error) {
327	tip, ok := c.defaultTip(ctx, repo)
328	if !ok {
329		return &scheduleEntry{}, nil // no repository, no default branch, nothing to schedule
330	}
331	e := &scheduleEntry{tip: tip}
332	files, err := c.workflowFiles(ctx, repo, tip)
333	if err != nil {
334		return nil, err
335	}
336	for _, f := range files {
337		if f.problem != "" {
338			continue
339		}
340		wf, perrs := parseWorkflow(f.path, f.content)
341		if len(perrs) > 0 || wf.On == nil {
342			continue
343		}
344		ev := wf.On.Event(eventSchedule)
345		if ev == nil {
346			continue
347		}
348		sw := scheduledWorkflow{path: f.path, yaml: f.content}
349		for _, sc := range ev.Schedule {
350			cron, err := domain.ParseCron(sc.Cron)
351			if err != nil {
352				continue
353			}
354			loc, err := domain.ParseCronTimezone(sc.Timezone)
355			if err != nil {
356				continue // the push of the file reported it (parseWorkflow)
357			}
358			sw.crons = append(sw.crons, scheduledCron{spec: sc.Cron, sched: domain.CronSchedule{Cron: cron, Loc: loc}})
359		}
360		if len(sw.crons) > 0 {
361			e.files = append(e.files, sw)
362		}
363	}
364	return e, nil
365}
366
367// dueFor returns, for each workflow of the entry that has an entry matching the
368// minute, the text of the first one that does.
369func dueFor(e *scheduleEntry, minute time.Time) (idx []int, specs []string) {
370	for i, f := range e.files {
371		for _, sc := range f.crons {
372			if sc.sched.Due(minute) {
373				idx, specs = append(idx, i), append(specs, sc.spec)
374				break
375			}
376		}
377	}
378	return idx, specs
379}
380
381// scheduleRepo starts the scheduled workflows of a repository that are due at
382// the minute.
383func (c *CI) scheduleRepo(ctx context.Context, repo domain.RepoName, minute time.Time) error {
384	// The cache is of the commit the run will be on: it is read again when the
385	// default branch moved (a push marked it, or the default branch was changed).
386	tip, ok := c.defaultTip(ctx, repo)
387	if !ok {
388		c.sched.drop(repo)
389		return nil
390	}
391	e := c.sched.get(repo)
392	if e == nil || e.tip != tip {
393		var err error
394		if e, err = c.loadSchedules(ctx, repo); err != nil {
395			return fmt.Errorf("read the schedules: %w", err)
396		}
397		c.sched.put(repo, e)
398	}
399	idx, specs := dueFor(e, minute)
400	if len(idx) == 0 {
401		return nil
402	}
403	meta, err := c.cfg.Meta.Get(ctx, repo)
404	if err != nil {
405		c.sched.drop(repo)
406		return nil // deleted meanwhile
407	}
408	def, _, err := c.cfg.Repos.DefaultBranch(ctx, repo)
409	if err != nil {
410		return nil
411	}
412	commits, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, e.tip, 1)
413	if err != nil || len(commits) == 0 {
414		return fmt.Errorf("read the head commit of %s: %w", def, err)
415	}
416	var errs []error
417	for n, i := range idx {
418		errs = append(errs, c.fireSchedule(ctx, meta, def, e, commits[0], e.files[i], specs[n]))
419	}
420	return errors.Join(errs...)
421}
422
423// fireSchedule starts the run of one scheduled workflow.
424func (c *CI) fireSchedule(ctx context.Context, repo domain.Repo, def string, e *scheduleEntry, head domain.Commit, sw scheduledWorkflow, spec string) error {
425	wf, perrs := parseWorkflow(sw.path, sw.yaml)
426	if len(perrs) > 0 {
427		return nil
428	}
429	facts := pushFacts{
430		update: domain.RefUpdate{Ref: "refs/heads/" + def, Old: domain.ZeroOID, New: e.tip.String()}, commit: e.tip, head: head,
431		defaultBranch: def, custom: &customEvent{name: eventSchedule, cron: spec},
432	}
433	// Whoever moved the default branch to this commit, with their trust
434	// (ci_tips.go); the owner's name and no trust when that is not known.
435	actor, pusher, trusted := c.scheduleActor(ctx, repo.Name, def, e.tip)
436	t := ciTrigger{repo: repo.Name, actor: actor, pusher: pusher, trusted: trusted, at: c.now()}
437	if problem := c.matrixProblem(ctx, repo, t, facts, sw.path, wf); problem.text != "" {
438		name := sw.path
439		if problem.named {
440			name = workflowName(wf, sw.path)
441		}
442		return c.startInvalid(ctx, repo, t, facts, sw.path, name, problem.text)
443	}
444	_, err := c.launchRun(ctx, repo, t, facts, sw.path, string(sw.yaml), wf)
445	return err
446}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close