apply: a scheduled step is a container run on a cadence (ADR 0053)

The recurring twin of run-once, one modifier over: a container marked
schedule: "<cron>" is run to completion on its cadence, not started as a
service and not run once as a gate.

The gating rule is deliberately reversed. Installing a schedule records it
as present state and reports the node current at once (applySchedule) --
it never runs the container and does not gate what follows. A Scheduler,
held for the life of the daemon and re-established from each applied
declaration (the declaration is the source of truth, ADR 0018), fires the
container off an injected clock. A run that exits non-zero is logged and
never fails the apply or flips the node's state, because it happens
outside the apply and the store entirely. Runs never stack: a run still
going when the next is due is skipped, not started as a second copy.

No new host shape and no new action -- schedule is a string on the
container the host already has, and the host process runs the container
itself rather than installing a system timer (the rejected option 1). A
minimal five-field cron (declaration/cron.go) validates on arrival and
computes the next due minute; time is injected so the scheduler is tested
without the wall clock.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-06 14:08:44 +02:00
parent 24e9ae4065
commit 9d1f001dcc
8 changed files with 1076 additions and 22 deletions
+182
View File
@@ -0,0 +1,182 @@
package declaration
// A minimal five-field cron, enough to say when a scheduled step is due (novox/hq ADR 0053).
//
// **Written rather than pulled in.** The host depends on nothing it does not have to
// (novox/hq ADR 0005), and a scheduled step needs exactly two questions answered — *is this minute
// a match* and *when is the next one* — over the ordinary five fields (minute, hour, day-of-month,
// month, day-of-week) with `*`, lists (`,`), ranges (`-`) and steps (`/`). That is small enough to
// keep in the vocabulary the host already owns, and a cron library would be a dependency carried
// for the parts of it nobody here uses.
//
// **The host both validates and evaluates.** The control plane refuses a malformed cron near its
// author (novox/hq ADR 0053), and the host refuses it again on arrival for the same near-versus-far
// reason every other field is checked here: a declaration the host does not fully understand is
// refused whole rather than half-applied. The evaluation half — `Next` — is what the scheduler
// fires on.
import (
"fmt"
"strconv"
"strings"
"time"
)
// Cron is a parsed five-field expression, each field held as a bitset of the values it permits.
//
// The two day fields carry a flag for whether they were written as a bare `*`, because standard
// cron gives them a special rule: when day-of-month and day-of-week are *both* restricted, a day
// matches if *either* does; when one is `*`, only the other is consulted. Getting that wrong is the
// classic cron surprise, so the flag is kept rather than rediscovered.
type Cron struct {
minute uint64
hour uint64
dom uint64
month uint64
dow uint64
domStar bool
dowStar bool
}
// ParseCron reads a five-field cron expression, or says why it is not one.
func ParseCron(expr string) (*Cron, error) {
fields := strings.Fields(expr)
if len(fields) != 5 {
return nil, fmt.Errorf(
"a schedule is a five-field cron expression (minute hour day-of-month month "+
"day-of-week), and %q has %d field(s)", expr, len(fields))
}
minute, _, err := parseCronField(fields[0], 0, 59)
if err != nil {
return nil, fmt.Errorf("schedule minute field: %w", err)
}
hour, _, err := parseCronField(fields[1], 0, 23)
if err != nil {
return nil, fmt.Errorf("schedule hour field: %w", err)
}
dom, domStar, err := parseCronField(fields[2], 1, 31)
if err != nil {
return nil, fmt.Errorf("schedule day-of-month field: %w", err)
}
month, _, err := parseCronField(fields[3], 1, 12)
if err != nil {
return nil, fmt.Errorf("schedule month field: %w", err)
}
// Day-of-week is 0-6 with Sunday at 0, and 7 is also accepted for Sunday — the convention every
// cron keeps, so a crontab copied from elsewhere is not refused for saying 7.
dow, dowStar, err := parseCronField(fields[4], 0, 7)
if err != nil {
return nil, fmt.Errorf("schedule day-of-week field: %w", err)
}
if dow&(1<<7) != 0 {
dow |= 1 << 0
dow &^= 1 << 7
}
return &Cron{
minute: minute, hour: hour, dom: dom, month: month, dow: dow,
domStar: domStar, dowStar: dowStar,
}, nil
}
// parseCronField turns one field into the bitset of values it permits, and reports whether it was a
// bare `*` (which the day fields treat specially).
func parseCronField(spec string, min, max int) (uint64, bool, error) {
if spec == "" {
return 0, false, fmt.Errorf("is empty")
}
star := spec == "*"
var bits uint64
for _, part := range strings.Split(spec, ",") {
if part == "" {
return 0, false, fmt.Errorf("%q has an empty element between commas", spec)
}
// A step may follow either `*` or a range: `*/15`, `0-30/5`.
step := 1
rangePart := part
if slash := strings.IndexByte(part, '/'); slash >= 0 {
rangePart = part[:slash]
n, err := strconv.Atoi(part[slash+1:])
if err != nil || n < 1 {
return 0, false, fmt.Errorf("step in %q is not a positive number", part)
}
step = n
}
lo, hi := min, max
switch {
case rangePart == "*":
// Full range, already set.
case strings.IndexByte(rangePart, '-') >= 0:
dash := strings.IndexByte(rangePart, '-')
a, errA := strconv.Atoi(rangePart[:dash])
b, errB := strconv.Atoi(rangePart[dash+1:])
if errA != nil || errB != nil {
return 0, false, fmt.Errorf("range %q is not two numbers", rangePart)
}
lo, hi = a, b
default:
n, err := strconv.Atoi(rangePart)
if err != nil {
return 0, false, fmt.Errorf("%q is not a number", rangePart)
}
lo, hi = n, n
}
if lo < min || hi > max || lo > hi {
return 0, false, fmt.Errorf(
"%q is outside the allowed range %d-%d", part, min, max)
}
for v := lo; v <= hi; v += step {
bits |= 1 << uint(v)
}
}
return bits, star, nil
}
// Matches reports whether a scheduled step is due at this minute.
func (c *Cron) Matches(t time.Time) bool {
if c.minute&(1<<uint(t.Minute())) == 0 {
return false
}
if c.hour&(1<<uint(t.Hour())) == 0 {
return false
}
if c.month&(1<<uint(int(t.Month()))) == 0 {
return false
}
domMatch := c.dom&(1<<uint(t.Day())) != 0
dowMatch := c.dow&(1<<uint(int(t.Weekday()))) != 0
// The standard day rule: both restricted means either may match; one as `*` defers to the other.
switch {
case c.domStar && c.dowStar:
return true
case c.domStar:
return dowMatch
case c.dowStar:
return domMatch
default:
return domMatch || dowMatch
}
}
// Next is the first minute strictly after t at which the step is due, and false if there is none
// within a bound generous enough to cover a once-a-year, leap-day schedule.
//
// Minute-by-minute rather than a closed form: the day rule above makes a closed form fiddly and
// error-prone, and this is called once per fire — rarely — so simple and obviously correct wins.
func (c *Cron) Next(after time.Time) (time.Time, bool) {
t := after.Truncate(time.Minute).Add(time.Minute)
// Five years of minutes: enough that "29 2 * * *" (Feb 29) always finds its next leap year.
for i := 0; i < 5*366*24*60; i++ {
if c.Matches(t) {
return t, true
}
t = t.Add(time.Minute)
}
return time.Time{}, false
}
+100
View File
@@ -0,0 +1,100 @@
package declaration
import (
"testing"
"time"
)
// A scheduled step is due when its cron says so, and the host both refuses a malformed cron on
// arrival and computes the next due minute the scheduler fires on (novox/hq ADR 0053). These test
// the parsing and the evaluation; the firing is tested against the scheduler.
func TestParseCronRefusesMalformed(t *testing.T) {
for _, expr := range []string{
"", // nothing
"* * * *", // four fields
"* * * * * *", // six fields
"60 * * * *", // minute out of range
"* 24 * * *", // hour out of range
"* * 0 * *", // day-of-month below 1
"* * 32 * *", // day-of-month above 31
"* * * 13 *", // month out of range
"* * * * 8", // day-of-week above 7
"a * * * *", // not a number
"*/0 * * * *", // zero step
"5-1 * * * *", // inverted range
"1,,2 * * * *", // empty element
} {
if _, err := ParseCron(expr); err == nil {
t.Errorf("a malformed cron %q was accepted", expr)
}
}
}
func TestParseCronAcceptsTheOrdinaryForms(t *testing.T) {
for _, expr := range []string{
"* * * * *", // every minute
"0 3 * * *", // 03:00 daily
"*/15 * * * *", // every 15 minutes
"0 0 1 1 *", // new year
"0 9-17 * * 1-5", // business hours, weekdays
"0 0 * * 7", // Sunday as 7
"0,30 * * * *", // twice an hour
} {
if _, err := ParseCron(expr); err != nil {
t.Errorf("a valid cron %q was refused: %v", expr, err)
}
}
}
func TestCronNextIsTheNextMatchingMinute(t *testing.T) {
// A Monday at 12:00:30, so "next" must round up to a whole minute and land on the first match
// strictly after now — never re-firing the minute we are already in.
now := time.Date(2026, 9, 7, 12, 0, 30, 0, time.UTC) // 2026-09-07 is a Monday
cases := []struct {
expr string
want time.Time
}{
{"* * * * *", time.Date(2026, 9, 7, 12, 1, 0, 0, time.UTC)},
{"0 3 * * *", time.Date(2026, 9, 8, 3, 0, 0, 0, time.UTC)},
{"*/15 * * * *", time.Date(2026, 9, 7, 12, 15, 0, 0, time.UTC)},
{"0 0 1 1 *", time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)},
}
for _, c := range cases {
cron, err := ParseCron(c.expr)
if err != nil {
t.Fatalf("%q: %v", c.expr, err)
}
got, ok := cron.Next(now)
if !ok {
t.Errorf("%q: no next time found", c.expr)
continue
}
if !got.Equal(c.want) {
t.Errorf("%q: next is %s, want %s", c.expr, got, c.want)
}
}
}
func TestCronDayFieldsAreOredWhenBothRestricted(t *testing.T) {
// The standard cron surprise: when day-of-month and day-of-week are both set, a day matches if
// EITHER does. "1 * 13 * 5" is due on the 13th OR on any Friday. 2026-09-07 is a Monday the 7th
// — neither — and 2026-09-11 is a Friday, and 2026-11-13 is the 13th.
cron, err := ParseCron("0 0 13 * 5")
if err != nil {
t.Fatal(err)
}
friday := time.Date(2026, 9, 11, 0, 0, 0, 0, time.UTC)
if !cron.Matches(friday) {
t.Error("a Friday did not match a cron restricted to the 13th OR Friday")
}
thirteenth := time.Date(2026, 11, 13, 0, 0, 0, 0, time.UTC) // a non-Friday 13th
if !cron.Matches(thirteenth) {
t.Error("the 13th did not match a cron restricted to the 13th OR Friday")
}
neither := time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC)
if cron.Matches(neither) {
t.Error("a Monday the 7th matched a cron restricted to the 13th OR Friday")
}
}
+29
View File
@@ -533,6 +533,18 @@ type Container struct {
// the digest of this declaration, so a re-apply does not re-run it unless the declaration
// changed.
RunOnce bool `json:"run-once,omitempty"`
// Schedule marks a container the host runs on a recurring cadence — a five-field cron
// expression (novox/hq ADR 0053). It is the recurring twin of RunOnce: the same container, run
// to completion, but again and again on the clock rather than once. Installing it does not run
// it — the schedule is state that is present, like a running service, so the apply is current as
// soon as it is recorded and does NOT gate what follows. The host's scheduler fires the
// container when the cron is due, re-established from this declaration each apply because the
// declaration is the source of truth (ADR 0018). A run that exits non-zero is recorded and never
// fails the apply or flips the node's state; a run still going when the next is due is skipped
// rather than stacked. It is exclusive with RunOnce and with restart-on: a container runs once
// and gates, runs on a cadence, or stays up — never two of these.
Schedule string `json:"schedule,omitempty"`
}
func (c *Container) Identity() string { return c.ID }
@@ -551,6 +563,23 @@ func (c *Container) validate(where string, _ bool) []string {
problems = append(problems, where+": a run-once container cannot also declare restart-on; "+
"it runs to completion rather than staying running to be restarted")
}
// A container runs once and gates, on a cadence, or stays up — never two of these
// (novox/hq ADR 0053). run-once and schedule are the two "runs to completion" lifecycles and
// contradict each other, and a scheduled step does not stay running to be brought back by
// restart-on either. Refused here on arrival, as the control plane refuses it near its author.
if c.RunOnce && c.Schedule != "" {
problems = append(problems, where+": a container is run-once or scheduled, not both; "+
"run-once runs once and gates what follows, a schedule runs it again on a cadence")
}
if c.Schedule != "" && len(c.RestartOn) > 0 {
problems = append(problems, where+": a scheduled container cannot also declare restart-on; "+
"it runs to completion on its cadence rather than staying running to be restarted")
}
if c.Schedule != "" {
if _, err := ParseCron(c.Schedule); err != nil {
problems = append(problems, where+": "+err.Error())
}
}
return append(problems, checkImage(where, c.Image)...)
}
@@ -0,0 +1,53 @@
package declaration
import (
"strings"
"testing"
)
// The host refuses a schedule it does not fully understand, on arrival, for the same reason the
// control plane refuses it near its author (novox/hq ADR 0053): a declaration half-understood is
// refused whole rather than half-applied.
func schedulePinned() string { return "registry.example/runtime@sha256:" + strings.Repeat("a", 64) }
func TestAScheduledContainerCarriesTheCronField(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","schedule":"0 3 * * *"}
]}`))
if err != nil {
t.Fatalf("a valid scheduled container was refused: %v", err)
}
c, ok := d.Resources[0].(*Container)
if !ok {
t.Fatalf("the scheduled resource is not a container: %T", d.Resources[0])
}
if c.Schedule != "0 3 * * *" {
t.Errorf("the schedule did not survive parsing: %q", c.Schedule)
}
}
func TestAMalformedScheduleIsRefused(t *testing.T) {
_, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","schedule":"every night"}
]}`))
if err == nil {
t.Fatal("a container with a malformed schedule was accepted")
}
if !strings.Contains(err.Error(), "cron") && !strings.Contains(err.Error(), "field") {
t.Errorf("refused for the wrong reason: %v", err)
}
}
func TestAContainerCannotBeBothRunOnceAndScheduled(t *testing.T) {
// A container runs once and gates, or on a cadence, or stays up — never two (novox/hq ADR 0053).
_, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","run-once":true,"schedule":"0 3 * * *"}
]}`))
if err == nil {
t.Fatal("a container that was both run-once and scheduled was accepted")
}
if !strings.Contains(err.Error(), "run-once") && !strings.Contains(err.Error(), "scheduled") {
t.Errorf("refused for the wrong reason: %v", err)
}
}