console: schedules meet the plan's minimum interval
schedule.ShortestInterval gives a cron line's smallest gap between firings over a 62-day window, so cron cannot slip under the minimum.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+157
−0
internal/console/apischedule.go+10 −0internal/console/plancaps.go+20 −0internal/console/plancaps_doors_test.go+50 −0internal/schedule/mingap.go+49 −0internal/schedule/mingap_test.go+28 −0
diff --git a/internal/console/apischedule.go b/internal/console/apischedule.go| index ae63a84f5..c5fb19618 100644 |
| --- a/internal/console/apischedule.go |
| +++ b/internal/console/apischedule.go |
| @@ -219,6 +219,16 @@ func (s *Server) handleAPIScheduleSet(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusBadRequest, err.Error()) |
| return |
| } |
| + // Plans 3b: a schedule may not run more often than the plan allows. |
| + if err := s.checkSchedule(acct, sc); err != nil { |
| + if isPlanCap(err) { |
| + writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| + return |
| + } |
| + log.Printf("console: api schedule set %s/%s: plan: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, planCheckFailedCopy) |
| + return |
| + } |
| ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| defer cancel() |
| // ReadDeclaration already folds a genuine not-found into an empty, |
diff --git a/internal/console/plancaps.go b/internal/console/plancaps.go| index 8a701751c..d733a342f 100644 |
| --- a/internal/console/plancaps.go |
| +++ b/internal/console/plancaps.go |
| @@ -6,8 +6,10 @@ import ( |
| "fmt" |
| "net/http" |
| "strings" |
| + "time" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/schedule" |
| ) |
| // Creation caps (plans-caps-packs spec §5.1, §8.1, §8.4; plans 3a). Every |
| @@ -308,3 +310,21 @@ func (s *Server) checkAlwaysOn(acct *Account) error { |
| } |
| return &planCapError{msg: capMessage("cli.cap.always_on")} |
| } |
| + |
| +// checkSchedule refuses a schedule that would run more often than the |
| +// plan's minimum interval (plans 3b; spec §5.2). Cron lines are held to |
| +// their shortest gap between firings. |
| +func (s *Server) checkSchedule(acct *Account, sc schedule.Schedule) error { |
| + l, err := s.EffectiveLimits(acct) |
| + if err != nil || l == nil || l.MinScheduleSeconds <= 0 { |
| + return err |
| + } |
| + shortest, err := sc.ShortestInterval() |
| + if err != nil { |
| + return err |
| + } |
| + if shortest < time.Duration(l.MinScheduleSeconds)*time.Second { |
| + return &planCapError{msg: capMessage("cli.cap.schedule", l.MinScheduleSeconds/60)} |
| + } |
| + return nil |
| +} |
diff --git a/internal/console/plancaps_doors_test.go b/internal/console/plancaps_doors_test.go| index 314347077..df1ad7531 100644 |
| --- a/internal/console/plancaps_doors_test.go |
| +++ b/internal/console/plancaps_doors_test.go |
| @@ -477,3 +477,53 @@ func TestSidecarEnableHibernateFalseWithoutACatalogUnchanged(t *testing.T) { |
| t.Fatalf("hibernate:false with no catalog = %d %s, want 200", status, out) |
| } |
| } |
| + |
| +func setSchedule(t *testing.T, fx *consoleFixture, token, body string) int { |
| + t.Helper() |
| + status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token, body) |
| + return status |
| +} |
| + |
| +func TestScheduleRefusedBelowThePlanMinimum(t *testing.T) { |
| + fx, token := scheduleFixture(t, "code-cap-schedule") |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + for _, body := range []string{ |
| + `{"name":"often","every":"5m","path":"/jobs/x"}`, |
| + `{"name":"often","cron":"*/1 * * * *","path":"/jobs/x"}`, |
| + } { |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token, body) |
| + if status != http.StatusConflict || !strings.Contains(string(out), `"code":"cap"`) { |
| + t.Errorf("%s on free = %d %s, want 409 cap", body, status, out) |
| + } |
| + } |
| + if _, etag := declaredSchedules(t, fx, "titogo"); etag != "" { |
| + t.Fatal("a refused schedule was written") |
| + } |
| + for _, body := range []string{ |
| + `{"name":"quarter","every":"15m","path":"/jobs/x"}`, |
| + `{"name":"quartercron","cron":"*/15 * * * *","path":"/jobs/y"}`, |
| + } { |
| + if status := setSchedule(t, fx, token, body); status != http.StatusOK { |
| + t.Errorf("%s on free = %d, want 200", body, status) |
| + } |
| + } |
| +} |
| + |
| +func TestScheduleMinimumOnAPaidPlan(t *testing.T) { |
| + fx, token := scheduleFixture(t, "code-cap-schedule-paid") |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + ops, _ := fx.st.AccountBySqid(fx.sqid) |
| + if _, err := fx.st.SetAccountPlan(ops.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + if status := setSchedule(t, fx, token, `{"name":"minutely","every":"1m","path":"/jobs/x"}`); status != http.StatusOK { |
| + t.Fatalf("every 1m on personal = %d, want 200", status) |
| + } |
| +} |
| + |
| +func TestScheduleWithoutACatalogUnchanged(t *testing.T) { |
| + fx, token := scheduleFixture(t, "code-cap-schedule-nocat") |
| + if status := setSchedule(t, fx, token, `{"name":"minutely","every":"1m","path":"/jobs/x"}`); status != http.StatusOK { |
| + t.Fatalf("every 1m with no catalog = %d, want 200", status) |
| + } |
| +} |
diff --git a/internal/schedule/mingap.go b/internal/schedule/mingap.go| new file mode 100644 |
| index 000000000..38b1476e8 |
| --- /dev/null |
| +++ b/internal/schedule/mingap.go |
| @@ -0,0 +1,49 @@ |
| +package schedule |
| + |
| +import ( |
| + "errors" |
| + "time" |
| +) |
| + |
| +// shortestWindow is how far ShortestInterval looks for a cron line's |
| +// firings: long enough to see two of a monthly line's. |
| +const shortestWindow = 62 * 24 * time.Hour |
| + |
| +// shortestFrom is a fixed starting instant (a Monday at midnight UTC), so |
| +// the answer never depends on when it is asked. |
| +var shortestFrom = time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC) |
| + |
| +// ShortestInterval is the least time between two runs of s: its every: |
| +// duration, or for a cron line the smallest gap between consecutive firings |
| +// across shortestWindow. A line that fires at most once in the window |
| +// answers the window. Plans 3b uses it to hold schedules to a plan's |
| +// minimum. |
| +func (s Schedule) ShortestInterval() (time.Duration, error) { |
| + sp, err := s.Spec() |
| + if err != nil { |
| + return 0, err |
| + } |
| + switch { |
| + case sp.Every > 0: |
| + return sp.Every, nil |
| + case sp.Cron != nil: |
| + shortest := shortestWindow |
| + end := shortestFrom.Add(shortestWindow) |
| + prev := sp.Cron.Next(shortestFrom) |
| + for !prev.IsZero() && prev.Before(end) { |
| + next := sp.Cron.Next(prev) |
| + if next.IsZero() || !next.Before(end) { |
| + break |
| + } |
| + if gap := next.Sub(prev); gap < shortest { |
| + shortest = gap |
| + if shortest <= MinEvery { |
| + break |
| + } |
| + } |
| + prev = next |
| + } |
| + return shortest, nil |
| + } |
| + return 0, errors.New("schedule has no interval") |
| +} |
diff --git a/internal/schedule/mingap_test.go b/internal/schedule/mingap_test.go| new file mode 100644 |
| index 000000000..20fbf3b14 |
| --- /dev/null |
| +++ b/internal/schedule/mingap_test.go |
| @@ -0,0 +1,28 @@ |
| +package schedule |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestCronMinGap(t *testing.T) { |
| + for _, c := range []struct { |
| + sc Schedule |
| + want time.Duration |
| + }{ |
| + {Schedule{Name: "a", Cron: "*/1 * * * *", Path: "/x"}, time.Minute}, |
| + {Schedule{Name: "a", Cron: "*/15 * * * *", Path: "/x"}, 15 * time.Minute}, |
| + {Schedule{Name: "a", Cron: "0,5 * * * *", Path: "/x"}, 5 * time.Minute}, |
| + {Schedule{Name: "a", Cron: "0 3 * * *", Path: "/x"}, 24 * time.Hour}, |
| + {Schedule{Name: "a", Cron: "0 9 * * 1", Path: "/x"}, 7 * 24 * time.Hour}, |
| + {Schedule{Name: "a", Every: "10m", Path: "/x"}, 10 * time.Minute}, |
| + } { |
| + got, err := c.sc.ShortestInterval() |
| + if err != nil || got != c.want { |
| + t.Errorf("%+v: ShortestInterval = %v, %v; want %v", c.sc, got, err, c.want) |
| + } |
| + } |
| + if _, err := (Schedule{Name: "a", Cron: "nope", Path: "/x"}).ShortestInterval(); err == nil { |
| + t.Error("an invalid cron line had a shortest interval") |
| + } |
| +} |