Skip to content

Commit

Permalink
Merge pull request #22 from adhocore/20-last-tick
Browse files Browse the repository at this point in the history
  • Loading branch information
adhocore authored Apr 9, 2023
2 parents 31f3843 + 58d1e49 commit 5fc5949
Show file tree
Hide file tree
Showing 11 changed files with 189 additions and 43 deletions.
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

0 comments on commit 5fc5949

Please sign in to comment.