From de34fce68e5ead44ae620ceac1b7e55bd28b63ed Mon Sep 17 00:00:00 2001 From: Jitendra A <2908547+adhocore@users.noreply.github.com> Date: Sat, 8 Apr 2023 21:02:24 +0700 Subject: [PATCH 1/9] refactor: shorten code --- batch.go | 7 ++----- gronx.go | 7 ++----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/batch.go b/batch.go index a2ea02c..3e41729 100644 --- a/batch.go +++ b/batch.go @@ -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 { diff --git a/gronx.go b/gronx.go index 6de8019..2caeb40 100644 --- a/gronx.go +++ b/gronx.go @@ -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 { From 15315378c3b73ad6f225b66c0f26590a0d6ef972 Mon Sep 17 00:00:00 2001 From: Jitendra A <2908547+adhocore@users.noreply.github.com> Date: Sat, 8 Apr 2023 21:05:02 +0700 Subject: [PATCH 2/9] refactor: make unreachable code reachable --- validator.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/validator.go b/validator.go index a8e12b5..d772ad7 100644 --- a/validator.go +++ b/validator.go @@ -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 @@ -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) { From e4dbb28119523940c92dfa4edfdbf0f39f5295bc Mon Sep 17 00:00:00 2001 From: Jitendra A <2908547+adhocore@users.noreply.github.com> Date: Sat, 8 Apr 2023 21:05:57 +0700 Subject: [PATCH 3/9] feat: add internal isDue() that checks if expr is really due --- gronx.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gronx.go b/gronx.go index 2caeb40..f86f904 100644 --- a/gronx.go +++ b/gronx.go @@ -69,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. From e760ca031bcbd59acc7fcb77c92c6c55df01a840 Mon Sep 17 00:00:00 2001 From: Jitendra A <2908547+adhocore@users.noreply.github.com> Date: Sat, 8 Apr 2023 21:07:19 +0700 Subject: [PATCH 4/9] fix(tasker): tickSec does not have to be 60 here --- pkg/tasker/tasker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/tasker/tasker.go b/pkg/tasker/tasker.go index 7cc72ad..90f82d5 100644 --- a/pkg/tasker/tasker.go +++ b/pkg/tasker/tasker.go @@ -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 } From be74ff024779dd0b15481fa087031cd14492a2a7 Mon Sep 17 00:00:00 2001 From: Jitendra A <2908547+adhocore@users.noreply.github.com> Date: Sat, 8 Apr 2023 21:08:08 +0700 Subject: [PATCH 5/9] feat(next): refactor for reverse mode --- next.go | 59 ++++++++++++++++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/next.go b/next.go index 23fc69d..34284fd 100644 --- a/next.go +++ b/next.go @@ -29,21 +29,20 @@ 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-- @@ -51,29 +50,36 @@ func loop(gron Gronx, segments []string, start time.Time, incl bool) (next time. 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 { @@ -81,7 +87,7 @@ func isPastYear(year string, ref time.Time, incl bool) bool { } 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 } } @@ -91,7 +97,7 @@ 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) { // iter := limit[pos] for iter > 0 { @@ -99,26 +105,31 @@ func bumpUntilDue(c Checker, segment string, pos int, ref time.Time) (time.Time, 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 } From ce124e11e697b2e89c231c09fa7306f264cd1b38 Mon Sep 17 00:00:00 2001 From: Jitendra A <2908547+adhocore@users.noreply.github.com> Date: Sat, 8 Apr 2023 21:11:31 +0700 Subject: [PATCH 6/9] feat(prev): expose method to get previous run time (tick) for a cron expr --- prev.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 prev.go diff --git a/prev.go b/prev.go new file mode 100644 index 0000000..d900bf7 --- /dev/null +++ b/prev.go @@ -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 +} From 41b63ce7d6ea7ef94a0e6b841af8f19df3db02cd Mon Sep 17 00:00:00 2001 From: Jitendra A <2908547+adhocore@users.noreply.github.com> Date: Sat, 8 Apr 2023 21:12:30 +0700 Subject: [PATCH 7/9] test: prev tick and tasker --- gronx_test.go | 2 +- pkg/tasker/tasker_test.go | 5 +++ prev_test.go | 81 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 prev_test.go diff --git a/gronx_test.go b/gronx_test.go index 24173f5..58e2d5e 100644 --- a/gronx_test.go +++ b/gronx_test.go @@ -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"}, diff --git a/pkg/tasker/tasker_test.go b/pkg/tasker/tasker_test.go index b30483c..ef3d751 100644 --- a/pkg/tasker/tasker_test.go +++ b/pkg/tasker/tasker_test.go @@ -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 diff --git a/prev_test.go b/prev_test.go new file mode 100644 index 0000000..ada3698 --- /dev/null +++ b/prev_test.go @@ -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) + } + }) + } + }) +} From 5675eaf47f65e9408e6b89cb5533804b008b1865 Mon Sep 17 00:00:00 2001 From: Jitendra A <2908547+adhocore@users.noreply.github.com> Date: Sat, 8 Apr 2023 21:36:48 +0700 Subject: [PATCH 8/9] docs: PrevTick() and others [skip ci][ci skip] --- README.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d55faaf..1a9d1d8 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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 @@ -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. From 58d1e49cf3a7cf417e273496494543c5d1498807 Mon Sep 17 00:00:00 2001 From: Jitendra A <2908547+adhocore@users.noreply.github.com> Date: Sat, 8 Apr 2023 22:00:01 +0700 Subject: [PATCH 9/9] build(workflow): run tests on go1.20 --- .github/workflows/test-action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index 6811090..8dc29c7 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -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: