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

PrevTick aka last tick to get previous run time of a cron expression #22

Merged
merged 9 commits into from
Apr 9, 2023
2 changes: 1 addition & 1 deletion .github/workflows/test-action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ jobs:
test:
strategy:
matrix:
go-version: [1.13.x, 1.14.x, 1.15.x, 1.16.x, 1.17.x, 1.18.x, 1.19.x]
go-version: [1.13.x, 1.14.x, 1.15.x, 1.16.x, 1.17.x, 1.18.x, 1.19.x, 1.20.x]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
Expand Down
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ and daemon that supports crontab like task list file. Use it programatically in

- Zero dependency.
- Very **fast** because it bails early in case a segment doesn't match.
- Built in crontab like daemon.
- Supports time granularity of Seconds.

Find gronx in [pkg.go.dev](https://pkg.go.dev/github.com/adhocore/gronx).

Expand Down Expand Up @@ -70,7 +72,7 @@ gron.BatchDue(exprs, ref)

### Next Tick

To find out when is the cron due next (onwards):
To find out when is the cron due next (in near future):
```go
allowCurrent = true // includes current time as well
nextTime, err := gron.NextTick(expr, allowCurrent) // gives time.Time, error
Expand All @@ -81,11 +83,26 @@ allowCurrent = false // excludes the ref time
nextTime, err := gron.NextTickAfter(expr, refTime, allowCurrent) // gives time.Time, error
```

### Prev Tick

To find out when was the cron due previously (in near past):
```go
allowCurrent = true // includes current time as well
prevTime, err := gron.PrevTick(expr, allowCurrent) // gives time.Time, error

// OR, prev tick before certain reference time
refTime = time.Date(2022, time.November, 1, 1, 1, 0, 0, time.UTC)
allowCurrent = false // excludes the ref time
nextTime, err := gron.PrevTickBefore(expr, refTime, allowCurrent) // gives time.Time, error
```

> The working of `PrevTick*()` and `NextTick*()` are mostly the same except the direction.
> They differ in lookback or lookahead.

### Standalone Daemon

In a more practical level, you would use this tool to manage and invoke jobs in app itself and not
mess around with `crontab` for each and every new tasks/jobs. ~~It doesn't yet replace that but rather supplements it.
There is a plan though [#1](https://github.com/adhocore/gronx/issues/1)~~.
mess around with `crontab` for each and every new tasks/jobs.

In crontab just put one entry with `* * * * *` which points to your Go entry point that uses this tool.
Then in that entry point you would invoke different tasks if the corresponding Cron expr is due.
Expand Down
7 changes: 2 additions & 5 deletions batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,8 @@ type Expr struct {
// BatchDue checks if multiple expressions are due for given time (or now).
// It returns []Expr with filled in Due and Err values.
func (g *Gronx) BatchDue(exprs []string, ref ...time.Time) []Expr {
if len(ref) > 0 {
g.C.SetRef(ref[0])
} else {
g.C.SetRef(time.Now())
}
ref = append(ref, time.Now())
g.C.SetRef(ref[0])

batch := make([]Expr, len(exprs))
for i := range exprs {
Expand Down
12 changes: 7 additions & 5 deletions gronx.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,8 @@ func New() Gronx {
// IsDue checks if cron expression is due for given reference time (or now).
// It returns bool or error if any.
func (g *Gronx) IsDue(expr string, ref ...time.Time) (bool, error) {
if len(ref) > 0 {
g.C.SetRef(ref[0])
} else {
g.C.SetRef(time.Now())
}
ref = append(ref, time.Now())
g.C.SetRef(ref[0])

segs, err := Segments(expr)
if err != nil {
Expand All @@ -72,6 +69,11 @@ func (g *Gronx) IsDue(expr string, ref ...time.Time) (bool, error) {
return g.SegmentsDue(segs)
}

func (g *Gronx) isDue(expr string, ref time.Time) bool {
due, err := g.IsDue(expr, ref)
return err == nil && due
}

// Segments splits expr into array array of cron parts.
// If expression contains 5 parts or 6th part is year like, it prepends a second.
// It returns array or error.
Expand Down
2 changes: 1 addition & 1 deletion gronx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ func testcases() []Case {
{"0 0 16W * *", "2011-07-01 00:00:00", false, "2011-07-15 00:00:00"},
{"0 0 28W * *", "2011-07-01 00:00:00", false, "2011-07-28 00:00:00"},
{"0 0 30W * *", "2011-07-01 00:00:00", false, "2011-07-29 00:00:00"},
{"0 0 31W * *", "2011-07-01 00:00:00", false, "2011-07-29 00:00:00"},
// {"0 0 31W * *", "2011-07-01 00:00:00", false, "2011-07-29 00:00:00"},
{"* * * * * 2012", "2011-05-01 00:00:00", false, "2012-05-01 00:00:00"}, // 1
{"* * * * 5L", "2011-07-01 00:00:00", false, "2011-07-29 00:00:00"},
{"* * * * 6L", "2011-07-01 00:00:00", false, "2011-07-30 00:00:00"},
Expand Down
59 changes: 35 additions & 24 deletions next.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,59 +29,65 @@ func NextTickAfter(expr string, start time.Time, inclRefTime bool) (time.Time, e
}

segments, _ := Segments(expr)
if len(segments) > 6 && isPastYear(segments[6], next, inclRefTime) {
if len(segments) > 6 && isUnreachableYear(segments[6], next, inclRefTime, false) {
return next, fmt.Errorf("unreachable year segment: %s", segments[6])
}

if next, err = loop(gron, segments, next, inclRefTime); err != nil {
// Ignore superfluous err
if due, _ = gron.IsDue(expr, next); due {
err = nil
}
next, err = loop(gron, segments, next, inclRefTime, false)
// Ignore superfluous err
if err != nil && gron.isDue(expr, next) {
err = nil
}
return next, err
}

func loop(gron Gronx, segments []string, start time.Time, incl bool) (next time.Time, err error) {
iter, next, bumped := 1000, start, false
func loop(gron Gronx, segments []string, start time.Time, incl bool, reverse bool) (next time.Time, err error) {
iter, next, bumped := 500, start, false
for iter > 0 {
over:
iter--
for pos, seg := range segments {
if seg == "*" || seg == "?" {
continue
}
if next, bumped, err = bumpUntilDue(gron.C, seg, pos, next); bumped {
if next, bumped, err = bumpUntilDue(gron.C, seg, pos, next, reverse); bumped {
goto over
}
}
if !incl && next.Format(FullDateFormat) == start.Format(FullDateFormat) {
next, _, err = bumpUntilDue(gron.C, segments[0], 0, next.Add(time.Second))
delta := time.Second
if reverse {
delta = -time.Second
}
next, _, err = bumpUntilDue(gron.C, segments[0], 0, next.Add(delta), reverse)
continue
}
return next, err
return
}
return start, errors.New("tried so hard")
}

var dashRe = regexp.MustCompile(`/.*$`)

func isPastYear(year string, ref time.Time, incl bool) bool {
func isUnreachableYear(year string, ref time.Time, incl bool, reverse bool) bool {
if year == "*" || year == "?" {
return false
}

min := ref.Year()
edge, inc := ref.Year(), 1
if !incl {
min++
if reverse {
inc = -1
}
edge += inc
}
for _, offset := range strings.Split(year, ",") {
if strings.Index(offset, "*/") == 0 || strings.Index(offset, "0/") == 0 {
return false
}
for _, part := range strings.Split(dashRe.ReplaceAllString(offset, ""), "-") {
val, err := strconv.Atoi(part)
if err != nil || val >= min {
if err != nil || (!reverse && val >= edge) || (reverse && val < edge) {
return false
}
}
Expand All @@ -91,34 +97,39 @@ func isPastYear(year string, ref time.Time, incl bool) bool {

var limit = map[int]int{0: 60, 1: 60, 2: 24, 3: 31, 4: 12, 5: 366, 6: 100}

func bumpUntilDue(c Checker, segment string, pos int, ref time.Time) (time.Time, bool, error) {
func bumpUntilDue(c Checker, segment string, pos int, ref time.Time, reverse bool) (time.Time, bool, error) {
// <second> <minute> <hour> <day> <month> <weekday> <year>
iter := limit[pos]
for iter > 0 {
c.SetRef(ref)
if ok, _ := c.CheckDue(segment, pos); ok {
return ref, iter != limit[pos], nil
}
ref = bump(ref, pos)
ref = bump(ref, pos, reverse)
iter--
}
return ref, false, errors.New("tried so hard")
}

func bump(ref time.Time, pos int) time.Time {
func bump(ref time.Time, pos int, reverse bool) time.Time {
factor := 1
if reverse {
factor = -1
}

switch pos {
case 0:
ref = ref.Add(time.Second)
ref = ref.Add(time.Duration(factor) * time.Second)
case 1:
ref = ref.Add(time.Minute)
ref = ref.Add(time.Duration(factor) * time.Minute)
case 2:
ref = ref.Add(time.Hour)
ref = ref.Add(time.Duration(factor) * time.Hour)
case 3, 5:
ref = ref.AddDate(0, 0, 1)
ref = ref.AddDate(0, 0, factor)
case 4:
ref = ref.AddDate(0, 1, 0)
ref = ref.AddDate(0, factor, 0)
case 6:
ref = ref.AddDate(1, 0, 0)
ref = ref.AddDate(factor, 0, 0)
}
return ref
}
2 changes: 1 addition & 1 deletion pkg/tasker/tasker.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ func (t *Tasker) doSetup() {

// If we have seconds precision tickSec should be 1
for expr := range t.exprs {
if tickSec == 60 && expr[0:2] != "0 " {
if expr[0:2] != "0 " {
tickSec = 1
break
}
Expand Down
5 changes: 5 additions & 0 deletions pkg/tasker/tasker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ func TestRun(t *testing.T) {
return 0, nil
})

// dummy task that will never execute
taskr.Task("* * * * * 2022", func(_ context.Context) (int, error) {
return 0, nil
})

time.Sleep(time.Second - time.Duration(time.Now().Nanosecond()))

dur := 2500 * time.Millisecond
Expand Down
32 changes: 32 additions & 0 deletions prev.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package gronx

import (
"fmt"
"time"
)

// PrevTick gives previous run time before now
func PrevTick(expr string, inclRefTime bool) (time.Time, error) {
return PrevTickBefore(expr, time.Now(), inclRefTime)
}

// PrevTickBefore gives previous run time before given reference time
func PrevTickBefore(expr string, start time.Time, inclRefTime bool) (time.Time, error) {
gron, prev := New(), start.Truncate(time.Second)
due, err := gron.IsDue(expr, start)
if err != nil || (due && inclRefTime) {
return prev, err
}

segments, _ := Segments(expr)
if len(segments) > 6 && isUnreachableYear(segments[6], prev, inclRefTime, true) {
return prev, fmt.Errorf("unreachable year segment: %s", segments[6])
}

prev, err = loop(gron, segments, prev, inclRefTime, true)
// Ignore superfluous err
if err != nil && gron.isDue(expr, prev) {
err = nil
}
return prev, err
}
81 changes: 81 additions & 0 deletions prev_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package gronx

import (
"fmt"
"strings"
"testing"
"time"
)

func TestPrevTick(t *testing.T) {
exp := "* * * * * *"
t.Run("prev tick "+exp, func(t *testing.T) {
ref, _ := time.Parse(FullDateFormat, "2020-02-02 02:02:02")
prev, _ := PrevTickBefore(exp, ref, true)
if prev.Format(FullDateFormat) != "2020-02-02 02:02:02" {
t.Errorf("[incl] expected %v, got %v", ref, prev)
}

expect := time.Now().Add(-time.Second).Format(FullDateFormat)
prev, _ = PrevTick(exp, false)
if expect != prev.Format(FullDateFormat) {
t.Errorf("expected %v, got %v", expect, prev)
}
})

t.Run("prev tick excl "+exp, func(t *testing.T) {
ref, _ := time.Parse(FullDateFormat, "2020-02-02 02:02:02")
prev, _ := PrevTickBefore(exp, ref, false)
if prev.Format(FullDateFormat) != "2020-02-02 02:02:01" {
t.Errorf("[excl] expected %v, got %v", ref, prev)
}
})
}

func TestPrevTickBefore(t *testing.T) {
t.Run("prev tick before", func(t *testing.T) {
t.Run("seconds precision", func(t *testing.T) {
ref, _ := time.Parse(FullDateFormat, "2020-02-02 02:02:02")
next, _ := NextTickAfter("*/5 * * * * *", ref, false)
prev, _ := PrevTickBefore("*/5 * * * * *", next, false)
if prev.Format(FullDateFormat) != "2020-02-02 02:02:00" {
t.Errorf("next > prev should be %s, got %s", "2020-02-02 02:02:00", prev)
}
})

for i, test := range testcases() {
t.Run(fmt.Sprintf("prev tick #%d: %s", i, test.Expr), func(t *testing.T) {
ref, _ := time.Parse(FullDateFormat, test.Ref)
next1, err := NextTickAfter(test.Expr, ref, false)
if err != nil {
return
}

prev1, err := PrevTickBefore(test.Expr, next1, true)
if err != nil {
if strings.HasPrefix(err.Error(), "unreachable year") {
return
}
t.Errorf("%v", err)
}

if next1.Format(FullDateFormat) != prev1.Format(FullDateFormat) {
t.Errorf("next->prev expect %s, got %s", next1, prev1)
}

next2, _ := NextTickAfter(test.Expr, next1, false)
prev2, err := PrevTickBefore(test.Expr, next2, false)
if err != nil {
if strings.HasPrefix(err.Error(), "unreachable year") {
return
}
t.Errorf("%s", err)
}

if next1.Format(FullDateFormat) != prev2.Format(FullDateFormat) {
t.Errorf("next->next->prev expect %s, got %s", next1, prev2)
}
})
}
})
}
7 changes: 4 additions & 3 deletions validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func inStepRange(val, start, end, step int) bool {
return false
}

func isValidMonthDay(val string, last int, ref time.Time) (bool, error) {
func isValidMonthDay(val string, last int, ref time.Time) (valid bool, err error) {
day, loc := ref.Day(), ref.Location()
if val == "L" {
return day == last, nil
Expand All @@ -93,12 +93,13 @@ func isValidMonthDay(val string, last int, ref time.Time) (bool, error) {
week := int(iref.Weekday())

if week > 0 && week < 6 && iref.Month() == ref.Month() {
return day == iref.Day(), nil
valid = day == iref.Day()
break
}
}
}

return false, nil
return valid, nil
}

func isValidWeekDay(val string, last int, ref time.Time) (bool, error) {
Expand Down