Skip to content

Commit

Permalink
feat: Add support for shorthand cron expressions (#1733)
Browse files Browse the repository at this point in the history
Fixes #1628

Supports:
- Every n seconds: `//ftl:cron 10s`
- Every n minutes: `//ftl:cron 30m`
- Every n hours: `//ftl:cron 12h` (Starting at UTC midnight)
- Day of the week (UTC midnight): `//ftl:cron Friday` or `//ftl:cron
Fri`
  - Case insensitive with at least the first 3 chars of the day name.
  • Loading branch information
gak authored Jun 12, 2024
1 parent fa6ca76 commit 990158e
Show file tree
Hide file tree
Showing 4 changed files with 264 additions and 9 deletions.
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
/*
// 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
}

0 comments on commit 990158e

Please sign in to comment.