Repository: go-co-op/gocron
Pull request #150 (2021-04-03 12:06:08)

add support for cron expressions
### What does this do?
- imports robfig's cron to handle parsing cron expressions
- tests for cron expressions and error cases when the wrong functions are chained together

### Which issue(s) does this PR fix/relate to?
<!--- Put `Resolves #XXX` here to auto-close the issue that your PR fixes (if such) --->


### List any changes that modify/break current functionality


### Have you included tests for your changes?


### Did you document any new/modified functionality?

- [x] Updated `example_test.go`
- [x] Updated `README.md`

### Notes



Patch:
diff --git a/README.md b/README.md
index 84a05ea..53be94e 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,9 @@ s.Every(5).Seconds().Do(func(){ ... })
 s.Every("5m").Do(func(){ ... })
 
 s.Every(5).Days().Do(func(){ ... })
+
+// cron expressions supported
+s.Cron("*/1 * * * *").Do(task) // every minute
 ```
 
 For more examples, take a look in our [go docs](https://pkg.go.dev/github.com/go-co-op/gocron#pkg-examples)
@@ -55,6 +58,27 @@ Default |  | jobs are rescheduled at every interval
 Job singleton | `SingletonMode()` | a long running job will not be rescheduled until the current run is completed
 Scheduler limit | `SetMaxConcurrentJobs()` | set a collective maximum number of concurrent jobs running across the scheduler
 
+## Tags
+
+Jobs may have arbitrary tags added which can be useful when tracking many jobs.
+The scheduler supports both enforcing tags to be unique and when not unique,
+running all jobs with a given tag.
+
+```golang
+s := gocron.NewScheduler(time.UTC)
+s.TagsUnique()
+
+_, _ = s.Every(1).Week().Tag("foo").Do(task)
+_, err := s.Every(1).Week().Tag("foo").Do(task)
+// error!!!
+
+s := gocron.NewScheduler(time.UTC)
+
+s.Every(2).Day().Tag("tag").At("10:00").Do(task)
+s.Every(1).Minute().Tag("tag").Do(task)
+s.RunByTag("tag")
+// both jobs will run
+```
 
 ## FAQ
 
diff --git a/go.mod b/go.mod
index 076eb43..d814781 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/go-co-op/gocron
 go 1.16
 
 require (
+	github.com/robfig/cron/v3 v3.0.0
 	github.com/stretchr/testify v1.7.0
 	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
 	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
diff --git a/go.sum b/go.sum
index 180c005..dc61ce2 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,8 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/robfig/cron/v3 v3.0.0 h1:kQ6Cb7aHOHTSzNVNEhmp8EcWKLb4CbiMW9h9VyIhO4E=
+github.com/robfig/cron/v3 v3.0.0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
diff --git a/gocron.go b/gocron.go
index 450ac5a..31e7241 100644
--- a/gocron.go
+++ b/gocron.go
@@ -23,13 +23,14 @@ var (
 	ErrUnsupportedTimeFormat         = errors.New("the given time format is not supported")
 	ErrInvalidInterval               = errors.New(".Every() interval must be greater than 0")
 	ErrInvalidIntervalType           = errors.New(".Every() interval must be int, time.Duration, or string")
-	ErrInvalidIntervalUnitsSelection = errors.New("an .Every() duration interval cannot be used with units (e.g. .Seconds())")
+	ErrInvalidIntervalUnitsSelection = errors.New(".Every(time.Duration) and .Cron() cannot be used with units (e.g. .Seconds())")
 
 	ErrAtTimeNotSupported     = errors.New("the At() method is not supported for this time unit")
 	ErrWeekdayNotSupported    = errors.New("weekday is not supported for time unit")
 	ErrTagsUnique             = func(tag string) error { return fmt.Errorf("a non-unique tag was set on the job: %s", tag) }
 	ErrWrongParams            = errors.New("wrong list of params")
 	ErrUpdateCalledWithoutJob = errors.New("a call to Scheduler.Update() requires a call to Scheduler.Job() first")
+	ErrCronParseFailure       = errors.New("cron expression failed to be parsed")
 )
 
 func wrapOrError(toWrap error, err error) error {
@@ -48,11 +49,11 @@ var (
 	timeWithoutSeconds = regexp.MustCompile(`(?m)^\d{1,2}:\d\d$`)
 )
 
-type timeUnit int
+type schedulingUnit int
 
 const (
 	// default unit is seconds
-	milliseconds timeUnit = iota
+	milliseconds schedulingUnit = iota
 	seconds
 	minutes
 	hours
@@ -60,6 +61,7 @@ const (
 	weeks
 	months
 	duration
+	crontab
 )
 
 func callJobFuncWithParams(jobFunc interface{}, params []interface{}) {
diff --git a/job.go b/job.go
index cba7030..ff3886b 100644
--- a/job.go
+++ b/job.go
@@ -6,6 +6,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/robfig/cron/v3"
 	"golang.org/x/sync/singleflight"
 )
 
@@ -13,20 +14,21 @@ import (
 type Job struct {
 	sync.RWMutex
 	jobFunction
-	interval          int           // pause interval * unit between runs
-	duration          time.Duration // time duration between runs
-	unit              timeUnit      // time units, ,e.g. 'minutes', 'hours'...
-	startsImmediately bool          // if the Job should run upon scheduler start
-	atTime            time.Duration // optional time at which this Job runs when interval is day
-	startAtTime       time.Time     // optional time at which the Job starts
-	error             error         // error related to Job
-	lastRun           time.Time     // datetime of last run
-	nextRun           time.Time     // datetime of next run
-	scheduledWeekday  *time.Weekday // Specific day of the week to start on
-	dayOfTheMonth     int           // Specific day of the month to run the job
-	tags              []string      // allow the user to tag Jobs with certain labels
-	runCount          int           // number of times the job ran
-	timer             *time.Timer   // handles running tasks at specific time
+	interval          int            // pause interval * unit between runs
+	duration          time.Duration  // time duration between runs
+	unit              schedulingUnit // time units, ,e.g. 'minutes', 'hours'...
+	startsImmediately bool           // if the Job should run upon scheduler start
+	atTime            time.Duration  // optional time at which this Job runs when interval is day
+	startAtTime       time.Time      // optional time at which the Job starts
+	error             error          // error related to Job
+	lastRun           time.Time      // datetime of last run
+	nextRun           time.Time      // datetime of next run
+	scheduledWeekday  *time.Weekday  // Specific day of the week to start on
+	dayOfTheMonth     int            // Specific day of the month to run the job
+	tags              []string       // allow the user to tag Jobs with certain labels
+	runCount          int            // number of times the job ran
+	timer             *time.Timer    // handles running tasks at specific time
+	cronSchedule      cron.Schedule  // stores the schedule when a task uses cron
 }
 
 type jobFunction struct {
@@ -107,13 +109,13 @@ func (j *Job) setStartAtTime(t time.Time) {
 	j.startAtTime = t
 }
 
-func (j *Job) getUnit() timeUnit {
+func (j *Job) getUnit() schedulingUnit {
 	j.RLock()
 	defer j.RUnlock()
 	return j.unit
 }
 
-func (j *Job) setUnit(t timeUnit) {
+func (j *Job) setUnit(t schedulingUnit) {
 	j.Lock()
 	defer j.Unlock()
 	j.unit = t
@@ -163,6 +165,8 @@ func (j *Job) Tags() []string {
 
 // ScheduledTime returns the time of the Job's next scheduled run
 func (j *Job) ScheduledTime() time.Time {
+	j.RLock()
+	defer j.RUnlock()
 	return j.nextRun
 }
 
diff --git a/scheduler.go b/scheduler.go
index d9eb030..4934c51 100644
--- a/scheduler.go
+++ b/scheduler.go
@@ -8,6 +8,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/robfig/cron/v3"
 	"golang.org/x/sync/semaphore"
 )
 
@@ -196,6 +197,8 @@ func (s *Scheduler) durationToNextRun(lastRun time.Time, job *Job) time.Duration
 		d = s.calculateMonths(job, lastRun)
 	case duration:
 		d = job.getDuration()
+	case crontab:
+		d = job.cronSchedule.Next(lastRun).Sub(lastRun)
 	}
 	return d
 }
@@ -524,8 +527,8 @@ func (s *Scheduler) jobPresent(j *Job) bool {
 
 // Clear clear all Jobs from this scheduler
 func (s *Scheduler) Clear() {
-	for _, j := range s.Jobs() {
-		j.stop()
+	for _, job := range s.Jobs() {
+		job.stop()
 	}
 	s.setJobs(make([]*Job, 0))
 }
@@ -546,11 +549,12 @@ func (s *Scheduler) stop() {
 func (s *Scheduler) Do(jobFun interface{}, params ...interface{}) (*Job, error) {
 	job := s.getCurrentJob()
 
-	if job.atTime != 0 && job.getUnit() <= hours {
+	jobUnit := job.getUnit()
+	if job.atTime != 0 && (jobUnit <= hours || jobUnit >= duration) {
 		job.error = wrapOrError(job.error, ErrAtTimeNotSupported)
 	}
 
-	if job.scheduledWeekday != nil && job.getUnit() != weeks {
+	if job.scheduledWeekday != nil && jobUnit != weeks {
 		job.error = wrapOrError(job.error, ErrWeekdayNotSupported)
 	}
 
@@ -641,10 +645,12 @@ func (s *Scheduler) StartAt(t time.Time) *Scheduler {
 }
 
 // setUnit sets the unit type
-func (s *Scheduler) setUnit(unit timeUnit) {
+func (s *Scheduler) setUnit(unit schedulingUnit) {
 	job := s.getCurrentJob()
-	if job.getUnit() == duration {
+	currentUnit := job.getUnit()
+	if currentUnit == duration || currentUnit == crontab {
 		job.error = wrapOrError(job.error, ErrInvalidIntervalUnitsSelection)
+		return
 	}
 	job.setUnit(unit)
 }
@@ -815,11 +821,26 @@ func (s *Scheduler) Job(j *Job) *Scheduler {
 // that were made to the job in the scheduler chain. Job() must be
 // called first to put the given job in focus.
 func (s *Scheduler) Update() (*Job, error) {
-	j := s.getCurrentJob()
+	job := s.getCurrentJob()
 
 	if !s.updateJob {
-		return j, wrapOrError(j.error, ErrUpdateCalledWithoutJob)
+		return job, wrapOrError(job.error, ErrUpdateCalledWithoutJob)
 	}
-	j.stop()
-	return s.Do(j.function, j.parameters...)
+	job.stop()
+	return s.Do(job.function, job.parameters...)
+}
+
+func (s *Scheduler) Cron(c string) *Scheduler {
+	job := NewJob(0)
+
+	cronSchedule, err := cron.ParseStandard(c)
+	if err != nil {
+		job.error = wrapOrError(err, ErrCronParseFailure)
+	}
+
+	job.cronSchedule = cronSchedule
+	job.unit = crontab
+
+	s.setJobs(append(s.Jobs(), job))
+	return s
 }


Test patch:
diff --git a/example_test.go b/example_test.go
index de40073..b0a915b 100644
--- a/example_test.go
+++ b/example_test.go
@@ -172,6 +172,14 @@ func ExampleScheduler_Clear() {
 	// 0
 }
 
+func ExampleScheduler_Cron() {
+	s := gocron.NewScheduler(time.UTC)
+
+	_, _ = s.Cron("*/1 * * * *").Do(task) // every minute
+	_, _ = s.Cron("0 1 * * *").Do(task)   // every day at 1 am
+	_, _ = s.Cron("0 0 * * 6,0").Do(task) // weekends only
+}
+
 func ExampleScheduler_Day() {
 	s := gocron.NewScheduler(time.UTC)
 
diff --git a/scheduler_test.go b/scheduler_test.go
index 75653c9..2a47b21 100644
--- a/scheduler_test.go
+++ b/scheduler_test.go
@@ -515,7 +515,7 @@ func TestSetUnit(t *testing.T) {
 
 	testCases := []struct {
 		desc     string
-		timeUnit timeUnit
+		timeUnit schedulingUnit
 	}{
 		{"seconds", seconds},
 		{"minutes", minutes},
@@ -1155,3 +1155,52 @@ func TestScheduler_RunByTag(t *testing.T) {
 	assert.Equal(t, 1, count)
 	assert.Error(t, s.RunByTag("wrong-tag"))
 }
+
+func TestScheduler_Cron(t *testing.T) {
+	ft := fakeTime{onNow: func(l *time.Location) time.Time {
+		return time.Date(1970, 1, 1, 12, 0, 0, 0, l)
+	}}
+
+	s := NewScheduler(time.UTC)
+	s.time = ft
+
+	testCases := []struct {
+		description     string
+		cronTab         string
+		expectedNextRun time.Time
+		expectedError   error
+	}{
+		// https://crontab.guru/
+		{"every minute", "*/1 * * * *", ft.onNow(time.UTC).Add(1 * time.Minute), nil},
+		{"every day 1am", "0 1 * * *", ft.onNow(time.UTC).Add(13 * time.Hour), nil},
+		{"weekends only", "0 0 * * 6,0", ft.onNow(time.UTC).Add(36 * time.Hour), nil},
+		{"at time monday thru friday", "0 22 * * 1-5", ft.onNow(time.UTC).Add(10 * time.Hour), nil},
+		{"hourly", "@hourly", ft.onNow(time.UTC).Add(1 * time.Hour), nil},
+		{"bad expression", "bad", time.Time{}, wrapOrError(fmt.Errorf("expected exactly 5 fields, found 1: [bad]"), ErrCronParseFailure)},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.description, func(t *testing.T) {
+			j, err := s.Cron(tc.cronTab).Do(func() {})
+			if tc.expectedError == nil {
+				require.NoError(t, err)
+
+				s.scheduleNextRun(j)
+
+				assert.Exactly(t, tc.expectedNextRun, j.NextRun())
+			} else {
+				assert.EqualError(t, err, tc.expectedError.Error())
+			}
+		})
+	}
+
+	t.Run("error At() called with Cron()", func(t *testing.T) {
+		_, err := s.Cron("@hourly").At("1:00").Do(func() {})
+		assert.EqualError(t, err, ErrAtTimeNotSupported.Error())
+	})
+
+	t.Run("error Weekday() called with Cron()", func(t *testing.T) {
+		_, err := s.Cron("@hourly").Sunday().Do(func() {})
+		assert.EqualError(t, err, wrapOrError(ErrInvalidIntervalUnitsSelection, ErrWeekdayNotSupported).Error())
+	})
+}