carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package schedule
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "log"
9 "os"
10 "sync"
11 "time"
12
13 "github.com/carlosframework/platform/internal/hostwrite"
14 "github.com/carlosframework/platform/internal/registry"
15 "github.com/carlosframework/platform/internal/release"
16)
17
18// Converger turns apps/<acct>/<app>/schedules.json into timers rows for every
19// exec-backed instance this box serves, acts on schedule-run.json, and prunes
20// what no longer belongs (spec §6.2). One instance per agent; Pass is called
21// on every serial move, hourly, and once at start.
22//
23// Etag memory is what keeps an idle box cheap and a run-signal
24// exactly-once: an unchanged declaration is one GET and no writes; a run
25// signal fires when its etag MOVES, and the first sighting only seeds
26// (a signal older than this process is history — restart-gen's rule).
27//
28// One instance, but THREE callers, and they are separate goroutines: the
29// agent's fast converge loop runs Pass at arming and on every serial move,
30// and its adopt loop runs Pass hourly. Pass is therefore serialized on mu —
31// see the field.
32type Converger struct {
33 Reg *registry.Registry
34 Store release.Store
35 Now func() time.Time
36 Box string // reported in Status.Box
37 Report func(rt registry.Route, st Status) // called for every host whose rows changed or that is unsupported; nil = no reports
38 Idle time.Duration // the activator's idle window; zero when no hibernation is armed on this box
39 HostWrites hostwrite.Config
40 // Frozen answers whether a move holds host's claim (moves spec §9.4,
41 // §19 K9): its timers are neither upserted nor pruned. Nil: none is.
42 Frozen func(host string) bool
43 // Gate is the per-host gate shared with the runner and a move's drain;
44 // convergeHost holds it across its Frozen check and its writes. Nil:
45 // no gate, main's converger.
46 Gate *HostGate
47
48 // afterFrozenCheck is the tests' pause inside the gate, after the
49 // Frozen check (TestConvergerCannotWriteAfterTheBarrier). Nil in the
50 // agent.
51 afterFrozenCheck func(host string)
52
53 declEtag map[[2]string]string // (acct, app) → last etag seen
54 declDoc map[[2]string]Declaration
55 runEtag map[[2]string]string
56
57 // reportedUnsupported tracks which unit-backed hosts have already had
58 // their one report — Report fires for them once, not every pass.
59 reportedUnsupported map[string]bool
60
61 // mu serializes Pass, and is held for the WHOLE of it. Two reasons, and
62 // the first one alone is fatal:
63 //
64 // 1. The four maps above are plain maps written on every pass. The agent
65 // calls Pass from two goroutines (fastConvergeLoop's seed and serial
66 // leg; adoptLoop's hourly floor), and they genuinely overlap — an
67 // hourly pass mid-flight when a console bump fires the fast one. A
68 // concurrent map read and write is a runtime THROW, not a data race
69 // you find later: it would kill the agent, and the activator and every
70 // exec child with it.
71 // 2. The etag memory IS the exactly-once mechanism for a manual run. Two
72 // interleaved passes can both read runEtag[k] before either writes it,
73 // both see movement, and both fire the same `carlos schedule run` —
74 // the one promise this struct's comment makes, broken.
75 //
76 // The lock lives HERE rather than at the agent's call sites (the shape
77 // configSyncMu/reconcileMu use in host/agent.go) because a Converger is a
78 // stateful object with an exported method: a future caller cannot forget a
79 // lock it never has to take.
80 mu sync.Mutex
81}
82
83const onceRetention = 7 * 24 * time.Hour
84
85func NewConverger(reg *registry.Registry, store release.Store, box string, report func(registry.Route, Status)) *Converger {
86 return &Converger{
87 Reg: reg, Store: store, Now: time.Now, Box: box, Report: report,
88 declEtag: map[[2]string]string{}, declDoc: map[[2]string]Declaration{},
89 runEtag: map[[2]string]string{}, reportedUnsupported: map[string]bool{},
90 }
91}
92
93// Pass is die-free per app: one app's unreadable declaration is logged and
94// its rows left exactly as they were (a baseline with holes manufactures
95// deletions), and every other app still converges.
96//
97// Serialized end to end (see mu): concurrent callers queue rather than
98// interleave. A pass is store-bound, so a queued caller can wait on a slow
99// bucket — that is the intended cost, and cheaper than the alternative for
100// both reasons mu documents.
101func (c *Converger) Pass(ctx context.Context) error {
102 c.mu.Lock()
103 defer c.mu.Unlock()
104 routes, err := c.Reg.List()
105 if err != nil {
106 return err
107 }
108 now := c.Now()
109 // alive is every row, held ones included, so PruneTimers never treats a
110 // frozen host's rows as orphans. frozenAny holds back the table-wide
111 // prune itself while a move has frozen any host here: its retention leg
112 // would otherwise delete a frozen host's finished once-rows. The
113 // runner's daily sweep still runs (it cannot be frozen; the timers
114 // digest leaves that history out, joint N14), so nothing accumulates.
115 alive := map[string]bool{}
116 frozenAny := false
117 byApp := map[[2]string][]registry.Route{}
118 for _, rt := range routes {
119 alive[rt.Host] = true
120 if c.Frozen != nil && !frozenAny && c.Frozen(rt.Host) {
121 frozenAny = true
122 }
123 if rt.Kind != "instance" {
124 continue
125 }
126 k := [2]string{release.RouteAccount(rt), rt.App}
127 if k[1] == "" { // no k[0] check: RouteAccount's floor is release.OpsAccount, never ""
128 continue
129 }
130 byApp[k] = append(byApp[k], rt)
131 }
132 var errs []error
133 for k, rts := range byApp {
134 if err := c.convergeApp(ctx, k[0], k[1], rts, now); err != nil {
135 errs = append(errs, fmt.Errorf("%s/%s: %w", k[0], k[1], err))
136 }
137 }
138 if !c.HostWrites.Enabled && !frozenAny {
139 if n, err := c.Reg.PruneTimers(alive, now.Add(-onceRetention).Unix()); err != nil {
140 errs = append(errs, err)
141 } else if n > 0 {
142 log.Printf("schedule: pruned %d timer row(s)", n)
143 }
144 }
145 return errors.Join(errs...)
146}
147
148func (c *Converger) convergeApp(ctx context.Context, acct, app string, rts []registry.Route, now time.Time) error {
149 // Capture once before either grouped remote input read. Reuse on every effect.
150 selected := map[string]hostwrite.Work{}
151 var errs []error
152 for _, rt := range rts {
153 work, err := c.HostWrites.Select(ctx, rt.Host, "timers")
154 if err != nil {
155 errs = append(errs, fmt.Errorf("%s: select lifecycle authority: %w", rt.Host, err))
156 continue
157 }
158 selected[rt.Host] = work
159 }
160 k := [2]string{acct, app}
161 // Reads the object directly rather than calling ReadDeclaration: the
162 // etag has to be compared against declEtag[k] BEFORE deciding whether
163 // to unmarshal, so an unchanged declaration costs one GET and zero JSON
164 // parses (the struct's own doc comment's promise). ReadDeclaration
165 // always unmarshals what it reads — right for a one-off caller, wrong
166 // for a loop that runs every 2s per app.
167 raw, etag, err := c.Store.Get(ctx, DeclarationKey(acct, app))
168 var decl Declaration
169 switch {
170 case errors.Is(err, os.ErrNotExist):
171 etag = ""
172 case err != nil:
173 return fmt.Errorf("read schedules.json: %w", err)
174 case etag == c.declEtag[k]:
175 decl = c.declDoc[k]
176 default:
177 if err := json.Unmarshal(raw, &decl); err != nil {
178 return fmt.Errorf("parse schedules.json: %w", err)
179 }
180 }
181 c.declEtag[k], c.declDoc[k] = etag, decl
182
183 // Run signal: seed silently on first sight, fire on movement.
184 runRaw, runEtag, err := c.Store.Get(ctx, RunKey(acct, app))
185 fireName := ""
186 switch {
187 case errors.Is(err, os.ErrNotExist):
188 runEtag = ""
189 case err != nil:
190 return fmt.Errorf("read schedule-run.json: %w", err)
191 }
192 if prev, seen := c.runEtag[k]; seen && runEtag != prev && runEtag != "" {
193 var sig RunSignal
194 if err := json.Unmarshal(runRaw, &sig); err == nil {
195 if _, ok := decl.Find(sig.Name); ok {
196 fireName = sig.Name
197 } else {
198 log.Printf("schedule: %s/%s: run requested for undeclared schedule %q — ignored", acct, app, sig.Name)
199 }
200 }
201 }
202 c.runEtag[k] = runEtag
203
204 for _, rt := range rts {
205 work, ok := selected[rt.Host]
206 if !ok {
207 continue
208 }
209 hctx := context.WithValue(ctx, timerWorkKey{}, timerWork{ctx: ctx, work: work})
210 changed, err := c.convergeHost(hctx, rt, decl, fireName, now)
211 if err != nil {
212 errs = append(errs, fmt.Errorf("%s: %w", rt.Host, err))
213 continue
214 }
215 if changed && c.Report != nil {
216 var st Status
217 err := withTimerWork(hctx, func() error {
218 var statusErr error
219 st, statusErr = StatusFor(c.Reg, rt, c.Box, now, c.Idle)
220 return statusErr
221 })
222 if err != nil {
223 errs = append(errs, err)
224 continue
225 }
226 c.Report(rt, st)
227 }
228 }
229 return errors.Join(errs...)
230}
231
232// convergeHost returns whether anything about the host's rows changed (or
233// the host is unsupported and should be reported so).
234//
235// A host a move has frozen (Frozen, moves spec §9.4 and §19 K9) is left
236// exactly as it is: nothing upserted, nothing pruned, a run signal not
237// fired. The check and every write below happen inside the host's gate, so
238// the cut's drain (HostGate.Drain after the hold) waits for a convergence
239// that checked before the freeze, and one that starts after the drain sees
240// it. A host the gate refuses is being drained: skipped the same way.
241func (c *Converger) convergeHost(ctx context.Context, rt registry.Route, decl Declaration, fireName string, now time.Time) (bool, error) {
242 if !c.Gate.tryEnter(rt.Host) {
243 return false, nil
244 }
245 defer c.Gate.leave(rt.Host)
246 if c.Frozen != nil && c.Frozen(rt.Host) {
247 return false, nil
248 }
249 if c.afterFrozenCheck != nil {
250 c.afterFrozenCheck(rt.Host)
251 }
252 var before []registry.Timer
253 if c.HostWrites.Enabled {
254 err := withTimerWork(ctx, func() error {
255 var readErr error
256 before, readErr = c.Reg.TimersFor(rt.Host)
257 return readErr
258 })
259 if err != nil {
260 return false, err
261 }
262 cutoff := now.Add(-onceRetention).Unix()
263 kept := before[:0]
264 for _, row := range before {
265 if row.Kind == KindOnce && row.NextAt == 0 && row.LastAt < cutoff {
266 if err := deleteSelectedTimer(ctx, c.Reg, row); err != nil {
267 return false, err
268 }
269 continue
270 }
271 kept = append(kept, row)
272 }
273 before = kept
274 }
275 if rt.Backing == registry.BackingUnit {
276 // A host can flip exec -> unit mid-lifetime (the app-capabilities
277 // migration door, registry.SetBacking): drop any schedule rows it
278 // still carries from before the flip, so "no rows" stays true for
279 // every unit-backed host, not just ones that were always unit-backed.
280 var n int
281 n, err := c.deleteSchedules(ctx, rt.Host, before, map[string]bool{})
282 if err != nil {
283 return false, err
284 }
285 changed := n > 0
286 // Reported once per host, and only when there is something to warn
287 // about — an undeclared app on a unit-backed host is silent.
288 if len(decl.Schedules) > 0 && c.unsupportedUnreported(rt.Host) {
289 changed = true
290 }
291 return changed, nil
292 }
293 if !c.HostWrites.Enabled {
294 var err error
295 before, err = c.Reg.TimersFor(rt.Host)
296 if err != nil {
297 return false, err
298 }
299 }
300 registrations := map[string]string{}
301 keep := map[string]bool{}
302 changed := false
303 for _, sc := range decl.Schedules {
304 sp, err := sc.Spec()
305 if err != nil {
306 log.Printf("schedule: %s: schedule %q has an invalid spec (%v) — skipped", rt.Host, sc.Name, err)
307 continue
308 }
309 keep[sc.Name] = true
310 // prev is the ONLY-schedule-kind baseline: a same-named once-row
311 // (the instance's own, off its control socket) is never "previous
312 // state" for this comparison — a kind collision decides itself
313 // below, at the write, not here.
314 prev := findTimer(before, sc.Name, KindSchedule)
315 wouldChange := prev == nil || prev.Spec != sp.String() || prev.Path != sc.Path
316 var next int64
317 neverFires := false
318 if wouldChange {
319 // Schedule.Validate refuses a spec whose first Next is zero at
320 // `set` time; one written some other way must not become a row
321 // due at the zero time's unix value (-62135596800 — in the past,
322 // so due on the very next tick, forever). It is parked at
323 // next_at=0 and told why, below.
324 if n := sp.Next(now); !n.IsZero() {
325 next = n.Unix()
326 } else {
327 neverFires = true
328 }
329 }
330 // A new occurrence is due whenever next moves (DueAt rides NextAt —
331 // UpsertTimer leaves both alone when NextAt is 0 and nothing changed).
332 var installed *registry.Timer
333 t := registry.Timer{Host: rt.Host, Name: sc.Name, Kind: KindSchedule, Spec: sp.String(), Path: sc.Path, NextAt: next, DueAt: next, CreatedAt: now.Unix()}
334 handled := false
335 if c.HostWrites.Enabled {
336 expected := ""
337 if prev != nil {
338 expected = prev.Registration
339 }
340 if prev != nil && !wouldChange && hasTimerMutator(ctx) {
341 installed, handled = prev, true
342 } else {
343 installed, handled, err = mutateSelectedTimer(ctx, t, expected, false)
344 }
345 }
346 if !handled {
347 err = withTimerWork(ctx, func() error {
348 if !c.HostWrites.Enabled {
349 return c.Reg.UpsertTimer(t)
350 }
351 expected := ""
352 if prev != nil {
353 expected = prev.Registration
354 }
355 var err error
356 installed, err = c.Reg.UpsertTimerRegistration(t, expected)
357 return err
358 })
359 }
360
361 if errors.Is(err, registry.ErrTimerKind) {
362 // Nothing was written — a collision must not pin `changed`,
363 // or a permanently-colliding name reports every single pass.
364 log.Printf("schedule: %s: %q is a once-timer the instance registered; the declared schedule of that name is skipped", rt.Host, sc.Name)
365 continue
366 }
367 if err != nil {
368 return changed, err
369 }
370 if installed != nil {
371 registrations[sc.Name] = installed.Registration
372 }
373 if neverFires {
374 log.Printf("schedule: %s: schedule %q never fires (no occurrence within the search window) — parked", rt.Host, sc.Name)
375 if err := withTimerWork(ctx, func() error {
376 if c.HostWrites.Enabled {
377 return c.Reg.SetTimerStatusRegistration(rt.Host, sc.Name, installed.Registration, StatusNeverFires)
378 }
379 return c.Reg.SetTimerStatus(rt.Host, sc.Name, StatusNeverFires)
380 }); err != nil {
381 return changed, err
382 }
383 }
384 if wouldChange {
385 changed = true
386 }
387 }
388 n, err := c.deleteSchedules(ctx, rt.Host, before, keep)
389 if err != nil {
390 return changed, err
391 }
392 if n > 0 {
393 changed = true
394 }
395 if fireName != "" && keep[fireName] {
396 fire := func() error {
397 if c.HostWrites.Enabled {
398 row, err := c.Reg.GetTimer(rt.Host, fireName)
399 if err != nil {
400 return err
401 }
402 if row.Registration != registrations[fireName] {
403 return registry.ErrTimerChanged
404 }
405 row.NextAt, row.DueAt, row.Attempts = now.Unix(), now.Unix(), 0
406 expected := row.Registration
407 row.Registration = ""
408 if _, handled, err := mutateSelectedTimer(ctx, *row, expected, false); handled {
409 return err
410 }
411 }
412 return withTimerWork(ctx, func() error {
413 if c.HostWrites.Enabled {
414 return c.Reg.FireTimerNowRegistration(rt.Host, fireName, registrations[fireName], now.Unix())
415 }
416 return c.Reg.FireTimerNow(rt.Host, fireName, now.Unix())
417 })
418 }
419 if err := fire(); err != nil {
420 return changed, err
421 }
422 changed = true
423 }
424 return changed, nil
425}
426
427// unsupportedUnreported reports whether host has NOT yet had its
428// unsupported-host report sent, and marks it sent — so the report is
429// written once per host's lifetime in this Converger, not every pass.
430func (c *Converger) unsupportedUnreported(host string) bool {
431 if c.reportedUnsupported == nil {
432 c.reportedUnsupported = map[string]bool{}
433 }
434 if c.reportedUnsupported[host] {
435 return false
436 }
437 c.reportedUnsupported[host] = true
438 return true
439}
440
441// findTimer returns the row named name with the given kind, or nil. Kind
442// matters: a once-row and a declared schedule can share a name (the
443// once-row is the instance's own, registered off its control socket), and
444// only a same-kind row is ever "previous state" for the change comparison
445// below — a kind collision is a skip (ErrTimerKind, at the UpsertTimer
446// call), never a manufactured "changed".
447func findTimer(rows []registry.Timer, name, kind string) *registry.Timer {
448 for i := range rows {
449 if rows[i].Name == name && rows[i].Kind == kind {
450 return &rows[i]
451 }
452 }
453 return nil
454}
455
456const UnsupportedUnit = "unit-backed instance — scheduled work delivers to exec-backed instances in this release"
457
458// StatusNeverFires is the last_status of a parked row: a declared cron
459// expression with no occurrence inside CronExpr.Next's window (Validate
460// refuses these at `set`; this is the defence for one that slipped past it).
461const StatusNeverFires = "never fires"
462
463// StatusFor builds the host's report from its rows. idle is the activator's
464// idle window on this box, threaded through to holdsAwake — zero when no
465// hibernation is armed on the box at all.
466//
467// Hibernation is also armed PER ROUTE (rt.Hibernate — activator.go will not
468// sweep a host unless Hibernate && Status == "ready"), so a box-wide window
469// is not sufficient on its own: a route with Hibernate false was never
470// going to sleep, schedule or no schedule, and holds_awake must say so by
471// reporting false for every one of its rows, not by naming a tight timer
472// that changes nothing for it.
473func StatusFor(reg *registry.Registry, rt registry.Route, box string, now time.Time, idle time.Duration) (Status, error) {
474 st := Status{Schema: 1, Host: rt.Host, Box: box, ReportedAt: now.UTC().Format(time.RFC3339), Timers: []TimerStatus{}}
475 if rt.Backing == registry.BackingUnit {
476 st.Unsupported = UnsupportedUnit
477 return st, nil
478 }
479 if !rt.Hibernate {
480 idle = 0
481 }
482 rows, err := reg.TimersFor(rt.Host)
483 if err != nil {
484 return st, err
485 }
486 for _, r := range rows {
487 st.Timers = append(st.Timers, toStatus(r, idle, now))
488 }
489 return st, nil
490}
491
492// toStatus maps one timers row onto its wire shape. Task 5 (the `carlos
493// schedule ls` reader / the runner's own report path) reuses this so the
494// row-to-status mapping is written once.
495func toStatus(t registry.Timer, idle time.Duration, now time.Time) TimerStatus {
496 return TimerStatus{
497 Name: t.Name, Kind: t.Kind, Spec: t.Spec, Path: t.Path,
498 NextAt: t.NextAt, DueAt: t.DueAt, LastAt: t.LastAt,
499 LastStatus: t.LastStatus, LastMs: t.LastMs, Attempts: t.Attempts,
500 HoldsAwake: holdsAwake(t, idle, now),
501 }
502}
503
504// holdsAwake reports whether t's interval is tight enough that its instance
505// never gets to sleep: the gap between consecutive occurrences is no wider
506// than idle, so the activator's guard (and, before it, the tick's own wake)
507// keeps the process resident indefinitely.
508//
509// The gap comes from two Next calls rather than Spec.Every, so a cron gets a
510// real answer instead of a special case — but for an irregular cron (e.g.
511// "0,5 * * * *": five minutes then fifty-five) that first gap is a SAMPLE,
512// not a bound: the same row can answer true at :59 and false at :01,
513// depending on when the report happens to be built. That is the direction
514// to err in — over-warning about a bill beats missing one — and is chosen
515// deliberately, not an oversight.
516//
517// idle <= 0 means hibernation is not armed — either the box has no
518// activator, or (StatusFor) the route itself has Hibernate off — so
519// nothing holds anything awake, because nothing sleeps. A once-timer fires
520// once and cannot hold anything. An unparseable spec is not evidence of a
521// problem, so it makes no claim.
522func holdsAwake(t registry.Timer, idle time.Duration, now time.Time) bool {
523 if idle <= 0 || t.Kind != KindSchedule {
524 return false
525 }
526 sp, err := ParseSpec(t.Spec)
527 if err != nil {
528 return false
529 }
530 first := sp.Next(now)
531 if first.IsZero() {
532 return false
533 }
534 second := sp.Next(first)
535 if second.IsZero() {
536 return false
537 }
538 return second.Sub(first) <= idle
539}
540
541// deleteSchedules only removes rows observed by this pass. A same-name
542// replacement keeps its registration and survives the stale deletion.
543func (c *Converger) deleteSchedules(ctx context.Context, host string, before []registry.Timer, keep map[string]bool) (int, error) {
544 if !c.HostWrites.Enabled {
545 return c.Reg.DeleteTimersExcept(host, KindSchedule, keep)
546 }
547 if err := withTimerWork(ctx, func() error { return nil }); err != nil {
548 return 0, err
549 }
550 n := 0
551 for _, row := range before {
552 if row.Kind != KindSchedule || keep[row.Name] {
553 continue
554 }
555 if err := deleteSelectedTimer(ctx, c.Reg, row); err != nil {
556 return n, err
557 }
558 n++
559 }
560 return n, nil
561}
562