// package schedule provides utilities for parsing and deserializing // cron-style expressions. package schedule import ( "fmt" "strings" "time" "github.com/robfig/cron/v3" "golang.org/x/xerrors" ) // For the purposes of this library, we only need minute, hour, and // day-of-week. However to ensure interoperability we will use the standard // five-valued cron format. Descriptors are not supported. const parserFormat = cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow var defaultParser = cron.NewParser(parserFormat) // Weekly parses a Schedule from spec scoped to a recurring weekly event. // Spec consists of the following space-delimited fields, in the following order: // - timezone e.g. CRON_TZ=US/Central (optional) // - minutes of hour e.g. 30 (required) // - hour of day e.g. 9 (required) // - day of month (must be *) // - month (must be *) // - day of week e.g. 1 (required) // // Example Usage: // // local_sched, _ := schedule.Weekly("59 23 *") // fmt.Println(sched.Next(time.Now().Format(time.RFC3339))) // // Output: 2022-04-04T23:59:00Z // // us_sched, _ := schedule.Weekly("CRON_TZ=US/Central 30 9 1-5") // fmt.Println(sched.Next(time.Now()).Format(time.RFC3339)) // // Output: 2022-04-04T14:30:00Z func Weekly(raw string) (*Schedule, error) { if err := validateWeeklySpec(raw); err != nil { return nil, xerrors.Errorf("validate weekly schedule: %w", err) } // If schedule does not specify a timezone, default to UTC. Otherwise, // the library will default to time.Local which we want to avoid. if !strings.HasPrefix(raw, "CRON_TZ=") { raw = "CRON_TZ=UTC " + raw } specSched, err := defaultParser.Parse(raw) if err != nil { return nil, xerrors.Errorf("parse schedule: %w", err) } schedule, ok := specSched.(*cron.SpecSchedule) if !ok { return nil, xerrors.Errorf("expected *cron.SpecSchedule but got %T", specSched) } if schedule.Location == time.Local { return nil, xerrors.Errorf("schedules scoped to time.Local are not supported") } // Strip the leading CRON_TZ prefix so we just store the cron string. // The timezone info is available in SpecSchedule. cronStr := raw if strings.HasPrefix(raw, "CRON_TZ=") { cronStr = strings.Join(strings.Fields(raw)[1:], " ") } cronSched := &Schedule{ sched: schedule, cronStr: cronStr, } return cronSched, nil } // Schedule represents a cron schedule. // It's essentially a wrapper for robfig/cron/v3 that has additional // convenience methods. type Schedule struct { sched *cron.SpecSchedule // XXX: there isn't any nice way for robfig/cron to serialize cronStr string } // String serializes the schedule to its original human-friendly format. // The leading CRON_TZ is maintained. func (s Schedule) String() string { var sb strings.Builder _, _ = sb.WriteString("CRON_TZ=") _, _ = sb.WriteString(s.sched.Location.String()) _, _ = sb.WriteString(" ") _, _ = sb.WriteString(s.cronStr) return sb.String() } // Location returns the IANA location for the schedule. func (s Schedule) Location() *time.Location { return s.sched.Location } // Cron returns the cron spec for the schedule with the leading CRON_TZ // stripped, if present. func (s Schedule) Cron() string { return s.cronStr } // Next returns the next time in the schedule relative to t. func (s Schedule) Next(t time.Time) time.Time { return s.sched.Next(t) } var t0 = time.Date(1970, 1, 1, 1, 1, 1, 0, time.UTC) var tMax = t0.Add(168 * time.Hour) // Min returns the minimum duration of the schedule. // This is calculated as follows: // - Let t(0) be a given point in time (1970-01-01T01:01:01Z00:00) // - Let t(max) be 168 hours after t(0). // - Let t(1) be the next scheduled time after t(0). // - Let t(n) be the next scheduled time after t(n-1). // - Then, the minimum duration of s d(min) // = min( t(n) - t(n-1) ∀ n ∈ N, t(n) < t(max) ) func (s Schedule) Min() time.Duration { durMin := tMax.Sub(t0) tPrev := s.Next(t0) tCurr := s.Next(tPrev) for { dur := tCurr.Sub(tPrev) if dur < durMin { durMin = dur } tPrev = tCurr tCurr = s.Next(tCurr) if tCurr.After(tMax) { break } } return durMin } // Time returns a humanized form of the minute and hour fields. func (s Schedule) Time() string { minute := strings.Fields(s.cronStr)[0] hour := strings.Fields(s.cronStr)[1] maybeTime := fmt.Sprintf("%s:%s", hour, minute) t, err := time.ParseInLocation("15:4", maybeTime, s.sched.Location) if err != nil { // return the original cronspec for minute and hour, who knows what's in there! return fmt.Sprintf("cron(%s %s)", minute, hour) } return t.Format(time.Kitchen) } // DaysOfWeek returns a humanized form of the day-of-week field. func (s Schedule) DaysOfWeek() string { dow := strings.Fields(s.cronStr)[4] if dow == "*" { return "daily" } for _, weekday := range []time.Weekday{ time.Sunday, time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday, time.Saturday, } { dow = strings.Replace(dow, fmt.Sprintf("%d", weekday), weekday.String()[:3], 1) } return dow } // validateWeeklySpec ensures that the day-of-month and month options of // spec are both set to * func validateWeeklySpec(spec string) error { parts := strings.Fields(spec) if len(parts) < 5 { return xerrors.Errorf("expected schedule to consist of 5 fields with an optional CRON_TZ= prefix") } if len(parts) == 6 { parts = parts[1:] } if parts[2] != "*" || parts[3] != "*" { return xerrors.Errorf("expected month and dom to be *") } return nil }