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

feat: Prev() function derives the previous time a SpecSchedule would have triggered #437

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
11 changes: 10 additions & 1 deletion constantdelay.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cron

import "time"
import (
"time"
)

// ConstantDelaySchedule represents a simple recurring duty cycle, e.g. "Every 5 minutes".
// It does not support jobs more frequent than once a second.
Expand All @@ -25,3 +27,10 @@ func Every(duration time.Duration) ConstantDelaySchedule {
func (schedule ConstantDelaySchedule) Next(t time.Time) time.Time {
return t.Add(schedule.Delay - time.Duration(t.Nanosecond())*time.Nanosecond)
}

// Prev returns the previous time this would have been run
// This rounds to nearest second.
func (schedule ConstantDelaySchedule) Prev(t time.Time) time.Time {
t = t.Add(-time.Duration(t.Nanosecond()) * time.Nanosecond)
return t.Add(-schedule.Delay)
}
34 changes: 34 additions & 0 deletions constantdelay_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,37 @@ func TestConstantDelayNext(t *testing.T) {
}
}
}

func TestConstantDelayPrev(t *testing.T) {
tests := []struct {
time string
delay time.Duration
expected string
}{
// Simple cases
{"Mon Jul 9 14:45 2012", 15*time.Minute + 50*time.Nanosecond, "Mon Jul 9 14:30 2012"},
{"Mon Jul 9 14:59 2012", 15 * time.Minute, "Mon Jul 9 14:44 2012"},

// Wrap around days
{"Tue Jul 10 00:00 2012", 14 * time.Minute, "Mon Jul 9 23:46 2012"},
{"Tue Jul 10 00:20:15 2012", 44*time.Minute + 24*time.Second, "Mon Jul 9 23:35:51 2012"},
{"Thu Jul 11 01:20:15 2012", 25*time.Hour + 44*time.Minute + 24*time.Second, "Mon Jul 9 23:35:51 2012"},

// Wrap around minute, hour, day, month, and year
{"Tue Jan 1 00:00:00 2013", 15 * time.Second, "Mon Dec 31 23:59:45 2012"},

// Round to nearest second on the delay
{"Mon Jul 9 15:00 2012", 15*time.Minute + 50*time.Nanosecond, "Mon Jul 9 14:45 2012"},

// Round to second when calculating the prev time.
{"Mon Jul 9 15:00:00.005 2012", 15 * time.Minute, "Mon Jul 9 14:45:00 2012"},
}

for _, c := range tests {
actual := Every(c.delay).Prev(getTime(c.time))
expected := getTime(c.expected)
if actual != expected {
t.Errorf("%s, \"%s\": (expected) %v != %v (actual)", c.time, c.delay, expected, actual)
}
}
}
3 changes: 3 additions & 0 deletions cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ type Schedule interface {
// Next returns the next activation time, later than the given time.
// Next is invoked initially, and then each time the job is run.
Next(time.Time) time.Time

// Prev returns the previous time this schedule is activated, less than the given time.
Prev(time.Time) time.Time
}

// EntryID identifies an entry within a Cron instance
Expand Down
4 changes: 4 additions & 0 deletions cron_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,10 @@ func (*ZeroSchedule) Next(time.Time) time.Time {
return time.Time{}
}

func (*ZeroSchedule) Prev(time.Time) time.Time {
return time.Time{}
}

// Tests that job without time does not run
func TestJobWithZeroTimeDoesNotRun(t *testing.T) {
cron := newWithSeconds()
Expand Down
97 changes: 97 additions & 0 deletions spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,103 @@ WRAP:
return t.In(origLocation)
}

// Prev returns the previous time this schedule is activated, less than the given
// time. If no time can be found to satisfy the schedule, return the zero time.
func (s *SpecSchedule) Prev(t time.Time) time.Time {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if it was wanted or not, but this function should be added to the Schedule interface for it to be accessible externally.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I'll add it to the interface and to the ConstantDelaySchedule struct.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay, done

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There hasn't been any contribution to the repo since 2020 so let's hope that someone merges this! Thanks for implementing it though

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that this change might be really beneficial for distributed systems (check if the previous job has been run). Is this project dead @robfig @juliev0?

// General approach is based on approach to Next() implementation

origLocation := t.Location()
loc := s.Location
if loc == time.Local {
loc = t.Location()
}
if s.Location != time.Local {
t = t.In(s.Location)
}

// Start at the previous second
t = t.Add(-1*time.Second - time.Duration(t.Nanosecond())*time.Nanosecond)

// If no time is found within five years, return zero.
yearLimit := t.Year() - 5

WRAP:
if t.Year() < yearLimit {
return time.Time{}
}

// Find the first applicable month.
// If it's this month, then do nothing.
for 1<<uint(t.Month())&s.Month == 0 {
// set t to the last second of the previous month
t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, loc)
t = t.Add(-1 * time.Second)

// Wrapped around.
if t.Month() == time.December {
goto WRAP
}
}

// Now get a day in that month.
//
// NOTE: This causes issues for daylight savings regimes where midnight does
// not exist. For example: Sao Paulo has DST that transforms midnight on
// 11/3 into 1am. Handle that by noticing when the Hour ends up != 0.
for !dayMatches(s, t) {
// set t to the last second of the previous day
saveMonth := t.Month()
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)

// Notice if the hour is no longer midnight due to DST.
// Add an hour if it's 23, subtract an hour if it's 1.
if t.Hour() != 0 {
if t.Hour() > 12 {
t = t.Add(time.Duration(24-t.Hour()) * time.Hour)
} else {
t = t.Add(time.Duration(-t.Hour()) * time.Hour)
}
}

t = t.Add(-1 * time.Second)

if saveMonth != t.Month() {
goto WRAP
}
}

for 1<<uint(t.Hour())&s.Hour == 0 {
// set t to the last second of the previous hour
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, loc)
t = t.Add(-1 * time.Second)

if t.Hour() == 23 {
goto WRAP
}
}

for 1<<uint(t.Minute())&s.Minute == 0 {
// set t to the last second of the previous minute
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, loc)
t = t.Add(-1 * time.Second)

if t.Minute() == 59 {
goto WRAP
}
}

for 1<<uint(t.Second())&s.Second == 0 {
// set t to the previous second
t = t.Add(-1 * time.Second)

if t.Second() == 59 {
goto WRAP
}
}

return t.In(origLocation)
}

// dayMatches returns true if the schedule's day-of-week and day-of-month
// restrictions are satisfied by the given time.
func dayMatches(s *SpecSchedule, t time.Time) bool {
Expand Down
61 changes: 61 additions & 0 deletions spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ func TestNext(t *testing.T) {
time, spec string
expected string
}{

// Simple cases
{"Mon Jul 9 14:45 2012", "0 0/15 * * * *", "Mon Jul 9 15:00 2012"},
{"Mon Jul 9 14:59 2012", "0 0/15 * * * *", "Mon Jul 9 15:00 2012"},
Expand Down Expand Up @@ -199,6 +200,66 @@ func TestNext(t *testing.T) {
}
}

func TestPrev(t *testing.T) {
runs := []struct {
time, spec string
expected string
}{
// Simple cases
{"Mon Jul 9 15:00 2012", "0 0/15 * * * *", "Mon Jul 9 14:45 2012"},
{"Mon Jul 9 14:59 2012", "0 0/15 * * * *", "Mon Jul 9 14:45 2012"},
{"Mon Jul 9 15:01:59 2012", "0 0/15 * * * *", "Mon Jul 9 15:00 2012"},

// Wrap around hours
{"Mon Jul 9 15:10 2012", "0 20-35/15 * * * *", "Mon Jul 9 14:35 2012"},

// Wrap around days
{"Tue Jul 10 00:00 2012", "0 */15 * * * *", "Tue Jul 9 23:45 2012"},
{"Tue Jul 10 00:00 2012", "0 20-35/15 * * * *", "Tue Jul 9 23:35 2012"},

// Wrap around months
{"Mon Jul 9 09:35 2012", "0 0 12 9 Apr-Oct ?", "Sat Jun 9 12:00 2012"},

// Leap year
{"Mon Jul 9 23:35 2018", "0 0 0 29 Feb ?", "Mon Feb 29 00:00 2016"},

// Daylight savings time 3am EDT (-4) -> 2am EST (-5)
{"2013-03-11T02:30:00-0400", "TZ=America/New_York 0 0 12 9 Mar ?", "2013-03-09T12:00:00-0500"},

// hourly job
{"2012-03-11T01:00:00-0500", "TZ=America/New_York 0 0 * * * ?", "2012-03-11T00:00:00-0500"},

// 2am nightly job (skipped)
{"2012-03-12T00:00:00-0400", "TZ=America/New_York 0 0 2 * * ?", "2012-03-10T02:00:00-0500"},

// 2am nightly job
{"2012-11-04T02:00:00-0500", "TZ=America/New_York 0 0 0 * * ?", "2012-11-04T00:00:00-0400"},
{"2012-11-05T02:00:00-0500", "TZ=America/New_York 0 0 2 * * ?", "2012-11-04T02:00:00-0500"},

// Unsatisfiable
{"Mon Jul 9 23:35 2012", "0 0 0 30 Feb ?", ""},
{"Mon Jul 9 23:35 2012", "0 0 0 31 Apr ?", ""},

// Monthly job
{"TZ=America/New_York 2012-12-03T00:00:00-0500", "0 0 3 3 * ?", "2012-11-03T03:00:00-0400"},
}

for _, c := range runs {
sched, err := secondParser.Parse(c.spec)
if err != nil {
t.Error(err)
continue
}
specSchedule, _ := sched.(*SpecSchedule)

actual := specSchedule.Prev(getTime(c.time))
expected := getTime(c.expected)
if !actual.Equal(expected) {
t.Errorf("%s, \"%s\": (expected) %v != %v (actual)", c.time, c.spec, expected, actual)
}
}
}

func TestErrors(t *testing.T) {
invalidSpecs := []string{
"xyz",
Expand Down