lock.go
1package engine
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "slices"
10 "strings"
11 "syscall"
12 "time"
13
14 "git.kilimanjaro.io/ci/internal/lex"
15)
16
17// Concurrency groups are flock(2) locks on <LocksDir>/<group>.lock. A run
18// takes a group's lock when it first runs an action in that group and holds
19// it until it ends or pauses at a gate (a run waiting for a person must not
20// block others), taking it again when it next runs an action in the group.
21
22func (e *Engine) locksDir() string {
23 if e.Opts.LocksDir != "" {
24 return e.Opts.LocksDir
25 }
26 return filepath.Join(filepath.Dir(e.Dir.Path()), ".locks")
27}
28
29// Locker takes concurrency groups. TryLock doesn't wait: it returns a release
30// function when it got the group, else who holds it.
31type Locker interface {
32 TryLock(ctx context.Context, group, holder string) (release func(), heldBy string, err error)
33}
34
35// FileLocker is flock(2) on <Dir>/<group>.lock, holder written inside.
36type FileLocker struct{ Dir string }
37
38func (l FileLocker) TryLock(_ context.Context, group, holder string) (func(), string, error) {
39 if err := os.MkdirAll(l.Dir, 0o755); err != nil {
40 return nil, "", err
41 }
42 f, err := os.OpenFile(filepath.Join(l.Dir, group+".lock"), os.O_CREATE|os.O_RDWR, 0o644)
43 if err != nil {
44 return nil, "", err
45 }
46 if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
47 by, _ := os.ReadFile(f.Name())
48 f.Close()
49 if errors.Is(err, syscall.EWOULDBLOCK) {
50 return nil, strings.TrimSpace(string(by)), nil
51 }
52 return nil, "", fmt.Errorf("lock %s: %w", group, err)
53 }
54 if err := f.Truncate(0); err == nil {
55 _, _ = f.WriteAt([]byte(holder+"\n"), 0)
56 }
57 return func() {
58 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
59 f.Close()
60 }, "", nil
61}
62
63func (e *Engine) locker() Locker {
64 if e.Opts.Locker != nil {
65 return e.Opts.Locker
66 }
67 return FileLocker{Dir: e.locksDir()}
68}
69
70// acquire blocks until this run holds group, or ctx ends.
71func (e *Engine) acquire(ctx context.Context, group string) error {
72 if _, held := e.locks[group]; held {
73 return nil
74 }
75 told := ""
76 for {
77 release, by, err := e.locker().TryLock(ctx, group, e.st.Meta.Run)
78 if err != nil {
79 return err
80 }
81 if release != nil {
82 if e.locks == nil {
83 e.locks = map[string]func(){}
84 }
85 e.locks[group] = release
86 return nil
87 }
88 if told != by {
89 e.emit(lex.RunEvent{Kind: lex.KindLog, Stream: "stderr",
90 Content: fmt.Sprintf("ci: waiting for concurrency group %s (held by run %s)", group, by)})
91 told = by
92 }
93 select {
94 case <-ctx.Done():
95 return ctx.Err()
96 case <-time.After(time.Second):
97 }
98 }
99}
100
101func (e *Engine) releaseLocks() {
102 for g, release := range e.locks {
103 release()
104 delete(e.locks, g)
105 }
106}
107
108// supersedeGroups returns the concurrency groups of g's options that ask for
109// superseding.
110func (e *Engine) supersedeGroups(g *PendingGate) []string {
111 var out []string
112 for _, o := range g.Options {
113 if c := e.M.Actions[o.Action].Concurrency; c != nil && c.Supersede && !slices.Contains(out, c.Group) {
114 out = append(out, c.Group)
115 }
116 }
117 return out
118}
119
120// supersedeOlder cancels older runs of the same repo and workflow that are
121// waiting at a gate in one of g's superseding concurrency groups: only the
122// newest deploy is worth approving.
123func (e *Engine) supersedeOlder(g *PendingGate) {
124 groups := e.supersedeGroups(g)
125 if len(groups) == 0 {
126 return
127 }
128 runs := filepath.Dir(e.Dir.Path())
129 ents, err := os.ReadDir(runs)
130 if err != nil {
131 return
132 }
133 for _, ent := range ents {
134 d := Dir(filepath.Join(runs, ent.Name()))
135 if d == e.Dir || !ent.IsDir() {
136 continue
137 }
138 st, err := Load(d)
139 if err != nil || st.Status != StatusPaused || st.Gate == nil ||
140 st.Meta.Repo != e.st.Meta.Repo || st.Meta.Workflow != e.st.Meta.Workflow || st.Created >= e.st.Created {
141 continue
142 }
143 other, err := Open(d, nil, Options{})
144 if err != nil {
145 continue
146 }
147 if shared := other.supersedeGroups(st.Gate); slices.ContainsFunc(shared, func(s string) bool { return slices.Contains(groups, s) }) {
148 other.emit(lex.RunEvent{Kind: lex.KindGate, Gate: other.gateRecord(st.Gate, lex.GateSuperseded, Choice{Actor: "run:" + e.st.Meta.Run})})
149 _, _ = other.finish(End{StatusCancelled, lex.ReasonSuper, "superseded by run " + e.st.Meta.Run}, "")
150 e.emit(lex.RunEvent{Kind: lex.KindLog, Stream: "stderr", Content: "ci: superseded run " + st.Meta.Run})
151 }
152 other.Log.Close()
153 }
154}