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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user