carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// This file is apps/<acct>/<app>/schedule-status/<host>.json (spec §4.3):
2// the box's best-effort report of what each of its instances will do next
3// and what happened last time, written after every fire and every converge
4// that changed a host's rows. It is what makes `carlos schedule ls` honest
5// for a member with no box access — the declaration alone says what was
6// asked; these objects say what is actually going to happen.
7
8package schedule
9
10import (
11 "context"
12 "encoding/json"
13 "errors"
14 "fmt"
15 "os"
16 "sort"
17 "strings"
18
19 "github.com/carlosframework/platform/internal/release"
20)
21
22// TimerStatus is one row of a box's report, and one row of `carlos schedule
23// ls` — the same shape end to end so the member reads what the box knows.
24type TimerStatus struct {
25 Name string `json:"name"`
26 Kind string `json:"kind"`
27 Spec string `json:"spec"`
28 Path string `json:"path"`
29 NextAt int64 `json:"next_at"`
30 DueAt int64 `json:"due_at"` // the occurrence next_at delivers (X-Carlos-Schedule-At); 0 from a pre-due_at box
31 LastAt int64 `json:"last_at"`
32 LastStatus string `json:"last_status"`
33 LastMs int64 `json:"last_ms"`
34 Attempts int `json:"attempts"`
35
36 // HoldsAwake is the box's warning: this row fires at least as often as
37 // the deployment's idle window, so its instance never hibernates between
38 // ticks and accrues instance-hours around the clock. Computed by the box,
39 // which is the only place that holds both numbers. False from a box that
40 // predates the field, and false wherever hibernation is not armed.
41 HoldsAwake bool `json:"holds_awake,omitempty"`
42}
43
44// Status is apps/<acct>/<app>/schedule-status/<host>.json (spec §4.3):
45// what THIS instance will do next and what happened last, written by the
46// box that holds it. Unsupported, when set, says why Timers is empty.
47type Status struct {
48 Schema int `json:"schema"`
49 Host string `json:"host"`
50 Box string `json:"box"`
51 ReportedAt string `json:"reported_at"`
52 Unsupported string `json:"unsupported"`
53 Timers []TimerStatus `json:"timers"`
54}
55
56// WriteStatus replaces the host's report. The store's Put is CAS-only, so
57// this reads the current etag first; a conflict (two writers for one host —
58// not a designed state) re-reads once. Never loops: a report is
59// best-effort and the next fire rewrites it anyway.
60func claimRawWriteStatus(ctx context.Context, s release.Store, acct, app string, st Status) error {
61 if err := checkIDs(acct, app); err != nil {
62 return err
63 }
64 st.Schema = 1
65 if st.Timers == nil {
66 st.Timers = []TimerStatus{}
67 }
68 raw, err := json.Marshal(st)
69 if err != nil {
70 return err
71 }
72 key := StatusKey(acct, app, st.Host)
73 for attempt := 0; attempt < 2; attempt++ {
74 _, etag, err := s.Get(ctx, key)
75 if errors.Is(err, os.ErrNotExist) {
76 etag = ""
77 } else if err != nil {
78 return err
79 }
80 if _, err := s.Put(ctx, key, raw, etag); err == nil {
81 return nil
82 } else if !errors.Is(err, release.ErrCASConflict) {
83 return err
84 }
85 }
86 return fmt.Errorf("status for %s: conflicting writer", st.Host)
87}
88
89// ListStatus reads every host's report for the app, sorted by host. An
90// unreadable object is skipped and its error joined into the return, with
91// the readable ones still returned — one bad report must not blank the list.
92func ListStatus(ctx context.Context, s release.Store, acct, app string) ([]Status, error) {
93 if err := checkIDs(acct, app); err != nil {
94 return nil, err
95 }
96 keys, err := s.List(ctx, StatusPrefix(acct, app))
97 if err != nil {
98 return nil, err
99 }
100 var out []Status
101 var errs []error
102 for _, k := range keys {
103 if !strings.HasSuffix(k, ".json") {
104 continue
105 }
106 raw, _, err := s.Get(ctx, k)
107 if err != nil {
108 errs = append(errs, err)
109 continue
110 }
111 var st Status
112 if err := json.Unmarshal(raw, &st); err != nil {
113 errs = append(errs, fmt.Errorf("%s: %w", k, err))
114 continue
115 }
116 if st.Timers == nil {
117 st.Timers = []TimerStatus{}
118 }
119 out = append(out, st)
120 }
121 sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
122 return out, errors.Join(errs...)
123}
124func WriteStatus(ctx context.Context, s release.Store, acct, app string, st Status) (claimResult0 error) {
125 claimResult0 = release.WithAppMutation(ctx, s, acct, app, "schedule.WriteStatus", func(claimContext context.Context) error {
126 claimResult0 = claimRawWriteStatus(claimContext, s, acct, app, st)
127 return claimResult0
128 })
129 return
130}
131