agent/taskengine/timermanager/cronscheduled.go (129 lines of code) (raw):

package timermanager import ( "errors" "fmt" "regexp" "strconv" "strings" "time" "github.com/aliyun/aliyun_assist_client/thirdparty/cronexpr" ) // CronScheduled provides nextRun() interface for cron expression type CronScheduled struct { expression *cronexpr.Expression location *time.Location isNoNextRun bool } var ( CronYearFieldRegexp = regexp.MustCompile(`^\*$|^\?$|19[789][0-9]|20[0-9]{2}|^\*/(\d+)$`) // GMT+13:00/GMT+14:00 do exist, even GMT+13:45. But GMT-13:**/GMT-14:00 do not. GMTOffsetTimezoneRegexp = regexp.MustCompile(`GMT([+-])([0-9]|1[0-4]):([0-5][0-9])`) ErrInvalidCronExpression = newCronParameterError("InvalidCronExpression", "invalid cron expression cannot be parsed") ErrTimezoneInformationCorrupt = newCronParameterError("TimezoneInformationCorrupt", "Information of sepcified timezone in cron expression cannot be parsed") ErrInvalidGMTOffsetForTimezone = newCronParameterError("InvalidGMTOffsetForTimezone", "invalid GMT+-offset format at timezone field cannot be parsed") ErrInvalidGMTOffsetHourForTimezone = newCronParameterError("InvalidGMTOffsetHourForTimezone", "invalid hour value for GMT+-offset format at timezone field cannot be parsed") ErrInvalidGMTOffsetMinuteForTimezone = newCronParameterError("InvalidGMTOffsetMinuteForTimezone", "invalid minute value for GMT+-offset format at timezone field cannot be parsed") ErrCronExpressionExpired = newCronParameterError("CronExpressionExpired", "cron expression had expired at the moment of system clock") ) // NewCronScheduled returns scheduler from cron expression // // Classic style and new styles of cron expression are both supported: // * Classic: `Seconds Minutes Hours Day_of_month Month Day_of_week` // * New: `Seconds Minutes Hours Day_of_month Month Day_of_week Year(optional) Timezone(optional)` // // For timezone specification, two formats are supported: // * Complete name in TZ database, e.g., Asia/Shanghai, America/Los_Angeles // * GMT offset (no leading zero in hour), e.g., GMT+8:00, GMT-6:00 // * Some fixed and unambiguous abbreviation names of timezone, namely: // + GMT // + UTC func NewCronScheduled(cronat string) (*CronScheduled, error) { canonicalizedCronat, location, err := _splitExpressionAndLocation(cronat) if err != nil { return nil, err } expression, err := cronexpr.Parse(canonicalizedCronat) if err != nil { return nil, err } schedule := CronScheduled{ expression: expression, location: location, isNoNextRun: false, } // Report cron expression expiration as early as possible if _, err := schedule.nextRun(); errors.Is(err, ErrNoNextRun) { return nil, ErrCronExpressionExpired } return &schedule, nil } func _splitExpressionAndLocation(cronat string) (string, *time.Location, error) { trimmedCronat := strings.TrimSpace(cronat) fields := strings.Fields(trimmedCronat) switch len(fields) { case 6: // Append wildcard year field if only 6 fields are present, i.e., // classic style of cron expression, just for: // * making year field optional // * compatiblity with previous version of agent // * overwriting default modification behavior of gorhill/cronexpr library // under such situation. return trimmedCronat + " *", nil, nil case 7: // If the last field satisfies any condition below, it would be // considered as the year field, and the whole expression is a valid // cron expression without the optional timezone field: // * it is asterisk wildcard character `\*` or question-mark wildcard // character `\?` // * it contains year number, i.e., matches regexp // `19[789][0-9]|20[0-9]{2}` // * it starts with asterisk wildcard character and interval delimiter // slash, i.e., `\*/` // See https://github.com/gorhill/cronexpr/blob/master/cronexpr_parse.go // for concrete implementation of year field matching. if CronYearFieldRegexp.MatchString(fields[6]) { return trimmedCronat, nil, nil } // Otherwise the last field is considered as timezone specification, and // the original string is in classic style but with timezone field. location, err := _parseLocation(fields[6]) if err != nil { return "", nil, err } fields[6] = "*" return strings.Join(fields, " "), location, nil case 8: location, err := _parseLocation(fields[7]) if err != nil { return "", nil, err } return strings.Join(fields[:7], " "), location, nil default: return "", nil, ErrInvalidCronExpression } } // For the supported format of timezone specification, see documentation of // NewCronScheduled function func _parseLocation(tzSpec string) (*time.Location, error) { trimmedTZSpec := strings.TrimSpace(tzSpec) // For complete name in TZ database, e.g., Asia/Shanghai, America/Los_Angeles: if strings.Contains(trimmedTZSpec, "/") { location, err := time.LoadLocation(trimmedTZSpec) if err != nil { return nil, fmt.Errorf("%w: %s", ErrTimezoneInformationCorrupt, err.Error()) } return location, nil } // * GMT offset (no leading zero in hour), e.g., GMT+8:00, GMT-6:00 if strings.ContainsAny(trimmedTZSpec, "+-") { matches := GMTOffsetTimezoneRegexp.FindAllStringSubmatch(trimmedTZSpec, -1) if len(matches) != 1 { return nil, ErrInvalidGMTOffsetForTimezone } // Now the only match contains 4 items: [[<NAME>, <SIGN>, <HOUR>, <MINUTE>]] locationName := matches[0][0] offsetSign := matches[0][1] offsetHour, err := strconv.Atoi(matches[0][2]) if err != nil { return nil, ErrInvalidGMTOffsetHourForTimezone } offsetMinute, err := strconv.Atoi(matches[0][3]) if err != nil { return nil, ErrInvalidGMTOffsetMinuteForTimezone } offsetSeconds := offsetHour * 60 * 60 + offsetMinute * 60 if offsetSign == "-" { offsetSeconds = -offsetSeconds } return time.FixedZone(locationName, offsetSeconds), nil } // For fixed and unambiguous abbreviation names of timezone if trimmedTZSpec == "GMT" || trimmedTZSpec == "UTC" { return time.UTC, nil } return nil, ErrTimezoneInformationCorrupt } func (c *CronScheduled) Location() *time.Location { return c.location } func (c *CronScheduled) NoNextRun() bool { return c.isNoNextRun } func (c *CronScheduled) NextRunFrom(t time.Time) (time.Duration, error) { nextRunTime := c.expression.Next(t) if nextRunTime.IsZero() { return time.Duration(-1), ErrNoNextRun } return nextRunTime.Sub(t), nil } func (c *CronScheduled) nextRun() (time.Duration, error) { now := time.Now() if c.location != nil { now = now.In(c.location) } timeToWait, err := c.NextRunFrom(now) if errors.Is(err, ErrNoNextRun) { c.isNoNextRun = true } return timeToWait, err }