github.com/blend/go-sdk@v1.20220411.3/cron/parse_schedule.go (about) 1 /* 2 3 Copyright (c) 2022 - Present. Blend Labs, Inc. All rights reserved 4 Use of this source code is governed by a MIT license that can be found in the LICENSE file. 5 6 */ 7 8 package cron 9 10 import ( 11 "fmt" 12 "sort" 13 "strconv" 14 "strings" 15 "time" 16 17 "github.com/blend/go-sdk/ex" 18 ) 19 20 /*ParseSchedule parses a cron formatted string into a schedule. 21 22 The string must be at least 5 components, whitespace separated. 23 If the string has 5 components a 0 will be prepended for the seconds component, and a * appended for the year component. 24 If the string has 6 components a * appended for the year component. 25 26 The components are (in short form / 5 component): 27 (minutes) (hours) (day of month) (month) (day of week) 28 29 The components are (in medium form / 6 component): 30 (seconds) (hours) (day of month) (month) (day of week) 31 32 The components are (in long form / 7 component): 33 (seconds) (minutes) (hours) (day of month) (month) (day of week) (year) 34 35 The full list of possible field values: 36 37 Field name Mandatory? Allowed values Allowed special characters 38 ---------- ---------- -------------- -------------------------- 39 Seconds No 0-59 * / , - 40 Minutes Yes 0-59 * / , - 41 Hours Yes 0-23 * / , - 42 Day of month Yes 1-31 * / , - L W 43 Month Yes 1-12 or JAN-DEC * / , - 44 Day of week Yes 0-6 or SUN-SAT * / , - L # 45 Year No 1970–2099 * / , - 46 47 You can also use shorthands: 48 49 "@yearly" is equivalent to "0 0 0 1 1 * *" 50 "@monthly" is equivalent to "0 0 0 1 * * *" 51 "@weekly" is equivalent to "0 0 0 * * 0 *" 52 "@daily" is equivalent to "0 0 0 * * * *" 53 "@hourly" is equivalent to "0 0 * * * * *" 54 "@every 500ms" is equivalent to "cron.Every(500 * time.Millisecond)"" 55 "@immediately-then @every 500ms" is equivalent to "cron.Immediately().Then(cron.Every(500*time.Millisecond))" 56 "@once-at 2021-06-05 13:04" is "cron.OnceAtUTC(time.Date(...))" 57 "@never" is equivalent to an unset schedule (i.e., only on demand) to avoid defaults 58 59 */ 60 func ParseSchedule(cronString string) (schedule Schedule, err error) { 61 cronString = strings.TrimSpace(cronString) 62 63 // check for "@never" 64 if cronString == StringScheduleNever { 65 schedule = Never() 66 return 67 } 68 69 // check for "@immediately" 70 if cronString == StringScheduleImmediately { 71 schedule = Immediately() 72 return 73 } 74 75 // check for "@once-at" 76 if strings.HasPrefix(cronString, StringScheduleOnceAt) { 77 cronString = strings.TrimPrefix(cronString, StringScheduleOnceAt) 78 cronString = strings.TrimSpace(cronString) 79 80 onceAtUTC, errOnceAtUTC := time.Parse(time.RFC3339, cronString) 81 if errOnceAtUTC != nil { 82 err = ex.New(ErrStringScheduleInvalid, ex.OptInner(errOnceAtUTC)) 83 return 84 } 85 schedule = OnceAtUTC(onceAtUTC.UTC()) 86 return 87 } 88 89 // here we assume the rest of the schedule is either 90 // @immediately-then ... 91 // @delay ... 92 // @immediately-then @delay ... 93 // etc. 94 95 // pull the @immediately-then off the beginning of 96 // the schedule if it's present 97 var immediately bool 98 if strings.HasPrefix(cronString, StringScheduleImmediatelyThen) { 99 immediately = true 100 cronString = strings.TrimPrefix(cronString, StringScheduleImmediatelyThen) 101 cronString = strings.TrimSpace(cronString) 102 } 103 104 var delay time.Duration 105 if strings.HasPrefix(cronString, StringScheduleDelay) { 106 cronString = strings.TrimPrefix(cronString, StringScheduleDelay) 107 cronString = strings.TrimSpace(cronString) 108 109 remainingFields := strings.Fields(cronString) 110 if len(remainingFields) < 2 { 111 err = ex.New(ErrStringScheduleInvalid, ex.OptInner(ex.Class("@delay must be in the form `@delay <DURATION> <THEN>`"))) 112 return 113 } 114 durationPart := remainingFields[0] 115 cronString = strings.TrimPrefix(cronString, durationPart) 116 cronString = strings.TrimSpace(cronString) 117 delay, err = time.ParseDuration(durationPart) 118 if err != nil { 119 err = ex.New(ErrStringScheduleInvalid, ex.OptInner(err)) 120 return 121 } 122 } 123 124 // DEFER / FINALLY BLOCK 125 // we add the optional immediately or delay 126 // directives into the final schedule. 127 defer func() { 128 if schedule != nil { 129 if delay > 0 { 130 schedule = Delay(delay, schedule) 131 } 132 if immediately { 133 schedule = Immediately().Then(schedule) 134 } 135 } 136 }() 137 138 // at this point, the rest of the cron string is considered 139 // a complete, single representation of a schedule 140 141 // handle the specific @every string representation 142 if strings.HasPrefix(cronString, StringScheduleEvery) { 143 cronString = strings.TrimPrefix(cronString, StringScheduleEvery) 144 cronString = strings.TrimSpace(cronString) 145 duration, durationErr := time.ParseDuration(cronString) 146 if durationErr != nil { 147 err = ex.New(ErrStringScheduleInvalid, ex.OptInner(durationErr)) 148 return 149 } 150 schedule = Every(duration) 151 return 152 } 153 154 // we now assume the string is a 'cron-like' string 155 // that is in the form "* * * * *" etc. 156 157 // check for shorthands, replace the shorthand with a proper cron-like string 158 if shorthand, ok := StringScheduleShorthands[cronString]; ok { 159 cronString = shorthand 160 } 161 162 parts := strings.Fields(cronString) 163 if len(parts) < 5 || len(parts) > 7 { 164 return nil, ex.New(ErrStringScheduleInvalid, ex.OptInner(ErrStringScheduleComponents), ex.OptMessagef("provided string; %s", cronString)) 165 } 166 // fill in optional components 167 if len(parts) == 5 { 168 parts = append([]string{"0"}, parts...) 169 parts = append(parts, "*") 170 } else if len(parts) == 6 { 171 parts = append(parts, "*") 172 } 173 174 seconds, err := parsePart(parts[0], parseInt, below(60)) 175 if err != nil { 176 return nil, ex.New(ErrStringScheduleInvalid, ex.OptInner(err), ex.OptMessage("seconds invalid")) 177 } 178 179 minutes, err := parsePart(parts[1], parseInt, below(60)) 180 if err != nil { 181 return nil, ex.New(ErrStringScheduleInvalid, ex.OptInner(err), ex.OptMessage("minutes invalid")) 182 } 183 184 hours, err := parsePart(parts[2], parseInt, below(24)) 185 if err != nil { 186 return nil, ex.New(ErrStringScheduleInvalid, ex.OptInner(err), ex.OptMessage("hours invalid")) 187 } 188 189 days, err := parsePart(parts[3], parseInt, between(1, 32)) 190 if err != nil { 191 return nil, ex.New(ErrStringScheduleInvalid, ex.OptInner(err), ex.OptMessage("days invalid")) 192 } 193 194 months, err := parsePart(parts[4], parseMonth, between(1, 13)) 195 if err != nil { 196 return nil, ex.New(ErrStringScheduleInvalid, ex.OptInner(err), ex.OptMessage("months invalid")) 197 } 198 199 daysOfWeek, err := parsePart(parts[5], parseDayOfWeek, between(0, 7)) 200 if err != nil { 201 return nil, ex.New(ErrStringScheduleInvalid, ex.OptInner(err), ex.OptMessage("days of week invalid")) 202 } 203 204 years, err := parsePart(parts[6], parseInt, between(1970, 2100)) 205 if err != nil { 206 return nil, ex.New(ErrStringScheduleInvalid, ex.OptInner(err), ex.OptMessage("years invalid")) 207 } 208 209 schedule = &StringSchedule{ 210 Original: cronString, 211 Seconds: seconds, 212 Minutes: minutes, 213 Hours: hours, 214 DaysOfMonth: days, 215 Months: months, 216 DaysOfWeek: daysOfWeek, 217 Years: years, 218 } 219 return 220 } 221 222 // Error Constants 223 const ( 224 ErrStringScheduleInvalid ex.Class = "cron: schedule string invalid" 225 ErrStringScheduleComponents ex.Class = "cron: must have at least (5) components space delimited; ex: '0 0 * * * * *'" 226 ErrStringScheduleValueOutOfRange ex.Class = "cron: string schedule part out of range" 227 ErrStringScheduleInvalidRange ex.Class = "cron: range (from-to) invalid" 228 ) 229 230 // String schedule constants 231 const ( 232 StringScheduleImmediately = "@immediately" 233 StringScheduleDelay = "@delay" 234 StringScheduleImmediatelyThen = "@immediately-then" 235 StringScheduleEvery = "@every" 236 StringScheduleOnceAt = "@once-at" 237 StringScheduleNever = "@never" 238 ) 239 240 // String schedule shorthands labels 241 const ( 242 StringScheduleShorthandAnnually = "@annually" 243 StringScheduleShorthandYearly = "@yearly" 244 StringScheduleShorthandMonthly = "@monthly" 245 StringScheduleShorthandWeekly = "@weekly" 246 StringScheduleShorthandDaily = "@daily" 247 StringScheduleShorthandHourly = "@hourly" 248 ) 249 250 // String schedule shorthand values 251 var ( 252 StringScheduleShorthands = map[string]string{ 253 StringScheduleShorthandAnnually: "0 0 0 1 1 * *", 254 StringScheduleShorthandYearly: "0 0 0 1 1 * *", 255 StringScheduleShorthandMonthly: "0 0 0 1 * * *", 256 StringScheduleShorthandDaily: "0 0 0 * * * *", 257 StringScheduleShorthandHourly: "0 0 * * * * *", 258 } 259 ) 260 261 // Interface assertions. 262 var ( 263 _ Schedule = (*StringSchedule)(nil) 264 _ fmt.Stringer = (*StringSchedule)(nil) 265 ) 266 267 // StringSchedule is a schedule generated from a cron string. 268 type StringSchedule struct { 269 Original string 270 271 Seconds []int 272 Minutes []int 273 Hours []int 274 DaysOfMonth []int 275 Months []int 276 DaysOfWeek []int 277 Years []int 278 } 279 280 // String returns the original string schedule. 281 func (ss *StringSchedule) String() string { 282 return ss.Original 283 } 284 285 // FullString returns a fully formed string representation of the schedule's components. 286 // It shows fields as expanded. 287 func (ss *StringSchedule) FullString() string { 288 fields := []string{ 289 csvOfInts(ss.Seconds, "*"), 290 csvOfInts(ss.Minutes, "*"), 291 csvOfInts(ss.Hours, "*"), 292 csvOfInts(ss.DaysOfMonth, "*"), 293 csvOfInts(ss.Months, "*"), 294 csvOfInts(ss.DaysOfWeek, "*"), 295 csvOfInts(ss.Years, "*"), 296 } 297 return strings.Join(fields, " ") 298 } 299 300 // Next implements cron.Schedule. 301 func (ss *StringSchedule) Next(after time.Time) time.Time { 302 working := after 303 if after.IsZero() { 304 working = Now() 305 } 306 original := working 307 308 if len(ss.Years) > 0 { 309 for _, year := range ss.Years { 310 if year >= working.Year() { 311 working = advanceYearTo(working, year) 312 break 313 } 314 } 315 } 316 317 if len(ss.Months) > 0 { 318 var didSet bool 319 for _, month := range ss.Months { 320 if time.Month(month) == working.Month() && working.After(original) { 321 didSet = true 322 break 323 } 324 if time.Month(month) > working.Month() { 325 working = advanceMonthTo(working, time.Month(month)) 326 didSet = true 327 break 328 } 329 } 330 if !didSet { 331 working = advanceYear(working) 332 for _, month := range ss.Months { 333 if time.Month(month) >= working.Month() { 334 working = advanceMonthTo(working, time.Month(month)) 335 break 336 } 337 } 338 } 339 } 340 341 if len(ss.DaysOfMonth) > 0 { 342 var didSet bool 343 for _, day := range ss.DaysOfMonth { 344 if day == working.Day() && working.After(original) { 345 didSet = true 346 break 347 } 348 if day > working.Day() { 349 working = advanceDayTo(working, day) 350 didSet = true 351 break 352 } 353 } 354 if !didSet { 355 working = advanceMonth(working) 356 for _, day := range ss.DaysOfMonth { 357 if day >= working.Day() { 358 working = advanceDayTo(working, day) 359 break 360 } 361 } 362 } 363 } 364 365 if len(ss.DaysOfWeek) > 0 { 366 var didSet bool 367 for _, dow := range ss.DaysOfWeek { 368 if dow == int(working.Weekday()) && working.After(original) { 369 didSet = true 370 break 371 } 372 if dow > int(working.Weekday()) { 373 working = advanceDayBy(working, (dow - int(working.Weekday()))) 374 didSet = true 375 break 376 } 377 } 378 if !didSet { 379 working = advanceToNextSunday(working) 380 for _, dow := range ss.DaysOfWeek { 381 if dow >= int(working.Weekday()) { 382 working = advanceDayBy(working, (dow - int(working.Weekday()))) 383 break 384 } 385 } 386 } 387 } 388 389 if len(ss.Hours) > 0 { 390 var didSet bool 391 for _, hour := range ss.Hours { 392 if hour == working.Hour() && working.After(original) { 393 didSet = true 394 break 395 } 396 if hour > working.Hour() { 397 working = advanceHourTo(working, hour) 398 didSet = true 399 break 400 } 401 } 402 if !didSet { 403 working = advanceDay(working) 404 for _, hour := range ss.Hours { 405 if hour >= working.Hour() { 406 working = advanceHourTo(working, hour) 407 break 408 } 409 } 410 } 411 } 412 413 if len(ss.Minutes) > 0 { 414 var didSet bool 415 for _, minute := range ss.Minutes { 416 if minute == working.Minute() && working.After(original) { 417 didSet = true 418 break 419 } 420 if minute > working.Minute() { 421 working = advanceMinuteTo(working, minute) 422 didSet = true 423 break 424 } 425 } 426 if !didSet { 427 working = advanceHour(working) 428 for _, minute := range ss.Minutes { 429 if minute >= working.Minute() { 430 working = advanceMinuteTo(working, minute) 431 break 432 } 433 } 434 } 435 } 436 437 if len(ss.Seconds) > 0 { 438 var didSet bool 439 for _, second := range ss.Seconds { 440 if second == working.Second() && working.After(original) { 441 didSet = true 442 break 443 } 444 if second > working.Second() { 445 working = advanceSecondTo(working, second) 446 didSet = true 447 break 448 } 449 } 450 if !didSet { 451 working = advanceMinute(working) 452 for _, second := range ss.Hours { 453 if second >= working.Second() { 454 working = advanceSecondTo(working, second) 455 break 456 } 457 } 458 } 459 } 460 461 return working 462 } 463 464 func parsePart(values string, parser func(string) (int, error), validator func(int) bool) ([]int, error) { 465 if values == string(cronSpecialStar) { 466 return nil, nil 467 } 468 469 // check if we need to expand an "every" pattern 470 if strings.HasPrefix(values, cronSpecialEvery) { 471 return parseEvery(values, parseInt, validator) 472 } 473 474 components := strings.Split(values, string(cronSpecialComma)) 475 476 output := map[int]bool{} 477 var component string 478 for x := 0; x < len(components); x++ { 479 component = components[x] 480 if strings.Contains(component, string(cronSpecialDash)) { 481 rangeValues, err := parseRange(values, parser, validator) 482 if err != nil { 483 return nil, err 484 } 485 486 for _, value := range rangeValues { 487 output[value] = true 488 } 489 continue 490 } 491 492 part, err := parser(component) 493 if err != nil { 494 return nil, ex.New(err) 495 } 496 if validator != nil && !validator(part) { 497 return nil, ex.New(err) 498 } 499 output[part] = true 500 } 501 return mapKeysToArray(output), nil 502 } 503 504 func parseEvery(values string, parser func(string) (int, error), validator func(int) bool) ([]int, error) { 505 every, err := parser(strings.TrimPrefix(values, "*/")) 506 if err != nil { 507 return nil, ex.New(err) 508 } 509 if validator != nil && !validator(every) { 510 return nil, ex.New(ErrStringScheduleValueOutOfRange) 511 } 512 513 var output []int 514 for x := 0; x < 60; x += every { 515 output = append(output, x) 516 } 517 return output, nil 518 } 519 520 func parseRange(values string, parser func(string) (int, error), validator func(int) bool) ([]int, error) { 521 parts := strings.Split(values, string(cronSpecialDash)) 522 523 if len(parts) != 2 { 524 return nil, ex.New(ErrStringScheduleInvalidRange, ex.OptMessagef("invalid range: %s", values)) 525 } 526 527 from, err := parser(parts[0]) 528 if err != nil { 529 return nil, ex.New(err) 530 } 531 to, err := parser(parts[1]) 532 if err != nil { 533 return nil, ex.New(err) 534 } 535 536 if validator != nil && !validator(from) { 537 return nil, ex.New(ErrStringScheduleValueOutOfRange, ex.OptMessage("invalid range from")) 538 } 539 if validator != nil && !validator(to) { 540 return nil, ex.New(ErrStringScheduleValueOutOfRange, ex.OptMessage("invalid range to")) 541 } 542 543 if from >= to { 544 return nil, ex.New(ErrStringScheduleInvalidRange, ex.OptMessage("invalid range; from greater than to")) 545 } 546 547 var output []int 548 for x := from; x <= to; x++ { 549 output = append(output, x) 550 } 551 return output, nil 552 } 553 554 func parseInt(s string) (int, error) { 555 return strconv.Atoi(s) 556 } 557 558 func parseMonth(s string) (int, error) { 559 if value, ok := validMonths[s]; ok { 560 return value, nil 561 } 562 value, err := strconv.Atoi(s) 563 if err != nil { 564 return 0, ex.New(err, ex.OptMessage("month not a valid integer")) 565 } 566 if value < 1 || value > 12 { 567 return 0, ex.New(ErrStringScheduleValueOutOfRange, ex.OptMessagef("month out of range (1-12): %s", s)) 568 } 569 return value, nil 570 } 571 572 func parseDayOfWeek(s string) (int, error) { 573 if value, ok := validDaysOfWeek[s]; ok { 574 return value, nil 575 } 576 value, err := strconv.Atoi(s) 577 if err != nil { 578 return 0, ex.New(err, ex.OptMessage("day of week not a valid integer")) 579 } 580 if value < 0 || value > 6 { 581 return 0, ex.New(ErrStringScheduleValueOutOfRange, ex.OptMessagef("day of week out of range (0-6): %s", s)) 582 } 583 return value, nil 584 } 585 586 func below(max int) func(int) bool { 587 return between(0, max) 588 } 589 590 func between(min, max int) func(int) bool { 591 return func(value int) bool { 592 return value >= min && value < max 593 } 594 } 595 596 func mapKeysToArray(values map[int]bool) []int { 597 output := make([]int, len(values)) 598 var index int 599 for key := range values { 600 output[index] = key 601 index++ 602 } 603 sort.Ints(output) 604 return output 605 } 606 607 // 608 // time helpers 609 // 610 611 func advanceYear(t time.Time) time.Time { 612 return time.Date(t.Year(), 1, 1, 0, 0, 0, 0, t.Location()).AddDate(1, 0, 0) 613 } 614 615 func advanceYearTo(t time.Time, year int) time.Time { 616 return time.Date(year, 1, 1, 0, 0, 0, 0, t.Location()) 617 } 618 619 func advanceMonth(t time.Time) time.Time { 620 return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()).AddDate(0, 1, 0) 621 } 622 623 func advanceMonthTo(t time.Time, month time.Month) time.Time { 624 return time.Date(t.Year(), month, 1, 0, 0, 0, 0, t.Location()) 625 } 626 627 func advanceDayTo(t time.Time, day int) time.Time { 628 return time.Date(t.Year(), t.Month(), day, 0, 0, 0, 0, t.Location()) 629 } 630 631 func advanceToNextSunday(t time.Time) time.Time { 632 daysUntilSunday := 7 - int(t.Weekday()) 633 return t.AddDate(0, 0, daysUntilSunday) 634 } 635 636 func advanceDay(t time.Time) time.Time { 637 return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, 1) 638 } 639 640 func advanceDayBy(t time.Time, days int) time.Time { 641 return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, days) 642 } 643 644 func advanceHour(t time.Time) time.Time { 645 return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location()).Add(time.Hour) 646 } 647 648 func advanceHourTo(t time.Time, hour int) time.Time { 649 return time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, t.Location()) 650 } 651 652 func advanceMinute(t time.Time) time.Time { 653 return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, t.Location()).Add(time.Minute) 654 } 655 656 func advanceMinuteTo(t time.Time, minute int) time.Time { 657 return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), minute, 0, 0, t.Location()) 658 } 659 660 func advanceSecondTo(t time.Time, second int) time.Time { 661 return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), second, 0, t.Location()) 662 } 663 664 func csvOfInts(values []int, placeholder string) string { 665 if len(values) == 0 { 666 return placeholder 667 } 668 valueStrings := make([]string, len(values)) 669 for x := 0; x < len(values); x++ { 670 valueStrings[x] = strconv.Itoa(values[x]) 671 } 672 return strings.Join(valueStrings, ",") 673 } 674 675 // these are special characters 676 const ( 677 cronSpecialComma = ',' // 678 cronSpecialDash = '-' 679 cronSpecialStar = '*' 680 681 // these are unused 682 // cronSpecialSlash = '/' 683 // cronSpecialQuestion = '?' // sometimes used as the startup time, sometimes as a * 684 685 // cronSpecialLast = 'L' 686 // cronSpecialWeekday = 'W' // nearest weekday to the given day of the month 687 // cronSpecialDayOfMonth = '#' // 688 689 cronSpecialEvery = "*/" 690 ) 691 692 var ( 693 validMonths = map[string]int{ 694 "JAN": 1, 695 "FEB": 2, 696 "MAR": 3, 697 "APR": 4, 698 "MAY": 5, 699 "JUN": 6, 700 "JUL": 7, 701 "AUG": 8, 702 "SEP": 9, 703 "OCT": 10, 704 "NOV": 11, 705 "DEC": 12, 706 } 707 708 validDaysOfWeek = map[string]int{ 709 "SUN": 0, 710 "MON": 1, 711 "TUE": 2, 712 "WED": 3, 713 "THU": 4, 714 "FRI": 5, 715 "SAT": 6, 716 } 717 )