Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for shorthand cron expressions #1733

Merged
merged 7 commits into from
Jun 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion docs/content/docs/reference/cron.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,34 @@ top = false

A cron job is an Empty verb that will be called on a schedule. The syntax is described [here](https://pubs.opengroup.org/onlinepubs/9699919799.2018edition/utilities/crontab.html).

eg. The following function will be called hourly:
You can also use a shorthand syntax for the cron job, supporting seconds (`s`), minutes (`m`), hours (`h`), and specific days of the week (e.g. `Mon`).

### Examples

The following function will be called hourly:

```go
//ftl:cron 0 * * * *
func Hourly(ctx context.Context) error {
// ...
}
```

Every 12 hours, starting at UTC midnight:

```go
//ftl:cron 12h
func TwiceADay(ctx context.Context) error {
// ...
}
```

Every Monday at UTC midnight:

```go
//ftl:cron Mon
func Mondays(ctx context.Context) error {
// ...
}
```

83 changes: 83 additions & 0 deletions internal/cron/cron_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,63 @@ func TestNext(t *testing.T) {
time.Date(2024, 6, 9, 18, 20, 0, 0, time.UTC),
},
}},
// */5 * * * * * *
{"5s", [][]time.Time{
{
time.Date(2025, 6, 5, 3, 7, 5, 123, time.UTC),
time.Date(2025, 6, 5, 3, 7, 10, 0, time.UTC),
},
{
time.Date(2025, 6, 5, 3, 59, 55, 123, time.UTC),
time.Date(2025, 6, 5, 4, 0, 0, 0, time.UTC),
},
}},
// 25m should be every 25 minutes: 0 */25 * * * * * ie 0,25,50
{"25m", [][]time.Time{
{
time.Date(2025, 6, 5, 3, 7, 5, 123, time.UTC),
time.Date(2025, 6, 5, 3, 25, 0, 0, time.UTC),
},
{
time.Date(2025, 6, 5, 3, 49, 5, 123, time.UTC),
time.Date(2025, 6, 5, 3, 50, 0, 0, time.UTC),
},
{
time.Date(2025, 6, 5, 3, 50, 5, 123, time.UTC),
time.Date(2025, 6, 5, 4, 0, 0, 0, time.UTC),
},
}},
// 5h should be every 5 hours: 0 0 */5 * * * *, ie 0,5,10,15,20
{"5h", [][]time.Time{
{
time.Date(2025, 6, 5, 3, 7, 5, 123, time.UTC),
time.Date(2025, 6, 5, 5, 0, 0, 0, time.UTC),
},
{
time.Date(2025, 6, 5, 19, 59, 5, 123, time.UTC),
time.Date(2025, 6, 5, 20, 0, 0, 0, time.UTC),
},
{
time.Date(2025, 6, 5, 21, 59, 5, 123, time.UTC),
time.Date(2025, 6, 6, 0, 0, 0, 0, time.UTC),
},
}},
// TODO: These two are failing on the NextAfter with inclusive=true
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤦‍♂️ #1763

/*
// Every wednesday
{"0 0 0 * * 3 *", [][]time.Time{
{ // 2024-06-09 is a Sunday
time.Date(2024, 6, 9, 0, 0, 0, 0, time.UTC),
time.Date(2024, 6, 12, 0, 0, 0, 0, time.UTC),
},
}},
{"Wednesday", [][]time.Time{
{ // 2024-06-09 is a Sunday
time.Date(2024, 6, 9, 0, 0, 0, 0, time.UTC),
time.Date(2024, 6, 12, 0, 0, 0, 0, time.UTC),
},
}},
*/
} {
t.Run(fmt.Sprintf("CronSeries:%s", tt.str), func(t *testing.T) {
pattern, err := Parse(tt.str)
Expand Down Expand Up @@ -205,6 +262,32 @@ func TestSeries(t *testing.T) {
time.Date(2024, 1, 31, 0, 0, 0, 0, time.UTC),
31,
},
{ // An hour worth of 5 minutes
"0 */5 * * * * *",
time.Date(2025, 1, 2, 3, 4, 5, 6, time.UTC),
time.Date(2025, 1, 2, 4, 4, 5, 6, time.UTC),
12,
},
{ // An hour worth of 5 minutes using shorthand
"5m",
time.Date(2025, 1, 2, 3, 4, 5, 6, time.UTC),
time.Date(2025, 1, 2, 4, 4, 5, 6, time.UTC),
12,
},
{ // A month of Fridays
"0 0 0 * * 5 *",
// 2025-01-01 is a Wednesday
time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC),
5,
},
{ // A month of Fridays
"fri",
// 2025-01-01 is a Wednesday
time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC),
5,
},
} {
t.Run(fmt.Sprintf("CronSeries:%s", tt.str), func(t *testing.T) {
pattern, err := Parse(tt.str)
Expand Down
125 changes: 124 additions & 1 deletion internal/cron/pattern.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/alecthomas/participle/v2"
"github.com/alecthomas/participle/v2/lexer"

"github.com/TBD54566975/ftl/internal/duration"
"github.com/TBD54566975/ftl/internal/slices"
)

Expand All @@ -24,6 +25,7 @@ var (

parserOptions = []participle.Option{
participle.Lexer(lex),
participle.CaseInsensitive("Ident"),
participle.Elide("Whitespace"),
participle.Unquote(),
participle.Map(func(token lexer.Token) (lexer.Token, error) {
Expand All @@ -36,7 +38,9 @@ var (
)

type Pattern struct {
Components []Component `parser:"@@*"`
Duration *string `parser:"@(Number (?! Whitespace) Ident)+"`
DayOfWeek *DayOfWeek `parser:"| @('Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | 'Sun')"`
Components []Component `parser:"| @@*"`
}

func (p Pattern) String() string {
Expand All @@ -46,6 +50,38 @@ func (p Pattern) String() string {
}

func (p Pattern) standardizedComponents() ([]Component, error) {
if p.Duration != nil {
parsed, err := duration.ParseComponents(*p.Duration)
if err != nil {
return nil, err
}
// Do not allow durations with days, as it is confusing for the user.
if parsed.Days > 0 {
return nil, fmt.Errorf("durations with days are not allowed")
}

ss := newShortState()
ss.push(parsed.Seconds)
ss.push(parsed.Minutes)
ss.push(parsed.Hours)
ss.full() // Day of month
ss.full() // Month
ss.full() // Day of week
ss.full() // Year
return ss.done()
}

if p.DayOfWeek != nil {
dayOfWeekInt, err := p.DayOfWeek.toInt()
if err != nil {
return nil, err
}

components := newComponentsFilled()
components[5] = newComponentWithValue(dayOfWeekInt)
return components, nil
}

switch len(p.Components) {
case 5:
// Convert "a b c d e" -> "0 a b c d e *"
Expand Down Expand Up @@ -96,6 +132,14 @@ type Component struct {
List []Step `parser:"(@@ (',' @@)*)"`
}

func newComponentsFilled() []Component {
var c []Component
for range 7 {
c = append(c, newComponentWithFullRange())
}
return c
}

func newComponentWithFullRange() Component {
return Component{
List: []Step{
Expand All @@ -114,6 +158,15 @@ func newComponentWithValue(value int) Component {
}
}

func newComponentWithStep(value int) Component {
var step Step
step.Step = &value
step.ValueRange.IsFullRange = true
return Component{
List: []Step{step},
}
}

func (c Component) String() string {
return strings.Join(slices.Map(c.List, func(step Step) string {
return step.String()
Expand Down Expand Up @@ -166,3 +219,73 @@ func Parse(text string) (Pattern, error) {
}
return *pattern, nil
}

// A helper struct to build up a cron pattern with a short syntax.
type shortState struct {
position int
seenNonZero bool
components []Component
err error
}

func newShortState() shortState {
return shortState{
seenNonZero: false,
components: make([]Component, 0, 7),
}
}

func (ss *shortState) push(value int) {
var component Component
if value == 0 {
if ss.seenNonZero {
component = newComponentWithFullRange()
} else {
component = newComponentWithValue(value)
}
} else {
if ss.seenNonZero {
ss.err = fmt.Errorf("only one non-zero component is allowed")
}
ss.seenNonZero = true
component = newComponentWithStep(value)
}

ss.components = append(ss.components, component)
}

func (ss *shortState) full() {
ss.components = append(ss.components, newComponentWithFullRange())
}

func (ss shortState) done() ([]Component, error) {
if ss.err != nil {
return nil, ss.err
}
return ss.components, nil
}

type DayOfWeek string

// toInt converts a DayOfWeek to an integer, where Sunday is 0 and Saturday is 6.
// Case insensitively check the first three characters to match.
func (d *DayOfWeek) toInt() (int, error) {
switch strings.ToLower(string(*d)[:3]) {
case "sun":
return 0, nil
case "mon":
return 1, nil
case "tue":
return 2, nil
case "wed":
return 3, nil
case "thu":
return 4, nil
case "fri":
return 5, nil
case "sat":
return 6, nil
default:
return 0, fmt.Errorf("invalid day of week: %q", *d)
}
}
40 changes: 33 additions & 7 deletions internal/duration/duration.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,43 +7,69 @@ import (
"time"
)

type Components struct {
Days int
Hours int
Minutes int
Seconds int
}

func (c Components) Duration() time.Duration {
return time.Duration(c.Days*24)*time.Hour +
time.Duration(c.Hours)*time.Hour +
time.Duration(c.Minutes)*time.Minute +
time.Duration(c.Seconds)*time.Second
}

func Parse(str string) (time.Duration, error) {
components, err := ParseComponents(str)
if err != nil {
return 0, err
}

return components.Duration(), nil
}

func ParseComponents(str string) (*Components, error) {
// regex is more lenient than what is valid to allow for better error messages.
re := regexp.MustCompile(`^(\d+)([a-zA-Z]+)`)

var duration time.Duration
var components Components
previousUnitDuration := time.Duration(0)
for len(str) > 0 {
matches := re.FindStringSubmatchIndex(str)
if matches == nil {
return 0, fmt.Errorf("unable to parse duration %q - expected duration in format like '1m' or '30s'", str)
return nil, fmt.Errorf("unable to parse duration %q - expected duration in format like '1m' or '30s'", str)
}
num, err := strconv.Atoi(str[matches[2]:matches[3]])
if err != nil {
return 0, fmt.Errorf("unable to parse duration %q: %w", str, err)
return nil, fmt.Errorf("unable to parse duration %q: %w", str, err)
}

unitStr := str[matches[4]:matches[5]]
var unitDuration time.Duration
switch unitStr {
case "d":
components.Days = num
unitDuration = time.Hour * 24
case "h":
components.Hours = num
unitDuration = time.Hour
case "m":
components.Minutes = num
unitDuration = time.Minute
case "s":
components.Seconds = num
unitDuration = time.Second
default:
return 0, fmt.Errorf("duration has unknown unit %q - use 'd', 'h', 'm' or 's', eg '1d' or '30s'", unitStr)
return nil, fmt.Errorf("duration has unknown unit %q - use 'd', 'h', 'm' or 's', eg '1d' or '30s'", unitStr)
}
if previousUnitDuration != 0 && previousUnitDuration <= unitDuration {
return 0, fmt.Errorf("duration has unit %q out of order - units need to be ordered from largest to smallest - eg '1d3h2m'", unitStr)
return nil, fmt.Errorf("duration has unit %q out of order - units need to be ordered from largest to smallest - eg '1d3h2m'", unitStr)
}
previousUnitDuration = unitDuration
duration += time.Duration(num) * unitDuration
str = str[matches[1]:]
}

return duration, nil
return &components, nil
}
Loading