Files
multica/server/internal/service/cron.go
YYClaw 465546b83b feat(autopilots): redesign the autopilot schedule editor (#5457)
Replace the free-form trigger-config form with a structured schedule
editor built on an orthogonal cron model: separate frequency, time, and
day-of-week/day-of-month dimensions map to and from cron expressions via
a dedicated grammar and mapping layer, with validation and a
human-readable describe() summary. The grammar suite drives the editor
against a combinatorially generated corpus of 51,755 distinct cron
expressions - every token form of every field, crossed - each judged
against a reference robfig/cron v3 parser.

Add a server-side /autopilot/cron-preview endpoint (plus schema and
React Query hook) so the editor shows upcoming run times, and echo
wildcard-carrying cron lists correctly instead of collapsing them.

Supporting pieces: timezone-aware formatting helper, segmented-toggle
and debounced-value utilities, a reworked time-input, and refreshed
en/ja/ko/zh-Hans locale strings.
2026-07-17 16:44:31 +08:00

139 lines
5.3 KiB
Go

package service
import (
"fmt"
"strings"
"time"
"github.com/robfig/cron/v3"
)
// cronParser accepts standard 5-field cron expressions.
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
// NextOccurrenceAfterUTC parses cronExpr in the named IANA timezone and
// returns the next activation strictly after `after`. The result is always
// in UTC and represents the canonical fire time of the next occurrence.
//
// `after` is interpreted as an absolute instant; callers should pass DB
// time (e.g. `SELECT now()`) rather than `time.Now()` so that two
// app instances with skewed clocks still produce the same answer.
//
// This is the building block the new scheduler.AutopilotScheduleDispatchJob
// uses to compute plan_times; the handler / UI write paths use it via
// ComputeNextRunFromUTC to fill in the display-only
// autopilot_trigger.next_run_at column.
func NextOccurrenceAfterUTC(cronExpr, timezone string, after time.Time) (time.Time, error) {
sched, loc, err := parseCronSchedule(cronExpr, timezone)
if err != nil {
return time.Time{}, err
}
return sched.Next(after.In(loc)).UTC(), nil
}
// NextOccurrencesAfterUTC parses cronExpr in `timezone` once and returns the
// next `count` activations strictly after `after`, ascending, in UTC. It stops
// early — returning a shorter slice — when the expression has no further
// occurrence within robfig's five-year search horizon (e.g. "0 0 30 2 *"), which
// robfig reports as the zero time.
//
// Unlike calling NextOccurrenceAfterUTC in a loop, the expression is parsed and
// the location loaded exactly once. The schedule editor's preview endpoint asks
// for this on every debounced keystroke, so the difference is not academic.
func NextOccurrencesAfterUTC(cronExpr, timezone string, after time.Time, count int) ([]time.Time, error) {
sched, loc, err := parseCronSchedule(cronExpr, timezone)
if err != nil {
return nil, err
}
out := make([]time.Time, 0, count)
cursor := after.In(loc)
for i := 0; i < count; i++ {
next := sched.Next(cursor)
if next.IsZero() {
break
}
out = append(out, next.UTC())
cursor = next
}
return out, nil
}
// NextOccurrencesUTC parses cronExpr in `timezone` and returns every
// activation in the half-open interval `(after, until]`, in canonical
// UTC order (ascending). Used by the Autopilot schedule dispatch job to
// enumerate every plan_time that became due between the last stored
// occurrence and DB now().
//
// The slice is capped at 1024 entries — a safety net against an
// accidental "every second" cron over a multi-day catch-up window.
// The scheduler manager additionally caps the returned slice at
// JobSpec.MaxPlansPerTick.
func NextOccurrencesUTC(cronExpr, timezone string, after, until time.Time) ([]time.Time, error) {
sched, loc, err := parseCronSchedule(cronExpr, timezone)
if err != nil {
return nil, err
}
const hardCap = 1024
out := make([]time.Time, 0, 8)
cursor := after.In(loc)
untilLocal := until.In(loc)
for len(out) < hardCap {
next := sched.Next(cursor)
if next.After(untilLocal) {
break
}
out = append(out, next.UTC())
cursor = next
}
return out, nil
}
// ComputeNextRun evaluates the cron at the app's local now() and backs
// the display-only autopilot_trigger.next_run_at column for the trigger
// create/update handlers and the failure monitor. Using the local clock
// for this display value is deliberate: app/DB clock skew under NTP is far
// below the column's minute-level granularity, so threading DB time
// through these UI write paths would buy no user-visible accuracy.
//
// The scheduler's post-dispatch advance keeps next_run_at on the same
// local clock but calls NextOccurrenceAfterUTC anchored at
// max(now, plan_time) rather than this helper, so a lagging app clock can
// never re-point the column at the slot that just fired (see
// scheduler.advancedNextRun / autopilotHandler, MUL-3749).
//
// Scheduling decisions are a separate concern and MUST go through
// NextOccurrencesUTC / NextOccurrenceAfterUTC against DB time instead:
// dispatch correctness across clock-skewed app instances depends on it.
func ComputeNextRun(cronExpr, timezone string) (time.Time, error) {
return NextOccurrenceAfterUTC(cronExpr, timezone, time.Now())
}
// ValidateTimezone returns an error if the timezone string is not recognized.
func ValidateTimezone(timezone string) error {
_, err := time.LoadLocation(timezone)
if err != nil {
return fmt.Errorf("invalid timezone %q: %w", timezone, err)
}
return nil
}
func parseCronSchedule(cronExpr, timezone string) (cron.Schedule, *time.Location, error) {
// robfig v3.0.1 reads an optional "TZ="/"CRON_TZ=" prefix up to the first
// space and panics (parser.go:99, slice[:-1]) when that space is missing.
// The preview endpoint feeds raw user text here, so reject the shape the
// parser cannot survive instead of turning a typo into a 500.
if (strings.HasPrefix(cronExpr, "TZ=") || strings.HasPrefix(cronExpr, "CRON_TZ=")) &&
!strings.Contains(cronExpr, " ") {
return nil, nil, fmt.Errorf("parse cron: missing schedule after timezone prefix %q", cronExpr)
}
sched, err := cronParser.Parse(cronExpr)
if err != nil {
return nil, nil, fmt.Errorf("parse cron: %w", err)
}
loc, err := time.LoadLocation(timezone)
if err != nil {
return nil, nil, fmt.Errorf("invalid timezone %q: %w", timezone, err)
}
return sched, loc, nil
}