Files

336 lines
9.7 KiB
Go

// The MIT License
//
// Copyright (c) 2020 Temporal Technologies Inc. All rights reserved.
//
// Copyright (c) 2020 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
package scheduler
import (
"errors"
"fmt"
"math"
"strings"
"time"
"github.com/dgryski/go-farm"
schedpb "go.temporal.io/api/schedule/v1"
"go.temporal.io/server/common/primitives/timestamp"
"go.temporal.io/server/common/util"
)
type (
CompiledSpec struct {
spec *schedpb.ScheduleSpec
tz *time.Location
calendar []*compiledCalendar
excludes []*compiledCalendar
}
getNextTimeResult struct {
Nominal time.Time // scheduled time before adding jitter
Next time.Time // scheduled time after adding jitter
}
)
func NewCompiledSpec(spec *schedpb.ScheduleSpec) (*CompiledSpec, error) {
spec, err := canonicalizeSpec(spec)
if err != nil {
return nil, err
}
// load timezone
tz, err := loadTimezone(spec)
if err != nil {
return nil, err
}
// compile StructuredCalendarSpecs
ccs := make([]*compiledCalendar, len(spec.StructuredCalendar))
for i, structured := range spec.StructuredCalendar {
ccs[i] = newCompiledCalendar(structured, tz)
}
// compile excludes
excludes := make([]*compiledCalendar, len(spec.ExcludeStructuredCalendar))
for i, excal := range spec.ExcludeStructuredCalendar {
excludes[i] = newCompiledCalendar(excal, tz)
}
cspec := &CompiledSpec{
spec: spec,
tz: tz,
calendar: ccs,
excludes: excludes,
}
return cspec, nil
}
func canonicalizeSpec(spec *schedpb.ScheduleSpec) (*schedpb.ScheduleSpec, error) {
// make shallow copy so we can change some fields
specCopy := *spec
spec = &specCopy
// parse CalendarSpecs to StructuredCalendarSpecs
for _, cal := range spec.Calendar {
structured, err := parseCalendarToStructured(cal)
if err != nil {
return nil, err
}
spec.StructuredCalendar = append(spec.StructuredCalendar, structured)
}
spec.Calendar = nil
// parse ExcludeCalendars
for _, cal := range spec.ExcludeCalendar {
structured, err := parseCalendarToStructured(cal)
if err != nil {
return nil, err
}
spec.ExcludeStructuredCalendar = append(spec.ExcludeStructuredCalendar, structured)
}
spec.ExcludeCalendar = nil
// parse CronStrings
const unset = "__unset__"
cronTZ := unset
for _, cs := range spec.CronString {
structured, interval, tz, err := parseCronString(cs)
if err != nil {
return nil, err
}
if cronTZ != unset && tz != cronTZ {
// all cron strings must agree on timezone (whether present or not)
return nil, errConflictingTimezoneNames
}
cronTZ = tz
if structured != nil {
spec.StructuredCalendar = append(spec.StructuredCalendar, structured)
}
if interval != nil {
spec.Interval = append(spec.Interval, interval)
}
}
spec.CronString = nil
// if we have cron string(s), copy the timezone to spec, checking for conflict first.
// if cron string timezone is empty string, don't copy, let the one in spec be used.
if cronTZ != unset && cronTZ != "" {
if spec.TimezoneName != "" && spec.TimezoneName != cronTZ || spec.TimezoneData != nil {
return nil, errConflictingTimezoneNames
} else if spec.TimezoneName == "" {
spec.TimezoneName = cronTZ
}
}
// validate structured calendar
for _, structured := range spec.StructuredCalendar {
if err := validateStructuredCalendar(structured); err != nil {
return nil, err
}
}
// validate intervals
for _, interval := range spec.Interval {
if err := validateInterval(interval); err != nil {
return nil, err
}
}
return spec, nil
}
func validateStructuredCalendar(scs *schedpb.StructuredCalendarSpec) error {
var errs []string
checkRanges := func(ranges []*schedpb.Range, field string, min, max int32) {
for _, r := range ranges {
if r == nil { // shouldn't happen
errs = append(errs, "range is nil")
continue
}
if r.Start < min || r.Start > max {
errs = append(errs, fmt.Sprintf("%s Start is not in range [%d-%d]", field, min, max))
}
if r.End != 0 && (r.End < r.Start || r.End > max) {
errs = append(errs, fmt.Sprintf("%s End is before Start or not in range [%d-%d]", field, min, max))
}
if r.Step < 0 {
errs = append(errs, fmt.Sprintf("%s has invalid Step", field))
}
}
}
checkRanges(scs.Second, "Second", 0, 59)
checkRanges(scs.Minute, "Minute", 0, 59)
checkRanges(scs.Hour, "Hour", 0, 23)
checkRanges(scs.DayOfMonth, "DayOfMonth", 1, 31)
checkRanges(scs.Month, "Month", 1, 12)
checkRanges(scs.Year, "Year", minCalendarYear, maxCalendarYear)
checkRanges(scs.DayOfWeek, "DayOfWeek", 0, 6)
if len(scs.Comment) > maxCommentLen {
errs = append(errs, "comment is too long")
}
if len(errs) > 0 {
return errors.New("invalid calendar spec: " + strings.Join(errs, ", "))
}
return nil
}
func validateInterval(i *schedpb.IntervalSpec) error {
if i == nil {
return errors.New("interval is nil")
}
iv, phase := timestamp.DurationValue(i.Interval), timestamp.DurationValue(i.Phase)
if iv < time.Second {
return errors.New("interval is too small")
} else if phase < 0 {
return errors.New("phase is negative")
} else if phase >= iv {
return errors.New("phase cannot be greater than Interval")
}
return nil
}
func loadTimezone(spec *schedpb.ScheduleSpec) (*time.Location, error) {
if spec.TimezoneData != nil {
return time.LoadLocationFromTZData(spec.TimezoneName, spec.TimezoneData)
}
return time.LoadLocation(spec.TimezoneName)
}
func (cs *CompiledSpec) CanonicalForm() *schedpb.ScheduleSpec {
return cs.spec
}
// Returns the earliest time that matches the schedule spec that is after the given time.
// Returns: Nominal is the time that matches, pre-jitter. Next is the nominal time with
// jitter applied. If there is no matching time, Nominal and Next will be the zero time.
func (cs *CompiledSpec) getNextTime(after time.Time) getNextTimeResult {
// If we're starting before the schedule's allowed time range, jump up to right before
// it (so that we can still return the first second of the range if it happens to match).
if cs.spec.StartTime != nil && after.Before(timestamp.TimeValue(cs.spec.StartTime)) {
after = cs.spec.StartTime.Add(-time.Second)
}
var nominal time.Time
for {
nominal = cs.rawNextTime(after)
if nominal.IsZero() || (cs.spec.EndTime != nil && nominal.After(*cs.spec.EndTime)) {
return getNextTimeResult{}
}
// check against excludes
if !cs.excluded(nominal) {
break
}
after = nominal
}
maxJitter := timestamp.DurationValue(cs.spec.Jitter)
// Ensure that jitter doesn't push this time past the _next_ nominal start time
if following := cs.rawNextTime(nominal); !following.IsZero() {
maxJitter = util.Min(maxJitter, following.Sub(nominal))
}
next := cs.addJitter(nominal, maxJitter)
return getNextTimeResult{Nominal: nominal, Next: next}
}
// Returns the next matching time (without jitter), or the zero value if no time matches.
func (cs *CompiledSpec) rawNextTime(after time.Time) (nominal time.Time) {
var minTimestamp int64 = math.MaxInt64 // unix seconds-since-epoch as int64
for _, cal := range cs.calendar {
if next := cal.next(after); !next.IsZero() {
nextTs := next.Unix()
if nextTs < minTimestamp {
minTimestamp = nextTs
}
}
}
ts := after.Unix()
for _, iv := range cs.spec.Interval {
next := cs.nextIntervalTime(iv, ts)
if next < minTimestamp {
minTimestamp = next
}
}
if minTimestamp == math.MaxInt64 {
return time.Time{}
}
return time.Unix(minTimestamp, 0).UTC()
}
// Returns the next matching time for a single interval spec.
func (cs *CompiledSpec) nextIntervalTime(iv *schedpb.IntervalSpec, ts int64) int64 {
interval := int64(timestamp.DurationValue(iv.Interval) / time.Second)
if interval < 1 {
interval = 1
}
phase := int64(timestamp.DurationValue(iv.Phase) / time.Second)
if phase < 0 {
phase = 0
}
return (((ts-phase)/interval)+1)*interval + phase
}
// Returns true if any exclude spec matches the time.
func (cs *CompiledSpec) excluded(nominal time.Time) bool {
for _, excal := range cs.excludes {
if excal.matches(nominal) {
return true
}
}
return false
}
// Adds jitter to a nominal time, deterministically (by hashing the given time).
func (cs *CompiledSpec) addJitter(nominal time.Time, maxJitter time.Duration) time.Time {
if maxJitter < 0 {
maxJitter = 0
}
bin, err := nominal.MarshalBinary()
if err != nil {
return nominal
}
// we want to fit the result of a multiply in 64 bits, and use 32 bits of hash, which
// leaves 32 bits for the range. if we use nanoseconds or microseconds, our range is
// limited to only a few seconds or hours. using milliseconds supports up to 49 days.
fp := uint64(farm.Fingerprint32(bin))
ms := uint64(maxJitter.Milliseconds())
if ms > math.MaxUint32 {
ms = math.MaxUint32
}
jitter := time.Duration((fp*ms)>>32) * time.Millisecond
return nominal.Add(jitter)
}