| new file mode 100644 |
| index 000000000..dea12c171 |
| --- /dev/null |
| +++ b/internal/schedule/converge.go |
| @@ -0,0 +1,244 @@ |
| +package schedule |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Converger turns apps/<acct>/<app>/schedules.json into timers rows for every |
| +// exec-backed instance this box serves, acts on schedule-run.json, and prunes |
| +// what no longer belongs (spec §6.2). One instance per agent; Pass is called |
| +// on every serial move, hourly, and once at start. |
| +// |
| +// Etag memory is what keeps an idle box cheap and a run-signal |
| +// exactly-once: an unchanged declaration is one GET and no writes; a run |
| +// signal fires when its etag MOVES, and the first sighting only seeds |
| +// (a signal older than this process is history — restart-gen's rule). |
| +type Converger struct { |
| + Reg *registry.Registry |
| + Store release.Store |
| + Now func() time.Time |
| + Box string // reported in Status.Box |
| + Report func(rt registry.Route, st Status) // called for every host whose rows changed or that is unsupported; nil = no reports |
| + |
| + declEtag map[[2]string]string // (acct, app) → last etag seen |
| + declDoc map[[2]string]Declaration |
| + runEtag map[[2]string]string |
| + |
| + // reportedUnsupported tracks which unit-backed hosts have already had |
| + // their one report — Report fires for them once, not every pass. |
| + reportedUnsupported map[string]bool |
| +} |
| + |
| +const onceRetention = 7 * 24 * time.Hour |
| + |
| +func NewConverger(reg *registry.Registry, store release.Store, box string, report func(registry.Route, Status)) *Converger { |
| + return &Converger{ |
| + Reg: reg, Store: store, Now: time.Now, Box: box, Report: report, |
| + declEtag: map[[2]string]string{}, declDoc: map[[2]string]Declaration{}, |
| + runEtag: map[[2]string]string{}, reportedUnsupported: map[string]bool{}, |
| + } |
| +} |
| + |
| +// Pass is die-free per app: one app's unreadable declaration is logged and |
| +// its rows left exactly as they were (a baseline with holes manufactures |
| +// deletions), and every other app still converges. |
| +func (c *Converger) Pass(ctx context.Context) error { |
| + routes, err := c.Reg.List() |
| + if err != nil { |
| + return err |
| + } |
| + now := c.Now() |
| + alive := map[string]bool{} |
| + byApp := map[[2]string][]registry.Route{} |
| + for _, rt := range routes { |
| + alive[rt.Host] = true |
| + if rt.Kind != "instance" { |
| + continue |
| + } |
| + k := [2]string{release.RouteAccount(rt), rt.App} |
| + if k[0] == "" || k[1] == "" { |
| + continue |
| + } |
| + byApp[k] = append(byApp[k], rt) |
| + } |
| + var errs []error |
| + for k, rts := range byApp { |
| + if err := c.convergeApp(ctx, k[0], k[1], rts, now); err != nil { |
| + errs = append(errs, fmt.Errorf("%s/%s: %w", k[0], k[1], err)) |
| + } |
| + } |
| + if n, err := c.Reg.PruneTimers(alive, now.Add(-onceRetention).Unix()); err != nil { |
| + errs = append(errs, err) |
| + } else if n > 0 { |
| + log.Printf("schedule: pruned %d timer row(s)", n) |
| + } |
| + return errors.Join(errs...) |
| +} |
| + |
| +func (c *Converger) convergeApp(ctx context.Context, acct, app string, rts []registry.Route, now time.Time) error { |
| + k := [2]string{acct, app} |
| + raw, etag, err := c.Store.Get(ctx, DeclarationKey(acct, app)) |
| + var decl Declaration |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + etag = "" |
| + case err != nil: |
| + return fmt.Errorf("read schedules.json: %w", err) |
| + case etag == c.declEtag[k]: |
| + decl = c.declDoc[k] |
| + default: |
| + if err := json.Unmarshal(raw, &decl); err != nil { |
| + return fmt.Errorf("parse schedules.json: %w", err) |
| + } |
| + } |
| + c.declEtag[k], c.declDoc[k] = etag, decl |
| + |
| + // Run signal: seed silently on first sight, fire on movement. |
| + runRaw, runEtag, err := c.Store.Get(ctx, RunKey(acct, app)) |
| + fireName := "" |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + runEtag = "" |
| + case err != nil: |
| + return fmt.Errorf("read schedule-run.json: %w", err) |
| + } |
| + if prev, seen := c.runEtag[k]; seen && runEtag != prev && runEtag != "" { |
| + var sig RunSignal |
| + if err := json.Unmarshal(runRaw, &sig); err == nil { |
| + if _, ok := decl.Find(sig.Name); ok { |
| + fireName = sig.Name |
| + } else { |
| + log.Printf("schedule: %s/%s: run requested for undeclared schedule %q — ignored", acct, app, sig.Name) |
| + } |
| + } |
| + } |
| + c.runEtag[k] = runEtag |
| + |
| + var errs []error |
| + for _, rt := range rts { |
| + changed, err := c.convergeHost(rt, decl, fireName, now) |
| + if err != nil { |
| + errs = append(errs, fmt.Errorf("%s: %w", rt.Host, err)) |
| + continue |
| + } |
| + if changed && c.Report != nil { |
| + st, err := StatusFor(c.Reg, rt, c.Box, now) |
| + if err != nil { |
| + errs = append(errs, err) |
| + continue |
| + } |
| + c.Report(rt, st) |
| + } |
| + } |
| + return errors.Join(errs...) |
| +} |
| + |
| +// convergeHost returns whether anything about the host's rows changed (or |
| +// the host is unsupported and should be reported so). |
| +func (c *Converger) convergeHost(rt registry.Route, decl Declaration, fireName string, now time.Time) (bool, error) { |
| + if rt.Backing == registry.BackingUnit { |
| + // Reported once per host, and only when there is something to warn |
| + // about — an undeclared app on a unit-backed host is silent. |
| + return len(decl.Schedules) > 0 && c.unsupportedUnreported(rt.Host), nil |
| + } |
| + before, err := c.Reg.TimersFor(rt.Host) |
| + if err != nil { |
| + return false, err |
| + } |
| + keep := map[string]bool{} |
| + changed := false |
| + for _, sc := range decl.Schedules { |
| + sp, err := sc.Spec() |
| + if err != nil { |
| + log.Printf("schedule: %s: schedule %q has an invalid spec (%v) — skipped", rt.Host, sc.Name, err) |
| + continue |
| + } |
| + keep[sc.Name] = true |
| + var next int64 |
| + prev := findTimer(before, sc.Name) |
| + if prev == nil || prev.Spec != sp.String() || prev.Path != sc.Path { |
| + next = sp.Next(now).Unix() |
| + changed = true |
| + } |
| + err = c.Reg.UpsertTimer(registry.Timer{Host: rt.Host, Name: sc.Name, Kind: KindSchedule, Spec: sp.String(), Path: sc.Path, NextAt: next, CreatedAt: now.Unix()}) |
| + if errors.Is(err, registry.ErrTimerKind) { |
| + log.Printf("schedule: %s: %q is a once-timer the instance registered; the declared schedule of that name is skipped", rt.Host, sc.Name) |
| + continue |
| + } |
| + if err != nil { |
| + return changed, err |
| + } |
| + } |
| + n, err := c.Reg.DeleteTimersExcept(rt.Host, KindSchedule, keep) |
| + if err != nil { |
| + return changed, err |
| + } |
| + if n > 0 { |
| + changed = true |
| + } |
| + if fireName != "" && keep[fireName] { |
| + if err := c.Reg.FireTimerNow(rt.Host, fireName, now.Unix()); err != nil { |
| + return changed, err |
| + } |
| + changed = true |
| + } |
| + return changed, nil |
| +} |
| + |
| +// unsupportedUnreported reports whether host has NOT yet had its |
| +// unsupported-host report sent, and marks it sent — so the report is |
| +// written once per host's lifetime in this Converger, not every pass. |
| +func (c *Converger) unsupportedUnreported(host string) bool { |
| + if c.reportedUnsupported == nil { |
| + c.reportedUnsupported = map[string]bool{} |
| + } |
| + if c.reportedUnsupported[host] { |
| + return false |
| + } |
| + c.reportedUnsupported[host] = true |
| + return true |
| +} |
| + |
| +func findTimer(rows []registry.Timer, name string) *registry.Timer { |
| + for i := range rows { |
| + if rows[i].Name == name { |
| + return &rows[i] |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +const UnsupportedUnit = "unit-backed instance — scheduled work delivers to exec-backed instances in this release" |
| + |
| +// StatusFor builds the host's report from its rows. |
| +func StatusFor(reg *registry.Registry, rt registry.Route, box string, now time.Time) (Status, error) { |
| + st := Status{Schema: 1, Host: rt.Host, Box: box, ReportedAt: now.UTC().Format(time.RFC3339), Timers: []TimerStatus{}} |
| + if rt.Backing == registry.BackingUnit { |
| + st.Unsupported = UnsupportedUnit |
| + return st, nil |
| + } |
| + rows, err := reg.TimersFor(rt.Host) |
| + if err != nil { |
| + return st, err |
| + } |
| + for _, r := range rows { |
| + st.Timers = append(st.Timers, toStatus(r)) |
| + } |
| + return st, nil |
| +} |
| + |
| +// toStatus maps one timers row onto its wire shape. Task 5 (the `carlos |
| +// schedule ls` reader / the runner's own report path) reuses this so the |
| +// row-to-status mapping is written once. |
| +func toStatus(t registry.Timer) TimerStatus { |
| + return TimerStatus{Name: t.Name, Kind: t.Kind, Spec: t.Spec, Path: t.Path, NextAt: t.NextAt, LastAt: t.LastAt, LastStatus: t.LastStatus, LastMs: t.LastMs, Attempts: t.Attempts} |
| +} |