├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── chain.go ├── chain_test.go ├── constantdelay.go ├── constantdelay_test.go ├── cron.go ├── cron_test.go ├── doc.go ├── go.mod ├── logger.go ├── option.go ├── option_test.go ├── parser.go ├── parser_test.go ├── spec.go └── spec_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2012 Rob Figueiredo 2 | All Rights Reserved. 3 | 4 | MIT LICENSE 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | this software and associated documentation files (the "Software"), to deal in 8 | the Software without restriction, including without limitation the rights to 9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 10 | the Software, and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 19 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 20 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GoDoc](http://godoc.org/github.com/robfig/cron?status.png)](http://godoc.org/github.com/robfig/cron) 2 | [![Build Status](https://travis-ci.org/robfig/cron.svg?branch=master)](https://travis-ci.org/robfig/cron) 3 | 4 | # cron 5 | 6 | Cron V3 has been released! 7 | 8 | To download the specific tagged release, run: 9 | ```bash 10 | go get github.com/robfig/cron/v3@v3.0.0 11 | ``` 12 | Import it in your program as: 13 | ```go 14 | import "github.com/robfig/cron/v3" 15 | ``` 16 | It requires Go 1.11 or later due to usage of Go Modules. 17 | 18 | Refer to the documentation here: 19 | http://godoc.org/github.com/robfig/cron 20 | 21 | The rest of this document describes the the advances in v3 and a list of 22 | breaking changes for users that wish to upgrade from an earlier version. 23 | 24 | ## Upgrading to v3 (June 2019) 25 | 26 | cron v3 is a major upgrade to the library that addresses all outstanding bugs, 27 | feature requests, and rough edges. It is based on a merge of master which 28 | contains various fixes to issues found over the years and the v2 branch which 29 | contains some backwards-incompatible features like the ability to remove cron 30 | jobs. In addition, v3 adds support for Go Modules, cleans up rough edges like 31 | the timezone support, and fixes a number of bugs. 32 | 33 | New features: 34 | 35 | - Support for Go modules. Callers must now import this library as 36 | `github.com/robfig/cron/v3`, instead of `gopkg.in/...` 37 | 38 | - Fixed bugs: 39 | - 0f01e6b parser: fix combining of Dow and Dom (#70) 40 | - dbf3220 adjust times when rolling the clock forward to handle non-existent midnight (#157) 41 | - eeecf15 spec_test.go: ensure an error is returned on 0 increment (#144) 42 | - 70971dc cron.Entries(): update request for snapshot to include a reply channel (#97) 43 | - 1cba5e6 cron: fix: removing a job causes the next scheduled job to run too late (#206) 44 | 45 | - Standard cron spec parsing by default (first field is "minute"), with an easy 46 | way to opt into the seconds field (quartz-compatible). Although, note that the 47 | year field (optional in Quartz) is not supported. 48 | 49 | - Extensible, key/value logging via an interface that complies with 50 | the https://github.com/go-logr/logr project. 51 | 52 | - The new Chain & JobWrapper types allow you to install "interceptors" to add 53 | cross-cutting behavior like the following: 54 | - Recover any panics from jobs 55 | - Delay a job's execution if the previous run hasn't completed yet 56 | - Skip a job's execution if the previous run hasn't completed yet 57 | - Log each job's invocations 58 | - Notification when jobs are completed 59 | 60 | It is backwards incompatible with both v1 and v2. These updates are required: 61 | 62 | - The v1 branch accepted an optional seconds field at the beginning of the cron 63 | spec. This is non-standard and has led to a lot of confusion. The new default 64 | parser conforms to the standard as described by [the Cron wikipedia page]. 65 | 66 | UPDATING: To retain the old behavior, construct your Cron with a custom 67 | parser: 68 | ```go 69 | // Seconds field, required 70 | cron.New(cron.WithSeconds()) 71 | 72 | // Seconds field, optional 73 | cron.New(cron.WithParser(cron.NewParser( 74 | cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor, 75 | ))) 76 | ``` 77 | - The Cron type now accepts functional options on construction rather than the 78 | previous ad-hoc behavior modification mechanisms (setting a field, calling a setter). 79 | 80 | UPDATING: Code that sets Cron.ErrorLogger or calls Cron.SetLocation must be 81 | updated to provide those values on construction. 82 | 83 | - CRON_TZ is now the recommended way to specify the timezone of a single 84 | schedule, which is sanctioned by the specification. The legacy "TZ=" prefix 85 | will continue to be supported since it is unambiguous and easy to do so. 86 | 87 | UPDATING: No update is required. 88 | 89 | - By default, cron will no longer recover panics in jobs that it runs. 90 | Recovering can be surprising (see issue #192) and seems to be at odds with 91 | typical behavior of libraries. Relatedly, the `cron.WithPanicLogger` option 92 | has been removed to accommodate the more general JobWrapper type. 93 | 94 | UPDATING: To opt into panic recovery and configure the panic logger: 95 | ```go 96 | cron.New(cron.WithChain( 97 | cron.Recover(logger), // or use cron.DefaultLogger 98 | )) 99 | ``` 100 | - In adding support for https://github.com/go-logr/logr, `cron.WithVerboseLogger` was 101 | removed, since it is duplicative with the leveled logging. 102 | 103 | UPDATING: Callers should use `WithLogger` and specify a logger that does not 104 | discard `Info` logs. For convenience, one is provided that wraps `*log.Logger`: 105 | ```go 106 | cron.New( 107 | cron.WithLogger(cron.VerbosePrintfLogger(logger))) 108 | ``` 109 | 110 | ### Background - Cron spec format 111 | 112 | There are two cron spec formats in common usage: 113 | 114 | - The "standard" cron format, described on [the Cron wikipedia page] and used by 115 | the cron Linux system utility. 116 | 117 | - The cron format used by [the Quartz Scheduler], commonly used for scheduled 118 | jobs in Java software 119 | 120 | [the Cron wikipedia page]: https://en.wikipedia.org/wiki/Cron 121 | [the Quartz Scheduler]: http://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/tutorial-lesson-06.html 122 | 123 | The original version of this package included an optional "seconds" field, which 124 | made it incompatible with both of these formats. Now, the "standard" format is 125 | the default format accepted, and the Quartz format is opt-in. 126 | -------------------------------------------------------------------------------- /chain.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | // JobWrapper decorates the given Job with some behavior. 11 | type JobWrapper func(Job) Job 12 | 13 | // Chain is a sequence of JobWrappers that decorates submitted jobs with 14 | // cross-cutting behaviors like logging or synchronization. 15 | type Chain struct { 16 | wrappers []JobWrapper 17 | } 18 | 19 | // NewChain returns a Chain consisting of the given JobWrappers. 20 | func NewChain(c ...JobWrapper) Chain { 21 | return Chain{c} 22 | } 23 | 24 | // Then decorates the given job with all JobWrappers in the chain. 25 | // 26 | // This: 27 | // NewChain(m1, m2, m3).Then(job) 28 | // is equivalent to: 29 | // m1(m2(m3(job))) 30 | func (c Chain) Then(j Job) Job { 31 | for i := range c.wrappers { 32 | j = c.wrappers[len(c.wrappers)-i-1](j) 33 | } 34 | return j 35 | } 36 | 37 | // Recover panics in wrapped jobs and log them with the provided logger. 38 | func Recover(logger Logger) JobWrapper { 39 | return func(j Job) Job { 40 | return FuncJob(func() { 41 | defer func() { 42 | if r := recover(); r != nil { 43 | const size = 64 << 10 44 | buf := make([]byte, size) 45 | buf = buf[:runtime.Stack(buf, false)] 46 | err, ok := r.(error) 47 | if !ok { 48 | err = fmt.Errorf("%v", r) 49 | } 50 | logger.Error(err, "panic", "stack", "...\n"+string(buf)) 51 | } 52 | }() 53 | j.Run() 54 | }) 55 | } 56 | } 57 | 58 | // DelayIfStillRunning serializes jobs, delaying subsequent runs until the 59 | // previous one is complete. Jobs running after a delay of more than a minute 60 | // have the delay logged at Info. 61 | func DelayIfStillRunning(logger Logger) JobWrapper { 62 | return func(j Job) Job { 63 | var mu sync.Mutex 64 | return FuncJob(func() { 65 | start := time.Now() 66 | mu.Lock() 67 | defer mu.Unlock() 68 | if dur := time.Since(start); dur > time.Minute { 69 | logger.Info("delay", "duration", dur) 70 | } 71 | j.Run() 72 | }) 73 | } 74 | } 75 | 76 | // SkipIfStillRunning skips an invocation of the Job if a previous invocation is 77 | // still running. It logs skips to the given logger at Info level. 78 | func SkipIfStillRunning(logger Logger) JobWrapper { 79 | return func(j Job) Job { 80 | var ch = make(chan struct{}, 1) 81 | ch <- struct{}{} 82 | return FuncJob(func() { 83 | select { 84 | case v := <-ch: 85 | defer func() { ch <- v }() 86 | j.Run() 87 | default: 88 | logger.Info("skip") 89 | } 90 | }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /chain_test.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | "reflect" 7 | "sync" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func appendingJob(slice *[]int, value int) Job { 13 | var m sync.Mutex 14 | return FuncJob(func() { 15 | m.Lock() 16 | *slice = append(*slice, value) 17 | m.Unlock() 18 | }) 19 | } 20 | 21 | func appendingWrapper(slice *[]int, value int) JobWrapper { 22 | return func(j Job) Job { 23 | return FuncJob(func() { 24 | appendingJob(slice, value).Run() 25 | j.Run() 26 | }) 27 | } 28 | } 29 | 30 | func TestChain(t *testing.T) { 31 | var nums []int 32 | var ( 33 | append1 = appendingWrapper(&nums, 1) 34 | append2 = appendingWrapper(&nums, 2) 35 | append3 = appendingWrapper(&nums, 3) 36 | append4 = appendingJob(&nums, 4) 37 | ) 38 | NewChain(append1, append2, append3).Then(append4).Run() 39 | if !reflect.DeepEqual(nums, []int{1, 2, 3, 4}) { 40 | t.Error("unexpected order of calls:", nums) 41 | } 42 | } 43 | 44 | func TestChainRecover(t *testing.T) { 45 | panickingJob := FuncJob(func() { 46 | panic("panickingJob panics") 47 | }) 48 | 49 | t.Run("panic exits job by default", func(t *testing.T) { 50 | defer func() { 51 | if err := recover(); err == nil { 52 | t.Errorf("panic expected, but none received") 53 | } 54 | }() 55 | NewChain().Then(panickingJob). 56 | Run() 57 | }) 58 | 59 | t.Run("Recovering JobWrapper recovers", func(t *testing.T) { 60 | NewChain(Recover(PrintfLogger(log.New(ioutil.Discard, "", 0)))). 61 | Then(panickingJob). 62 | Run() 63 | }) 64 | 65 | t.Run("composed with the *IfStillRunning wrappers", func(t *testing.T) { 66 | NewChain(Recover(PrintfLogger(log.New(ioutil.Discard, "", 0)))). 67 | Then(panickingJob). 68 | Run() 69 | }) 70 | } 71 | 72 | type countJob struct { 73 | m sync.Mutex 74 | started int 75 | done int 76 | delay time.Duration 77 | } 78 | 79 | func (j *countJob) Run() { 80 | j.m.Lock() 81 | j.started++ 82 | j.m.Unlock() 83 | time.Sleep(j.delay) 84 | j.m.Lock() 85 | j.done++ 86 | j.m.Unlock() 87 | } 88 | 89 | func (j *countJob) Started() int { 90 | defer j.m.Unlock() 91 | j.m.Lock() 92 | return j.started 93 | } 94 | 95 | func (j *countJob) Done() int { 96 | defer j.m.Unlock() 97 | j.m.Lock() 98 | return j.done 99 | } 100 | 101 | func TestChainDelayIfStillRunning(t *testing.T) { 102 | 103 | t.Run("runs immediately", func(t *testing.T) { 104 | var j countJob 105 | wrappedJob := NewChain(DelayIfStillRunning(DiscardLogger)).Then(&j) 106 | go wrappedJob.Run() 107 | time.Sleep(2 * time.Millisecond) // Give the job 2ms to complete. 108 | if c := j.Done(); c != 1 { 109 | t.Errorf("expected job run once, immediately, got %d", c) 110 | } 111 | }) 112 | 113 | t.Run("second run immediate if first done", func(t *testing.T) { 114 | var j countJob 115 | wrappedJob := NewChain(DelayIfStillRunning(DiscardLogger)).Then(&j) 116 | go func() { 117 | go wrappedJob.Run() 118 | time.Sleep(time.Millisecond) 119 | go wrappedJob.Run() 120 | }() 121 | time.Sleep(3 * time.Millisecond) // Give both jobs 3ms to complete. 122 | if c := j.Done(); c != 2 { 123 | t.Errorf("expected job run twice, immediately, got %d", c) 124 | } 125 | }) 126 | 127 | t.Run("second run delayed if first not done", func(t *testing.T) { 128 | var j countJob 129 | j.delay = 10 * time.Millisecond 130 | wrappedJob := NewChain(DelayIfStillRunning(DiscardLogger)).Then(&j) 131 | go func() { 132 | go wrappedJob.Run() 133 | time.Sleep(time.Millisecond) 134 | go wrappedJob.Run() 135 | }() 136 | 137 | // After 5ms, the first job is still in progress, and the second job was 138 | // run but should be waiting for it to finish. 139 | time.Sleep(5 * time.Millisecond) 140 | started, done := j.Started(), j.Done() 141 | if started != 1 || done != 0 { 142 | t.Error("expected first job started, but not finished, got", started, done) 143 | } 144 | 145 | // Verify that the second job completes. 146 | time.Sleep(25 * time.Millisecond) 147 | started, done = j.Started(), j.Done() 148 | if started != 2 || done != 2 { 149 | t.Error("expected both jobs done, got", started, done) 150 | } 151 | }) 152 | 153 | } 154 | 155 | func TestChainSkipIfStillRunning(t *testing.T) { 156 | 157 | t.Run("runs immediately", func(t *testing.T) { 158 | var j countJob 159 | wrappedJob := NewChain(SkipIfStillRunning(DiscardLogger)).Then(&j) 160 | go wrappedJob.Run() 161 | time.Sleep(2 * time.Millisecond) // Give the job 2ms to complete. 162 | if c := j.Done(); c != 1 { 163 | t.Errorf("expected job run once, immediately, got %d", c) 164 | } 165 | }) 166 | 167 | t.Run("second run immediate if first done", func(t *testing.T) { 168 | var j countJob 169 | wrappedJob := NewChain(SkipIfStillRunning(DiscardLogger)).Then(&j) 170 | go func() { 171 | go wrappedJob.Run() 172 | time.Sleep(time.Millisecond) 173 | go wrappedJob.Run() 174 | }() 175 | time.Sleep(3 * time.Millisecond) // Give both jobs 3ms to complete. 176 | if c := j.Done(); c != 2 { 177 | t.Errorf("expected job run twice, immediately, got %d", c) 178 | } 179 | }) 180 | 181 | t.Run("second run skipped if first not done", func(t *testing.T) { 182 | var j countJob 183 | j.delay = 10 * time.Millisecond 184 | wrappedJob := NewChain(SkipIfStillRunning(DiscardLogger)).Then(&j) 185 | go func() { 186 | go wrappedJob.Run() 187 | time.Sleep(time.Millisecond) 188 | go wrappedJob.Run() 189 | }() 190 | 191 | // After 5ms, the first job is still in progress, and the second job was 192 | // aleady skipped. 193 | time.Sleep(5 * time.Millisecond) 194 | started, done := j.Started(), j.Done() 195 | if started != 1 || done != 0 { 196 | t.Error("expected first job started, but not finished, got", started, done) 197 | } 198 | 199 | // Verify that the first job completes and second does not run. 200 | time.Sleep(25 * time.Millisecond) 201 | started, done = j.Started(), j.Done() 202 | if started != 1 || done != 1 { 203 | t.Error("expected second job skipped, got", started, done) 204 | } 205 | }) 206 | 207 | t.Run("skip 10 jobs on rapid fire", func(t *testing.T) { 208 | var j countJob 209 | j.delay = 10 * time.Millisecond 210 | wrappedJob := NewChain(SkipIfStillRunning(DiscardLogger)).Then(&j) 211 | for i := 0; i < 11; i++ { 212 | go wrappedJob.Run() 213 | } 214 | time.Sleep(200 * time.Millisecond) 215 | done := j.Done() 216 | if done != 1 { 217 | t.Error("expected 1 jobs executed, 10 jobs dropped, got", done) 218 | } 219 | }) 220 | 221 | t.Run("different jobs independent", func(t *testing.T) { 222 | var j1, j2 countJob 223 | j1.delay = 10 * time.Millisecond 224 | j2.delay = 10 * time.Millisecond 225 | chain := NewChain(SkipIfStillRunning(DiscardLogger)) 226 | wrappedJob1 := chain.Then(&j1) 227 | wrappedJob2 := chain.Then(&j2) 228 | for i := 0; i < 11; i++ { 229 | go wrappedJob1.Run() 230 | go wrappedJob2.Run() 231 | } 232 | time.Sleep(100 * time.Millisecond) 233 | var ( 234 | done1 = j1.Done() 235 | done2 = j2.Done() 236 | ) 237 | if done1 != 1 || done2 != 1 { 238 | t.Error("expected both jobs executed once, got", done1, "and", done2) 239 | } 240 | }) 241 | 242 | } 243 | -------------------------------------------------------------------------------- /constantdelay.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import "time" 4 | 5 | // ConstantDelaySchedule represents a simple recurring duty cycle, e.g. "Every 5 minutes". 6 | // It does not support jobs more frequent than once a second. 7 | type ConstantDelaySchedule struct { 8 | Delay time.Duration 9 | } 10 | 11 | // Every returns a crontab Schedule that activates once every duration. 12 | // Delays of less than a second are not supported (will round up to 1 second). 13 | // Any fields less than a Second are truncated. 14 | func Every(duration time.Duration) ConstantDelaySchedule { 15 | if duration < time.Second { 16 | duration = time.Second 17 | } 18 | return ConstantDelaySchedule{ 19 | Delay: duration - time.Duration(duration.Nanoseconds())%time.Second, 20 | } 21 | } 22 | 23 | // Next returns the next time this should be run. 24 | // This rounds so that the next activation time will be on the second. 25 | func (schedule ConstantDelaySchedule) Next(t time.Time) time.Time { 26 | return t.Add(schedule.Delay - time.Duration(t.Nanosecond())*time.Nanosecond) 27 | } 28 | -------------------------------------------------------------------------------- /constantdelay_test.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestConstantDelayNext(t *testing.T) { 9 | tests := []struct { 10 | time string 11 | delay time.Duration 12 | expected string 13 | }{ 14 | // Simple cases 15 | {"Mon Jul 9 14:45 2012", 15*time.Minute + 50*time.Nanosecond, "Mon Jul 9 15:00 2012"}, 16 | {"Mon Jul 9 14:59 2012", 15 * time.Minute, "Mon Jul 9 15:14 2012"}, 17 | {"Mon Jul 9 14:59:59 2012", 15 * time.Minute, "Mon Jul 9 15:14:59 2012"}, 18 | 19 | // Wrap around hours 20 | {"Mon Jul 9 15:45 2012", 35 * time.Minute, "Mon Jul 9 16:20 2012"}, 21 | 22 | // Wrap around days 23 | {"Mon Jul 9 23:46 2012", 14 * time.Minute, "Tue Jul 10 00:00 2012"}, 24 | {"Mon Jul 9 23:45 2012", 35 * time.Minute, "Tue Jul 10 00:20 2012"}, 25 | {"Mon Jul 9 23:35:51 2012", 44*time.Minute + 24*time.Second, "Tue Jul 10 00:20:15 2012"}, 26 | {"Mon Jul 9 23:35:51 2012", 25*time.Hour + 44*time.Minute + 24*time.Second, "Thu Jul 11 01:20:15 2012"}, 27 | 28 | // Wrap around months 29 | {"Mon Jul 9 23:35 2012", 91*24*time.Hour + 25*time.Minute, "Thu Oct 9 00:00 2012"}, 30 | 31 | // Wrap around minute, hour, day, month, and year 32 | {"Mon Dec 31 23:59:45 2012", 15 * time.Second, "Tue Jan 1 00:00:00 2013"}, 33 | 34 | // Round to nearest second on the delay 35 | {"Mon Jul 9 14:45 2012", 15*time.Minute + 50*time.Nanosecond, "Mon Jul 9 15:00 2012"}, 36 | 37 | // Round up to 1 second if the duration is less. 38 | {"Mon Jul 9 14:45:00 2012", 15 * time.Millisecond, "Mon Jul 9 14:45:01 2012"}, 39 | 40 | // Round to nearest second when calculating the next time. 41 | {"Mon Jul 9 14:45:00.005 2012", 15 * time.Minute, "Mon Jul 9 15:00 2012"}, 42 | 43 | // Round to nearest second for both. 44 | {"Mon Jul 9 14:45:00.005 2012", 15*time.Minute + 50*time.Nanosecond, "Mon Jul 9 15:00 2012"}, 45 | } 46 | 47 | for _, c := range tests { 48 | actual := Every(c.delay).Next(getTime(c.time)) 49 | expected := getTime(c.expected) 50 | if actual != expected { 51 | t.Errorf("%s, \"%s\": (expected) %v != %v (actual)", c.time, c.delay, expected, actual) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /cron.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "context" 5 | "sort" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | // Cron keeps track of any number of entries, invoking the associated func as 11 | // specified by the schedule. It may be started, stopped, and the entries may 12 | // be inspected while running. 13 | type Cron struct { 14 | entries []*Entry 15 | chain Chain 16 | stop chan struct{} 17 | add chan *Entry 18 | remove chan EntryID 19 | snapshot chan chan []Entry 20 | running bool 21 | logger Logger 22 | runningMu sync.Mutex 23 | location *time.Location 24 | parser ScheduleParser 25 | nextID EntryID 26 | jobWaiter sync.WaitGroup 27 | } 28 | 29 | // ScheduleParser is an interface for schedule spec parsers that return a Schedule 30 | type ScheduleParser interface { 31 | Parse(spec string) (Schedule, error) 32 | } 33 | 34 | // Job is an interface for submitted cron jobs. 35 | type Job interface { 36 | Run() 37 | } 38 | 39 | // Schedule describes a job's duty cycle. 40 | type Schedule interface { 41 | // Next returns the next activation time, later than the given time. 42 | // Next is invoked initially, and then each time the job is run. 43 | Next(time.Time) time.Time 44 | } 45 | 46 | // EntryID identifies an entry within a Cron instance 47 | type EntryID int 48 | 49 | // Entry consists of a schedule and the func to execute on that schedule. 50 | type Entry struct { 51 | // ID is the cron-assigned ID of this entry, which may be used to look up a 52 | // snapshot or remove it. 53 | ID EntryID 54 | 55 | // Schedule on which this job should be run. 56 | Schedule Schedule 57 | 58 | // Next time the job will run, or the zero time if Cron has not been 59 | // started or this entry's schedule is unsatisfiable 60 | Next time.Time 61 | 62 | // Prev is the last time this job was run, or the zero time if never. 63 | Prev time.Time 64 | 65 | // WrappedJob is the thing to run when the Schedule is activated. 66 | WrappedJob Job 67 | 68 | // Job is the thing that was submitted to cron. 69 | // It is kept around so that user code that needs to get at the job later, 70 | // e.g. via Entries() can do so. 71 | Job Job 72 | } 73 | 74 | // Valid returns true if this is not the zero entry. 75 | func (e Entry) Valid() bool { return e.ID != 0 } 76 | 77 | // byTime is a wrapper for sorting the entry array by time 78 | // (with zero time at the end). 79 | type byTime []*Entry 80 | 81 | func (s byTime) Len() int { return len(s) } 82 | func (s byTime) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 83 | func (s byTime) Less(i, j int) bool { 84 | // Two zero times should return false. 85 | // Otherwise, zero is "greater" than any other time. 86 | // (To sort it at the end of the list.) 87 | if s[i].Next.IsZero() { 88 | return false 89 | } 90 | if s[j].Next.IsZero() { 91 | return true 92 | } 93 | return s[i].Next.Before(s[j].Next) 94 | } 95 | 96 | // New returns a new Cron job runner, modified by the given options. 97 | // 98 | // Available Settings 99 | // 100 | // Time Zone 101 | // Description: The time zone in which schedules are interpreted 102 | // Default: time.Local 103 | // 104 | // Parser 105 | // Description: Parser converts cron spec strings into cron.Schedules. 106 | // Default: Accepts this spec: https://en.wikipedia.org/wiki/Cron 107 | // 108 | // Chain 109 | // Description: Wrap submitted jobs to customize behavior. 110 | // Default: A chain that recovers panics and logs them to stderr. 111 | // 112 | // See "cron.With*" to modify the default behavior. 113 | func New(opts ...Option) *Cron { 114 | c := &Cron{ 115 | entries: nil, 116 | chain: NewChain(), 117 | add: make(chan *Entry), 118 | stop: make(chan struct{}), 119 | snapshot: make(chan chan []Entry), 120 | remove: make(chan EntryID), 121 | running: false, 122 | runningMu: sync.Mutex{}, 123 | logger: DefaultLogger, 124 | location: time.Local, 125 | parser: standardParser, 126 | } 127 | for _, opt := range opts { 128 | opt(c) 129 | } 130 | return c 131 | } 132 | 133 | // FuncJob is a wrapper that turns a func() into a cron.Job 134 | type FuncJob func() 135 | 136 | func (f FuncJob) Run() { f() } 137 | 138 | // AddFunc adds a func to the Cron to be run on the given schedule. 139 | // The spec is parsed using the time zone of this Cron instance as the default. 140 | // An opaque ID is returned that can be used to later remove it. 141 | func (c *Cron) AddFunc(spec string, cmd func()) (EntryID, error) { 142 | return c.AddJob(spec, FuncJob(cmd)) 143 | } 144 | 145 | // AddJob adds a Job to the Cron to be run on the given schedule. 146 | // The spec is parsed using the time zone of this Cron instance as the default. 147 | // An opaque ID is returned that can be used to later remove it. 148 | func (c *Cron) AddJob(spec string, cmd Job) (EntryID, error) { 149 | schedule, err := c.parser.Parse(spec) 150 | if err != nil { 151 | return 0, err 152 | } 153 | return c.Schedule(schedule, cmd), nil 154 | } 155 | 156 | // Schedule adds a Job to the Cron to be run on the given schedule. 157 | // The job is wrapped with the configured Chain. 158 | func (c *Cron) Schedule(schedule Schedule, cmd Job) EntryID { 159 | c.runningMu.Lock() 160 | defer c.runningMu.Unlock() 161 | c.nextID++ 162 | entry := &Entry{ 163 | ID: c.nextID, 164 | Schedule: schedule, 165 | WrappedJob: c.chain.Then(cmd), 166 | Job: cmd, 167 | } 168 | if !c.running { 169 | c.entries = append(c.entries, entry) 170 | } else { 171 | c.add <- entry 172 | } 173 | return entry.ID 174 | } 175 | 176 | // Entries returns a snapshot of the cron entries. 177 | func (c *Cron) Entries() []Entry { 178 | c.runningMu.Lock() 179 | defer c.runningMu.Unlock() 180 | if c.running { 181 | replyChan := make(chan []Entry, 1) 182 | c.snapshot <- replyChan 183 | return <-replyChan 184 | } 185 | return c.entrySnapshot() 186 | } 187 | 188 | // Location gets the time zone location 189 | func (c *Cron) Location() *time.Location { 190 | return c.location 191 | } 192 | 193 | // Entry returns a snapshot of the given entry, or nil if it couldn't be found. 194 | func (c *Cron) Entry(id EntryID) Entry { 195 | for _, entry := range c.Entries() { 196 | if id == entry.ID { 197 | return entry 198 | } 199 | } 200 | return Entry{} 201 | } 202 | 203 | // Remove an entry from being run in the future. 204 | func (c *Cron) Remove(id EntryID) { 205 | c.runningMu.Lock() 206 | defer c.runningMu.Unlock() 207 | if c.running { 208 | c.remove <- id 209 | } else { 210 | c.removeEntry(id) 211 | } 212 | } 213 | 214 | // Start the cron scheduler in its own goroutine, or no-op if already started. 215 | func (c *Cron) Start() { 216 | c.runningMu.Lock() 217 | defer c.runningMu.Unlock() 218 | if c.running { 219 | return 220 | } 221 | c.running = true 222 | go c.run() 223 | } 224 | 225 | // Run the cron scheduler, or no-op if already running. 226 | func (c *Cron) Run() { 227 | c.runningMu.Lock() 228 | if c.running { 229 | c.runningMu.Unlock() 230 | return 231 | } 232 | c.running = true 233 | c.runningMu.Unlock() 234 | c.run() 235 | } 236 | 237 | // run the scheduler.. this is private just due to the need to synchronize 238 | // access to the 'running' state variable. 239 | func (c *Cron) run() { 240 | c.logger.Info("start") 241 | 242 | // Figure out the next activation times for each entry. 243 | now := c.now() 244 | for _, entry := range c.entries { 245 | entry.Next = entry.Schedule.Next(now) 246 | c.logger.Info("schedule", "now", now, "entry", entry.ID, "next", entry.Next) 247 | } 248 | 249 | for { 250 | // Determine the next entry to run. 251 | sort.Sort(byTime(c.entries)) 252 | 253 | var timer *time.Timer 254 | if len(c.entries) == 0 || c.entries[0].Next.IsZero() { 255 | // If there are no entries yet, just sleep - it still handles new entries 256 | // and stop requests. 257 | timer = time.NewTimer(100000 * time.Hour) 258 | } else { 259 | timer = time.NewTimer(c.entries[0].Next.Sub(now)) 260 | } 261 | 262 | for { 263 | select { 264 | case now = <-timer.C: 265 | now = now.In(c.location) 266 | c.logger.Info("wake", "now", now) 267 | 268 | // Run every entry whose next time was less than now 269 | for _, e := range c.entries { 270 | if e.Next.After(now) || e.Next.IsZero() { 271 | break 272 | } 273 | c.startJob(e.WrappedJob) 274 | e.Prev = e.Next 275 | e.Next = e.Schedule.Next(now) 276 | c.logger.Info("run", "now", now, "entry", e.ID, "next", e.Next) 277 | } 278 | 279 | case newEntry := <-c.add: 280 | timer.Stop() 281 | now = c.now() 282 | newEntry.Next = newEntry.Schedule.Next(now) 283 | c.entries = append(c.entries, newEntry) 284 | c.logger.Info("added", "now", now, "entry", newEntry.ID, "next", newEntry.Next) 285 | 286 | case replyChan := <-c.snapshot: 287 | replyChan <- c.entrySnapshot() 288 | continue 289 | 290 | case <-c.stop: 291 | timer.Stop() 292 | c.logger.Info("stop") 293 | return 294 | 295 | case id := <-c.remove: 296 | timer.Stop() 297 | now = c.now() 298 | c.removeEntry(id) 299 | c.logger.Info("removed", "entry", id) 300 | } 301 | 302 | break 303 | } 304 | } 305 | } 306 | 307 | // startJob runs the given job in a new goroutine. 308 | func (c *Cron) startJob(j Job) { 309 | c.jobWaiter.Add(1) 310 | go func() { 311 | defer c.jobWaiter.Done() 312 | j.Run() 313 | }() 314 | } 315 | 316 | // now returns current time in c location 317 | func (c *Cron) now() time.Time { 318 | return time.Now().In(c.location) 319 | } 320 | 321 | // Stop stops the cron scheduler if it is running; otherwise it does nothing. 322 | // A context is returned so the caller can wait for running jobs to complete. 323 | func (c *Cron) Stop() context.Context { 324 | c.runningMu.Lock() 325 | defer c.runningMu.Unlock() 326 | if c.running { 327 | c.stop <- struct{}{} 328 | c.running = false 329 | } 330 | ctx, cancel := context.WithCancel(context.Background()) 331 | go func() { 332 | c.jobWaiter.Wait() 333 | cancel() 334 | }() 335 | return ctx 336 | } 337 | 338 | // entrySnapshot returns a copy of the current cron entry list. 339 | func (c *Cron) entrySnapshot() []Entry { 340 | var entries = make([]Entry, len(c.entries)) 341 | for i, e := range c.entries { 342 | entries[i] = *e 343 | } 344 | return entries 345 | } 346 | 347 | func (c *Cron) removeEntry(id EntryID) { 348 | var entries []*Entry 349 | for _, e := range c.entries { 350 | if e.ID != id { 351 | entries = append(entries, e) 352 | } 353 | } 354 | c.entries = entries 355 | } 356 | -------------------------------------------------------------------------------- /cron_test.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "strings" 8 | "sync" 9 | "sync/atomic" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | // Many tests schedule a job for every second, and then wait at most a second 15 | // for it to run. This amount is just slightly larger than 1 second to 16 | // compensate for a few milliseconds of runtime. 17 | const OneSecond = 1*time.Second + 50*time.Millisecond 18 | 19 | type syncWriter struct { 20 | wr bytes.Buffer 21 | m sync.Mutex 22 | } 23 | 24 | func (sw *syncWriter) Write(data []byte) (n int, err error) { 25 | sw.m.Lock() 26 | n, err = sw.wr.Write(data) 27 | sw.m.Unlock() 28 | return 29 | } 30 | 31 | func (sw *syncWriter) String() string { 32 | sw.m.Lock() 33 | defer sw.m.Unlock() 34 | return sw.wr.String() 35 | } 36 | 37 | func newBufLogger(sw *syncWriter) Logger { 38 | return PrintfLogger(log.New(sw, "", log.LstdFlags)) 39 | } 40 | 41 | func TestFuncPanicRecovery(t *testing.T) { 42 | var buf syncWriter 43 | cron := New(WithParser(secondParser), 44 | WithChain(Recover(newBufLogger(&buf)))) 45 | cron.Start() 46 | defer cron.Stop() 47 | cron.AddFunc("* * * * * ?", func() { 48 | panic("YOLO") 49 | }) 50 | 51 | select { 52 | case <-time.After(OneSecond): 53 | if !strings.Contains(buf.String(), "YOLO") { 54 | t.Error("expected a panic to be logged, got none") 55 | } 56 | return 57 | } 58 | } 59 | 60 | type DummyJob struct{} 61 | 62 | func (d DummyJob) Run() { 63 | panic("YOLO") 64 | } 65 | 66 | func TestJobPanicRecovery(t *testing.T) { 67 | var job DummyJob 68 | 69 | var buf syncWriter 70 | cron := New(WithParser(secondParser), 71 | WithChain(Recover(newBufLogger(&buf)))) 72 | cron.Start() 73 | defer cron.Stop() 74 | cron.AddJob("* * * * * ?", job) 75 | 76 | select { 77 | case <-time.After(OneSecond): 78 | if !strings.Contains(buf.String(), "YOLO") { 79 | t.Error("expected a panic to be logged, got none") 80 | } 81 | return 82 | } 83 | } 84 | 85 | // Start and stop cron with no entries. 86 | func TestNoEntries(t *testing.T) { 87 | cron := newWithSeconds() 88 | cron.Start() 89 | 90 | select { 91 | case <-time.After(OneSecond): 92 | t.Fatal("expected cron will be stopped immediately") 93 | case <-stop(cron): 94 | } 95 | } 96 | 97 | // Start, stop, then add an entry. Verify entry doesn't run. 98 | func TestStopCausesJobsToNotRun(t *testing.T) { 99 | wg := &sync.WaitGroup{} 100 | wg.Add(1) 101 | 102 | cron := newWithSeconds() 103 | cron.Start() 104 | cron.Stop() 105 | cron.AddFunc("* * * * * ?", func() { wg.Done() }) 106 | 107 | select { 108 | case <-time.After(OneSecond): 109 | // No job ran! 110 | case <-wait(wg): 111 | t.Fatal("expected stopped cron does not run any job") 112 | } 113 | } 114 | 115 | // Add a job, start cron, expect it runs. 116 | func TestAddBeforeRunning(t *testing.T) { 117 | wg := &sync.WaitGroup{} 118 | wg.Add(1) 119 | 120 | cron := newWithSeconds() 121 | cron.AddFunc("* * * * * ?", func() { wg.Done() }) 122 | cron.Start() 123 | defer cron.Stop() 124 | 125 | // Give cron 2 seconds to run our job (which is always activated). 126 | select { 127 | case <-time.After(OneSecond): 128 | t.Fatal("expected job runs") 129 | case <-wait(wg): 130 | } 131 | } 132 | 133 | // Start cron, add a job, expect it runs. 134 | func TestAddWhileRunning(t *testing.T) { 135 | wg := &sync.WaitGroup{} 136 | wg.Add(1) 137 | 138 | cron := newWithSeconds() 139 | cron.Start() 140 | defer cron.Stop() 141 | cron.AddFunc("* * * * * ?", func() { wg.Done() }) 142 | 143 | select { 144 | case <-time.After(OneSecond): 145 | t.Fatal("expected job runs") 146 | case <-wait(wg): 147 | } 148 | } 149 | 150 | // Test for #34. Adding a job after calling start results in multiple job invocations 151 | func TestAddWhileRunningWithDelay(t *testing.T) { 152 | cron := newWithSeconds() 153 | cron.Start() 154 | defer cron.Stop() 155 | time.Sleep(5 * time.Second) 156 | var calls int64 157 | cron.AddFunc("* * * * * *", func() { atomic.AddInt64(&calls, 1) }) 158 | 159 | <-time.After(OneSecond) 160 | if atomic.LoadInt64(&calls) != 1 { 161 | t.Errorf("called %d times, expected 1\n", calls) 162 | } 163 | } 164 | 165 | // Add a job, remove a job, start cron, expect nothing runs. 166 | func TestRemoveBeforeRunning(t *testing.T) { 167 | wg := &sync.WaitGroup{} 168 | wg.Add(1) 169 | 170 | cron := newWithSeconds() 171 | id, _ := cron.AddFunc("* * * * * ?", func() { wg.Done() }) 172 | cron.Remove(id) 173 | cron.Start() 174 | defer cron.Stop() 175 | 176 | select { 177 | case <-time.After(OneSecond): 178 | // Success, shouldn't run 179 | case <-wait(wg): 180 | t.FailNow() 181 | } 182 | } 183 | 184 | // Start cron, add a job, remove it, expect it doesn't run. 185 | func TestRemoveWhileRunning(t *testing.T) { 186 | wg := &sync.WaitGroup{} 187 | wg.Add(1) 188 | 189 | cron := newWithSeconds() 190 | cron.Start() 191 | defer cron.Stop() 192 | id, _ := cron.AddFunc("* * * * * ?", func() { wg.Done() }) 193 | cron.Remove(id) 194 | 195 | select { 196 | case <-time.After(OneSecond): 197 | case <-wait(wg): 198 | t.FailNow() 199 | } 200 | } 201 | 202 | // Test timing with Entries. 203 | func TestSnapshotEntries(t *testing.T) { 204 | wg := &sync.WaitGroup{} 205 | wg.Add(1) 206 | 207 | cron := New() 208 | cron.AddFunc("@every 2s", func() { wg.Done() }) 209 | cron.Start() 210 | defer cron.Stop() 211 | 212 | // Cron should fire in 2 seconds. After 1 second, call Entries. 213 | select { 214 | case <-time.After(OneSecond): 215 | cron.Entries() 216 | } 217 | 218 | // Even though Entries was called, the cron should fire at the 2 second mark. 219 | select { 220 | case <-time.After(OneSecond): 221 | t.Error("expected job runs at 2 second mark") 222 | case <-wait(wg): 223 | } 224 | } 225 | 226 | // Test that the entries are correctly sorted. 227 | // Add a bunch of long-in-the-future entries, and an immediate entry, and ensure 228 | // that the immediate entry runs immediately. 229 | // Also: Test that multiple jobs run in the same instant. 230 | func TestMultipleEntries(t *testing.T) { 231 | wg := &sync.WaitGroup{} 232 | wg.Add(2) 233 | 234 | cron := newWithSeconds() 235 | cron.AddFunc("0 0 0 1 1 ?", func() {}) 236 | cron.AddFunc("* * * * * ?", func() { wg.Done() }) 237 | id1, _ := cron.AddFunc("* * * * * ?", func() { t.Fatal() }) 238 | id2, _ := cron.AddFunc("* * * * * ?", func() { t.Fatal() }) 239 | cron.AddFunc("0 0 0 31 12 ?", func() {}) 240 | cron.AddFunc("* * * * * ?", func() { wg.Done() }) 241 | 242 | cron.Remove(id1) 243 | cron.Start() 244 | cron.Remove(id2) 245 | defer cron.Stop() 246 | 247 | select { 248 | case <-time.After(OneSecond): 249 | t.Error("expected job run in proper order") 250 | case <-wait(wg): 251 | } 252 | } 253 | 254 | // Test running the same job twice. 255 | func TestRunningJobTwice(t *testing.T) { 256 | wg := &sync.WaitGroup{} 257 | wg.Add(2) 258 | 259 | cron := newWithSeconds() 260 | cron.AddFunc("0 0 0 1 1 ?", func() {}) 261 | cron.AddFunc("0 0 0 31 12 ?", func() {}) 262 | cron.AddFunc("* * * * * ?", func() { wg.Done() }) 263 | 264 | cron.Start() 265 | defer cron.Stop() 266 | 267 | select { 268 | case <-time.After(2 * OneSecond): 269 | t.Error("expected job fires 2 times") 270 | case <-wait(wg): 271 | } 272 | } 273 | 274 | func TestRunningMultipleSchedules(t *testing.T) { 275 | wg := &sync.WaitGroup{} 276 | wg.Add(2) 277 | 278 | cron := newWithSeconds() 279 | cron.AddFunc("0 0 0 1 1 ?", func() {}) 280 | cron.AddFunc("0 0 0 31 12 ?", func() {}) 281 | cron.AddFunc("* * * * * ?", func() { wg.Done() }) 282 | cron.Schedule(Every(time.Minute), FuncJob(func() {})) 283 | cron.Schedule(Every(time.Second), FuncJob(func() { wg.Done() })) 284 | cron.Schedule(Every(time.Hour), FuncJob(func() {})) 285 | 286 | cron.Start() 287 | defer cron.Stop() 288 | 289 | select { 290 | case <-time.After(2 * OneSecond): 291 | t.Error("expected job fires 2 times") 292 | case <-wait(wg): 293 | } 294 | } 295 | 296 | // Test that the cron is run in the local time zone (as opposed to UTC). 297 | func TestLocalTimezone(t *testing.T) { 298 | wg := &sync.WaitGroup{} 299 | wg.Add(2) 300 | 301 | now := time.Now() 302 | // FIX: Issue #205 303 | // This calculation doesn't work in seconds 58 or 59. 304 | // Take the easy way out and sleep. 305 | if now.Second() >= 58 { 306 | time.Sleep(2 * time.Second) 307 | now = time.Now() 308 | } 309 | spec := fmt.Sprintf("%d,%d %d %d %d %d ?", 310 | now.Second()+1, now.Second()+2, now.Minute(), now.Hour(), now.Day(), now.Month()) 311 | 312 | cron := newWithSeconds() 313 | cron.AddFunc(spec, func() { wg.Done() }) 314 | cron.Start() 315 | defer cron.Stop() 316 | 317 | select { 318 | case <-time.After(OneSecond * 2): 319 | t.Error("expected job fires 2 times") 320 | case <-wait(wg): 321 | } 322 | } 323 | 324 | // Test that the cron is run in the given time zone (as opposed to local). 325 | func TestNonLocalTimezone(t *testing.T) { 326 | wg := &sync.WaitGroup{} 327 | wg.Add(2) 328 | 329 | loc, err := time.LoadLocation("Atlantic/Cape_Verde") 330 | if err != nil { 331 | fmt.Printf("Failed to load time zone Atlantic/Cape_Verde: %+v", err) 332 | t.Fail() 333 | } 334 | 335 | now := time.Now().In(loc) 336 | // FIX: Issue #205 337 | // This calculation doesn't work in seconds 58 or 59. 338 | // Take the easy way out and sleep. 339 | if now.Second() >= 58 { 340 | time.Sleep(2 * time.Second) 341 | now = time.Now().In(loc) 342 | } 343 | spec := fmt.Sprintf("%d,%d %d %d %d %d ?", 344 | now.Second()+1, now.Second()+2, now.Minute(), now.Hour(), now.Day(), now.Month()) 345 | 346 | cron := New(WithLocation(loc), WithParser(secondParser)) 347 | cron.AddFunc(spec, func() { wg.Done() }) 348 | cron.Start() 349 | defer cron.Stop() 350 | 351 | select { 352 | case <-time.After(OneSecond * 2): 353 | t.Error("expected job fires 2 times") 354 | case <-wait(wg): 355 | } 356 | } 357 | 358 | // Test that calling stop before start silently returns without 359 | // blocking the stop channel. 360 | func TestStopWithoutStart(t *testing.T) { 361 | cron := New() 362 | cron.Stop() 363 | } 364 | 365 | type testJob struct { 366 | wg *sync.WaitGroup 367 | name string 368 | } 369 | 370 | func (t testJob) Run() { 371 | t.wg.Done() 372 | } 373 | 374 | // Test that adding an invalid job spec returns an error 375 | func TestInvalidJobSpec(t *testing.T) { 376 | cron := New() 377 | _, err := cron.AddJob("this will not parse", nil) 378 | if err == nil { 379 | t.Errorf("expected an error with invalid spec, got nil") 380 | } 381 | } 382 | 383 | // Test blocking run method behaves as Start() 384 | func TestBlockingRun(t *testing.T) { 385 | wg := &sync.WaitGroup{} 386 | wg.Add(1) 387 | 388 | cron := newWithSeconds() 389 | cron.AddFunc("* * * * * ?", func() { wg.Done() }) 390 | 391 | var unblockChan = make(chan struct{}) 392 | 393 | go func() { 394 | cron.Run() 395 | close(unblockChan) 396 | }() 397 | defer cron.Stop() 398 | 399 | select { 400 | case <-time.After(OneSecond): 401 | t.Error("expected job fires") 402 | case <-unblockChan: 403 | t.Error("expected that Run() blocks") 404 | case <-wait(wg): 405 | } 406 | } 407 | 408 | // Test that double-running is a no-op 409 | func TestStartNoop(t *testing.T) { 410 | var tickChan = make(chan struct{}, 2) 411 | 412 | cron := newWithSeconds() 413 | cron.AddFunc("* * * * * ?", func() { 414 | tickChan <- struct{}{} 415 | }) 416 | 417 | cron.Start() 418 | defer cron.Stop() 419 | 420 | // Wait for the first firing to ensure the runner is going 421 | <-tickChan 422 | 423 | cron.Start() 424 | 425 | <-tickChan 426 | 427 | // Fail if this job fires again in a short period, indicating a double-run 428 | select { 429 | case <-time.After(time.Millisecond): 430 | case <-tickChan: 431 | t.Error("expected job fires exactly twice") 432 | } 433 | } 434 | 435 | // Simple test using Runnables. 436 | func TestJob(t *testing.T) { 437 | wg := &sync.WaitGroup{} 438 | wg.Add(1) 439 | 440 | cron := newWithSeconds() 441 | cron.AddJob("0 0 0 30 Feb ?", testJob{wg, "job0"}) 442 | cron.AddJob("0 0 0 1 1 ?", testJob{wg, "job1"}) 443 | job2, _ := cron.AddJob("* * * * * ?", testJob{wg, "job2"}) 444 | cron.AddJob("1 0 0 1 1 ?", testJob{wg, "job3"}) 445 | cron.Schedule(Every(5*time.Second+5*time.Nanosecond), testJob{wg, "job4"}) 446 | job5 := cron.Schedule(Every(5*time.Minute), testJob{wg, "job5"}) 447 | 448 | // Test getting an Entry pre-Start. 449 | if actualName := cron.Entry(job2).Job.(testJob).name; actualName != "job2" { 450 | t.Error("wrong job retrieved:", actualName) 451 | } 452 | if actualName := cron.Entry(job5).Job.(testJob).name; actualName != "job5" { 453 | t.Error("wrong job retrieved:", actualName) 454 | } 455 | 456 | cron.Start() 457 | defer cron.Stop() 458 | 459 | select { 460 | case <-time.After(OneSecond): 461 | t.FailNow() 462 | case <-wait(wg): 463 | } 464 | 465 | // Ensure the entries are in the right order. 466 | expecteds := []string{"job2", "job4", "job5", "job1", "job3", "job0"} 467 | 468 | var actuals []string 469 | for _, entry := range cron.Entries() { 470 | actuals = append(actuals, entry.Job.(testJob).name) 471 | } 472 | 473 | for i, expected := range expecteds { 474 | if actuals[i] != expected { 475 | t.Fatalf("Jobs not in the right order. (expected) %s != %s (actual)", expecteds, actuals) 476 | } 477 | } 478 | 479 | // Test getting Entries. 480 | if actualName := cron.Entry(job2).Job.(testJob).name; actualName != "job2" { 481 | t.Error("wrong job retrieved:", actualName) 482 | } 483 | if actualName := cron.Entry(job5).Job.(testJob).name; actualName != "job5" { 484 | t.Error("wrong job retrieved:", actualName) 485 | } 486 | } 487 | 488 | // Issue #206 489 | // Ensure that the next run of a job after removing an entry is accurate. 490 | func TestScheduleAfterRemoval(t *testing.T) { 491 | var wg1 sync.WaitGroup 492 | var wg2 sync.WaitGroup 493 | wg1.Add(1) 494 | wg2.Add(1) 495 | 496 | // The first time this job is run, set a timer and remove the other job 497 | // 750ms later. Correct behavior would be to still run the job again in 498 | // 250ms, but the bug would cause it to run instead 1s later. 499 | 500 | var calls int 501 | var mu sync.Mutex 502 | 503 | cron := newWithSeconds() 504 | hourJob := cron.Schedule(Every(time.Hour), FuncJob(func() {})) 505 | cron.Schedule(Every(time.Second), FuncJob(func() { 506 | mu.Lock() 507 | defer mu.Unlock() 508 | switch calls { 509 | case 0: 510 | wg1.Done() 511 | calls++ 512 | case 1: 513 | time.Sleep(750 * time.Millisecond) 514 | cron.Remove(hourJob) 515 | calls++ 516 | case 2: 517 | calls++ 518 | wg2.Done() 519 | case 3: 520 | panic("unexpected 3rd call") 521 | } 522 | })) 523 | 524 | cron.Start() 525 | defer cron.Stop() 526 | 527 | // the first run might be any length of time 0 - 1s, since the schedule 528 | // rounds to the second. wait for the first run to true up. 529 | wg1.Wait() 530 | 531 | select { 532 | case <-time.After(2 * OneSecond): 533 | t.Error("expected job fires 2 times") 534 | case <-wait(&wg2): 535 | } 536 | } 537 | 538 | type ZeroSchedule struct{} 539 | 540 | func (*ZeroSchedule) Next(time.Time) time.Time { 541 | return time.Time{} 542 | } 543 | 544 | // Tests that job without time does not run 545 | func TestJobWithZeroTimeDoesNotRun(t *testing.T) { 546 | cron := newWithSeconds() 547 | var calls int64 548 | cron.AddFunc("* * * * * *", func() { atomic.AddInt64(&calls, 1) }) 549 | cron.Schedule(new(ZeroSchedule), FuncJob(func() { t.Error("expected zero task will not run") })) 550 | cron.Start() 551 | defer cron.Stop() 552 | <-time.After(OneSecond) 553 | if atomic.LoadInt64(&calls) != 1 { 554 | t.Errorf("called %d times, expected 1\n", calls) 555 | } 556 | } 557 | 558 | func TestStopAndWait(t *testing.T) { 559 | t.Run("nothing running, returns immediately", func(t *testing.T) { 560 | cron := newWithSeconds() 561 | cron.Start() 562 | ctx := cron.Stop() 563 | select { 564 | case <-ctx.Done(): 565 | case <-time.After(time.Millisecond): 566 | t.Error("context was not done immediately") 567 | } 568 | }) 569 | 570 | t.Run("repeated calls to Stop", func(t *testing.T) { 571 | cron := newWithSeconds() 572 | cron.Start() 573 | _ = cron.Stop() 574 | time.Sleep(time.Millisecond) 575 | ctx := cron.Stop() 576 | select { 577 | case <-ctx.Done(): 578 | case <-time.After(time.Millisecond): 579 | t.Error("context was not done immediately") 580 | } 581 | }) 582 | 583 | t.Run("a couple fast jobs added, still returns immediately", func(t *testing.T) { 584 | cron := newWithSeconds() 585 | cron.AddFunc("* * * * * *", func() {}) 586 | cron.Start() 587 | cron.AddFunc("* * * * * *", func() {}) 588 | cron.AddFunc("* * * * * *", func() {}) 589 | cron.AddFunc("* * * * * *", func() {}) 590 | time.Sleep(time.Second) 591 | ctx := cron.Stop() 592 | select { 593 | case <-ctx.Done(): 594 | case <-time.After(time.Millisecond): 595 | t.Error("context was not done immediately") 596 | } 597 | }) 598 | 599 | t.Run("a couple fast jobs and a slow job added, waits for slow job", func(t *testing.T) { 600 | cron := newWithSeconds() 601 | cron.AddFunc("* * * * * *", func() {}) 602 | cron.Start() 603 | cron.AddFunc("* * * * * *", func() { time.Sleep(2 * time.Second) }) 604 | cron.AddFunc("* * * * * *", func() {}) 605 | time.Sleep(time.Second) 606 | 607 | ctx := cron.Stop() 608 | 609 | // Verify that it is not done for at least 750ms 610 | select { 611 | case <-ctx.Done(): 612 | t.Error("context was done too quickly immediately") 613 | case <-time.After(750 * time.Millisecond): 614 | // expected, because the job sleeping for 1 second is still running 615 | } 616 | 617 | // Verify that it IS done in the next 500ms (giving 250ms buffer) 618 | select { 619 | case <-ctx.Done(): 620 | // expected 621 | case <-time.After(1500 * time.Millisecond): 622 | t.Error("context not done after job should have completed") 623 | } 624 | }) 625 | 626 | t.Run("repeated calls to stop, waiting for completion and after", func(t *testing.T) { 627 | cron := newWithSeconds() 628 | cron.AddFunc("* * * * * *", func() {}) 629 | cron.AddFunc("* * * * * *", func() { time.Sleep(2 * time.Second) }) 630 | cron.Start() 631 | cron.AddFunc("* * * * * *", func() {}) 632 | time.Sleep(time.Second) 633 | ctx := cron.Stop() 634 | ctx2 := cron.Stop() 635 | 636 | // Verify that it is not done for at least 1500ms 637 | select { 638 | case <-ctx.Done(): 639 | t.Error("context was done too quickly immediately") 640 | case <-ctx2.Done(): 641 | t.Error("context2 was done too quickly immediately") 642 | case <-time.After(1500 * time.Millisecond): 643 | // expected, because the job sleeping for 2 seconds is still running 644 | } 645 | 646 | // Verify that it IS done in the next 1s (giving 500ms buffer) 647 | select { 648 | case <-ctx.Done(): 649 | // expected 650 | case <-time.After(time.Second): 651 | t.Error("context not done after job should have completed") 652 | } 653 | 654 | // Verify that ctx2 is also done. 655 | select { 656 | case <-ctx2.Done(): 657 | // expected 658 | case <-time.After(time.Millisecond): 659 | t.Error("context2 not done even though context1 is") 660 | } 661 | 662 | // Verify that a new context retrieved from stop is immediately done. 663 | ctx3 := cron.Stop() 664 | select { 665 | case <-ctx3.Done(): 666 | // expected 667 | case <-time.After(time.Millisecond): 668 | t.Error("context not done even when cron Stop is completed") 669 | } 670 | 671 | }) 672 | } 673 | 674 | func TestMultiThreadedStartAndStop(t *testing.T) { 675 | cron := New() 676 | go cron.Run() 677 | time.Sleep(2 * time.Millisecond) 678 | cron.Stop() 679 | } 680 | 681 | func wait(wg *sync.WaitGroup) chan bool { 682 | ch := make(chan bool) 683 | go func() { 684 | wg.Wait() 685 | ch <- true 686 | }() 687 | return ch 688 | } 689 | 690 | func stop(cron *Cron) chan bool { 691 | ch := make(chan bool) 692 | go func() { 693 | cron.Stop() 694 | ch <- true 695 | }() 696 | return ch 697 | } 698 | 699 | // newWithSeconds returns a Cron with the seconds field enabled. 700 | func newWithSeconds() *Cron { 701 | return New(WithParser(secondParser), WithChain()) 702 | } 703 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package cron implements a cron spec parser and job runner. 3 | 4 | Installation 5 | 6 | To download the specific tagged release, run: 7 | 8 | go get github.com/robfig/cron/v3@v3.0.0 9 | 10 | Import it in your program as: 11 | 12 | import "github.com/robfig/cron/v3" 13 | 14 | It requires Go 1.11 or later due to usage of Go Modules. 15 | 16 | Usage 17 | 18 | Callers may register Funcs to be invoked on a given schedule. Cron will run 19 | them in their own goroutines. 20 | 21 | c := cron.New() 22 | c.AddFunc("30 * * * *", func() { fmt.Println("Every hour on the half hour") }) 23 | c.AddFunc("30 3-6,20-23 * * *", func() { fmt.Println(".. in the range 3-6am, 8-11pm") }) 24 | c.AddFunc("CRON_TZ=Asia/Tokyo 30 04 * * *", func() { fmt.Println("Runs at 04:30 Tokyo time every day") }) 25 | c.AddFunc("@hourly", func() { fmt.Println("Every hour, starting an hour from now") }) 26 | c.AddFunc("@every 1h30m", func() { fmt.Println("Every hour thirty, starting an hour thirty from now") }) 27 | c.Start() 28 | .. 29 | // Funcs are invoked in their own goroutine, asynchronously. 30 | ... 31 | // Funcs may also be added to a running Cron 32 | c.AddFunc("@daily", func() { fmt.Println("Every day") }) 33 | .. 34 | // Inspect the cron job entries' next and previous run times. 35 | inspect(c.Entries()) 36 | .. 37 | c.Stop() // Stop the scheduler (does not stop any jobs already running). 38 | 39 | CRON Expression Format 40 | 41 | A cron expression represents a set of times, using 5 space-separated fields. 42 | 43 | Field name | Mandatory? | Allowed values | Allowed special characters 44 | ---------- | ---------- | -------------- | -------------------------- 45 | Minutes | Yes | 0-59 | * / , - 46 | Hours | Yes | 0-23 | * / , - 47 | Day of month | Yes | 1-31 | * / , - ? 48 | Month | Yes | 1-12 or JAN-DEC | * / , - 49 | Day of week | Yes | 0-6 or SUN-SAT | * / , - ? 50 | 51 | Month and Day-of-week field values are case insensitive. "SUN", "Sun", and 52 | "sun" are equally accepted. 53 | 54 | The specific interpretation of the format is based on the Cron Wikipedia page: 55 | https://en.wikipedia.org/wiki/Cron 56 | 57 | Alternative Formats 58 | 59 | Alternative Cron expression formats support other fields like seconds. You can 60 | implement that by creating a custom Parser as follows. 61 | 62 | cron.New( 63 | cron.WithParser( 64 | cron.NewParser( 65 | cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor))) 66 | 67 | Since adding Seconds is the most common modification to the standard cron spec, 68 | cron provides a builtin function to do that, which is equivalent to the custom 69 | parser you saw earlier, except that its seconds field is REQUIRED: 70 | 71 | cron.New(cron.WithSeconds()) 72 | 73 | That emulates Quartz, the most popular alternative Cron schedule format: 74 | http://www.quartz-scheduler.org/documentation/quartz-2.x/tutorials/crontrigger.html 75 | 76 | Special Characters 77 | 78 | Asterisk ( * ) 79 | 80 | The asterisk indicates that the cron expression will match for all values of the 81 | field; e.g., using an asterisk in the 5th field (month) would indicate every 82 | month. 83 | 84 | Slash ( / ) 85 | 86 | Slashes are used to describe increments of ranges. For example 3-59/15 in the 87 | 1st field (minutes) would indicate the 3rd minute of the hour and every 15 88 | minutes thereafter. The form "*\/..." is equivalent to the form "first-last/...", 89 | that is, an increment over the largest possible range of the field. The form 90 | "N/..." is accepted as meaning "N-MAX/...", that is, starting at N, use the 91 | increment until the end of that specific range. It does not wrap around. 92 | 93 | Comma ( , ) 94 | 95 | Commas are used to separate items of a list. For example, using "MON,WED,FRI" in 96 | the 5th field (day of week) would mean Mondays, Wednesdays and Fridays. 97 | 98 | Hyphen ( - ) 99 | 100 | Hyphens are used to define ranges. For example, 9-17 would indicate every 101 | hour between 9am and 5pm inclusive. 102 | 103 | Question mark ( ? ) 104 | 105 | Question mark may be used instead of '*' for leaving either day-of-month or 106 | day-of-week blank. 107 | 108 | Predefined schedules 109 | 110 | You may use one of several pre-defined schedules in place of a cron expression. 111 | 112 | Entry | Description | Equivalent To 113 | ----- | ----------- | ------------- 114 | @yearly (or @annually) | Run once a year, midnight, Jan. 1st | 0 0 1 1 * 115 | @monthly | Run once a month, midnight, first of month | 0 0 1 * * 116 | @weekly | Run once a week, midnight between Sat/Sun | 0 0 * * 0 117 | @daily (or @midnight) | Run once a day, midnight | 0 0 * * * 118 | @hourly | Run once an hour, beginning of hour | 0 * * * * 119 | 120 | Intervals 121 | 122 | You may also schedule a job to execute at fixed intervals, starting at the time it's added 123 | or cron is run. This is supported by formatting the cron spec like this: 124 | 125 | @every 126 | 127 | where "duration" is a string accepted by time.ParseDuration 128 | (http://golang.org/pkg/time/#ParseDuration). 129 | 130 | For example, "@every 1h30m10s" would indicate a schedule that activates after 131 | 1 hour, 30 minutes, 10 seconds, and then every interval after that. 132 | 133 | Note: The interval does not take the job runtime into account. For example, 134 | if a job takes 3 minutes to run, and it is scheduled to run every 5 minutes, 135 | it will have only 2 minutes of idle time between each run. 136 | 137 | Time zones 138 | 139 | By default, all interpretation and scheduling is done in the machine's local 140 | time zone (time.Local). You can specify a different time zone on construction: 141 | 142 | cron.New( 143 | cron.WithLocation(time.UTC)) 144 | 145 | Individual cron schedules may also override the time zone they are to be 146 | interpreted in by providing an additional space-separated field at the beginning 147 | of the cron spec, of the form "CRON_TZ=Asia/Tokyo". 148 | 149 | For example: 150 | 151 | # Runs at 6am in time.Local 152 | cron.New().AddFunc("0 6 * * ?", ...) 153 | 154 | # Runs at 6am in America/New_York 155 | nyc, _ := time.LoadLocation("America/New_York") 156 | c := cron.New(cron.WithLocation(nyc)) 157 | c.AddFunc("0 6 * * ?", ...) 158 | 159 | # Runs at 6am in Asia/Tokyo 160 | cron.New().AddFunc("CRON_TZ=Asia/Tokyo 0 6 * * ?", ...) 161 | 162 | # Runs at 6am in Asia/Tokyo 163 | c := cron.New(cron.WithLocation(nyc)) 164 | c.SetLocation("America/New_York") 165 | c.AddFunc("CRON_TZ=Asia/Tokyo 0 6 * * ?", ...) 166 | 167 | The prefix "TZ=(TIME ZONE)" is also supported for legacy compatibility. 168 | 169 | Be aware that jobs scheduled during daylight-savings leap-ahead transitions will 170 | not be run! 171 | 172 | Job Wrappers 173 | 174 | A Cron runner may be configured with a chain of job wrappers to add 175 | cross-cutting functionality to all submitted jobs. For example, they may be used 176 | to achieve the following effects: 177 | 178 | - Recover any panics from jobs (activated by default) 179 | - Delay a job's execution if the previous run hasn't completed yet 180 | - Skip a job's execution if the previous run hasn't completed yet 181 | - Log each job's invocations 182 | 183 | Install wrappers for all jobs added to a cron using the `cron.WithChain` option: 184 | 185 | cron.New(cron.WithChain( 186 | cron.SkipIfStillRunning(logger), 187 | )) 188 | 189 | Install wrappers for individual jobs by explicitly wrapping them: 190 | 191 | job = cron.NewChain( 192 | cron.SkipIfStillRunning(logger), 193 | ).Then(job) 194 | 195 | Thread safety 196 | 197 | Since the Cron service runs concurrently with the calling code, some amount of 198 | care must be taken to ensure proper synchronization. 199 | 200 | All cron methods are designed to be correctly synchronized as long as the caller 201 | ensures that invocations have a clear happens-before ordering between them. 202 | 203 | Logging 204 | 205 | Cron defines a Logger interface that is a subset of the one defined in 206 | github.com/go-logr/logr. It has two logging levels (Info and Error), and 207 | parameters are key/value pairs. This makes it possible for cron logging to plug 208 | into structured logging systems. An adapter, [Verbose]PrintfLogger, is provided 209 | to wrap the standard library *log.Logger. 210 | 211 | For additional insight into Cron operations, verbose logging may be activated 212 | which will record job runs, scheduling decisions, and added or removed jobs. 213 | Activate it with a one-off logger as follows: 214 | 215 | cron.New( 216 | cron.WithLogger( 217 | cron.VerbosePrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags)))) 218 | 219 | 220 | Implementation 221 | 222 | Cron entries are stored in an array, sorted by their next activation time. Cron 223 | sleeps until the next job is due to be run. 224 | 225 | Upon waking: 226 | - it runs each entry that is active on that second 227 | - it calculates the next run times for the jobs that were run 228 | - it re-sorts the array of entries by next activation time. 229 | - it goes to sleep until the soonest job. 230 | */ 231 | package cron 232 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/robfig/cron/v3 2 | 3 | go 1.12 4 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | "os" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | // DefaultLogger is used by Cron if none is specified. 12 | var DefaultLogger Logger = PrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags)) 13 | 14 | // DiscardLogger can be used by callers to discard all log messages. 15 | var DiscardLogger Logger = PrintfLogger(log.New(ioutil.Discard, "", 0)) 16 | 17 | // Logger is the interface used in this package for logging, so that any backend 18 | // can be plugged in. It is a subset of the github.com/go-logr/logr interface. 19 | type Logger interface { 20 | // Info logs routine messages about cron's operation. 21 | Info(msg string, keysAndValues ...interface{}) 22 | // Error logs an error condition. 23 | Error(err error, msg string, keysAndValues ...interface{}) 24 | } 25 | 26 | // PrintfLogger wraps a Printf-based logger (such as the standard library "log") 27 | // into an implementation of the Logger interface which logs errors only. 28 | func PrintfLogger(l interface{ Printf(string, ...interface{}) }) Logger { 29 | return printfLogger{l, false} 30 | } 31 | 32 | // VerbosePrintfLogger wraps a Printf-based logger (such as the standard library 33 | // "log") into an implementation of the Logger interface which logs everything. 34 | func VerbosePrintfLogger(l interface{ Printf(string, ...interface{}) }) Logger { 35 | return printfLogger{l, true} 36 | } 37 | 38 | type printfLogger struct { 39 | logger interface{ Printf(string, ...interface{}) } 40 | logInfo bool 41 | } 42 | 43 | func (pl printfLogger) Info(msg string, keysAndValues ...interface{}) { 44 | if pl.logInfo { 45 | keysAndValues = formatTimes(keysAndValues) 46 | pl.logger.Printf( 47 | formatString(len(keysAndValues)), 48 | append([]interface{}{msg}, keysAndValues...)...) 49 | } 50 | } 51 | 52 | func (pl printfLogger) Error(err error, msg string, keysAndValues ...interface{}) { 53 | keysAndValues = formatTimes(keysAndValues) 54 | pl.logger.Printf( 55 | formatString(len(keysAndValues)+2), 56 | append([]interface{}{msg, "error", err}, keysAndValues...)...) 57 | } 58 | 59 | // formatString returns a logfmt-like format string for the number of 60 | // key/values. 61 | func formatString(numKeysAndValues int) string { 62 | var sb strings.Builder 63 | sb.WriteString("%s") 64 | if numKeysAndValues > 0 { 65 | sb.WriteString(", ") 66 | } 67 | for i := 0; i < numKeysAndValues/2; i++ { 68 | if i > 0 { 69 | sb.WriteString(", ") 70 | } 71 | sb.WriteString("%v=%v") 72 | } 73 | return sb.String() 74 | } 75 | 76 | // formatTimes formats any time.Time values as RFC3339. 77 | func formatTimes(keysAndValues []interface{}) []interface{} { 78 | var formattedArgs []interface{} 79 | for _, arg := range keysAndValues { 80 | if t, ok := arg.(time.Time); ok { 81 | arg = t.Format(time.RFC3339) 82 | } 83 | formattedArgs = append(formattedArgs, arg) 84 | } 85 | return formattedArgs 86 | } 87 | -------------------------------------------------------------------------------- /option.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Option represents a modification to the default behavior of a Cron. 8 | type Option func(*Cron) 9 | 10 | // WithLocation overrides the timezone of the cron instance. 11 | func WithLocation(loc *time.Location) Option { 12 | return func(c *Cron) { 13 | c.location = loc 14 | } 15 | } 16 | 17 | // WithSeconds overrides the parser used for interpreting job schedules to 18 | // include a seconds field as the first one. 19 | func WithSeconds() Option { 20 | return WithParser(NewParser( 21 | Second | Minute | Hour | Dom | Month | Dow | Descriptor, 22 | )) 23 | } 24 | 25 | // WithParser overrides the parser used for interpreting job schedules. 26 | func WithParser(p ScheduleParser) Option { 27 | return func(c *Cron) { 28 | c.parser = p 29 | } 30 | } 31 | 32 | // WithChain specifies Job wrappers to apply to all jobs added to this cron. 33 | // Refer to the Chain* functions in this package for provided wrappers. 34 | func WithChain(wrappers ...JobWrapper) Option { 35 | return func(c *Cron) { 36 | c.chain = NewChain(wrappers...) 37 | } 38 | } 39 | 40 | // WithLogger uses the provided logger. 41 | func WithLogger(logger Logger) Option { 42 | return func(c *Cron) { 43 | c.logger = logger 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /option_test.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestWithLocation(t *testing.T) { 11 | c := New(WithLocation(time.UTC)) 12 | if c.location != time.UTC { 13 | t.Errorf("expected UTC, got %v", c.location) 14 | } 15 | } 16 | 17 | func TestWithParser(t *testing.T) { 18 | var parser = NewParser(Dow) 19 | c := New(WithParser(parser)) 20 | if c.parser != parser { 21 | t.Error("expected provided parser") 22 | } 23 | } 24 | 25 | func TestWithVerboseLogger(t *testing.T) { 26 | var buf syncWriter 27 | var logger = log.New(&buf, "", log.LstdFlags) 28 | c := New(WithLogger(VerbosePrintfLogger(logger))) 29 | if c.logger.(printfLogger).logger != logger { 30 | t.Error("expected provided logger") 31 | } 32 | 33 | c.AddFunc("@every 1s", func() {}) 34 | c.Start() 35 | time.Sleep(OneSecond) 36 | c.Stop() 37 | out := buf.String() 38 | if !strings.Contains(out, "schedule,") || 39 | !strings.Contains(out, "run,") { 40 | t.Error("expected to see some actions, got:", out) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /parser.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strconv" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | // Configuration options for creating a parser. Most options specify which 12 | // fields should be included, while others enable features. If a field is not 13 | // included the parser will assume a default value. These options do not change 14 | // the order fields are parse in. 15 | type ParseOption int 16 | 17 | const ( 18 | Second ParseOption = 1 << iota // Seconds field, default 0 19 | SecondOptional // Optional seconds field, default 0 20 | Minute // Minutes field, default 0 21 | Hour // Hours field, default 0 22 | Dom // Day of month field, default * 23 | Month // Month field, default * 24 | Dow // Day of week field, default * 25 | DowOptional // Optional day of week field, default * 26 | Descriptor // Allow descriptors such as @monthly, @weekly, etc. 27 | ) 28 | 29 | var places = []ParseOption{ 30 | Second, 31 | Minute, 32 | Hour, 33 | Dom, 34 | Month, 35 | Dow, 36 | } 37 | 38 | var defaults = []string{ 39 | "0", 40 | "0", 41 | "0", 42 | "*", 43 | "*", 44 | "*", 45 | } 46 | 47 | // A custom Parser that can be configured. 48 | type Parser struct { 49 | options ParseOption 50 | } 51 | 52 | // NewParser creates a Parser with custom options. 53 | // 54 | // It panics if more than one Optional is given, since it would be impossible to 55 | // correctly infer which optional is provided or missing in general. 56 | // 57 | // Examples 58 | // 59 | // // Standard parser without descriptors 60 | // specParser := NewParser(Minute | Hour | Dom | Month | Dow) 61 | // sched, err := specParser.Parse("0 0 15 */3 *") 62 | // 63 | // // Same as above, just excludes time fields 64 | // specParser := NewParser(Dom | Month | Dow) 65 | // sched, err := specParser.Parse("15 */3 *") 66 | // 67 | // // Same as above, just makes Dow optional 68 | // specParser := NewParser(Dom | Month | DowOptional) 69 | // sched, err := specParser.Parse("15 */3") 70 | // 71 | func NewParser(options ParseOption) Parser { 72 | optionals := 0 73 | if options&DowOptional > 0 { 74 | optionals++ 75 | } 76 | if options&SecondOptional > 0 { 77 | optionals++ 78 | } 79 | if optionals > 1 { 80 | panic("multiple optionals may not be configured") 81 | } 82 | return Parser{options} 83 | } 84 | 85 | // Parse returns a new crontab schedule representing the given spec. 86 | // It returns a descriptive error if the spec is not valid. 87 | // It accepts crontab specs and features configured by NewParser. 88 | func (p Parser) Parse(spec string) (Schedule, error) { 89 | if len(spec) == 0 { 90 | return nil, fmt.Errorf("empty spec string") 91 | } 92 | 93 | // Extract timezone if present 94 | var loc = time.Local 95 | if strings.HasPrefix(spec, "TZ=") || strings.HasPrefix(spec, "CRON_TZ=") { 96 | var err error 97 | i := strings.Index(spec, " ") 98 | eq := strings.Index(spec, "=") 99 | if loc, err = time.LoadLocation(spec[eq+1 : i]); err != nil { 100 | return nil, fmt.Errorf("provided bad location %s: %v", spec[eq+1:i], err) 101 | } 102 | spec = strings.TrimSpace(spec[i:]) 103 | } 104 | 105 | // Handle named schedules (descriptors), if configured 106 | if strings.HasPrefix(spec, "@") { 107 | if p.options&Descriptor == 0 { 108 | return nil, fmt.Errorf("parser does not accept descriptors: %v", spec) 109 | } 110 | return parseDescriptor(spec, loc) 111 | } 112 | 113 | // Split on whitespace. 114 | fields := strings.Fields(spec) 115 | 116 | // Validate & fill in any omitted or optional fields 117 | var err error 118 | fields, err = normalizeFields(fields, p.options) 119 | if err != nil { 120 | return nil, err 121 | } 122 | 123 | field := func(field string, r bounds) uint64 { 124 | if err != nil { 125 | return 0 126 | } 127 | var bits uint64 128 | bits, err = getField(field, r) 129 | return bits 130 | } 131 | 132 | var ( 133 | second = field(fields[0], seconds) 134 | minute = field(fields[1], minutes) 135 | hour = field(fields[2], hours) 136 | dayofmonth = field(fields[3], dom) 137 | month = field(fields[4], months) 138 | dayofweek = field(fields[5], dow) 139 | ) 140 | if err != nil { 141 | return nil, err 142 | } 143 | 144 | return &SpecSchedule{ 145 | Second: second, 146 | Minute: minute, 147 | Hour: hour, 148 | Dom: dayofmonth, 149 | Month: month, 150 | Dow: dayofweek, 151 | Location: loc, 152 | }, nil 153 | } 154 | 155 | // normalizeFields takes a subset set of the time fields and returns the full set 156 | // with defaults (zeroes) populated for unset fields. 157 | // 158 | // As part of performing this function, it also validates that the provided 159 | // fields are compatible with the configured options. 160 | func normalizeFields(fields []string, options ParseOption) ([]string, error) { 161 | // Validate optionals & add their field to options 162 | optionals := 0 163 | if options&SecondOptional > 0 { 164 | options |= Second 165 | optionals++ 166 | } 167 | if options&DowOptional > 0 { 168 | options |= Dow 169 | optionals++ 170 | } 171 | if optionals > 1 { 172 | return nil, fmt.Errorf("multiple optionals may not be configured") 173 | } 174 | 175 | // Figure out how many fields we need 176 | max := 0 177 | for _, place := range places { 178 | if options&place > 0 { 179 | max++ 180 | } 181 | } 182 | min := max - optionals 183 | 184 | // Validate number of fields 185 | if count := len(fields); count < min || count > max { 186 | if min == max { 187 | return nil, fmt.Errorf("expected exactly %d fields, found %d: %s", min, count, fields) 188 | } 189 | return nil, fmt.Errorf("expected %d to %d fields, found %d: %s", min, max, count, fields) 190 | } 191 | 192 | // Populate the optional field if not provided 193 | if min < max && len(fields) == min { 194 | switch { 195 | case options&DowOptional > 0: 196 | fields = append(fields, defaults[5]) // TODO: improve access to default 197 | case options&SecondOptional > 0: 198 | fields = append([]string{defaults[0]}, fields...) 199 | default: 200 | return nil, fmt.Errorf("unknown optional field") 201 | } 202 | } 203 | 204 | // Populate all fields not part of options with their defaults 205 | n := 0 206 | expandedFields := make([]string, len(places)) 207 | copy(expandedFields, defaults) 208 | for i, place := range places { 209 | if options&place > 0 { 210 | expandedFields[i] = fields[n] 211 | n++ 212 | } 213 | } 214 | return expandedFields, nil 215 | } 216 | 217 | var standardParser = NewParser( 218 | Minute | Hour | Dom | Month | Dow | Descriptor, 219 | ) 220 | 221 | // ParseStandard returns a new crontab schedule representing the given 222 | // standardSpec (https://en.wikipedia.org/wiki/Cron). It requires 5 entries 223 | // representing: minute, hour, day of month, month and day of week, in that 224 | // order. It returns a descriptive error if the spec is not valid. 225 | // 226 | // It accepts 227 | // - Standard crontab specs, e.g. "* * * * ?" 228 | // - Descriptors, e.g. "@midnight", "@every 1h30m" 229 | func ParseStandard(standardSpec string) (Schedule, error) { 230 | return standardParser.Parse(standardSpec) 231 | } 232 | 233 | // getField returns an Int with the bits set representing all of the times that 234 | // the field represents or error parsing field value. A "field" is a comma-separated 235 | // list of "ranges". 236 | func getField(field string, r bounds) (uint64, error) { 237 | var bits uint64 238 | ranges := strings.FieldsFunc(field, func(r rune) bool { return r == ',' }) 239 | for _, expr := range ranges { 240 | bit, err := getRange(expr, r) 241 | if err != nil { 242 | return bits, err 243 | } 244 | bits |= bit 245 | } 246 | return bits, nil 247 | } 248 | 249 | // getRange returns the bits indicated by the given expression: 250 | // number | number "-" number [ "/" number ] 251 | // or error parsing range. 252 | func getRange(expr string, r bounds) (uint64, error) { 253 | var ( 254 | start, end, step uint 255 | rangeAndStep = strings.Split(expr, "/") 256 | lowAndHigh = strings.Split(rangeAndStep[0], "-") 257 | singleDigit = len(lowAndHigh) == 1 258 | err error 259 | ) 260 | 261 | var extra uint64 262 | if lowAndHigh[0] == "*" || lowAndHigh[0] == "?" { 263 | start = r.min 264 | end = r.max 265 | extra = starBit 266 | } else { 267 | start, err = parseIntOrName(lowAndHigh[0], r.names) 268 | if err != nil { 269 | return 0, err 270 | } 271 | switch len(lowAndHigh) { 272 | case 1: 273 | end = start 274 | case 2: 275 | end, err = parseIntOrName(lowAndHigh[1], r.names) 276 | if err != nil { 277 | return 0, err 278 | } 279 | default: 280 | return 0, fmt.Errorf("too many hyphens: %s", expr) 281 | } 282 | } 283 | 284 | switch len(rangeAndStep) { 285 | case 1: 286 | step = 1 287 | case 2: 288 | step, err = mustParseInt(rangeAndStep[1]) 289 | if err != nil { 290 | return 0, err 291 | } 292 | 293 | // Special handling: "N/step" means "N-max/step". 294 | if singleDigit { 295 | end = r.max 296 | } 297 | if step > 1 { 298 | extra = 0 299 | } 300 | default: 301 | return 0, fmt.Errorf("too many slashes: %s", expr) 302 | } 303 | 304 | if start < r.min { 305 | return 0, fmt.Errorf("beginning of range (%d) below minimum (%d): %s", start, r.min, expr) 306 | } 307 | if end > r.max { 308 | return 0, fmt.Errorf("end of range (%d) above maximum (%d): %s", end, r.max, expr) 309 | } 310 | if start > end { 311 | return 0, fmt.Errorf("beginning of range (%d) beyond end of range (%d): %s", start, end, expr) 312 | } 313 | if step == 0 { 314 | return 0, fmt.Errorf("step of range should be a positive number: %s", expr) 315 | } 316 | 317 | return getBits(start, end, step) | extra, nil 318 | } 319 | 320 | // parseIntOrName returns the (possibly-named) integer contained in expr. 321 | func parseIntOrName(expr string, names map[string]uint) (uint, error) { 322 | if names != nil { 323 | if namedInt, ok := names[strings.ToLower(expr)]; ok { 324 | return namedInt, nil 325 | } 326 | } 327 | return mustParseInt(expr) 328 | } 329 | 330 | // mustParseInt parses the given expression as an int or returns an error. 331 | func mustParseInt(expr string) (uint, error) { 332 | num, err := strconv.Atoi(expr) 333 | if err != nil { 334 | return 0, fmt.Errorf("failed to parse int from %s: %s", expr, err) 335 | } 336 | if num < 0 { 337 | return 0, fmt.Errorf("negative number (%d) not allowed: %s", num, expr) 338 | } 339 | 340 | return uint(num), nil 341 | } 342 | 343 | // getBits sets all bits in the range [min, max], modulo the given step size. 344 | func getBits(min, max, step uint) uint64 { 345 | var bits uint64 346 | 347 | // If step is 1, use shifts. 348 | if step == 1 { 349 | return ^(math.MaxUint64 << (max + 1)) & (math.MaxUint64 << min) 350 | } 351 | 352 | // Else, use a simple loop. 353 | for i := min; i <= max; i += step { 354 | bits |= 1 << i 355 | } 356 | return bits 357 | } 358 | 359 | // all returns all bits within the given bounds. (plus the star bit) 360 | func all(r bounds) uint64 { 361 | return getBits(r.min, r.max, 1) | starBit 362 | } 363 | 364 | // parseDescriptor returns a predefined schedule for the expression, or error if none matches. 365 | func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) { 366 | switch descriptor { 367 | case "@yearly", "@annually": 368 | return &SpecSchedule{ 369 | Second: 1 << seconds.min, 370 | Minute: 1 << minutes.min, 371 | Hour: 1 << hours.min, 372 | Dom: 1 << dom.min, 373 | Month: 1 << months.min, 374 | Dow: all(dow), 375 | Location: loc, 376 | }, nil 377 | 378 | case "@monthly": 379 | return &SpecSchedule{ 380 | Second: 1 << seconds.min, 381 | Minute: 1 << minutes.min, 382 | Hour: 1 << hours.min, 383 | Dom: 1 << dom.min, 384 | Month: all(months), 385 | Dow: all(dow), 386 | Location: loc, 387 | }, nil 388 | 389 | case "@weekly": 390 | return &SpecSchedule{ 391 | Second: 1 << seconds.min, 392 | Minute: 1 << minutes.min, 393 | Hour: 1 << hours.min, 394 | Dom: all(dom), 395 | Month: all(months), 396 | Dow: 1 << dow.min, 397 | Location: loc, 398 | }, nil 399 | 400 | case "@daily", "@midnight": 401 | return &SpecSchedule{ 402 | Second: 1 << seconds.min, 403 | Minute: 1 << minutes.min, 404 | Hour: 1 << hours.min, 405 | Dom: all(dom), 406 | Month: all(months), 407 | Dow: all(dow), 408 | Location: loc, 409 | }, nil 410 | 411 | case "@hourly": 412 | return &SpecSchedule{ 413 | Second: 1 << seconds.min, 414 | Minute: 1 << minutes.min, 415 | Hour: all(hours), 416 | Dom: all(dom), 417 | Month: all(months), 418 | Dow: all(dow), 419 | Location: loc, 420 | }, nil 421 | 422 | } 423 | 424 | const every = "@every " 425 | if strings.HasPrefix(descriptor, every) { 426 | duration, err := time.ParseDuration(descriptor[len(every):]) 427 | if err != nil { 428 | return nil, fmt.Errorf("failed to parse duration %s: %s", descriptor, err) 429 | } 430 | return Every(duration), nil 431 | } 432 | 433 | return nil, fmt.Errorf("unrecognized descriptor: %s", descriptor) 434 | } 435 | -------------------------------------------------------------------------------- /parser_test.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | var secondParser = NewParser(Second | Minute | Hour | Dom | Month | DowOptional | Descriptor) 11 | 12 | func TestRange(t *testing.T) { 13 | zero := uint64(0) 14 | ranges := []struct { 15 | expr string 16 | min, max uint 17 | expected uint64 18 | err string 19 | }{ 20 | {"5", 0, 7, 1 << 5, ""}, 21 | {"0", 0, 7, 1 << 0, ""}, 22 | {"7", 0, 7, 1 << 7, ""}, 23 | 24 | {"5-5", 0, 7, 1 << 5, ""}, 25 | {"5-6", 0, 7, 1<<5 | 1<<6, ""}, 26 | {"5-7", 0, 7, 1<<5 | 1<<6 | 1<<7, ""}, 27 | 28 | {"5-6/2", 0, 7, 1 << 5, ""}, 29 | {"5-7/2", 0, 7, 1<<5 | 1<<7, ""}, 30 | {"5-7/1", 0, 7, 1<<5 | 1<<6 | 1<<7, ""}, 31 | 32 | {"*", 1, 3, 1<<1 | 1<<2 | 1<<3 | starBit, ""}, 33 | {"*/2", 1, 3, 1<<1 | 1<<3, ""}, 34 | 35 | {"5--5", 0, 0, zero, "too many hyphens"}, 36 | {"jan-x", 0, 0, zero, "failed to parse int from"}, 37 | {"2-x", 1, 5, zero, "failed to parse int from"}, 38 | {"*/-12", 0, 0, zero, "negative number"}, 39 | {"*//2", 0, 0, zero, "too many slashes"}, 40 | {"1", 3, 5, zero, "below minimum"}, 41 | {"6", 3, 5, zero, "above maximum"}, 42 | {"5-3", 3, 5, zero, "beyond end of range"}, 43 | {"*/0", 0, 0, zero, "should be a positive number"}, 44 | } 45 | 46 | for _, c := range ranges { 47 | actual, err := getRange(c.expr, bounds{c.min, c.max, nil}) 48 | if len(c.err) != 0 && (err == nil || !strings.Contains(err.Error(), c.err)) { 49 | t.Errorf("%s => expected %v, got %v", c.expr, c.err, err) 50 | } 51 | if len(c.err) == 0 && err != nil { 52 | t.Errorf("%s => unexpected error %v", c.expr, err) 53 | } 54 | if actual != c.expected { 55 | t.Errorf("%s => expected %d, got %d", c.expr, c.expected, actual) 56 | } 57 | } 58 | } 59 | 60 | func TestField(t *testing.T) { 61 | fields := []struct { 62 | expr string 63 | min, max uint 64 | expected uint64 65 | }{ 66 | {"5", 1, 7, 1 << 5}, 67 | {"5,6", 1, 7, 1<<5 | 1<<6}, 68 | {"5,6,7", 1, 7, 1<<5 | 1<<6 | 1<<7}, 69 | {"1,5-7/2,3", 1, 7, 1<<1 | 1<<5 | 1<<7 | 1<<3}, 70 | } 71 | 72 | for _, c := range fields { 73 | actual, _ := getField(c.expr, bounds{c.min, c.max, nil}) 74 | if actual != c.expected { 75 | t.Errorf("%s => expected %d, got %d", c.expr, c.expected, actual) 76 | } 77 | } 78 | } 79 | 80 | func TestAll(t *testing.T) { 81 | allBits := []struct { 82 | r bounds 83 | expected uint64 84 | }{ 85 | {minutes, 0xfffffffffffffff}, // 0-59: 60 ones 86 | {hours, 0xffffff}, // 0-23: 24 ones 87 | {dom, 0xfffffffe}, // 1-31: 31 ones, 1 zero 88 | {months, 0x1ffe}, // 1-12: 12 ones, 1 zero 89 | {dow, 0x7f}, // 0-6: 7 ones 90 | } 91 | 92 | for _, c := range allBits { 93 | actual := all(c.r) // all() adds the starBit, so compensate for that.. 94 | if c.expected|starBit != actual { 95 | t.Errorf("%d-%d/%d => expected %b, got %b", 96 | c.r.min, c.r.max, 1, c.expected|starBit, actual) 97 | } 98 | } 99 | } 100 | 101 | func TestBits(t *testing.T) { 102 | bits := []struct { 103 | min, max, step uint 104 | expected uint64 105 | }{ 106 | {0, 0, 1, 0x1}, 107 | {1, 1, 1, 0x2}, 108 | {1, 5, 2, 0x2a}, // 101010 109 | {1, 4, 2, 0xa}, // 1010 110 | } 111 | 112 | for _, c := range bits { 113 | actual := getBits(c.min, c.max, c.step) 114 | if c.expected != actual { 115 | t.Errorf("%d-%d/%d => expected %b, got %b", 116 | c.min, c.max, c.step, c.expected, actual) 117 | } 118 | } 119 | } 120 | 121 | func TestParseScheduleErrors(t *testing.T) { 122 | var tests = []struct{ expr, err string }{ 123 | {"* 5 j * * *", "failed to parse int from"}, 124 | {"@every Xm", "failed to parse duration"}, 125 | {"@unrecognized", "unrecognized descriptor"}, 126 | {"* * * *", "expected 5 to 6 fields"}, 127 | {"", "empty spec string"}, 128 | } 129 | for _, c := range tests { 130 | actual, err := secondParser.Parse(c.expr) 131 | if err == nil || !strings.Contains(err.Error(), c.err) { 132 | t.Errorf("%s => expected %v, got %v", c.expr, c.err, err) 133 | } 134 | if actual != nil { 135 | t.Errorf("expected nil schedule on error, got %v", actual) 136 | } 137 | } 138 | } 139 | 140 | func TestParseSchedule(t *testing.T) { 141 | tokyo, _ := time.LoadLocation("Asia/Tokyo") 142 | entries := []struct { 143 | parser Parser 144 | expr string 145 | expected Schedule 146 | }{ 147 | {secondParser, "0 5 * * * *", every5min(time.Local)}, 148 | {standardParser, "5 * * * *", every5min(time.Local)}, 149 | {secondParser, "CRON_TZ=UTC 0 5 * * * *", every5min(time.UTC)}, 150 | {standardParser, "CRON_TZ=UTC 5 * * * *", every5min(time.UTC)}, 151 | {secondParser, "CRON_TZ=Asia/Tokyo 0 5 * * * *", every5min(tokyo)}, 152 | {secondParser, "@every 5m", ConstantDelaySchedule{5 * time.Minute}}, 153 | {secondParser, "@midnight", midnight(time.Local)}, 154 | {secondParser, "TZ=UTC @midnight", midnight(time.UTC)}, 155 | {secondParser, "TZ=Asia/Tokyo @midnight", midnight(tokyo)}, 156 | {secondParser, "@yearly", annual(time.Local)}, 157 | {secondParser, "@annually", annual(time.Local)}, 158 | { 159 | parser: secondParser, 160 | expr: "* 5 * * * *", 161 | expected: &SpecSchedule{ 162 | Second: all(seconds), 163 | Minute: 1 << 5, 164 | Hour: all(hours), 165 | Dom: all(dom), 166 | Month: all(months), 167 | Dow: all(dow), 168 | Location: time.Local, 169 | }, 170 | }, 171 | } 172 | 173 | for _, c := range entries { 174 | actual, err := c.parser.Parse(c.expr) 175 | if err != nil { 176 | t.Errorf("%s => unexpected error %v", c.expr, err) 177 | } 178 | if !reflect.DeepEqual(actual, c.expected) { 179 | t.Errorf("%s => expected %b, got %b", c.expr, c.expected, actual) 180 | } 181 | } 182 | } 183 | 184 | func TestOptionalSecondSchedule(t *testing.T) { 185 | parser := NewParser(SecondOptional | Minute | Hour | Dom | Month | Dow | Descriptor) 186 | entries := []struct { 187 | expr string 188 | expected Schedule 189 | }{ 190 | {"0 5 * * * *", every5min(time.Local)}, 191 | {"5 5 * * * *", every5min5s(time.Local)}, 192 | {"5 * * * *", every5min(time.Local)}, 193 | } 194 | 195 | for _, c := range entries { 196 | actual, err := parser.Parse(c.expr) 197 | if err != nil { 198 | t.Errorf("%s => unexpected error %v", c.expr, err) 199 | } 200 | if !reflect.DeepEqual(actual, c.expected) { 201 | t.Errorf("%s => expected %b, got %b", c.expr, c.expected, actual) 202 | } 203 | } 204 | } 205 | 206 | func TestNormalizeFields(t *testing.T) { 207 | tests := []struct { 208 | name string 209 | input []string 210 | options ParseOption 211 | expected []string 212 | }{ 213 | { 214 | "AllFields_NoOptional", 215 | []string{"0", "5", "*", "*", "*", "*"}, 216 | Second | Minute | Hour | Dom | Month | Dow | Descriptor, 217 | []string{"0", "5", "*", "*", "*", "*"}, 218 | }, 219 | { 220 | "AllFields_SecondOptional_Provided", 221 | []string{"0", "5", "*", "*", "*", "*"}, 222 | SecondOptional | Minute | Hour | Dom | Month | Dow | Descriptor, 223 | []string{"0", "5", "*", "*", "*", "*"}, 224 | }, 225 | { 226 | "AllFields_SecondOptional_NotProvided", 227 | []string{"5", "*", "*", "*", "*"}, 228 | SecondOptional | Minute | Hour | Dom | Month | Dow | Descriptor, 229 | []string{"0", "5", "*", "*", "*", "*"}, 230 | }, 231 | { 232 | "SubsetFields_NoOptional", 233 | []string{"5", "15", "*"}, 234 | Hour | Dom | Month, 235 | []string{"0", "0", "5", "15", "*", "*"}, 236 | }, 237 | { 238 | "SubsetFields_DowOptional_Provided", 239 | []string{"5", "15", "*", "4"}, 240 | Hour | Dom | Month | DowOptional, 241 | []string{"0", "0", "5", "15", "*", "4"}, 242 | }, 243 | { 244 | "SubsetFields_DowOptional_NotProvided", 245 | []string{"5", "15", "*"}, 246 | Hour | Dom | Month | DowOptional, 247 | []string{"0", "0", "5", "15", "*", "*"}, 248 | }, 249 | { 250 | "SubsetFields_SecondOptional_NotProvided", 251 | []string{"5", "15", "*"}, 252 | SecondOptional | Hour | Dom | Month, 253 | []string{"0", "0", "5", "15", "*", "*"}, 254 | }, 255 | } 256 | 257 | for _, test := range tests { 258 | t.Run(test.name, func(t *testing.T) { 259 | actual, err := normalizeFields(test.input, test.options) 260 | if err != nil { 261 | t.Errorf("unexpected error: %v", err) 262 | } 263 | if !reflect.DeepEqual(actual, test.expected) { 264 | t.Errorf("expected %v, got %v", test.expected, actual) 265 | } 266 | }) 267 | } 268 | } 269 | 270 | func TestNormalizeFields_Errors(t *testing.T) { 271 | tests := []struct { 272 | name string 273 | input []string 274 | options ParseOption 275 | err string 276 | }{ 277 | { 278 | "TwoOptionals", 279 | []string{"0", "5", "*", "*", "*", "*"}, 280 | SecondOptional | Minute | Hour | Dom | Month | DowOptional, 281 | "", 282 | }, 283 | { 284 | "TooManyFields", 285 | []string{"0", "5", "*", "*"}, 286 | SecondOptional | Minute | Hour, 287 | "", 288 | }, 289 | { 290 | "NoFields", 291 | []string{}, 292 | SecondOptional | Minute | Hour, 293 | "", 294 | }, 295 | { 296 | "TooFewFields", 297 | []string{"*"}, 298 | SecondOptional | Minute | Hour, 299 | "", 300 | }, 301 | } 302 | for _, test := range tests { 303 | t.Run(test.name, func(t *testing.T) { 304 | actual, err := normalizeFields(test.input, test.options) 305 | if err == nil { 306 | t.Errorf("expected an error, got none. results: %v", actual) 307 | } 308 | if !strings.Contains(err.Error(), test.err) { 309 | t.Errorf("expected error %q, got %q", test.err, err.Error()) 310 | } 311 | }) 312 | } 313 | } 314 | 315 | func TestStandardSpecSchedule(t *testing.T) { 316 | entries := []struct { 317 | expr string 318 | expected Schedule 319 | err string 320 | }{ 321 | { 322 | expr: "5 * * * *", 323 | expected: &SpecSchedule{1 << seconds.min, 1 << 5, all(hours), all(dom), all(months), all(dow), time.Local}, 324 | }, 325 | { 326 | expr: "@every 5m", 327 | expected: ConstantDelaySchedule{time.Duration(5) * time.Minute}, 328 | }, 329 | { 330 | expr: "5 j * * *", 331 | err: "failed to parse int from", 332 | }, 333 | { 334 | expr: "* * * *", 335 | err: "expected exactly 5 fields", 336 | }, 337 | } 338 | 339 | for _, c := range entries { 340 | actual, err := ParseStandard(c.expr) 341 | if len(c.err) != 0 && (err == nil || !strings.Contains(err.Error(), c.err)) { 342 | t.Errorf("%s => expected %v, got %v", c.expr, c.err, err) 343 | } 344 | if len(c.err) == 0 && err != nil { 345 | t.Errorf("%s => unexpected error %v", c.expr, err) 346 | } 347 | if !reflect.DeepEqual(actual, c.expected) { 348 | t.Errorf("%s => expected %b, got %b", c.expr, c.expected, actual) 349 | } 350 | } 351 | } 352 | 353 | func TestNoDescriptorParser(t *testing.T) { 354 | parser := NewParser(Minute | Hour) 355 | _, err := parser.Parse("@every 1m") 356 | if err == nil { 357 | t.Error("expected an error, got none") 358 | } 359 | } 360 | 361 | func every5min(loc *time.Location) *SpecSchedule { 362 | return &SpecSchedule{1 << 0, 1 << 5, all(hours), all(dom), all(months), all(dow), loc} 363 | } 364 | 365 | func every5min5s(loc *time.Location) *SpecSchedule { 366 | return &SpecSchedule{1 << 5, 1 << 5, all(hours), all(dom), all(months), all(dow), loc} 367 | } 368 | 369 | func midnight(loc *time.Location) *SpecSchedule { 370 | return &SpecSchedule{1, 1, 1, all(dom), all(months), all(dow), loc} 371 | } 372 | 373 | func annual(loc *time.Location) *SpecSchedule { 374 | return &SpecSchedule{ 375 | Second: 1 << seconds.min, 376 | Minute: 1 << minutes.min, 377 | Hour: 1 << hours.min, 378 | Dom: 1 << dom.min, 379 | Month: 1 << months.min, 380 | Dow: all(dow), 381 | Location: loc, 382 | } 383 | } 384 | -------------------------------------------------------------------------------- /spec.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import "time" 4 | 5 | // SpecSchedule specifies a duty cycle (to the second granularity), based on a 6 | // traditional crontab specification. It is computed initially and stored as bit sets. 7 | type SpecSchedule struct { 8 | Second, Minute, Hour, Dom, Month, Dow uint64 9 | 10 | // Override location for this schedule. 11 | Location *time.Location 12 | } 13 | 14 | // bounds provides a range of acceptable values (plus a map of name to value). 15 | type bounds struct { 16 | min, max uint 17 | names map[string]uint 18 | } 19 | 20 | // The bounds for each field. 21 | var ( 22 | seconds = bounds{0, 59, nil} 23 | minutes = bounds{0, 59, nil} 24 | hours = bounds{0, 23, nil} 25 | dom = bounds{1, 31, nil} 26 | months = bounds{1, 12, map[string]uint{ 27 | "jan": 1, 28 | "feb": 2, 29 | "mar": 3, 30 | "apr": 4, 31 | "may": 5, 32 | "jun": 6, 33 | "jul": 7, 34 | "aug": 8, 35 | "sep": 9, 36 | "oct": 10, 37 | "nov": 11, 38 | "dec": 12, 39 | }} 40 | dow = bounds{0, 6, map[string]uint{ 41 | "sun": 0, 42 | "mon": 1, 43 | "tue": 2, 44 | "wed": 3, 45 | "thu": 4, 46 | "fri": 5, 47 | "sat": 6, 48 | }} 49 | ) 50 | 51 | const ( 52 | // Set the top bit if a star was included in the expression. 53 | starBit = 1 << 63 54 | ) 55 | 56 | // Next returns the next time this schedule is activated, greater than the given 57 | // time. If no time can be found to satisfy the schedule, return the zero time. 58 | func (s *SpecSchedule) Next(t time.Time) time.Time { 59 | // General approach 60 | // 61 | // For Month, Day, Hour, Minute, Second: 62 | // Check if the time value matches. If yes, continue to the next field. 63 | // If the field doesn't match the schedule, then increment the field until it matches. 64 | // While incrementing the field, a wrap-around brings it back to the beginning 65 | // of the field list (since it is necessary to re-verify previous field 66 | // values) 67 | 68 | // Convert the given time into the schedule's timezone, if one is specified. 69 | // Save the original timezone so we can convert back after we find a time. 70 | // Note that schedules without a time zone specified (time.Local) are treated 71 | // as local to the time provided. 72 | origLocation := t.Location() 73 | loc := s.Location 74 | if loc == time.Local { 75 | loc = t.Location() 76 | } 77 | if s.Location != time.Local { 78 | t = t.In(s.Location) 79 | } 80 | 81 | // Start at the earliest possible time (the upcoming second). 82 | t = t.Add(1*time.Second - time.Duration(t.Nanosecond())*time.Nanosecond) 83 | 84 | // This flag indicates whether a field has been incremented. 85 | added := false 86 | 87 | // If no time is found within five years, return zero. 88 | yearLimit := t.Year() + 5 89 | 90 | WRAP: 91 | if t.Year() > yearLimit { 92 | return time.Time{} 93 | } 94 | 95 | // Find the first applicable month. 96 | // If it's this month, then do nothing. 97 | for 1< 12 { 127 | t = t.Add(time.Duration(24-t.Hour()) * time.Hour) 128 | } else { 129 | t = t.Add(time.Duration(-t.Hour()) * time.Hour) 130 | } 131 | } 132 | 133 | if t.Day() == 1 { 134 | goto WRAP 135 | } 136 | } 137 | 138 | for 1< 0 182 | dowMatch bool = 1< 0 183 | ) 184 | if s.Dom&starBit > 0 || s.Dow&starBit > 0 { 185 | return domMatch && dowMatch 186 | } 187 | return domMatch || dowMatch 188 | } 189 | -------------------------------------------------------------------------------- /spec_test.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestActivation(t *testing.T) { 10 | tests := []struct { 11 | time, spec string 12 | expected bool 13 | }{ 14 | // Every fifteen minutes. 15 | {"Mon Jul 9 15:00 2012", "0/15 * * * *", true}, 16 | {"Mon Jul 9 15:45 2012", "0/15 * * * *", true}, 17 | {"Mon Jul 9 15:40 2012", "0/15 * * * *", false}, 18 | 19 | // Every fifteen minutes, starting at 5 minutes. 20 | {"Mon Jul 9 15:05 2012", "5/15 * * * *", true}, 21 | {"Mon Jul 9 15:20 2012", "5/15 * * * *", true}, 22 | {"Mon Jul 9 15:50 2012", "5/15 * * * *", true}, 23 | 24 | // Named months 25 | {"Sun Jul 15 15:00 2012", "0/15 * * Jul *", true}, 26 | {"Sun Jul 15 15:00 2012", "0/15 * * Jun *", false}, 27 | 28 | // Everything set. 29 | {"Sun Jul 15 08:30 2012", "30 08 ? Jul Sun", true}, 30 | {"Sun Jul 15 08:30 2012", "30 08 15 Jul ?", true}, 31 | {"Mon Jul 16 08:30 2012", "30 08 ? Jul Sun", false}, 32 | {"Mon Jul 16 08:30 2012", "30 08 15 Jul ?", false}, 33 | 34 | // Predefined schedules 35 | {"Mon Jul 9 15:00 2012", "@hourly", true}, 36 | {"Mon Jul 9 15:04 2012", "@hourly", false}, 37 | {"Mon Jul 9 15:00 2012", "@daily", false}, 38 | {"Mon Jul 9 00:00 2012", "@daily", true}, 39 | {"Mon Jul 9 00:00 2012", "@weekly", false}, 40 | {"Sun Jul 8 00:00 2012", "@weekly", true}, 41 | {"Sun Jul 8 01:00 2012", "@weekly", false}, 42 | {"Sun Jul 8 00:00 2012", "@monthly", false}, 43 | {"Sun Jul 1 00:00 2012", "@monthly", true}, 44 | 45 | // Test interaction of DOW and DOM. 46 | // If both are restricted, then only one needs to match. 47 | {"Sun Jul 15 00:00 2012", "* * 1,15 * Sun", true}, 48 | {"Fri Jun 15 00:00 2012", "* * 1,15 * Sun", true}, 49 | {"Wed Aug 1 00:00 2012", "* * 1,15 * Sun", true}, 50 | {"Sun Jul 15 00:00 2012", "* * */10 * Sun", true}, // verifies #70 51 | 52 | // However, if one has a star, then both need to match. 53 | {"Sun Jul 15 00:00 2012", "* * * * Mon", false}, 54 | {"Mon Jul 9 00:00 2012", "* * 1,15 * *", false}, 55 | {"Sun Jul 15 00:00 2012", "* * 1,15 * *", true}, 56 | {"Sun Jul 15 00:00 2012", "* * */2 * Sun", true}, 57 | } 58 | 59 | for _, test := range tests { 60 | sched, err := ParseStandard(test.spec) 61 | if err != nil { 62 | t.Error(err) 63 | continue 64 | } 65 | actual := sched.Next(getTime(test.time).Add(-1 * time.Second)) 66 | expected := getTime(test.time) 67 | if test.expected && expected != actual || !test.expected && expected == actual { 68 | t.Errorf("Fail evaluating %s on %s: (expected) %s != %s (actual)", 69 | test.spec, test.time, expected, actual) 70 | } 71 | } 72 | } 73 | 74 | func TestNext(t *testing.T) { 75 | runs := []struct { 76 | time, spec string 77 | expected string 78 | }{ 79 | // Simple cases 80 | {"Mon Jul 9 14:45 2012", "0 0/15 * * * *", "Mon Jul 9 15:00 2012"}, 81 | {"Mon Jul 9 14:59 2012", "0 0/15 * * * *", "Mon Jul 9 15:00 2012"}, 82 | {"Mon Jul 9 14:59:59 2012", "0 0/15 * * * *", "Mon Jul 9 15:00 2012"}, 83 | 84 | // Wrap around hours 85 | {"Mon Jul 9 15:45 2012", "0 20-35/15 * * * *", "Mon Jul 9 16:20 2012"}, 86 | 87 | // Wrap around days 88 | {"Mon Jul 9 23:46 2012", "0 */15 * * * *", "Tue Jul 10 00:00 2012"}, 89 | {"Mon Jul 9 23:45 2012", "0 20-35/15 * * * *", "Tue Jul 10 00:20 2012"}, 90 | {"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 * * * *", "Tue Jul 10 00:20:15 2012"}, 91 | {"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 1/2 * * *", "Tue Jul 10 01:20:15 2012"}, 92 | {"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 10-12 * * *", "Tue Jul 10 10:20:15 2012"}, 93 | 94 | {"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 1/2 */2 * *", "Thu Jul 11 01:20:15 2012"}, 95 | {"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 * 9-20 * *", "Wed Jul 10 00:20:15 2012"}, 96 | {"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 * 9-20 Jul *", "Wed Jul 10 00:20:15 2012"}, 97 | 98 | // Wrap around months 99 | {"Mon Jul 9 23:35 2012", "0 0 0 9 Apr-Oct ?", "Thu Aug 9 00:00 2012"}, 100 | {"Mon Jul 9 23:35 2012", "0 0 0 */5 Apr,Aug,Oct Mon", "Tue Aug 1 00:00 2012"}, 101 | {"Mon Jul 9 23:35 2012", "0 0 0 */5 Oct Mon", "Mon Oct 1 00:00 2012"}, 102 | 103 | // Wrap around years 104 | {"Mon Jul 9 23:35 2012", "0 0 0 * Feb Mon", "Mon Feb 4 00:00 2013"}, 105 | {"Mon Jul 9 23:35 2012", "0 0 0 * Feb Mon/2", "Fri Feb 1 00:00 2013"}, 106 | 107 | // Wrap around minute, hour, day, month, and year 108 | {"Mon Dec 31 23:59:45 2012", "0 * * * * *", "Tue Jan 1 00:00:00 2013"}, 109 | 110 | // Leap year 111 | {"Mon Jul 9 23:35 2012", "0 0 0 29 Feb ?", "Mon Feb 29 00:00 2016"}, 112 | 113 | // Daylight savings time 2am EST (-5) -> 3am EDT (-4) 114 | {"2012-03-11T00:00:00-0500", "TZ=America/New_York 0 30 2 11 Mar ?", "2013-03-11T02:30:00-0400"}, 115 | 116 | // hourly job 117 | {"2012-03-11T00:00:00-0500", "TZ=America/New_York 0 0 * * * ?", "2012-03-11T01:00:00-0500"}, 118 | {"2012-03-11T01:00:00-0500", "TZ=America/New_York 0 0 * * * ?", "2012-03-11T03:00:00-0400"}, 119 | {"2012-03-11T03:00:00-0400", "TZ=America/New_York 0 0 * * * ?", "2012-03-11T04:00:00-0400"}, 120 | {"2012-03-11T04:00:00-0400", "TZ=America/New_York 0 0 * * * ?", "2012-03-11T05:00:00-0400"}, 121 | 122 | // hourly job using CRON_TZ 123 | {"2012-03-11T00:00:00-0500", "CRON_TZ=America/New_York 0 0 * * * ?", "2012-03-11T01:00:00-0500"}, 124 | {"2012-03-11T01:00:00-0500", "CRON_TZ=America/New_York 0 0 * * * ?", "2012-03-11T03:00:00-0400"}, 125 | {"2012-03-11T03:00:00-0400", "CRON_TZ=America/New_York 0 0 * * * ?", "2012-03-11T04:00:00-0400"}, 126 | {"2012-03-11T04:00:00-0400", "CRON_TZ=America/New_York 0 0 * * * ?", "2012-03-11T05:00:00-0400"}, 127 | 128 | // 1am nightly job 129 | {"2012-03-11T00:00:00-0500", "TZ=America/New_York 0 0 1 * * ?", "2012-03-11T01:00:00-0500"}, 130 | {"2012-03-11T01:00:00-0500", "TZ=America/New_York 0 0 1 * * ?", "2012-03-12T01:00:00-0400"}, 131 | 132 | // 2am nightly job (skipped) 133 | {"2012-03-11T00:00:00-0500", "TZ=America/New_York 0 0 2 * * ?", "2012-03-12T02:00:00-0400"}, 134 | 135 | // Daylight savings time 2am EDT (-4) => 1am EST (-5) 136 | {"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 30 2 04 Nov ?", "2012-11-04T02:30:00-0500"}, 137 | {"2012-11-04T01:45:00-0400", "TZ=America/New_York 0 30 1 04 Nov ?", "2012-11-04T01:30:00-0500"}, 138 | 139 | // hourly job 140 | {"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 0 * * * ?", "2012-11-04T01:00:00-0400"}, 141 | {"2012-11-04T01:00:00-0400", "TZ=America/New_York 0 0 * * * ?", "2012-11-04T01:00:00-0500"}, 142 | {"2012-11-04T01:00:00-0500", "TZ=America/New_York 0 0 * * * ?", "2012-11-04T02:00:00-0500"}, 143 | 144 | // 1am nightly job (runs twice) 145 | {"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 0 1 * * ?", "2012-11-04T01:00:00-0400"}, 146 | {"2012-11-04T01:00:00-0400", "TZ=America/New_York 0 0 1 * * ?", "2012-11-04T01:00:00-0500"}, 147 | {"2012-11-04T01:00:00-0500", "TZ=America/New_York 0 0 1 * * ?", "2012-11-05T01:00:00-0500"}, 148 | 149 | // 2am nightly job 150 | {"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 0 2 * * ?", "2012-11-04T02:00:00-0500"}, 151 | {"2012-11-04T02:00:00-0500", "TZ=America/New_York 0 0 2 * * ?", "2012-11-05T02:00:00-0500"}, 152 | 153 | // 3am nightly job 154 | {"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 0 3 * * ?", "2012-11-04T03:00:00-0500"}, 155 | {"2012-11-04T03:00:00-0500", "TZ=America/New_York 0 0 3 * * ?", "2012-11-05T03:00:00-0500"}, 156 | 157 | // hourly job 158 | {"TZ=America/New_York 2012-11-04T00:00:00-0400", "0 0 * * * ?", "2012-11-04T01:00:00-0400"}, 159 | {"TZ=America/New_York 2012-11-04T01:00:00-0400", "0 0 * * * ?", "2012-11-04T01:00:00-0500"}, 160 | {"TZ=America/New_York 2012-11-04T01:00:00-0500", "0 0 * * * ?", "2012-11-04T02:00:00-0500"}, 161 | 162 | // 1am nightly job (runs twice) 163 | {"TZ=America/New_York 2012-11-04T00:00:00-0400", "0 0 1 * * ?", "2012-11-04T01:00:00-0400"}, 164 | {"TZ=America/New_York 2012-11-04T01:00:00-0400", "0 0 1 * * ?", "2012-11-04T01:00:00-0500"}, 165 | {"TZ=America/New_York 2012-11-04T01:00:00-0500", "0 0 1 * * ?", "2012-11-05T01:00:00-0500"}, 166 | 167 | // 2am nightly job 168 | {"TZ=America/New_York 2012-11-04T00:00:00-0400", "0 0 2 * * ?", "2012-11-04T02:00:00-0500"}, 169 | {"TZ=America/New_York 2012-11-04T02:00:00-0500", "0 0 2 * * ?", "2012-11-05T02:00:00-0500"}, 170 | 171 | // 3am nightly job 172 | {"TZ=America/New_York 2012-11-04T00:00:00-0400", "0 0 3 * * ?", "2012-11-04T03:00:00-0500"}, 173 | {"TZ=America/New_York 2012-11-04T03:00:00-0500", "0 0 3 * * ?", "2012-11-05T03:00:00-0500"}, 174 | 175 | // Unsatisfiable 176 | {"Mon Jul 9 23:35 2012", "0 0 0 30 Feb ?", ""}, 177 | {"Mon Jul 9 23:35 2012", "0 0 0 31 Apr ?", ""}, 178 | 179 | // Monthly job 180 | {"TZ=America/New_York 2012-11-04T00:00:00-0400", "0 0 3 3 * ?", "2012-12-03T03:00:00-0500"}, 181 | 182 | // Test the scenario of DST resulting in midnight not being a valid time. 183 | // https://github.com/robfig/cron/issues/157 184 | {"2018-10-17T05:00:00-0400", "TZ=America/Sao_Paulo 0 0 9 10 * ?", "2018-11-10T06:00:00-0500"}, 185 | {"2018-02-14T05:00:00-0500", "TZ=America/Sao_Paulo 0 0 9 22 * ?", "2018-02-22T07:00:00-0500"}, 186 | } 187 | 188 | for _, c := range runs { 189 | sched, err := secondParser.Parse(c.spec) 190 | if err != nil { 191 | t.Error(err) 192 | continue 193 | } 194 | actual := sched.Next(getTime(c.time)) 195 | expected := getTime(c.expected) 196 | if !actual.Equal(expected) { 197 | t.Errorf("%s, \"%s\": (expected) %v != %v (actual)", c.time, c.spec, expected, actual) 198 | } 199 | } 200 | } 201 | 202 | func TestErrors(t *testing.T) { 203 | invalidSpecs := []string{ 204 | "xyz", 205 | "60 0 * * *", 206 | "0 60 * * *", 207 | "0 0 * * XYZ", 208 | } 209 | for _, spec := range invalidSpecs { 210 | _, err := ParseStandard(spec) 211 | if err == nil { 212 | t.Error("expected an error parsing: ", spec) 213 | } 214 | } 215 | } 216 | 217 | func getTime(value string) time.Time { 218 | if value == "" { 219 | return time.Time{} 220 | } 221 | 222 | var location = time.Local 223 | if strings.HasPrefix(value, "TZ=") { 224 | parts := strings.Fields(value) 225 | loc, err := time.LoadLocation(parts[0][len("TZ="):]) 226 | if err != nil { 227 | panic("could not parse location:" + err.Error()) 228 | } 229 | location = loc 230 | value = parts[1] 231 | } 232 | 233 | var layouts = []string{ 234 | "Mon Jan 2 15:04 2006", 235 | "Mon Jan 2 15:04:05 2006", 236 | } 237 | for _, layout := range layouts { 238 | if t, err := time.ParseInLocation(layout, value, location); err == nil { 239 | return t 240 | } 241 | } 242 | if t, err := time.ParseInLocation("2006-01-02T15:04:05-0700", value, location); err == nil { 243 | return t 244 | } 245 | panic("could not parse time value " + value) 246 | } 247 | 248 | func TestNextWithTz(t *testing.T) { 249 | runs := []struct { 250 | time, spec string 251 | expected string 252 | }{ 253 | // Failing tests 254 | {"2016-01-03T13:09:03+0530", "14 14 * * *", "2016-01-03T14:14:00+0530"}, 255 | {"2016-01-03T04:09:03+0530", "14 14 * * ?", "2016-01-03T14:14:00+0530"}, 256 | 257 | // Passing tests 258 | {"2016-01-03T14:09:03+0530", "14 14 * * *", "2016-01-03T14:14:00+0530"}, 259 | {"2016-01-03T14:00:00+0530", "14 14 * * ?", "2016-01-03T14:14:00+0530"}, 260 | } 261 | for _, c := range runs { 262 | sched, err := ParseStandard(c.spec) 263 | if err != nil { 264 | t.Error(err) 265 | continue 266 | } 267 | actual := sched.Next(getTimeTZ(c.time)) 268 | expected := getTimeTZ(c.expected) 269 | if !actual.Equal(expected) { 270 | t.Errorf("%s, \"%s\": (expected) %v != %v (actual)", c.time, c.spec, expected, actual) 271 | } 272 | } 273 | } 274 | 275 | func getTimeTZ(value string) time.Time { 276 | if value == "" { 277 | return time.Time{} 278 | } 279 | t, err := time.Parse("Mon Jan 2 15:04 2006", value) 280 | if err != nil { 281 | t, err = time.Parse("Mon Jan 2 15:04:05 2006", value) 282 | if err != nil { 283 | t, err = time.Parse("2006-01-02T15:04:05-0700", value) 284 | if err != nil { 285 | panic(err) 286 | } 287 | } 288 | } 289 | 290 | return t 291 | } 292 | 293 | // https://github.com/robfig/cron/issues/144 294 | func TestSlash0NoHang(t *testing.T) { 295 | schedule := "TZ=America/New_York 15/0 * * * *" 296 | _, err := ParseStandard(schedule) 297 | if err == nil { 298 | t.Error("expected an error on 0 increment") 299 | } 300 | } 301 | --------------------------------------------------------------------------------