carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package provision
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "sort"
8 "time"
9
10 "github.com/carlosframework/platform/internal/release"
11 "github.com/carlosframework/platform/internal/runs"
12)
13
14// RunState is a `carlos run` on the instance record (run spec §5.1). Box
15// and BootID name the box and agent boot that picked it; Deadline is the
16// pickup deadline while queued and the run's own deadline once picked.
17// By is the caller, so a refusal can name who holds the instance (an
18// addition to the spec's struct, index item 3).
19type RunState struct {
20 ID string `json:"id"`
21 State string `json:"state"`
22 By string `json:"by,omitempty"`
23 Box string `json:"box,omitempty"`
24 BootID string `json:"boot,omitempty"`
25 Deadline time.Time `json:"deadline"`
26}
27
28// The run's states. queued, picked and cancel are open; the rest are
29// terminal, and a terminal run never blocks a run, a move or an import.
30const (
31 RunQueued = "queued"
32 RunPicked = "picked"
33 RunCancel = "cancel"
34 RunDone = "done"
35 RunCancelled = "cancelled"
36 RunExpired = "expired"
37)
38
39// Open reports whether r holds the instance: queued, picked or cancel.
40func (r *RunState) Open() bool {
41 return r != nil && (r.State == RunQueued || r.State == RunPicked || r.State == RunCancel)
42}
43
44// ImportUncommitted reports whether the record's seed is an import that is
45// not committed yet (seeded spec §5.4): a run is refused then, at the door
46// and again at launch (run spec §5.1 step 9, codex R2-2).
47func (r Record) ImportUncommitted() bool {
48 return r.Seed.BlocksRun()
49}
50
51var (
52 // ErrRunOpen: a run is open on the instance. A RunOpenError carries it.
53 ErrRunOpen = errors.New("provision: a run is open on the instance")
54 // ErrRunConflict: the record's run is not in the state the transition
55 // needs (another box took it, it was cancelled or expired, or it is
56 // another run).
57 ErrRunConflict = errors.New("provision: run state conflict")
58 // ErrRunMoving: a move is in flight.
59 ErrRunMoving = errors.New("provision: the instance is moving")
60 // ErrRunImport: the instance is an import that is not committed.
61 ErrRunImport = errors.New("provision: the instance is an import that is not committed")
62)
63
64// RunOpenError is a refusal because Run holds the instance.
65type RunOpenError struct{ Run RunState }
66
67func (e *RunOpenError) Error() string { return ErrRunOpen.Error() + ": " + e.Run.ID }
68func (e *RunOpenError) Is(target error) bool { return target == ErrRunOpen }
69
70// MoveRunOpenError is a move transition's refusal while a run is open: a
71// move conflict, so both move doors answer 409. The legacy door answers
72// Error(), which carries Sentence (RUN-C43).
73type MoveRunOpenError struct{ Run RunState }
74
75func (e *MoveRunOpenError) Error() string { return ErrMoveConflict.Error() + ": " + e.Sentence() }
76func (e *MoveRunOpenError) Unwrap() error { return ErrMoveConflict }
77func (e *MoveRunOpenError) Sentence() string {
78 return fmt.Sprintf("a run is open on this instance: %s. wait for it to finish or cancel it, then move the instance", e.Run.ID)
79}
80
81// SetRecordRun applies one run transition under the record's ETag, with
82// updateRecord's one retry: a transition re-checks its own state on the
83// fresh read, so a lost race refuses rather than overwrites.
84func SetRecordRun(ctx context.Context, s release.Store, acct, app, host string, mut func(*Record) error) (*Record, error) {
85 return updateRecord(ctx, s, acct, app, host, mut)
86}
87
88// RunAdmission is what the console decided for a new run. Key is the
89// caller's scoped Idempotency-Key (runs.ScopedKey), "" for an unkeyed
90// create; Digest is the request's runs.MetaDigest and Caller the caller's
91// runs.CallerOf, both set with Key.
92type RunAdmission struct {
93 ID, By string
94 Deadline time.Time // the pickup deadline
95 Key, Digest, Caller string
96}
97
98// RunKey is one keyed admission on the record: the scoped key, the run it
99// admitted, the request's digest, the caller (runs.CallerOf, which the
100// per-caller cap counts by) and when.
101type RunKey struct {
102 Key string `json:"key"`
103 RunID string `json:"run_id"`
104 Digest string `json:"digest"`
105 Caller string `json:"caller"`
106 At time.Time `json:"at"`
107}
108
109// RunKeyReplay is RunAdmit finding its key already admitted with the same
110// digest: RunID is the run that admission made, and this one admitted
111// nothing. It is the caller's own earlier write when RunID is the id it
112// minted (updateRecord's retry after a lost answer), and a twin's otherwise.
113type RunKeyReplay struct{ RunID string }
114
115func (e *RunKeyReplay) Error() string {
116 return "provision: the idempotency key already admitted " + e.RunID
117}
118
119// ErrRunKeyReused: the key was admitted for a different request.
120var ErrRunKeyReused = errors.New("provision: the idempotency key was used for a different request")
121
122// RunKeyFor is the record's entry for the scoped key, when one younger than
123// runs.KeyTTL is there.
124func (r Record) RunKeyFor(key string, now time.Time) (RunKey, bool) {
125 for _, k := range r.RunKeys {
126 if k.Key == key && now.Sub(k.At) < runs.KeyTTL {
127 return k, true
128 }
129 }
130 return RunKey{}, false
131}
132
133// prunedRunKeys is keys without the entries runs.KeyTTL old or older, then
134// with only the newest runs.MaxRunKeysPerCaller of each caller, in age order
135// (oldest first). The cap is per caller so that no caller's admissions can
136// evict another caller's key: a key is honoured for runs.KeyTTL, or until
137// that same caller makes runs.MaxRunKeysPerCaller newer keyed admissions on
138// this instance, whichever comes first.
139func prunedRunKeys(keys []RunKey, now time.Time) []RunKey {
140 var fresh []RunKey
141 for _, k := range keys {
142 if now.Sub(k.At) < runs.KeyTTL {
143 fresh = append(fresh, k)
144 }
145 }
146 sort.SliceStable(fresh, func(i, j int) bool { return fresh[i].At.Before(fresh[j].At) })
147 kept := make([]bool, len(fresh))
148 per := map[string]int{}
149 for i := len(fresh) - 1; i >= 0; i-- {
150 if per[fresh[i].Caller] < runs.MaxRunKeysPerCaller {
151 per[fresh[i].Caller]++
152 kept[i] = true
153 }
154 }
155 var out []RunKey
156 for i, k := range fresh {
157 if kept[i] {
158 out = append(out, k)
159 }
160 }
161 return out
162}
163
164// RunAdmit admits a run: refused while a move is in flight, while a run is
165// open, or while the record is an uncommitted import. A terminal run is
166// replaced.
167//
168// A keyed admission checks its key first, before every refusal, so a retry
169// is answered even while its own run is open: the key already admitted with
170// the same digest is a *RunKeyReplay, with another digest ErrRunKeyReused.
171// A keyed admission that succeeds records its key in the same write.
172// run_keys is pruned by age on every admission and by each caller's count
173// when one is added; nothing else changes it.
174func RunAdmit(p RunAdmission) func(*Record) error {
175 return func(rec *Record) error {
176 now := time.Now().UTC()
177 keys := prunedRunKeys(rec.RunKeys, now)
178 if p.Key != "" {
179 if p.Digest == "" || p.Caller == "" {
180 return fmt.Errorf("%w: malformed admission", ErrRunConflict)
181 }
182 if k, ok := (Record{RunKeys: keys}).RunKeyFor(p.Key, now); ok {
183 if k.Digest != p.Digest {
184 return ErrRunKeyReused
185 }
186 return &RunKeyReplay{RunID: k.RunID}
187 }
188 }
189 switch {
190 case p.ID == "" || p.Deadline.IsZero():
191 return fmt.Errorf("%w: malformed admission", ErrRunConflict)
192 case rec.Move != nil:
193 return ErrRunMoving
194 case rec.ActiveRun.Open():
195 return &RunOpenError{Run: *rec.ActiveRun}
196 case rec.ActiveRun != nil && rec.ActiveRun.ID == p.ID:
197 // A terminal run's id is never reused: a box still retrying
198 // RunPick for it would launch a run its caller cancelled or that
199 // expired (codex R2-6).
200 return fmt.Errorf("%w: run id already used", ErrRunConflict)
201 case rec.ImportUncommitted():
202 return ErrRunImport
203 }
204 rec.ActiveRun = &RunState{ID: p.ID, State: RunQueued, By: p.By, Deadline: p.Deadline.UTC()}
205 if p.Key != "" {
206 keys = prunedRunKeys(append(keys, RunKey{Key: p.Key, RunID: p.ID, Digest: p.Digest, Caller: p.Caller, At: now}), now)
207 }
208 if len(keys) == 0 {
209 keys = nil // an empty list stays absent from the record
210 }
211 rec.RunKeys = keys
212 return nil
213 }
214}
215
216// runIn returns a copy of the record's run when it is id in one of states.
217func runIn(rec *Record, id string, states ...string) (RunState, error) {
218 r := rec.ActiveRun
219 if r == nil || r.ID != id {
220 return RunState{}, ErrRunConflict
221 }
222 for _, s := range states {
223 if r.State == s {
224 return *r, nil
225 }
226 }
227 return RunState{}, ErrRunConflict
228}
229
230// RunPick moves a queued run to picked by box under boot, with the run's
231// own deadline (run spec §5.2). Only the box whose CAS lands launches.
232func RunPick(id, box, boot string, deadline time.Time) func(*Record) error {
233 return func(rec *Record) error {
234 if box == "" || boot == "" || deadline.IsZero() {
235 // A zero deadline would make the run lost the moment it is picked.
236 return fmt.Errorf("%w: malformed pick", ErrRunConflict)
237 }
238 r, err := runIn(rec, id, RunQueued)
239 if err != nil {
240 return err
241 }
242 r.State, r.Box, r.BootID, r.Deadline = RunPicked, box, boot, deadline.UTC()
243 rec.ActiveRun = &r
244 return nil
245 }
246}
247
248// RunCancelTransition cancels: queued becomes cancelled, which is terminal,
249// so no box ever launches it; picked becomes cancel, and the owning box
250// stops the unit (run spec §5.7). cancel stays cancel.
251func RunCancelTransition(id string) func(*Record) error {
252 return func(rec *Record) error {
253 r, err := runIn(rec, id, RunQueued, RunPicked, RunCancel)
254 if err != nil {
255 return err
256 }
257 if r.State == RunQueued {
258 r.State = RunCancelled
259 } else {
260 r.State = RunCancel
261 }
262 rec.ActiveRun = &r
263 return nil
264 }
265}
266
267// RunExpire expires a queued run (run spec §5.2, §5.8): terminal, so a box
268// that turns up late can never pick it. A picked run is not expired: the
269// pickup won.
270func RunExpire(id string) func(*Record) error {
271 return func(rec *Record) error {
272 r, err := runIn(rec, id, RunQueued)
273 if err != nil {
274 return err
275 }
276 r.State = RunExpired
277 rec.ActiveRun = &r
278 return nil
279 }
280}
281
282// RunFinish marks a picked or cancelling run done: the box after it wrote
283// result.json, or the console's sweep for a lost run.
284func RunFinish(id string) func(*Record) error {
285 return func(rec *Record) error {
286 r, err := runIn(rec, id, RunPicked, RunCancel)
287 if err != nil {
288 return err
289 }
290 r.State = RunDone
291 rec.ActiveRun = &r
292 return nil
293 }
294}
295
296// RunClear removes a terminal run from the record, only while the record
297// still names this run (run spec §5.8).
298func RunClear(id string) func(*Record) error {
299 return func(rec *Record) error {
300 if _, err := runIn(rec, id, RunDone, RunCancelled, RunExpired); err != nil {
301 return err
302 }
303 rec.ActiveRun = nil
304 return nil
305 }
306}
307