Skip to content

Commit

Permalink
feat(cli): support schedule specs with @ mark
Browse files Browse the repository at this point in the history
  • Loading branch information
macrat committed Jun 3, 2023
1 parent 03a88e4 commit b2e67ec
Show file tree
Hide file tree
Showing 3 changed files with 166 additions and 7 deletions.
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -576,9 +576,20 @@ The above command means checking `your-service.example.com` every 5 minutes from
│ │ │ ┌──── month (1 - 12)
│ │ │ │ ┌─── [optional] day of the week (0 - 6 (sunday - saturday))
│ │ │ │ │
'* * * * *'
'* * * * ?'
```

There are some special values for ease to specify.

| special spec | standard spec | description |
|--------------------------|---------------|-----------------------------------------------|
| `@yearly` or `@annually` | `0 0 1 1 ?` | Check once a year at 1st January. |
| `@monthly` | `0 0 1 * ?` | Check once a month at the first day of month. |
| `@daily` | `0 0 * * ?` | Check once a day at midnight. |
| `@hourly` | `0 * * * ?` | Check once an hour. |
| `@reboot` | - | Check once when Ayd started. |
| `@after 5m` | - | Check once after 5 minutes after Ayd started. |


### Status pages and endpoints

Expand Down
86 changes: 80 additions & 6 deletions cmd/ayd/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"fmt"
"math"
"regexp"
"strings"
"time"
Expand All @@ -21,6 +22,10 @@ type Schedule interface {
}

func ParseSchedule(spec string) (Schedule, error) {
if s, err := ParseAfterSchedule(spec); err == nil {
return s, nil
}

if s, err := ParseIntervalSchedule(spec); err == nil {
return s, nil
}
Expand Down Expand Up @@ -58,13 +63,24 @@ type CronSchedule struct {
}

func ParseCronSchedule(spec string) (CronSchedule, error) {
delimiter := regexp.MustCompile("[ \t]+")

ss := delimiter.Split(strings.TrimSpace(spec), -1)
if len(ss) == 4 {
ss = append(ss, "?")
switch spec {
case "@yearly", "@annually":
spec = "0 0 1 1 ?"
case "@monthly":
spec = "0 0 1 * ?"
case "@weekly":
spec = "0 0 * * 0"
case "@daily":
spec = "0 0 * * ?"
default:
delimiter := regexp.MustCompile("[ \t]+")

ss := delimiter.Split(strings.TrimSpace(spec), -1)
if len(ss) == 4 {
ss = append(ss, "?")
}
spec = strings.Join(ss, " ")
}
spec = strings.Join(ss, " ")

if s, err := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.DowOptional).Parse(spec); err != nil {
return CronSchedule{}, err
Expand All @@ -87,3 +103,61 @@ func (s CronSchedule) String() string {
func (s CronSchedule) NeedKickWhenStart() bool {
return false
}

type AfterSchedule struct {
Delay time.Duration
At time.Time
}

func ParseAfterSchedule(spec string) (Schedule, error) {
if spec == "@reboot" {
return RebootSchedule{}, nil
}

if !strings.HasPrefix(spec, "@after ") {
return nil, fmt.Errorf("invalid schedule spec: %q", spec)
}

delay, err := time.ParseDuration(strings.TrimSpace(spec[len("@after "):]))
if err != nil {
return nil, err
}

if delay == 0 {
return RebootSchedule{}, nil
}

return AfterSchedule{
Delay: delay,
At: CurrentTime().Add(delay),
}, nil
}

func (s AfterSchedule) Next(t time.Time) time.Time {
if t.After(s.At) {
return time.UnixMicro(math.MaxInt64)
}
return s.At
}

func (s AfterSchedule) String() string {
return "@after " + s.Delay.String()
}

func (s AfterSchedule) NeedKickWhenStart() bool {
return false
}

type RebootSchedule struct{}

func (s RebootSchedule) Next(t time.Time) time.Time {
return time.UnixMicro(math.MaxInt64)
}

func (s RebootSchedule) String() string {
return "@reboot"
}

func (s RebootSchedule) NeedKickWhenStart() bool {
return true
}
74 changes: 74 additions & 0 deletions cmd/ayd/schedule_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package main_test

import (
"math"
"testing"
"time"

"github.com/macrat/ayd/cmd/ayd"
)
Expand All @@ -17,6 +19,11 @@ func TestParseCronSchedule(t *testing.T) {
{"5values", "1 2 3 4 5", "1 2 3 4 5", ""},
{"spaces", "1 2 \t3 4", "1 2 3 4 ?", ""},
{"3values", "1 2 3", "", "expected 4 to 5 fields, found 3: [1 2 3]"},
{"@yearly", "@yearly", "0 0 1 1 ?", ""},
{"@annually", "@annually", "0 0 1 1 ?", ""},
{"@monthly", "@monthly", "0 0 1 * ?", ""},
{"@weekly", "@weekly", "0 0 * * 0", ""},
{"@daily", "@daily", "0 0 * * ?", ""},
}

for _, tt := range tests {
Expand All @@ -35,3 +42,70 @@ func TestParseCronSchedule(t *testing.T) {
})
}
}

func TestAfterSchedule(t *testing.T) {
type TimePair struct {
Input time.Time
Next time.Time
}

never := time.UnixMicro(math.MaxInt64)

tests := []struct {
Input string
String string
Times []TimePair
KickWhenStart bool
}{
{
Input: "@reboot",
String: "@reboot",
Times: []TimePair{
{time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), never},
},
KickWhenStart: true,
},
{
Input: "@after 0m",
String: "@reboot",
Times: []TimePair{
{time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), never},
},
KickWhenStart: true,
},
{
Input: "@after 5m",
String: "@after 5m0s",
Times: []TimePair{
{time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2001, 2, 3, 16, 10, 6, 0, time.UTC)},
{time.Date(2000, 2, 1, 0, 0, 0, 0, time.UTC), time.Date(2001, 2, 3, 16, 10, 6, 0, time.UTC)},
{time.Date(2002, 1, 1, 0, 0, 0, 0, time.UTC), never},
},
KickWhenStart: false,
},
}

for _, tt := range tests {
t.Run(tt.Input, func(t *testing.T) {
schedule, err := main.ParseAfterSchedule(tt.Input)
if err != nil {
t.Fatalf("failed to parse schedule: %s", err)
}

if tt.String != schedule.String() {
t.Errorf("unexpected string: expected %#v but got %#v", tt.String, schedule.String())
}

for _, tp := range tt.Times {
n := schedule.Next(tp.Input)
if !n.Equal(tp.Next) {
t.Errorf("unexpected next schedule for %s: expected %s but got %s", tp.Input, tp.Next, n)
}
}

if schedule.NeedKickWhenStart() != tt.KickWhenStart {
t.Errorf("unexpected NeedKickWhenStart value: expected %v but got %v", tt.KickWhenStart, schedule.NeedKickWhenStart())
}
})
}
}

0 comments on commit b2e67ec

Please sign in to comment.