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
183 lines
5.9 KiB
Go
183 lines
5.9 KiB
Go
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
|
|
}
|