Skip to content
This repository has been archived by the owner on Jan 11, 2019. It is now read-only.

Commit

Permalink
Added support for DST with configurable behavior.
Browse files Browse the repository at this point in the history
  • Loading branch information
abecciu committed Oct 31, 2016
1 parent f098431 commit b09ef46
Show file tree
Hide file tree
Showing 5 changed files with 808 additions and 17 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The reference documentation for this implementation is found at
Year No 1970–2099 * / , -

#### Asterisk ( * )
The asterisk indicates that the cron expression matches for all values of the field. E.g., using an asterisk in the 4th field (month) indicates every month.
The asterisk indicates that the cron expression matches for all values of the field. E.g., using an asterisk in the 4th field (month) indicates every month.

#### Slash ( / )
Slashes describe increments of ranges. For example `3-59/15` in the minute field indicate the third minute of the hour and every 15 minutes thereafter. The form `*/...` is equivalent to the form "first-last/...", that is, an increment over the largest possible range of the field.
Expand Down Expand Up @@ -52,7 +52,7 @@ The `W` character can also be combined with `L`, i.e. `LW` to mean "the last bus

Predefined cron expressions
---------------------------
(Copied from <https://en.wikipedia.org/wiki/Cron#Predefined_scheduling_definitions>, with text modified according to this implementation)
(Copied from <https://en.wikipedia.org/wiki/Cron#Predefined_scheduling_definitions>, with text modified according to this implementation)

Entry Description Equivalent to
@annually Run once a year at midnight in the morning of January 1 0 0 0 1 1 * *
Expand All @@ -69,6 +69,7 @@ Other details
* If only five fields are present, a `0` second field is prepended and a wildcard year field is appended, that is, `* * * * Mon` internally become `0 * * * * Mon *`.
* Domain for day-of-week field is [0-7] instead of [0-6], 7 being Sunday (like 0). This to comply with http://linux.die.net/man/5/crontab#.
* As of now, the behavior of the code is undetermined if a malformed cron expression is supplied
* By default, on a DST change, it returns the times that would have been skipped when the clock moves forward and returns only once the times that would have been repeated when the clock moves backwards. For customized behavior, see the godoc documentation.

Install
-------
Expand Down Expand Up @@ -131,4 +132,3 @@ License: pick the one which suits you best:

- GPL v3 see <https://www.gnu.org/licenses/gpl.html>
- APL v2 see <http://www.apache.org/licenses/LICENSE-2.0>

35 changes: 34 additions & 1 deletion cronexpr.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
// <https://github.com/gorhill/cronexpr#implementation>
type Expression struct {
expression string
options Options
secondList []int
minuteList []int
hourList []int
Expand All @@ -42,6 +43,30 @@ type Expression struct {
lastWeekDaysOfWeek map[int]bool
daysOfWeekRestricted bool
yearList []int

// indicates an instance of a expression used internally
// for rounding a time
rounding bool
}

type DSTFlags uint

const (
// DSTLeapUnskip indicates the parser to not skip times that would have been
// been skipped when the clock moves forward on a DST change.
DSTLeapUnskip = 1 << iota

// DSTFallFireEarly indicates the parser to return the earliest time
// when a time is repeated due to a DST fall.
DSTFallFireEarly

// DSTFallFireLate indicates the parser to return the latest time
// when a time is repeated due to a DST fall.
DSTFallFireLate
)

type Options struct {
DSTFlags DSTFlags
}

/******************************************************************************/
Expand All @@ -67,6 +92,14 @@ func MustParse(cronLine string) *Expression {
// about what is a well-formed cron expression from this library's point of
// view.
func Parse(cronLine string) (*Expression, error) {
return ParseWithOptions(cronLine, Options{DSTFlags: DSTLeapUnskip | DSTFallFireEarly})
}

// ParseWithOptions is used to build a Expression pointer with custom options.
func ParseWithOptions(cronLine string, options Options) (*Expression, error) {
if options.DSTFlags == 0 {
return nil, fmt.Errorf("missing DST flags")
}

// Maybe one of the built-in aliases is being used
cron := cronNormalizer.Replace(cronLine)
Expand All @@ -81,7 +114,7 @@ func Parse(cronLine string) (*Expression, error) {
fieldCount = 7
}

var expr = Expression{}
var expr = Expression{options: options}
var field = 0
var err error

Expand Down
214 changes: 202 additions & 12 deletions cronexpr_next.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,18 @@ func (expr *Expression) nextYear(t time.Time) time.Time {
0,
t.Location()))
}
return time.Date(

next := time.Date(
expr.yearList[i],
time.Month(expr.monthList[0]),
expr.actualDaysOfMonthList[0],
expr.hourList[0],
expr.minuteList[0],
expr.secondList[0],
0,
t.Location())
time.UTC)

return expr.nextTime(t, next)
}

/******************************************************************************/
Expand All @@ -87,15 +90,17 @@ func (expr *Expression) nextMonth(t time.Time) time.Time {
t.Location()))
}

return time.Date(
next := time.Date(
t.Year(),
time.Month(expr.monthList[i]),
expr.actualDaysOfMonthList[0],
expr.hourList[0],
expr.minuteList[0],
expr.secondList[0],
0,
t.Location())
time.UTC)

return expr.nextTime(t, next)
}

/******************************************************************************/
Expand All @@ -108,15 +113,17 @@ func (expr *Expression) nextDayOfMonth(t time.Time) time.Time {
return expr.nextMonth(t)
}

return time.Date(
next := time.Date(
t.Year(),
t.Month(),
expr.actualDaysOfMonthList[i],
expr.hourList[0],
expr.minuteList[0],
expr.secondList[0],
0,
t.Location())
time.UTC)

return expr.nextTime(t, next)
}

/******************************************************************************/
Expand All @@ -129,15 +136,17 @@ func (expr *Expression) nextHour(t time.Time) time.Time {
return expr.nextDayOfMonth(t)
}

return time.Date(
next := time.Date(
t.Year(),
t.Month(),
t.Day(),
expr.hourList[i],
expr.minuteList[0],
expr.secondList[0],
0,
t.Location())
time.UTC)

return expr.nextTime(t, next)
}

/******************************************************************************/
Expand All @@ -150,15 +159,17 @@ func (expr *Expression) nextMinute(t time.Time) time.Time {
return expr.nextHour(t)
}

return time.Date(
next := time.Date(
t.Year(),
t.Month(),
t.Day(),
t.Hour(),
expr.minuteList[i],
expr.secondList[0],
0,
t.Location())
time.UTC)

return expr.nextTime(t, next)
}

/******************************************************************************/
Expand All @@ -174,15 +185,114 @@ func (expr *Expression) nextSecond(t time.Time) time.Time {
return expr.nextMinute(t)
}

return time.Date(
next := time.Date(
t.Year(),
t.Month(),
t.Day(),
t.Hour(),
t.Minute(),
expr.secondList[i],
0,
t.Location())
time.UTC)

return expr.nextTime(t, next)
}

func (expr *Expression) roundTime(t time.Time) time.Time {
roundingExpr := new(Expression)
*roundingExpr = *expr
roundingExpr.rounding = true

i := sort.SearchInts(expr.hourList, t.Hour())
if i == len(expr.hourList) || expr.hourList[i] != t.Hour() {
return roundingExpr.nextHour(t)
}

i = sort.SearchInts(expr.minuteList, t.Minute())
if i == len(expr.minuteList) || expr.minuteList[i] != t.Minute() {
return roundingExpr.nextMinute(t)
}

i = sort.SearchInts(expr.secondList, t.Second())
if i == len(expr.secondList) || expr.secondList[i] != t.Second() {
return roundingExpr.nextSecond(t)
}

return t
}

func (expr *Expression) isRounded(t time.Time) bool {
i := sort.SearchInts(expr.hourList, t.Hour())
if i == len(expr.hourList) || expr.hourList[i] != t.Hour() {
return false
}

i = sort.SearchInts(expr.minuteList, t.Minute())
if i == len(expr.minuteList) || expr.minuteList[i] != t.Minute() {
return false
}

i = sort.SearchInts(expr.secondList, t.Second())
if i == len(expr.secondList) || expr.secondList[i] != t.Second() {
return false
}

return true
}

func (expr *Expression) nextTime(prev, next time.Time) time.Time {
dstFlags := expr.options.DSTFlags
t := prev.Add(noTZDiff(prev, next))
offsetDiff := utcOffset(t) - utcOffset(prev)

// a dst leap occurred
if offsetDiff > 0 {
if dstFlags&DSTLeapUnskip != 0 {
return findTimeOfDSTChange(prev, t).Add(1 * time.Second)
}

return expr.roundTime(t)
}

// a dst fall occurred
if offsetDiff < 0 {
twinT := findTwinTime(prev)

if !twinT.IsZero() {
if dstFlags&DSTFallFireLate != 0 {
return twinT
}
if dstFlags&DSTFallFireEarly != 0 {
// skip the twin time
return expr.Next(expr.roundTime(twinT))
}
}

if dstFlags&DSTFallFireEarly != 0 {
return t
}

return expr.roundTime(t)
}

twinT := findTwinTime(t)
if !twinT.IsZero() {
if dstFlags&DSTFallFireEarly != 0 {
return t
}
if dstFlags&DSTFallFireLate != 0 {
return twinT
}
}

if dstFlags&DSTFallFireLate == 0 && !expr.rounding {
twinT = findTwinTime(prev)
if !twinT.IsZero() && twinT.Before(prev) && !expr.isRounded(prev) {
return expr.Next(t)
}
}

return t
}

/******************************************************************************/
Expand Down Expand Up @@ -290,3 +400,83 @@ func workdayOfMonth(targetDom, lastDom time.Time) int {
}
return dom
}

func utcOffset(t time.Time) int {
_, offset := t.Zone()
return offset
}

func noTZ(t time.Time) time.Time {
return t.UTC().Add(time.Duration(utcOffset(t)) * time.Second)
}

func noTZDiff(t1, t2 time.Time) time.Duration {
t1 = noTZ(t1)
t2 = noTZ(t2)
return t2.Sub(t1)
}

// findTimeOfDSTChange returns the time a second before a DST change occurs,
// and returns zero time in case there's no DST change.
func findTimeOfDSTChange(t1, t2 time.Time) time.Time {
if t1.Location() != t2.Location() || utcOffset(t1) == utcOffset(t2) || t1.Location() == time.UTC {
return time.Time{}
}

// make sure t2 > t1
if t2.Before(t1) {
t := t2
t1 = t2
t2 = t
}

// do a binary search to find the time one second before the dst change
len := t2.Unix() - t1.Unix()
var a int64
b := len
for len > 1 {
len = (b - a + 1) / 2
if utcOffset(t1.Add(time.Duration(a+len)*time.Second)) != utcOffset(t1) {
b = a + len
} else {
a = a + len
}
}

return t1.Add(time.Duration(a) * time.Second)
}

// When a DST fall accurs, a certain interval of time is repeated. Once
// in DST time and once in standard time.
// findTwinTime tries to find the repated "twin" time if one exists.
func findTwinTime(t time.Time) time.Time {
offsetDiff := utcOffset(t.Add(12*time.Hour)) - utcOffset(t)
// a fall occurs within the next 8 hours
if offsetDiff < 0 {
border := findTimeOfDSTChange(t, t.Add(12*time.Hour))
t0 := border.Add(time.Duration(offsetDiff) * time.Second)

if t0.After(t) {
return t
}

dur := t.Sub(t0)
return border.Add(dur)
}

offsetDiff = utcOffset(t) - utcOffset(t.Add(-12*time.Second))
// a fall occurred in the past 8 hours
if offsetDiff < 0 {
border := findTimeOfDSTChange(t.Add(-12*time.Hour), t)
t0 := border.Add(time.Duration(offsetDiff) * time.Second)

if t0.Add(time.Duration(-2*offsetDiff) * time.Second).Before(t) {
return t
}

dur := t.Sub(border)
return t0.Add(dur)
}

return time.Time{}
}
Loading

0 comments on commit b09ef46

Please sign in to comment.