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 }