github.com/TBD54566975/ftl@v0.219.0/internal/cron/cron.go (about)

     1  package cron
     2  
     3  import (
     4  	"fmt"
     5  	"time"
     6  
     7  	"github.com/TBD54566975/ftl/internal/slices"
     8  )
     9  
    10  /*
    11   This cron package is a simple implementation of a cron pattern parser and evaluator.
    12   It supports the following:
    13   - 5 component patterns interpreted as second, minute, hour, day of month, month
    14   - 6 component patterns interpreted as:
    15     - if last component has a 4 digit number, it is interpreted as minute, hour, day of month, month, year
    16     - otherwise, it is interpreted as second, minute, hour, day of month, month, day of week
    17  - 7 component patterns, interpreted as second, minute, hour, day of month, month, day of week, year
    18  
    19  It supports the following features:
    20  - * for all values
    21  - ranges with - (eg 1-5)
    22  - steps with / (eg 1-5/2)
    23  - lists with , (eg 1,2,3)
    24  */
    25  
    26  type componentType int
    27  
    28  const (
    29  	second componentType = iota
    30  	minute
    31  	hour
    32  	dayOfMonth
    33  	month     // 1 is Jan, 12 is Dec (same as time.Month)
    34  	dayOfWeek // 0 and 7 are both Sunday (same as time.Weekday, except extra case of 7 == Sunday)
    35  	year
    36  )
    37  
    38  // dayBehavior represents the behaviour of a cron pattern regarding which of the dayOfMonth and dayOfWeek components are used
    39  type dayBehavior int
    40  
    41  const (
    42  	dayOfMonthOnly dayBehavior = iota
    43  	dayOfWeekOnly
    44  	dayOfMonthOrWeek
    45  )
    46  
    47  // componentValues represents the values of a time.Time in the order of componentType
    48  // dayOfWeek is ignored
    49  // a value of -1 represents a value that is not set (behaves as "lower than min value")
    50  type componentValues []int
    51  
    52  // Next calculates the next time that matches the pattern after the current time
    53  // See NextAfter for more details
    54  func Next(pattern Pattern, allowCurrentTime bool) (time.Time, error) {
    55  	return NextAfter(pattern, time.Now().UTC(), allowCurrentTime)
    56  }
    57  
    58  // NextAfter calculcates the next time that matches the pattern after the origin time
    59  // If inclusive is true, the origin time is considered a valid match
    60  // All calculations are done in UTC, and the result is returned in UTC
    61  func NextAfter(pattern Pattern, origin time.Time, inclusive bool) (time.Time, error) {
    62  	// set original to the first acceptable time, irregardless of pattern
    63  	origin = origin.UTC()
    64  	if !inclusive || origin.Nanosecond() != 0 {
    65  		origin = origin.Add(time.Second - time.Duration(origin.Nanosecond())*time.Nanosecond)
    66  	}
    67  
    68  	components, err := pattern.standardizedComponents()
    69  	if err != nil {
    70  		return origin, err
    71  	}
    72  
    73  	for idx, component := range components {
    74  		if err = validateComponent(component, componentType(idx)); err != nil {
    75  			return origin, err
    76  		}
    77  	}
    78  
    79  	// dayOfMonth used to represent processing day, using dayOfMonth and dayOfWeek
    80  	processingOrder := []componentType{year, month, dayOfMonth, hour, minute, second}
    81  
    82  	values := componentValuesFromTime(origin)
    83  
    84  	firstDisallowedIdx := -1
    85  	for idx, t := range processingOrder {
    86  		if !isCurrentValueAllowed(components, values, t) {
    87  			firstDisallowedIdx = idx
    88  			break
    89  		}
    90  	}
    91  	if firstDisallowedIdx == -1 {
    92  		return timeFromValues(values), nil
    93  	}
    94  
    95  	i := firstDisallowedIdx
    96  	for i >= 0 {
    97  		t := processingOrder[i]
    98  		next, err := nextValue(components, values, t)
    99  		if err != nil {
   100  			// no next value for this type, need to go up a level
   101  			for ii := i; ii < len(processingOrder); ii++ {
   102  				tt := processingOrder[ii]
   103  				values[tt] = -1
   104  			}
   105  			i--
   106  			continue
   107  		}
   108  
   109  		values[t] = next
   110  		couldNotFindValueForIdx := -1
   111  		for ii := i + 1; ii < len(processingOrder); ii++ {
   112  			tt := processingOrder[ii]
   113  			first, err := firstValueForComponents(components, values, tt)
   114  			if err != nil {
   115  				couldNotFindValueForIdx = ii
   116  				break
   117  			}
   118  			values[tt] = first
   119  		}
   120  		if couldNotFindValueForIdx != -1 {
   121  			// Could not find a value for a smaller type. Go up one level from that type
   122  			i = couldNotFindValueForIdx - 1
   123  			continue
   124  		}
   125  
   126  		return timeFromValues(values), nil
   127  	}
   128  
   129  	return origin, fmt.Errorf("could not find next time for pattern %q", pattern.String())
   130  }
   131  
   132  func componentValuesFromTime(t time.Time) componentValues {
   133  	return []int{
   134  		t.Second(),
   135  		t.Minute(),
   136  		t.Hour(),
   137  		t.Day(),
   138  		int(t.Month()),
   139  		int(t.Weekday()),
   140  		t.Year(),
   141  	}
   142  }
   143  
   144  func isCurrentValueAllowed(components []Component, values componentValues, t componentType) bool {
   145  	if t == dayOfWeek {
   146  		// use dayOfMonth to check day of month and week
   147  		panic("unexpected dayOfWeek value")
   148  	} else if t == dayOfMonth {
   149  		behavior := dayBehaviorForComponents(components)
   150  
   151  		if behavior == dayOfMonthOnly || behavior == dayOfMonthOrWeek {
   152  			if isCurrentValueAllowedForSteps(components[t].List, values, t) {
   153  				return true
   154  			}
   155  		}
   156  		if behavior == dayOfWeekOnly || behavior == dayOfMonthOrWeek {
   157  			for _, step := range components[dayOfWeek].List {
   158  				if isCurrentValueAllowedForDayOfWeekStep(step, values, t) {
   159  					return true
   160  				}
   161  			}
   162  		}
   163  		return false
   164  	}
   165  	return isCurrentValueAllowedForSteps(components[t].List, values, t)
   166  }
   167  
   168  func isCurrentValueAllowedForSteps(steps []Step, values componentValues, t componentType) bool {
   169  	for _, step := range steps {
   170  		if isCurrentValueAllowedForStep(step, values, t) {
   171  			return true
   172  		}
   173  	}
   174  	return false
   175  }
   176  
   177  func isCurrentValueAllowedForStep(step Step, values componentValues, t componentType) bool {
   178  	start, end, incr := rangeParametersForStep(step, t)
   179  	if values[t] < start || values[t] > end {
   180  		return false
   181  	}
   182  	if (values[t]-start)%incr != 0 {
   183  		return false
   184  	}
   185  	return true
   186  }
   187  
   188  func isCurrentValueAllowedForDayOfWeekStep(step Step, values componentValues, t componentType) bool {
   189  	start, end, incr := rangeParametersForStep(step, t)
   190  	value := int(time.Date(values[year], time.Month(values[month]), values[dayOfMonth], 0, 0, 0, 0, time.UTC).Weekday())
   191  	// Sunday is both 0 and 7
   192  	days := []int{value}
   193  	if value == 0 {
   194  		days = append(days, 7)
   195  	} else if value == 7 {
   196  		days = append(days, 0)
   197  	}
   198  
   199  	results := slices.Map(days, func(day int) bool {
   200  		if values[t] < start || values[t] > end {
   201  			return false
   202  		}
   203  		if (values[t]-start)%incr != 0 {
   204  			return false
   205  		}
   206  		return true
   207  	})
   208  
   209  	for _, result := range results {
   210  		if result {
   211  			return true
   212  		}
   213  	}
   214  	return false
   215  }
   216  
   217  func nextValue(components []Component, values componentValues, t componentType) (int, error) {
   218  	if t == dayOfWeek {
   219  		// use dayOfMonth to check day of month and week
   220  		panic("unexpected dayOfWeek value")
   221  	} else if t == dayOfMonth {
   222  		behavior := dayBehaviorForComponents(components)
   223  
   224  		next := -1
   225  		if behavior == dayOfMonthOnly || behavior == dayOfMonthOrWeek {
   226  			if possible, err := nextValueForSteps(components[t].List, values, t); err == nil {
   227  				if next == -1 || possible < next {
   228  					next = possible
   229  				}
   230  			}
   231  		}
   232  		if behavior == dayOfWeekOnly || behavior == dayOfMonthOrWeek {
   233  			for _, step := range components[dayOfWeek].List {
   234  				if possible, ok := nextValueForDayOfWeekStep(step, values, t); ok {
   235  					if next == -1 || possible < next {
   236  						next = possible
   237  					}
   238  				}
   239  			}
   240  		}
   241  		if next == -1 {
   242  			return -1, fmt.Errorf("no next value for %s", stringForComponentType(t))
   243  		}
   244  		return next, nil
   245  	}
   246  	return nextValueForSteps(components[t].List, values, t)
   247  }
   248  
   249  func nextValueForSteps(steps []Step, values componentValues, t componentType) (int, error) {
   250  	next := -1
   251  	for _, step := range steps {
   252  		if v, ok := nextValueForStep(step, values, t); ok {
   253  			if next == -1 || v < next {
   254  				next = v
   255  			}
   256  		}
   257  	}
   258  	if next == -1 {
   259  		return -1, fmt.Errorf("no next value for %s", stringForComponentType(t))
   260  	}
   261  	return next, nil
   262  }
   263  
   264  func nextValueForStep(step Step, values componentValues, t componentType) (int, bool) {
   265  	// Value of -1 means no existing value and the first valid value should be returned
   266  	if t == dayOfWeek {
   267  		// use dayOfMonth to check day of month and week
   268  		panic("unexpected dayOfWeek value")
   269  	}
   270  
   271  	start, end, incr := rangeParametersForStep(step, t)
   272  
   273  	current := values[t]
   274  	var next int
   275  	if current < start {
   276  		next = start
   277  	} else {
   278  		// round down to the nearest increment from start, then add one increment
   279  		next = start + (((current-start)/incr)+1)*incr
   280  	}
   281  	if next < start || next > end {
   282  		return -1, false
   283  	}
   284  
   285  	// Any type specific checks
   286  	if t == dayOfMonth {
   287  		date := time.Date(values[year], time.Month(values[month]), next, 0, 0, 0, 0, time.UTC)
   288  		if date.Day() != next {
   289  			// This month does not not have this day in this particular year (eg Feb 30th)
   290  			return -1, false
   291  		}
   292  	}
   293  	return next, true
   294  }
   295  
   296  func nextValueForDayOfWeekStep(step Step, values componentValues, t componentType) (int, bool) {
   297  	start, end, incr := rangeParametersForStep(step, t)
   298  	stepAllowsSecondSunday := (start <= 7 && end >= 7 && (7-start)%incr == 0)
   299  
   300  	result := -1
   301  	if standardResult, ok := nextValueForDayOfStandardizedWeekStep(step, values, t); ok {
   302  		result = standardResult
   303  	}
   304  	// If Sunday as a value of 7 is allowed by step, check the logic with a value of 0
   305  	if stepAllowsSecondSunday {
   306  		if secondSundayResult, ok := nextValueForDayOfStandardizedWeekStep(newStepWithValue(0), values, t); ok {
   307  			if result == -1 || secondSundayResult < result {
   308  				result = secondSundayResult
   309  			}
   310  		}
   311  	}
   312  	return result, result != -1
   313  }
   314  
   315  func nextValueForDayOfStandardizedWeekStep(step Step, values componentValues, t componentType) (int, bool) {
   316  	// Ignores Sunday == 7
   317  	start, end, incr := rangeParametersForStep(step, t)
   318  	if start == 7 {
   319  		return -1, false
   320  	}
   321  	if end == 7 {
   322  		end = 6
   323  	}
   324  
   325  	valueForCurrentWeekday := max(0, values[dayOfMonth]) // is value is -1, we want day before the current month (ie 0)
   326  	currentDate := time.Date(values[year], time.Month(values[month]), valueForCurrentWeekday, 0, 0, 0, 0, time.UTC)
   327  	currentWeekday := int(currentDate.Weekday())
   328  
   329  	startOfWeekInMonth := valueForCurrentWeekday - currentWeekday // Sunday
   330  
   331  	var nextDayOfWeek int
   332  	// try current week
   333  	if currentWeekday < start {
   334  		nextDayOfWeek = start
   335  	} else {
   336  		// round down to the nearest increment from start, then add one increment
   337  		nextDayOfWeek = start + (((currentWeekday-start)/incr)+1)*incr
   338  	}
   339  	if nextDayOfWeek < start || nextDayOfWeek > end {
   340  		// try next week
   341  		nextDayOfWeek = 7 + start
   342  	}
   343  
   344  	next := startOfWeekInMonth + nextDayOfWeek
   345  	date := time.Date(values[year], time.Month(values[month]), next, 0, 0, 0, 0, time.UTC)
   346  	if date.Day() != next {
   347  		// This month does not not have this day in this particular year (eg Feb 30th)
   348  		return -1, false
   349  	}
   350  	return next, true
   351  }
   352  
   353  func firstValueForComponents(components []Component, values componentValues, t componentType) (int, error) {
   354  	fakeValues := make([]int, len(values))
   355  	copy(fakeValues, values)
   356  	fakeValues[t] = -1
   357  	return nextValue(components, fakeValues, t)
   358  }
   359  
   360  func timeFromValues(values componentValues) time.Time {
   361  	return time.Date(values[year],
   362  		time.Month(values[month]),
   363  		values[dayOfMonth],
   364  		values[hour],
   365  		values[minute],
   366  		values[second],
   367  		0,
   368  		time.UTC)
   369  }
   370  
   371  func validateComponent(component Component, t componentType) error {
   372  	if len(component.List) == 0 {
   373  		return fmt.Errorf("%s must have at least value", stringForComponentType(t))
   374  	}
   375  	for _, step := range component.List {
   376  		if step.ValueRange.IsFullRange && (step.ValueRange.Start != nil || step.ValueRange.End != nil) {
   377  			return fmt.Errorf("range can not have start/end if it is a full range")
   378  		}
   379  		min, max := rangeForComponentType(t)
   380  
   381  		if step.Step != nil {
   382  			if *step.Step <= 0 {
   383  				return fmt.Errorf("step must be positive")
   384  			}
   385  			if *step.Step > max-min {
   386  				return fmt.Errorf("step %d is larger than allowed range of %d-%d", *step.Step, max, min)
   387  			}
   388  			if t == year && step.ValueRange.IsFullRange {
   389  				// This may be supported in other cron implementations, but will require more research as to the correct behavior
   390  				return fmt.Errorf("asterix with a step value is not allowed for year component")
   391  			}
   392  		}
   393  
   394  		if step.ValueRange.IsFullRange {
   395  			continue
   396  		}
   397  		if step.ValueRange.Start == nil {
   398  			return fmt.Errorf("missing value in %s", stringForComponentType(t))
   399  		}
   400  		if *step.ValueRange.Start < min || *step.ValueRange.Start > max {
   401  			return fmt.Errorf("value %d out of allowed %s range of %d-%d", *step.ValueRange.Start, stringForComponentType(t), min, max)
   402  		}
   403  		if step.ValueRange.End != nil {
   404  			if *step.ValueRange.End < min || *step.ValueRange.End > max {
   405  				return fmt.Errorf("value %d out of allowed %s range of %d-%d", *step.ValueRange.End, stringForComponentType(t), min, max)
   406  			}
   407  			if *step.ValueRange.End < *step.ValueRange.Start {
   408  				return fmt.Errorf("range end %d is less than start %d", *step.ValueRange.End, *step.ValueRange.Start)
   409  			}
   410  		}
   411  	}
   412  
   413  	return nil
   414  }
   415  
   416  func rangeForComponentType(t componentType) (min int, max int) {
   417  	switch t {
   418  	case second, minute:
   419  		return 0, 59
   420  	case hour:
   421  		return 0, 23
   422  	case dayOfMonth:
   423  		return 1, 31
   424  	case month:
   425  		return 1, 12
   426  	case dayOfWeek:
   427  		return 0, 7
   428  	case year:
   429  		return 0, 3000
   430  	default:
   431  		panic("unknown component type")
   432  	}
   433  }
   434  
   435  func rangeParametersForStep(step Step, t componentType) (start, end, incr int) {
   436  	start, end = rangeForComponentType(t)
   437  	incr = 1
   438  	if step.Step != nil {
   439  		incr = *step.Step
   440  	}
   441  	if step.ValueRange.Start != nil {
   442  		start = *step.ValueRange.Start
   443  		if step.ValueRange.End == nil {
   444  			// "1/2" means start at 1 and increment by 2
   445  			// "1" means "1-1"
   446  			if step.Step == nil {
   447  				end = start
   448  			}
   449  		} else {
   450  			end = *step.ValueRange.End
   451  		}
   452  	}
   453  	return
   454  }
   455  
   456  func dayBehaviorForComponents(components []Component) dayBehavior {
   457  	// Spec: https://pubs.opengroup.org/onlinepubs/9699919799.2018edition/utilities/crontab.html
   458  	isMonthAsterix := components[month].String() == "*"
   459  	isDayOfMonthAsterix := components[dayOfMonth].String() == "*"
   460  	isDayOfWeekAsterix := components[dayOfWeek].String() == "*"
   461  
   462  	// If month, day of month, and day of week are all <asterisk> characters, every day shall be matched.
   463  	if isMonthAsterix && isDayOfMonthAsterix && isDayOfWeekAsterix {
   464  		return dayOfMonthOnly
   465  	}
   466  
   467  	// If either the month or day of month is specified as an element or list, but the day of week is an <asterisk>, the month and day of month fields shall specify the days that match.
   468  	if (!isMonthAsterix || !isDayOfMonthAsterix) && isDayOfWeekAsterix {
   469  		return dayOfMonthOnly
   470  	}
   471  
   472  	// If both month and day of month are specified as an <asterisk>, but day of week is an element or list, then only the specified days of the week match.
   473  	if isMonthAsterix && isDayOfMonthAsterix && !isDayOfWeekAsterix {
   474  		return dayOfWeekOnly
   475  	}
   476  
   477  	// Finally, if either the month or day of month is specified as an element or list, and the day of week is also specified as an element or list, then any day matching either the month and day of month, or the day of week, shall be matched.
   478  	return dayOfMonthOrWeek
   479  }
   480  
   481  func stringForComponentType(t componentType) string {
   482  	switch t {
   483  	case second:
   484  		return "second"
   485  	case minute:
   486  		return "minute"
   487  	case hour:
   488  		return "hour"
   489  	case dayOfMonth:
   490  		return "day of month"
   491  	case month:
   492  		return "month"
   493  	case dayOfWeek:
   494  		return "day of week"
   495  	case year:
   496  		return "year"
   497  	default:
   498  		panic("unknown component type")
   499  	}
   500  }