mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
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.
139 lines
5.3 KiB
Go
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
|
|
}
|