// This file is apps///schedule-status/.json (spec §4.3): // the box's best-effort report of what each of its instances will do next // and what happened last time, written after every fire and every converge // that changed a host's rows. It is what makes `carlos schedule ls` honest // for a member with no box access — the declaration alone says what was // asked; these objects say what is actually going to happen. package schedule import ( "context" "encoding/json" "errors" "fmt" "os" "sort" "strings" "github.com/carlosframework/platform/internal/release" ) // TimerStatus is one row of a box's report, and one row of `carlos schedule // ls` — the same shape end to end so the member reads what the box knows. type TimerStatus struct { Name string `json:"name"` Kind string `json:"kind"` Spec string `json:"spec"` Path string `json:"path"` NextAt int64 `json:"next_at"` DueAt int64 `json:"due_at"` // the occurrence next_at delivers (X-Carlos-Schedule-At); 0 from a pre-due_at box LastAt int64 `json:"last_at"` LastStatus string `json:"last_status"` LastMs int64 `json:"last_ms"` Attempts int `json:"attempts"` // HoldsAwake is the box's warning: this row fires at least as often as // the deployment's idle window, so its instance never hibernates between // ticks and accrues instance-hours around the clock. Computed by the box, // which is the only place that holds both numbers. False from a box that // predates the field, and false wherever hibernation is not armed. HoldsAwake bool `json:"holds_awake,omitempty"` } // Status is apps///schedule-status/.json (spec §4.3): // what THIS instance will do next and what happened last, written by the // box that holds it. Unsupported, when set, says why Timers is empty. type Status struct { Schema int `json:"schema"` Host string `json:"host"` Box string `json:"box"` ReportedAt string `json:"reported_at"` Unsupported string `json:"unsupported"` Timers []TimerStatus `json:"timers"` } // WriteStatus replaces the host's report. The store's Put is CAS-only, so // this reads the current etag first; a conflict (two writers for one host — // not a designed state) re-reads once. Never loops: a report is // best-effort and the next fire rewrites it anyway. func claimRawWriteStatus(ctx context.Context, s release.Store, acct, app string, st Status) error { if err := checkIDs(acct, app); err != nil { return err } st.Schema = 1 if st.Timers == nil { st.Timers = []TimerStatus{} } raw, err := json.Marshal(st) if err != nil { return err } key := StatusKey(acct, app, st.Host) for attempt := 0; attempt < 2; attempt++ { _, etag, err := s.Get(ctx, key) if errors.Is(err, os.ErrNotExist) { etag = "" } else if err != nil { return err } if _, err := s.Put(ctx, key, raw, etag); err == nil { return nil } else if !errors.Is(err, release.ErrCASConflict) { return err } } return fmt.Errorf("status for %s: conflicting writer", st.Host) } // ListStatus reads every host's report for the app, sorted by host. An // unreadable object is skipped and its error joined into the return, with // the readable ones still returned — one bad report must not blank the list. func ListStatus(ctx context.Context, s release.Store, acct, app string) ([]Status, error) { if err := checkIDs(acct, app); err != nil { return nil, err } keys, err := s.List(ctx, StatusPrefix(acct, app)) if err != nil { return nil, err } var out []Status var errs []error for _, k := range keys { if !strings.HasSuffix(k, ".json") { continue } raw, _, err := s.Get(ctx, k) if err != nil { errs = append(errs, err) continue } var st Status if err := json.Unmarshal(raw, &st); err != nil { errs = append(errs, fmt.Errorf("%s: %w", k, err)) continue } if st.Timers == nil { st.Timers = []TimerStatus{} } out = append(out, st) } sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host }) return out, errors.Join(errs...) } func WriteStatus(ctx context.Context, s release.Store, acct, app string, st Status) (claimResult0 error) { claimResult0 = release.WithAppMutation(ctx, s, acct, app, "schedule.WriteStatus", func(claimContext context.Context) error { claimResult0 = claimRawWriteStatus(claimContext, s, acct, app, st) return claimResult0 }) return }