schedule: specs, cron dialect, declarations and status objects
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+925
−0
internal/schedule/cron.go+169 −0internal/schedule/cron_test.go+63 −0internal/schedule/declaration.go+266 −0internal/schedule/declaration_test.go+114 −0internal/schedule/spec.go+108 −0internal/schedule/spec_test.go+60 −0internal/schedule/status.go+108 −0internal/schedule/status_test.go+37 −0
diff --git a/internal/schedule/cron.go b/internal/schedule/cron.go| new file mode 100644 |
| index 000000000..90f821832 |
| --- /dev/null |
| +++ b/internal/schedule/cron.go |
| @@ -0,0 +1,169 @@ |
| +// Package schedule is the platform's answer to "run this later": the WHEN of |
| +// scheduled work, so an app that hibernates does not have to keep a process |
| +// alive to keep a timer. Spec: docs/superpowers/specs/2026-08-23-scheduled-work-design.md. |
| +// |
| +// This file is the cron dialect. Deliberately small (five fields, UTC, no |
| +// names, no @aliases — spec §4.1) and deliberately ours: the box binary is |
| +// built with GOPROXY=off, and a parser this size is cheaper to own than to |
| +// vendor. |
| +package schedule |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "strconv" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// CronExpr is a parsed five-field expression. Each field is a bitset over |
| +// its legal range (minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, |
| +// day-of-week 0-6 with 7 folded onto 0). |
| +type CronExpr struct { |
| + min, hour, dom, month, dow uint64 |
| + raw string |
| + domStar, dowStar bool // "*" in a day field — the classic OR-vs-AND rule below |
| +} |
| + |
| +func (c *CronExpr) String() string { return c.raw } |
| + |
| +// ParseCron accepts "min hour dom month dow" with *, n, a-b, */n, a-b/n and |
| +// comma lists. Anything else — names, six fields, @daily — is an error with |
| +// the field named, because the member typing it has no box to debug on. |
| +func ParseCron(expr string) (*CronExpr, error) { |
| + fields := strings.Fields(expr) |
| + if len(fields) != 5 { |
| + return nil, fmt.Errorf("cron: want 5 fields (min hour day month weekday), got %d", len(fields)) |
| + } |
| + c := &CronExpr{raw: strings.Join(fields, " ")} |
| + var err error |
| + if c.min, _, err = parseField(fields[0], 0, 59, "minute"); err != nil { |
| + return nil, err |
| + } |
| + if c.hour, _, err = parseField(fields[1], 0, 23, "hour"); err != nil { |
| + return nil, err |
| + } |
| + if c.dom, c.domStar, err = parseField(fields[2], 1, 31, "day"); err != nil { |
| + return nil, err |
| + } |
| + if c.month, _, err = parseField(fields[3], 1, 12, "month"); err != nil { |
| + return nil, err |
| + } |
| + if c.dow, c.dowStar, err = parseField(fields[4], 0, 7, "weekday"); err != nil { |
| + return nil, err |
| + } |
| + if c.dow&(1<<7) != 0 { // 7 is Sunday too |
| + c.dow = (c.dow &^ (1 << 7)) | 1 |
| + } |
| + return c, nil |
| +} |
| + |
| +// parseField returns the bitset and whether the field was a bare "*". |
| +func parseField(f string, lo, hi int, name string) (uint64, bool, error) { |
| + var bits uint64 |
| + star := f == "*" |
| + for _, part := range strings.Split(f, ",") { |
| + rangePart, stepPart, hasStep := strings.Cut(part, "/") |
| + step := 1 |
| + if hasStep { |
| + n, err := strconv.Atoi(stepPart) |
| + if err != nil || n <= 0 { |
| + return 0, false, fmt.Errorf("cron %s: bad step %q", name, part) |
| + } |
| + step = n |
| + } |
| + a, b := lo, hi |
| + switch { |
| + case rangePart == "*": |
| + case strings.Contains(rangePart, "-"): |
| + as, bs, _ := strings.Cut(rangePart, "-") |
| + var err error |
| + if a, err = atoiIn(as, lo, hi); err != nil { |
| + return 0, false, fmt.Errorf("cron %s: %w", name, err) |
| + } |
| + if b, err = atoiIn(bs, lo, hi); err != nil { |
| + return 0, false, fmt.Errorf("cron %s: %w", name, err) |
| + } |
| + if a > b { |
| + return 0, false, fmt.Errorf("cron %s: range %q runs backwards", name, rangePart) |
| + } |
| + default: |
| + n, err := atoiIn(rangePart, lo, hi) |
| + if err != nil { |
| + return 0, false, fmt.Errorf("cron %s: %w", name, err) |
| + } |
| + a, b = n, n |
| + if hasStep { |
| + b = hi // "5/10" means from 5 to the end, stepping |
| + } |
| + } |
| + for v := a; v <= b; v += step { |
| + bits |= 1 << uint(v) |
| + } |
| + } |
| + if bits == 0 { |
| + return 0, false, errors.New("cron " + name + ": empty") |
| + } |
| + return bits, star, nil |
| +} |
| + |
| +func atoiIn(s string, lo, hi int) (int, error) { |
| + n, err := strconv.Atoi(s) |
| + if err != nil { |
| + return 0, fmt.Errorf("%q is not a number", s) |
| + } |
| + if n < lo || n > hi { |
| + return 0, fmt.Errorf("%d is outside %d-%d", n, lo, hi) |
| + } |
| + return n, nil |
| +} |
| + |
| +// Next is the first matching minute strictly after `after`, in UTC, or the |
| +// zero time if none falls within the search window (a "0 0 30 2 *" never |
| +// matches, and terminates quickly since each mismatched day is a single |
| +// jump, not a minute-by-minute walk). The window is 5 years, not 366 days: |
| +// "0 0 29 2 *" (Feb 29) can be up to ~4 years out depending on where `after` |
| +// falls in the leap-year cycle, and a 366-day window would wrongly report |
| +// "never" for it. |
| +func (c *CronExpr) Next(after time.Time) time.Time { |
| + t := after.UTC().Truncate(time.Minute).Add(time.Minute) |
| + limit := t.AddDate(5, 0, 0) |
| + for t.Before(limit) { |
| + if c.month&(1<<uint(t.Month())) == 0 { |
| + // Skip to the first of next month. |
| + t = time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, time.UTC) |
| + continue |
| + } |
| + if !c.dayMatches(t) { |
| + t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, time.UTC) |
| + continue |
| + } |
| + if c.hour&(1<<uint(t.Hour())) == 0 { |
| + t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, time.UTC) |
| + continue |
| + } |
| + if c.min&(1<<uint(t.Minute())) == 0 { |
| + t = t.Add(time.Minute) |
| + continue |
| + } |
| + return t |
| + } |
| + return time.Time{} |
| +} |
| + |
| +// dayMatches applies Vixie cron's rule: when BOTH day fields are restricted |
| +// the day matches if EITHER does; when one is "*" the other decides. |
| +func (c *CronExpr) dayMatches(t time.Time) bool { |
| + dom := c.dom&(1<<uint(t.Day())) != 0 |
| + dow := c.dow&(1<<uint(t.Weekday())) != 0 |
| + switch { |
| + case c.domStar && c.dowStar: |
| + return true |
| + case c.domStar: |
| + return dow |
| + case c.dowStar: |
| + return dom |
| + default: |
| + return dom || dow |
| + } |
| +} |
diff --git a/internal/schedule/cron_test.go b/internal/schedule/cron_test.go| new file mode 100644 |
| index 000000000..561ad87f4 |
| --- /dev/null |
| +++ b/internal/schedule/cron_test.go |
| @@ -0,0 +1,63 @@ |
| +package schedule |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +func at(s string) time.Time { |
| + t, err := time.Parse(time.RFC3339, s) |
| + if err != nil { |
| + panic(err) |
| + } |
| + return t.UTC() |
| +} |
| + |
| +func TestParseCronAcceptsTheGrammar(t *testing.T) { |
| + for _, ok := range []string{"* * * * *", "0 8 * * *", "*/15 * * * *", "0 */6 * * *", "0 9 1-5 * *", "30 7 * * 1,3,5", "0 0 1 1 *", "0 0 * * 7", "0-59/20 * * * *"} { |
| + if _, err := ParseCron(ok); err != nil { |
| + t.Errorf("ParseCron(%q) = %v, want ok", ok, err) |
| + } |
| + } |
| + for _, bad := range []string{"", "* * * *", "* * * * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "* * 32 * *", "* * * 13 *", "* * * * 8", "@daily", "*/0 * * * *", "5-3 * * * *", "a * * * *", "0 8 * * MON"} { |
| + if _, err := ParseCron(bad); err == nil { |
| + t.Errorf("ParseCron(%q) = ok, want error", bad) |
| + } |
| + } |
| +} |
| + |
| +func TestCronNextIsStrictlyAfterAndUTC(t *testing.T) { |
| + cases := []struct{ expr, after, want string }{ |
| + {"0 8 * * *", "2026-08-23T07:59:00Z", "2026-08-23T08:00:00Z"}, |
| + {"0 8 * * *", "2026-08-23T08:00:00Z", "2026-08-24T08:00:00Z"}, // strictly after |
| + {"0 8 * * *", "2026-08-23T08:00:30Z", "2026-08-24T08:00:00Z"}, // seconds truncated: 08:00 has passed |
| + {"*/15 * * * *", "2026-08-23T08:07:00Z", "2026-08-23T08:15:00Z"}, |
| + {"0 */6 * * *", "2026-08-23T13:00:00Z", "2026-08-23T18:00:00Z"}, |
| + {"0 9 * * 1", "2026-08-23T09:00:00Z", "2026-08-24T09:00:00Z"}, // 2026-08-23 is a Sunday |
| + {"0 0 31 * *", "2026-09-01T00:00:00Z", "2026-10-31T00:00:00Z"}, // September has no 31st |
| + {"0 0 29 2 *", "2026-03-01T00:00:00Z", "2028-02-29T00:00:00Z"}, |
| + {"0 0 * * 7", "2026-08-23T00:00:00Z", "2026-08-30T00:00:00Z"}, // 7 = Sunday |
| + } |
| + for _, c := range cases { |
| + expr, err := ParseCron(c.expr) |
| + if err != nil { |
| + t.Fatalf("%q: %v", c.expr, err) |
| + } |
| + if got := expr.Next(at(c.after)); !got.Equal(at(c.want)) { |
| + t.Errorf("%q after %s = %s, want %s", c.expr, c.after, got.Format(time.RFC3339), c.want) |
| + } |
| + if got := expr.Next(at(c.after)); got.Location() != time.UTC { |
| + t.Errorf("%q: Next not in UTC", c.expr) |
| + } |
| + } |
| +} |
| + |
| +func TestCronNextHonoursNonUTCInput(t *testing.T) { |
| + expr, _ := ParseCron("0 8 * * *") |
| + loc := time.FixedZone("plus2", 2*3600) |
| + // 09:30 +02:00 is 07:30Z — the next 08:00Z is the same day. |
| + got := expr.Next(time.Date(2026, 8, 23, 9, 30, 0, 0, loc)) |
| + if !got.Equal(at("2026-08-23T08:00:00Z")) { |
| + t.Fatalf("got %s", got.Format(time.RFC3339)) |
| + } |
| +} |
diff --git a/internal/schedule/declaration.go b/internal/schedule/declaration.go| new file mode 100644 |
| index 000000000..4477a6af3 |
| --- /dev/null |
| +++ b/internal/schedule/declaration.go |
| @@ -0,0 +1,266 @@ |
| +package schedule |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "os" |
| + "regexp" |
| + "sort" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const MaxSchedulesPerApp = 20 |
| + |
| +var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) |
| + |
| +func ValidName(name string) bool { return nameRE.MatchString(name) } |
| + |
| +// ValidPath: absolute, no scheme/host, no "..", short. A path is what the |
| +// tick POSTs to on the instance's own socket — nothing here can reach |
| +// another host, but a bad one should fail at the terminal, not on the box. |
| +func ValidPath(p string) error { |
| + switch { |
| + case p == "" || p[0] != '/': |
| + return errors.New("path must start with /") |
| + case strings.HasPrefix(p, "//"): |
| + return errors.New("path must not start with //") |
| + case len(p) > 256: |
| + return errors.New("path is longer than 256 bytes") |
| + case strings.ContainsAny(p, " \t\r\n"): |
| + return errors.New("path must not contain whitespace") |
| + } |
| + for _, seg := range strings.Split(strings.SplitN(p, "?", 2)[0], "/") { |
| + if seg == ".." { |
| + return errors.New("path must not contain ..") |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// Schedule is one declared recurring job, as the member typed it. |
| +type Schedule struct { |
| + Name string `json:"name"` |
| + Every string `json:"every,omitempty"` |
| + Cron string `json:"cron,omitempty"` |
| + Path string `json:"path"` |
| +} |
| + |
| +// Spec is the schedule as the box stores it. Exactly one of every/cron. |
| +func (s Schedule) Spec() (Spec, error) { |
| + switch { |
| + case s.Every != "" && s.Cron != "": |
| + return Spec{}, errors.New("give -every or -cron, not both") |
| + case s.Every != "": |
| + d, err := time.ParseDuration(s.Every) |
| + if err != nil { |
| + return Spec{}, fmt.Errorf("every: %w", err) |
| + } |
| + if err := ValidEvery(d); err != nil { |
| + return Spec{}, err |
| + } |
| + return Spec{Kind: KindSchedule, Every: d}, nil |
| + case s.Cron != "": |
| + c, err := ParseCron(s.Cron) |
| + if err != nil { |
| + return Spec{}, err |
| + } |
| + return Spec{Kind: KindSchedule, Cron: c}, nil |
| + } |
| + return Spec{}, errors.New("give -every <duration> or -cron \"<5 fields>\"") |
| +} |
| + |
| +// Validate checks every field: name, path shape, and exactly one of |
| +// every/cron parses. It does not mutate the receiver — normalising |
| +// every/cron to their canonical text (so what lands in the bucket is what |
| +// ParseSpec reads back) is UpsertSchedule's job, done on its own addressable |
| +// copy just before storing. |
| +func (s Schedule) Validate() error { |
| + if !ValidName(s.Name) { |
| + return fmt.Errorf("name %q: want [a-z0-9][a-z0-9-]{0,31}", s.Name) |
| + } |
| + if err := ValidPath(s.Path); err != nil { |
| + return err |
| + } |
| + _, err := s.Spec() |
| + return err |
| +} |
| + |
| +// Declaration is apps/<acct>/<app>/schedules.json (spec §4.1). |
| +type Declaration struct { |
| + Schema int `json:"schema"` |
| + UpdatedAt string `json:"updated_at"` |
| + Schedules []Schedule `json:"schedules"` |
| +} |
| + |
| +func (d Declaration) Find(name string) (Schedule, bool) { |
| + for _, s := range d.Schedules { |
| + if s.Name == name { |
| + return s, true |
| + } |
| + } |
| + return Schedule{}, false |
| +} |
| + |
| +func DeclarationKey(acct, app string) string { return release.AppPrefix(acct, app) + "schedules.json" } |
| +func RunKey(acct, app string) string { return release.AppPrefix(acct, app) + "schedule-run.json" } |
| +func StatusPrefix(acct, app string) string { return release.AppPrefix(acct, app) + "schedule-status/" } |
| +func StatusKey(acct, app, host string) string { return StatusPrefix(acct, app) + host + ".json" } |
| + |
| +// ReadDeclaration: absent is the empty declaration with etag "" — legal |
| +// forever, like restart-gen. |
| +func ReadDeclaration(ctx context.Context, s release.Store, acct, app string) (Declaration, string, error) { |
| + raw, etag, err := s.Get(ctx, DeclarationKey(acct, app)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return Declaration{Schema: 1}, "", nil |
| + } |
| + if err != nil { |
| + return Declaration{}, "", err |
| + } |
| + var d Declaration |
| + if err := json.Unmarshal(raw, &d); err != nil { |
| + return Declaration{}, "", fmt.Errorf("schedules.json: %w", err) |
| + } |
| + return d, etag, nil |
| +} |
| + |
| +func checkIDs(acct, app string) error { |
| + if !release.ValidAccount(acct) { |
| + return fmt.Errorf("schedule: invalid account %q", acct) |
| + } |
| + if !release.ValidApp(app) { |
| + return fmt.Errorf("schedule: invalid app %q", app) |
| + } |
| + return nil |
| +} |
| + |
| +// modify applies fn under CAS with ONE retry on conflict — two members |
| +// editing the same app at once should both land, not one of them fail. |
| +func modify(ctx context.Context, s release.Store, acct, app string, now time.Time, fn func(*Declaration) error) (Declaration, error) { |
| + if err := checkIDs(acct, app); err != nil { |
| + return Declaration{}, err |
| + } |
| + var last error |
| + for attempt := 0; attempt < 2; attempt++ { |
| + d, etag, err := ReadDeclaration(ctx, s, acct, app) |
| + if err != nil { |
| + return Declaration{}, err |
| + } |
| + if err := fn(&d); err != nil { |
| + return Declaration{}, err |
| + } |
| + sort.Slice(d.Schedules, func(i, j int) bool { return d.Schedules[i].Name < d.Schedules[j].Name }) |
| + d.Schema = 1 |
| + d.UpdatedAt = now.UTC().Format(time.RFC3339) |
| + raw, err := json.Marshal(d) |
| + if err != nil { |
| + return Declaration{}, err |
| + } |
| + if _, err := s.Put(ctx, DeclarationKey(acct, app), raw, etag); err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + last = err |
| + continue |
| + } |
| + return Declaration{}, err |
| + } |
| + return d, nil |
| + } |
| + return Declaration{}, fmt.Errorf("schedules.json changed underneath us twice: %w", last) |
| +} |
| + |
| +// UpsertSchedule validates sc, normalises its every/cron text to what |
| +// ParseSpec reads back, then replaces-or-appends it by name. |
| +func UpsertSchedule(ctx context.Context, s release.Store, acct, app string, sc Schedule, now time.Time) (Declaration, error) { |
| + if err := sc.Validate(); err != nil { |
| + return Declaration{}, err |
| + } |
| + sp, err := sc.Spec() // Validate already confirmed this succeeds |
| + if err != nil { |
| + return Declaration{}, err |
| + } |
| + if sp.Every > 0 { |
| + sc.Every = sp.Every.String() |
| + } else { |
| + sc.Cron = sp.Cron.String() |
| + } |
| + return modify(ctx, s, acct, app, now, func(d *Declaration) error { |
| + for i := range d.Schedules { |
| + if d.Schedules[i].Name == sc.Name { |
| + d.Schedules[i] = sc |
| + return nil |
| + } |
| + } |
| + if len(d.Schedules) >= MaxSchedulesPerApp { |
| + return fmt.Errorf("an app may declare at most %d schedules", MaxSchedulesPerApp) |
| + } |
| + d.Schedules = append(d.Schedules, sc) |
| + return nil |
| + }) |
| +} |
| + |
| +// RemoveSchedule drops name; present reports whether it was there. Removing |
| +// an absent name still rewrites updated_at — harmless, and it keeps the |
| +// door's answer uniform. |
| +func RemoveSchedule(ctx context.Context, s release.Store, acct, app, name string, now time.Time) (Declaration, bool, error) { |
| + present := false |
| + d, err := modify(ctx, s, acct, app, now, func(d *Declaration) error { |
| + kept := d.Schedules[:0] |
| + for _, sc := range d.Schedules { |
| + if sc.Name == name { |
| + present = true |
| + continue |
| + } |
| + kept = append(kept, sc) |
| + } |
| + d.Schedules = kept |
| + return nil |
| + }) |
| + return d, present, err |
| +} |
| + |
| +// RunSignal is schedule-run.json: a touch object whose etag movement is the |
| +// signal, exactly restart-gen's shape and rules (release/restartgen.go). |
| +type RunSignal struct { |
| + Schema int `json:"schema"` |
| + Name string `json:"name"` |
| + TouchedAt string `json:"touched_at"` |
| + Nonce string `json:"nonce"` |
| +} |
| + |
| +// TouchRun bumps the run signal for name. A CAS conflict is success: the |
| +// signal already fired. |
| +func TouchRun(ctx context.Context, s release.Store, acct, app, name string, now time.Time) error { |
| + if err := checkIDs(acct, app); err != nil { |
| + return err |
| + } |
| + if !ValidName(name) { |
| + return fmt.Errorf("schedule: invalid name %q", name) |
| + } |
| + key := RunKey(acct, app) |
| + _, etag, err := s.Get(ctx, key) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + if errors.Is(err, os.ErrNotExist) { |
| + etag = "" |
| + } |
| + nonce := make([]byte, 16) |
| + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { |
| + return err |
| + } |
| + raw, err := json.Marshal(RunSignal{Schema: 1, Name: name, TouchedAt: now.UTC().Format(time.RFC3339), Nonce: hex.EncodeToString(nonce)}) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := s.Put(ctx, key, raw, etag); err != nil && !errors.Is(err, release.ErrCASConflict) { |
| + return err |
| + } |
| + return nil |
| +} |
diff --git a/internal/schedule/declaration_test.go b/internal/schedule/declaration_test.go| new file mode 100644 |
| index 000000000..0bcee8212 |
| --- /dev/null |
| +++ b/internal/schedule/declaration_test.go |
| @@ -0,0 +1,114 @@ |
| +package schedule |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestValidNameAndPath(t *testing.T) { |
| + for _, ok := range []string{"a", "sync", "daily-digest", "x1", strings.Repeat("a", 32)} { |
| + if !ValidName(ok) { |
| + t.Errorf("ValidName(%q) = false", ok) |
| + } |
| + } |
| + for _, bad := range []string{"", "-a", "A", "a_b", "a b", strings.Repeat("a", 33), "sync/"} { |
| + if ValidName(bad) { |
| + t.Errorf("ValidName(%q) = true", bad) |
| + } |
| + } |
| + for _, ok := range []string{"/", "/jobs/sync", "/a/b-c_d.e?x=1"} { |
| + if err := ValidPath(ok); err != nil { |
| + t.Errorf("ValidPath(%q) = %v", ok, err) |
| + } |
| + } |
| + for _, bad := range []string{"", "jobs/sync", "/jobs/../etc", "https://x/y", "//host/p", "/" + strings.Repeat("a", 256)} { |
| + if err := ValidPath(bad); err == nil { |
| + t.Errorf("ValidPath(%q) = ok", bad) |
| + } |
| + } |
| +} |
| + |
| +func TestScheduleValidateNormalisesEvery(t *testing.T) { |
| + s := Schedule{Name: "sync", Every: "6h", Path: "/jobs/sync"} |
| + if err := s.Validate(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := (Schedule{Name: "x", Every: "6h", Cron: "* * * * *", Path: "/p"}).Spec(); err == nil { |
| + t.Error("both every and cron accepted") |
| + } |
| + if err := (Schedule{Name: "x", Path: "/p"}).Validate(); err == nil { |
| + t.Error("neither every nor cron accepted") |
| + } |
| + if err := (Schedule{Name: "x", Every: "30s", Path: "/p"}).Validate(); err == nil { |
| + t.Error("30s accepted") |
| + } |
| +} |
| + |
| +func TestUpsertRemoveAndCAS(t *testing.T) { |
| + ctx := context.Background() |
| + st := release.NewDirStore(t.TempDir()) |
| + now := at("2026-08-23T22:00:00Z") |
| + d, err := UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "sync", Every: "6h", Path: "/jobs/sync"}, now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(d.Schedules) != 1 || d.Schema != 1 || d.UpdatedAt != "2026-08-23T22:00:00Z" { |
| + t.Fatalf("declaration = %+v", d) |
| + } |
| + // Upsert by name replaces; a second name sorts in. |
| + d, _ = UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "sync", Every: "12h", Path: "/jobs/sync"}, now) |
| + d, _ = UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "alpha", Cron: "0 8 * * *", Path: "/jobs/a"}, now) |
| + if len(d.Schedules) != 2 || d.Schedules[0].Name != "alpha" || d.Schedules[1].Every != "12h0m0s" { |
| + t.Fatalf("declaration = %+v", d) |
| + } |
| + raw, _, _ := st.Get(ctx, DeclarationKey("bab", "app")) |
| + var onDisk Declaration |
| + if err := json.Unmarshal(raw, &onDisk); err != nil || len(onDisk.Schedules) != 2 { |
| + t.Fatalf("on disk: %s %v", raw, err) |
| + } |
| + // The cap. |
| + for i := 0; i < MaxSchedulesPerApp; i++ { |
| + UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "n" + string(rune('a'+i)), Every: "1h", Path: "/p"}, now) |
| + } |
| + if _, err := UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "one-too-many", Every: "1h", Path: "/p"}, now); err == nil { |
| + t.Fatal("cap not enforced") |
| + } |
| + d, present, err := RemoveSchedule(ctx, st, "bab", "app", "alpha", now) |
| + if err != nil || !present { |
| + t.Fatalf("remove: %v present=%v", err, present) |
| + } |
| + if _, ok := d.Find("alpha"); ok { |
| + t.Fatal("alpha still declared") |
| + } |
| + if _, present, _ := RemoveSchedule(ctx, st, "bab", "app", "alpha", now); present { |
| + t.Fatal("second remove reports present") |
| + } |
| +} |
| + |
| +func TestReadDeclarationAbsentIsEmpty(t *testing.T) { |
| + d, etag, err := ReadDeclaration(context.Background(), release.NewDirStore(t.TempDir()), "bab", "app") |
| + if err != nil || etag != "" || len(d.Schedules) != 0 { |
| + t.Fatalf("%+v %q %v", d, etag, err) |
| + } |
| +} |
| + |
| +func TestTouchRunMovesTheEtag(t *testing.T) { |
| + ctx := context.Background() |
| + st := release.NewDirStore(t.TempDir()) |
| + if err := TouchRun(ctx, st, "bab", "app", "sync", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, e1, _ := st.Get(ctx, RunKey("bab", "app")) |
| + if err := TouchRun(ctx, st, "bab", "app", "sync", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, e2, _ := st.Get(ctx, RunKey("bab", "app")) |
| + if e1 == "" || e1 == e2 { |
| + t.Fatalf("etag did not move: %q → %q", e1, e2) |
| + } |
| +} |
diff --git a/internal/schedule/spec.go b/internal/schedule/spec.go| new file mode 100644 |
| index 000000000..7b439c95b |
| --- /dev/null |
| +++ b/internal/schedule/spec.go |
| @@ -0,0 +1,108 @@ |
| +package schedule |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "strconv" |
| + "strings" |
| + "time" |
| +) |
| + |
| +const ( |
| + KindSchedule = "schedule" |
| + KindOnce = "once" |
| + |
| + MinEvery = time.Minute |
| + MaxEvery = 720 * time.Hour |
| + MaxOnceAhead = 400 * 24 * time.Hour |
| + |
| + // MaxOncePerHost bounds how many pending "at" jobs a single box may |
| + // hold at once — a later task's (the runner's) enforcement point, kept |
| + // here with the other schedule-wide limits. |
| + MaxOncePerHost = 1000 |
| +) |
| + |
| +// Spec is the parsed timers.spec column — one of three shapes, and the only |
| +// thing the runner needs to know to compute the next fire. |
| +type Spec struct { |
| + Kind string |
| + Every time.Duration |
| + Cron *CronExpr |
| + At time.Time |
| +} |
| + |
| +// ParseSpec reads the canonical column text: "every <duration>", |
| +// "cron <5 fields>", "at <unix>". String is its inverse. |
| +func ParseSpec(s string) (Spec, error) { |
| + kind, rest, _ := strings.Cut(strings.TrimSpace(s), " ") |
| + rest = strings.TrimSpace(rest) |
| + switch kind { |
| + case "every": |
| + d, err := time.ParseDuration(rest) |
| + if err != nil { |
| + return Spec{}, fmt.Errorf("every: %w", err) |
| + } |
| + if err := ValidEvery(d); err != nil { |
| + return Spec{}, err |
| + } |
| + return Spec{Kind: KindSchedule, Every: d}, nil |
| + case "cron": |
| + c, err := ParseCron(rest) |
| + if err != nil { |
| + return Spec{}, err |
| + } |
| + return Spec{Kind: KindSchedule, Cron: c}, nil |
| + case "at": |
| + n, err := strconv.ParseInt(rest, 10, 64) |
| + if err != nil { |
| + return Spec{}, fmt.Errorf("at: %q is not a unix time", rest) |
| + } |
| + return Spec{Kind: KindOnce, At: time.Unix(n, 0).UTC()}, nil |
| + } |
| + return Spec{}, fmt.Errorf("spec %q: want every|cron|at", s) |
| +} |
| + |
| +// ValidEvery is the member-facing bound: whole minutes, 1m to 30d. |
| +func ValidEvery(d time.Duration) error { |
| + switch { |
| + case d < MinEvery: |
| + return errors.New("every: shortest interval is 1m") |
| + case d > MaxEvery: |
| + return errors.New("every: longest interval is 720h (30 days)") |
| + case d%time.Minute != 0: |
| + return errors.New("every: whole minutes only") |
| + } |
| + return nil |
| +} |
| + |
| +func (s Spec) String() string { |
| + switch { |
| + case s.Every > 0: |
| + return "every " + s.Every.String() |
| + case s.Cron != nil: |
| + return "cron " + s.Cron.String() |
| + default: |
| + return "at " + strconv.FormatInt(s.At.Unix(), 10) |
| + } |
| +} |
| + |
| +// Next is the first fire strictly after now — epoch-aligned for every, |
| +// minute-stepped for cron, the instant itself for a pending once and the |
| +// zero time for a once that has passed. Always from now, never from the |
| +// scheduled time: that is what coalesces a missed run into one (spec §6.1). |
| +func (s Spec) Next(now time.Time) time.Time { |
| + now = now.UTC() |
| + switch { |
| + case s.Every > 0: |
| + n := now.Unix() |
| + e := int64(s.Every / time.Second) |
| + return time.Unix((n/e+1)*e, 0).UTC() |
| + case s.Cron != nil: |
| + return s.Cron.Next(now) |
| + default: |
| + if !s.At.After(now) { |
| + return time.Time{} |
| + } |
| + return s.At.UTC() |
| + } |
| +} |
diff --git a/internal/schedule/spec_test.go b/internal/schedule/spec_test.go| new file mode 100644 |
| index 000000000..20bf8268a |
| --- /dev/null |
| +++ b/internal/schedule/spec_test.go |
| @@ -0,0 +1,60 @@ |
| +package schedule |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestParseSpecRoundTrips(t *testing.T) { |
| + for _, s := range []string{"every 6h0m0s", "cron 0 8 * * *", "at 1787004000"} { |
| + sp, err := ParseSpec(s) |
| + if err != nil { |
| + t.Fatalf("%q: %v", s, err) |
| + } |
| + if got := sp.String(); got != s { |
| + t.Errorf("%q → %q", s, got) |
| + } |
| + } |
| + for _, bad := range []string{"", "every", "every 0s", "every 30s", "every 800h", "cron * *", "at x", "sometimes"} { |
| + if _, err := ParseSpec(bad); err == nil { |
| + t.Errorf("ParseSpec(%q) = ok, want error", bad) |
| + } |
| + } |
| +} |
| + |
| +// Epoch alignment (spec §6.1): "every 6h" fires at 00/06/12/18 UTC on every |
| +// box, whatever time the row was created, so there is no phase to persist |
| +// and nothing drifts across restarts. |
| +func TestEveryIsEpochAligned(t *testing.T) { |
| + sp := Spec{Kind: KindSchedule, Every: 6 * time.Hour} |
| + cases := map[string]string{ |
| + "2026-08-23T13:07:00Z": "2026-08-23T18:00:00Z", |
| + "2026-08-23T18:00:00Z": "2026-08-24T00:00:00Z", // strictly after |
| + "2026-08-23T23:59:59Z": "2026-08-24T00:00:00Z", |
| + } |
| + for now, want := range cases { |
| + if got := sp.Next(at(now)); !got.Equal(at(want)) { |
| + t.Errorf("Next(%s) = %s, want %s", now, got.Format(time.RFC3339), want) |
| + } |
| + } |
| +} |
| + |
| +// Coalescing (spec §6.1): Next is computed from NOW, so a box that was down |
| +// across several occurrences produces one next fire, not a burst. |
| +func TestNextNeverReturnsThePast(t *testing.T) { |
| + sp := Spec{Kind: KindSchedule, Every: time.Hour} |
| + now := at("2026-08-23T13:07:00Z") |
| + if got := sp.Next(now); !got.After(now) { |
| + t.Fatalf("Next(%s) = %s is not after now", now, got) |
| + } |
| +} |
| + |
| +func TestOnceFiresOnceThenNever(t *testing.T) { |
| + sp := Spec{Kind: KindOnce, At: at("2026-09-01T08:00:00Z")} |
| + if got := sp.Next(at("2026-08-23T00:00:00Z")); !got.Equal(sp.At) { |
| + t.Fatalf("before: %s", got) |
| + } |
| + if got := sp.Next(at("2026-09-01T08:00:00Z")); !got.IsZero() { |
| + t.Fatalf("after firing: %s, want zero", got) |
| + } |
| +} |
diff --git a/internal/schedule/status.go b/internal/schedule/status.go| new file mode 100644 |
| index 000000000..d691e4377 |
| --- /dev/null |
| +++ b/internal/schedule/status.go |
| @@ -0,0 +1,108 @@ |
| +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"` |
| + LastAt int64 `json:"last_at"` |
| + LastStatus string `json:"last_status"` |
| + LastMs int64 `json:"last_ms"` |
| + Attempts int `json:"attempts"` |
| +} |
| + |
| +// Status is apps/<acct>/<app>/schedule-status/<host>.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 WriteStatus(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...) |
| +} |
diff --git a/internal/schedule/status_test.go b/internal/schedule/status_test.go| new file mode 100644 |
| index 000000000..e6d3c8e60 |
| --- /dev/null |
| +++ b/internal/schedule/status_test.go |
| @@ -0,0 +1,37 @@ |
| +package schedule |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestWriteAndListStatus(t *testing.T) { |
| + ctx := context.Background() |
| + st := release.NewDirStore(t.TempDir()) |
| + b := Status{Host: "b.bab.oncarlos.com", Box: "box1", ReportedAt: "2026-08-23T22:00:00Z", Timers: []TimerStatus{{Name: "sync", Kind: KindSchedule, Spec: "every 6h0m0s", Path: "/jobs/sync", NextAt: 100}}} |
| + a := Status{Host: "a.bab.oncarlos.com", Box: "box1", ReportedAt: "2026-08-23T22:00:00Z", Unsupported: "unit-backed instance"} |
| + for _, s := range []Status{b, a, b} { // the rewrite must not conflict |
| + if err := WriteStatus(ctx, st, "bab", "app", s); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + got, err := ListStatus(ctx, st, "bab", "app") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != 2 || got[0].Host != a.Host || got[1].Timers[0].Name != "sync" || got[0].Schema != 1 { |
| + t.Fatalf("list = %+v", got) |
| + } |
| + if got[0].Timers == nil { |
| + t.Fatal("timers must marshal as [] not null — the CLI ranges over it") |
| + } |
| +} |
| + |
| +func TestListStatusEmptyPrefix(t *testing.T) { |
| + got, err := ListStatus(context.Background(), release.NewDirStore(t.TempDir()), "bab", "app") |
| + if err != nil || len(got) != 0 { |
| + t.Fatalf("%v %v", got, err) |
| + } |
| +} |