281 lines
7.1 KiB
Go
281 lines
7.1 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// CronExpr represents a parsed 5-field cron expression.
|
|
// Each field is expanded into a sorted slice of valid integer values.
|
|
type CronExpr struct {
|
|
Minutes []int // 0-59
|
|
Hours []int // 0-23
|
|
DaysOfMonth []int // 1-31
|
|
Months []int // 1-12
|
|
DaysOfWeek []int // 0-6 (0 = Sunday)
|
|
}
|
|
|
|
// fieldBounds defines the min/max for each cron field.
|
|
var fieldBounds = [5][2]int{
|
|
{0, 59}, // minute
|
|
{0, 23}, // hour
|
|
{1, 31}, // day of month
|
|
{1, 12}, // month
|
|
{0, 6}, // day of week
|
|
}
|
|
|
|
// ParseCron parses a standard 5-field cron expression into a CronExpr.
|
|
//
|
|
// Supported syntax per field:
|
|
// - * all values in range
|
|
// - N single number
|
|
// - N-M range from N to M inclusive
|
|
// - N-M/S range with step S
|
|
// - */S full range with step S
|
|
// - N,M,O list of values (each element can be a number or range)
|
|
func ParseCron(expr string) (*CronExpr, error) {
|
|
fields := strings.Fields(strings.TrimSpace(expr))
|
|
if len(fields) != 5 {
|
|
return nil, fmt.Errorf("cron: expected 5 fields, got %d in %q", len(fields), expr)
|
|
}
|
|
|
|
ce := &CronExpr{}
|
|
targets := []*[]int{&ce.Minutes, &ce.Hours, &ce.DaysOfMonth, &ce.Months, &ce.DaysOfWeek}
|
|
|
|
for i, field := range fields {
|
|
vals, err := parseField(field, fieldBounds[i][0], fieldBounds[i][1])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cron field %d (%q): %w", i+1, field, err)
|
|
}
|
|
if len(vals) == 0 {
|
|
return nil, fmt.Errorf("cron field %d (%q): produced no values", i+1, field)
|
|
}
|
|
*targets[i] = vals
|
|
}
|
|
|
|
return ce, nil
|
|
}
|
|
|
|
// parseField parses a single cron field into a sorted slice of ints.
|
|
func parseField(field string, min, max int) ([]int, error) {
|
|
// Handle lists: "1,3,5" or "1-3,7,10-12"
|
|
parts := strings.Split(field, ",")
|
|
seen := make(map[int]bool)
|
|
|
|
for _, part := range parts {
|
|
vals, err := parsePart(part, min, max)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, v := range vals {
|
|
seen[v] = true
|
|
}
|
|
}
|
|
|
|
// Collect and sort.
|
|
result := make([]int, 0, len(seen))
|
|
for v := range seen {
|
|
result = append(result, v)
|
|
}
|
|
sortInts(result)
|
|
return result, nil
|
|
}
|
|
|
|
// parsePart parses a single element that may be *, a number, a range, or have a step.
|
|
func parsePart(part string, min, max int) ([]int, error) {
|
|
// Split on "/" for step.
|
|
var stepStr string
|
|
base := part
|
|
if idx := strings.Index(part, "/"); idx >= 0 {
|
|
base = part[:idx]
|
|
stepStr = part[idx+1:]
|
|
}
|
|
|
|
// Determine the range.
|
|
var lo, hi int
|
|
if base == "*" {
|
|
lo, hi = min, max
|
|
} else if idx := strings.Index(base, "-"); idx >= 0 {
|
|
var err error
|
|
lo, err = strconv.Atoi(base[:idx])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid number %q: %w", base[:idx], err)
|
|
}
|
|
hi, err = strconv.Atoi(base[idx+1:])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid number %q: %w", base[idx+1:], err)
|
|
}
|
|
} else {
|
|
n, err := strconv.Atoi(base)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid number %q: %w", base, err)
|
|
}
|
|
if stepStr == "" {
|
|
// Single value, no step.
|
|
if n < min || n > max {
|
|
return nil, fmt.Errorf("value %d out of range [%d, %d]", n, min, max)
|
|
}
|
|
return []int{n}, nil
|
|
}
|
|
// e.g., "5/10" means starting at 5, step 10, up to max.
|
|
lo, hi = n, max
|
|
}
|
|
|
|
// Validate bounds.
|
|
if lo < min || lo > max {
|
|
return nil, fmt.Errorf("value %d out of range [%d, %d]", lo, min, max)
|
|
}
|
|
if hi < min || hi > max {
|
|
return nil, fmt.Errorf("value %d out of range [%d, %d]", hi, min, max)
|
|
}
|
|
if lo > hi {
|
|
return nil, fmt.Errorf("range start %d > end %d", lo, hi)
|
|
}
|
|
|
|
step := 1
|
|
if stepStr != "" {
|
|
var err error
|
|
step, err = strconv.Atoi(stepStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid step %q: %w", stepStr, err)
|
|
}
|
|
if step < 1 {
|
|
return nil, fmt.Errorf("step must be >= 1, got %d", step)
|
|
}
|
|
}
|
|
|
|
var vals []int
|
|
for v := lo; v <= hi; v += step {
|
|
vals = append(vals, v)
|
|
}
|
|
return vals, nil
|
|
}
|
|
|
|
// NextRun computes the next run time for a cron expression after the given time.
|
|
// It searches up to 2 years ahead before giving up.
|
|
func NextRun(schedule string, from time.Time) (time.Time, error) {
|
|
ce, err := ParseCron(schedule)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
return ce.Next(from)
|
|
}
|
|
|
|
// Next finds the earliest time after "from" that matches the cron expression.
|
|
func (ce *CronExpr) Next(from time.Time) (time.Time, error) {
|
|
// Start from the next whole minute.
|
|
t := from.Truncate(time.Minute).Add(time.Minute)
|
|
|
|
// Search limit: 2 years of minutes (~1,051,200). We iterate by
|
|
// advancing fields intelligently rather than minute-by-minute.
|
|
deadline := t.Add(2 * 365 * 24 * time.Hour)
|
|
|
|
for t.Before(deadline) {
|
|
// Check month.
|
|
if !contains(ce.Months, int(t.Month())) {
|
|
// Advance to next valid month.
|
|
t = advanceMonth(t, ce.Months)
|
|
continue
|
|
}
|
|
|
|
// Check day of month.
|
|
dom := t.Day()
|
|
domOk := contains(ce.DaysOfMonth, dom)
|
|
dowOk := contains(ce.DaysOfWeek, int(t.Weekday()))
|
|
if !domOk || !dowOk {
|
|
// Advance one day.
|
|
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location())
|
|
continue
|
|
}
|
|
|
|
// Check hour.
|
|
if !contains(ce.Hours, t.Hour()) {
|
|
// Advance to next valid hour today.
|
|
nextH := nextVal(ce.Hours, t.Hour())
|
|
if nextH == -1 {
|
|
// No more valid hours today, go to next day.
|
|
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location())
|
|
} else {
|
|
t = time.Date(t.Year(), t.Month(), t.Day(), nextH, 0, 0, 0, t.Location())
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Check minute.
|
|
if !contains(ce.Minutes, t.Minute()) {
|
|
nextM := nextVal(ce.Minutes, t.Minute())
|
|
if nextM == -1 {
|
|
// No more valid minutes this hour, advance hour.
|
|
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, t.Location())
|
|
} else {
|
|
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), nextM, 0, 0, t.Location())
|
|
}
|
|
continue
|
|
}
|
|
|
|
// All fields match.
|
|
return t, nil
|
|
}
|
|
|
|
return time.Time{}, fmt.Errorf("cron: no matching time found within 2 years for %q", ce.String())
|
|
}
|
|
|
|
// String reconstructs a human-readable representation of the cron expression.
|
|
func (ce *CronExpr) String() string {
|
|
return fmt.Sprintf("%v %v %v %v %v",
|
|
ce.Minutes, ce.Hours, ce.DaysOfMonth, ce.Months, ce.DaysOfWeek)
|
|
}
|
|
|
|
// contains checks if val is in the sorted slice.
|
|
func contains(vals []int, val int) bool {
|
|
for _, v := range vals {
|
|
if v == val {
|
|
return true
|
|
}
|
|
if v > val {
|
|
return false
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// nextVal returns the smallest value in vals that is > current, or -1.
|
|
func nextVal(vals []int, current int) int {
|
|
for _, v := range vals {
|
|
if v > current {
|
|
return v
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// advanceMonth jumps to day 1, hour 0, minute 0 of the next valid month.
|
|
func advanceMonth(t time.Time, months []int) time.Time {
|
|
cur := int(t.Month())
|
|
year := t.Year()
|
|
|
|
// Find next valid month in this year.
|
|
for _, m := range months {
|
|
if m > cur {
|
|
return time.Date(year, time.Month(m), 1, 0, 0, 0, 0, t.Location())
|
|
}
|
|
}
|
|
// Wrap to first valid month of next year.
|
|
return time.Date(year+1, time.Month(months[0]), 1, 0, 0, 0, 0, t.Location())
|
|
}
|
|
|
|
// sortInts performs an insertion sort on a small slice.
|
|
func sortInts(a []int) {
|
|
for i := 1; i < len(a); i++ {
|
|
key := a[i]
|
|
j := i - 1
|
|
for j >= 0 && a[j] > key {
|
|
a[j+1] = a[j]
|
|
j--
|
|
}
|
|
a[j+1] = key
|
|
}
|
|
}
|