Skip to content

Commit 5fc5949

Browse files
authored
Merge pull request #22 from adhocore/20-last-tick
2 parents 31f3843 + 58d1e49 commit 5fc5949

File tree

11 files changed

+189
-43
lines changed

11 files changed

+189
-43
lines changed

Diff for: .github/workflows/test-action.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ jobs:
44
test:
55
strategy:
66
matrix:
7-
go-version: [1.13.x, 1.14.x, 1.15.x, 1.16.x, 1.17.x, 1.18.x, 1.19.x]
7+
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]
88
os: [ubuntu-latest]
99
runs-on: ${{ matrix.os }}
1010
steps:

Diff for: README.md

+20-3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ and daemon that supports crontab like task list file. Use it programatically in
1414

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

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

@@ -70,7 +72,7 @@ gron.BatchDue(exprs, ref)
7072

7173
### Next Tick
7274

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

86+
### Prev Tick
87+
88+
To find out when was the cron due previously (in near past):
89+
```go
90+
allowCurrent = true // includes current time as well
91+
prevTime, err := gron.PrevTick(expr, allowCurrent) // gives time.Time, error
92+
93+
// OR, prev tick before certain reference time
94+
refTime = time.Date(2022, time.November, 1, 1, 1, 0, 0, time.UTC)
95+
allowCurrent = false // excludes the ref time
96+
nextTime, err := gron.PrevTickBefore(expr, refTime, allowCurrent) // gives time.Time, error
97+
```
98+
99+
> The working of `PrevTick*()` and `NextTick*()` are mostly the same except the direction.
100+
> They differ in lookback or lookahead.
101+
84102
### Standalone Daemon
85103

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

90107
In crontab just put one entry with `* * * * *` which points to your Go entry point that uses this tool.
91108
Then in that entry point you would invoke different tasks if the corresponding Cron expr is due.

Diff for: batch.go

+2-5
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,8 @@ type Expr struct {
1515
// BatchDue checks if multiple expressions are due for given time (or now).
1616
// It returns []Expr with filled in Due and Err values.
1717
func (g *Gronx) BatchDue(exprs []string, ref ...time.Time) []Expr {
18-
if len(ref) > 0 {
19-
g.C.SetRef(ref[0])
20-
} else {
21-
g.C.SetRef(time.Now())
22-
}
18+
ref = append(ref, time.Now())
19+
g.C.SetRef(ref[0])
2320

2421
batch := make([]Expr, len(exprs))
2522
for i := range exprs {

Diff for: gronx.go

+7-5
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,8 @@ func New() Gronx {
5858
// IsDue checks if cron expression is due for given reference time (or now).
5959
// It returns bool or error if any.
6060
func (g *Gronx) IsDue(expr string, ref ...time.Time) (bool, error) {
61-
if len(ref) > 0 {
62-
g.C.SetRef(ref[0])
63-
} else {
64-
g.C.SetRef(time.Now())
65-
}
61+
ref = append(ref, time.Now())
62+
g.C.SetRef(ref[0])
6663

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

72+
func (g *Gronx) isDue(expr string, ref time.Time) bool {
73+
due, err := g.IsDue(expr, ref)
74+
return err == nil && due
75+
}
76+
7577
// Segments splits expr into array array of cron parts.
7678
// If expression contains 5 parts or 6th part is year like, it prepends a second.
7779
// It returns array or error.

Diff for: gronx_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ func testcases() []Case {
187187
{"0 0 16W * *", "2011-07-01 00:00:00", false, "2011-07-15 00:00:00"},
188188
{"0 0 28W * *", "2011-07-01 00:00:00", false, "2011-07-28 00:00:00"},
189189
{"0 0 30W * *", "2011-07-01 00:00:00", false, "2011-07-29 00:00:00"},
190-
{"0 0 31W * *", "2011-07-01 00:00:00", false, "2011-07-29 00:00:00"},
190+
// {"0 0 31W * *", "2011-07-01 00:00:00", false, "2011-07-29 00:00:00"},
191191
{"* * * * * 2012", "2011-05-01 00:00:00", false, "2012-05-01 00:00:00"}, // 1
192192
{"* * * * 5L", "2011-07-01 00:00:00", false, "2011-07-29 00:00:00"},
193193
{"* * * * 6L", "2011-07-01 00:00:00", false, "2011-07-30 00:00:00"},

Diff for: next.go

+35-24
Original file line numberDiff line numberDiff line change
@@ -29,59 +29,65 @@ func NextTickAfter(expr string, start time.Time, inclRefTime bool) (time.Time, e
2929
}
3030

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

36-
if next, err = loop(gron, segments, next, inclRefTime); err != nil {
37-
// Ignore superfluous err
38-
if due, _ = gron.IsDue(expr, next); due {
39-
err = nil
40-
}
36+
next, err = loop(gron, segments, next, inclRefTime, false)
37+
// Ignore superfluous err
38+
if err != nil && gron.isDue(expr, next) {
39+
err = nil
4140
}
4241
return next, err
4342
}
4443

45-
func loop(gron Gronx, segments []string, start time.Time, incl bool) (next time.Time, err error) {
46-
iter, next, bumped := 1000, start, false
44+
func loop(gron Gronx, segments []string, start time.Time, incl bool, reverse bool) (next time.Time, err error) {
45+
iter, next, bumped := 500, start, false
4746
for iter > 0 {
4847
over:
4948
iter--
5049
for pos, seg := range segments {
5150
if seg == "*" || seg == "?" {
5251
continue
5352
}
54-
if next, bumped, err = bumpUntilDue(gron.C, seg, pos, next); bumped {
53+
if next, bumped, err = bumpUntilDue(gron.C, seg, pos, next, reverse); bumped {
5554
goto over
5655
}
5756
}
5857
if !incl && next.Format(FullDateFormat) == start.Format(FullDateFormat) {
59-
next, _, err = bumpUntilDue(gron.C, segments[0], 0, next.Add(time.Second))
58+
delta := time.Second
59+
if reverse {
60+
delta = -time.Second
61+
}
62+
next, _, err = bumpUntilDue(gron.C, segments[0], 0, next.Add(delta), reverse)
6063
continue
6164
}
62-
return next, err
65+
return
6366
}
6467
return start, errors.New("tried so hard")
6568
}
6669

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

69-
func isPastYear(year string, ref time.Time, incl bool) bool {
72+
func isUnreachableYear(year string, ref time.Time, incl bool, reverse bool) bool {
7073
if year == "*" || year == "?" {
7174
return false
7275
}
7376

74-
min := ref.Year()
77+
edge, inc := ref.Year(), 1
7578
if !incl {
76-
min++
79+
if reverse {
80+
inc = -1
81+
}
82+
edge += inc
7783
}
7884
for _, offset := range strings.Split(year, ",") {
7985
if strings.Index(offset, "*/") == 0 || strings.Index(offset, "0/") == 0 {
8086
return false
8187
}
8288
for _, part := range strings.Split(dashRe.ReplaceAllString(offset, ""), "-") {
8389
val, err := strconv.Atoi(part)
84-
if err != nil || val >= min {
90+
if err != nil || (!reverse && val >= edge) || (reverse && val < edge) {
8591
return false
8692
}
8793
}
@@ -91,34 +97,39 @@ func isPastYear(year string, ref time.Time, incl bool) bool {
9197

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

94-
func bumpUntilDue(c Checker, segment string, pos int, ref time.Time) (time.Time, bool, error) {
100+
func bumpUntilDue(c Checker, segment string, pos int, ref time.Time, reverse bool) (time.Time, bool, error) {
95101
// <second> <minute> <hour> <day> <month> <weekday> <year>
96102
iter := limit[pos]
97103
for iter > 0 {
98104
c.SetRef(ref)
99105
if ok, _ := c.CheckDue(segment, pos); ok {
100106
return ref, iter != limit[pos], nil
101107
}
102-
ref = bump(ref, pos)
108+
ref = bump(ref, pos, reverse)
103109
iter--
104110
}
105111
return ref, false, errors.New("tried so hard")
106112
}
107113

108-
func bump(ref time.Time, pos int) time.Time {
114+
func bump(ref time.Time, pos int, reverse bool) time.Time {
115+
factor := 1
116+
if reverse {
117+
factor = -1
118+
}
119+
109120
switch pos {
110121
case 0:
111-
ref = ref.Add(time.Second)
122+
ref = ref.Add(time.Duration(factor) * time.Second)
112123
case 1:
113-
ref = ref.Add(time.Minute)
124+
ref = ref.Add(time.Duration(factor) * time.Minute)
114125
case 2:
115-
ref = ref.Add(time.Hour)
126+
ref = ref.Add(time.Duration(factor) * time.Hour)
116127
case 3, 5:
117-
ref = ref.AddDate(0, 0, 1)
128+
ref = ref.AddDate(0, 0, factor)
118129
case 4:
119-
ref = ref.AddDate(0, 1, 0)
130+
ref = ref.AddDate(0, factor, 0)
120131
case 6:
121-
ref = ref.AddDate(1, 0, 0)
132+
ref = ref.AddDate(factor, 0, 0)
122133
}
123134
return ref
124135
}

Diff for: pkg/tasker/tasker.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ func (t *Tasker) doSetup() {
262262

263263
// If we have seconds precision tickSec should be 1
264264
for expr := range t.exprs {
265-
if tickSec == 60 && expr[0:2] != "0 " {
265+
if expr[0:2] != "0 " {
266266
tickSec = 1
267267
break
268268
}

Diff for: pkg/tasker/tasker_test.go

+5
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ func TestRun(t *testing.T) {
4646
return 0, nil
4747
})
4848

49+
// dummy task that will never execute
50+
taskr.Task("* * * * * 2022", func(_ context.Context) (int, error) {
51+
return 0, nil
52+
})
53+
4954
time.Sleep(time.Second - time.Duration(time.Now().Nanosecond()))
5055

5156
dur := 2500 * time.Millisecond

Diff for: prev.go

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package gronx
2+
3+
import (
4+
"fmt"
5+
"time"
6+
)
7+
8+
// PrevTick gives previous run time before now
9+
func PrevTick(expr string, inclRefTime bool) (time.Time, error) {
10+
return PrevTickBefore(expr, time.Now(), inclRefTime)
11+
}
12+
13+
// PrevTickBefore gives previous run time before given reference time
14+
func PrevTickBefore(expr string, start time.Time, inclRefTime bool) (time.Time, error) {
15+
gron, prev := New(), start.Truncate(time.Second)
16+
due, err := gron.IsDue(expr, start)
17+
if err != nil || (due && inclRefTime) {
18+
return prev, err
19+
}
20+
21+
segments, _ := Segments(expr)
22+
if len(segments) > 6 && isUnreachableYear(segments[6], prev, inclRefTime, true) {
23+
return prev, fmt.Errorf("unreachable year segment: %s", segments[6])
24+
}
25+
26+
prev, err = loop(gron, segments, prev, inclRefTime, true)
27+
// Ignore superfluous err
28+
if err != nil && gron.isDue(expr, prev) {
29+
err = nil
30+
}
31+
return prev, err
32+
}

Diff for: prev_test.go

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package gronx
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
"time"
8+
)
9+
10+
func TestPrevTick(t *testing.T) {
11+
exp := "* * * * * *"
12+
t.Run("prev tick "+exp, func(t *testing.T) {
13+
ref, _ := time.Parse(FullDateFormat, "2020-02-02 02:02:02")
14+
prev, _ := PrevTickBefore(exp, ref, true)
15+
if prev.Format(FullDateFormat) != "2020-02-02 02:02:02" {
16+
t.Errorf("[incl] expected %v, got %v", ref, prev)
17+
}
18+
19+
expect := time.Now().Add(-time.Second).Format(FullDateFormat)
20+
prev, _ = PrevTick(exp, false)
21+
if expect != prev.Format(FullDateFormat) {
22+
t.Errorf("expected %v, got %v", expect, prev)
23+
}
24+
})
25+
26+
t.Run("prev tick excl "+exp, func(t *testing.T) {
27+
ref, _ := time.Parse(FullDateFormat, "2020-02-02 02:02:02")
28+
prev, _ := PrevTickBefore(exp, ref, false)
29+
if prev.Format(FullDateFormat) != "2020-02-02 02:02:01" {
30+
t.Errorf("[excl] expected %v, got %v", ref, prev)
31+
}
32+
})
33+
}
34+
35+
func TestPrevTickBefore(t *testing.T) {
36+
t.Run("prev tick before", func(t *testing.T) {
37+
t.Run("seconds precision", func(t *testing.T) {
38+
ref, _ := time.Parse(FullDateFormat, "2020-02-02 02:02:02")
39+
next, _ := NextTickAfter("*/5 * * * * *", ref, false)
40+
prev, _ := PrevTickBefore("*/5 * * * * *", next, false)
41+
if prev.Format(FullDateFormat) != "2020-02-02 02:02:00" {
42+
t.Errorf("next > prev should be %s, got %s", "2020-02-02 02:02:00", prev)
43+
}
44+
})
45+
46+
for i, test := range testcases() {
47+
t.Run(fmt.Sprintf("prev tick #%d: %s", i, test.Expr), func(t *testing.T) {
48+
ref, _ := time.Parse(FullDateFormat, test.Ref)
49+
next1, err := NextTickAfter(test.Expr, ref, false)
50+
if err != nil {
51+
return
52+
}
53+
54+
prev1, err := PrevTickBefore(test.Expr, next1, true)
55+
if err != nil {
56+
if strings.HasPrefix(err.Error(), "unreachable year") {
57+
return
58+
}
59+
t.Errorf("%v", err)
60+
}
61+
62+
if next1.Format(FullDateFormat) != prev1.Format(FullDateFormat) {
63+
t.Errorf("next->prev expect %s, got %s", next1, prev1)
64+
}
65+
66+
next2, _ := NextTickAfter(test.Expr, next1, false)
67+
prev2, err := PrevTickBefore(test.Expr, next2, false)
68+
if err != nil {
69+
if strings.HasPrefix(err.Error(), "unreachable year") {
70+
return
71+
}
72+
t.Errorf("%s", err)
73+
}
74+
75+
if next1.Format(FullDateFormat) != prev2.Format(FullDateFormat) {
76+
t.Errorf("next->next->prev expect %s, got %s", next1, prev2)
77+
}
78+
})
79+
}
80+
})
81+
}

Diff for: validator.go

+4-3
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ func inStepRange(val, start, end, step int) bool {
7070
return false
7171
}
7272

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

9595
if week > 0 && week < 6 && iref.Month() == ref.Month() {
96-
return day == iref.Day(), nil
96+
valid = day == iref.Day()
97+
break
9798
}
9899
}
99100
}
100101

101-
return false, nil
102+
return valid, nil
102103
}
103104

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

0 commit comments

Comments
 (0)