Repository: go-co-op/gocron
Pull request #597 (2023-10-22 02:08:48)

V2 wip monthly
### What does this do?
adds the logic for monthly job and a start on some tests

### 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?

- [ ] Updated `example_test.go`
- [ ] Updated `README.md`

### Notes



Patch:
diff --git a/errors.go b/errors.go
index 649e8e6..7c85dc5 100644
--- a/errors.go
+++ b/errors.go
@@ -8,7 +8,11 @@ var (
 	ErrDurationRandomJobMinMax     = fmt.Errorf("gocron: DurationRandomJob: minimum duration must be less than maximum duration")
 	ErrEventListenerFuncNil        = fmt.Errorf("gocron: eventListenerFunc must not be nil")
 	ErrJobNotFound                 = fmt.Errorf("gocron: job not found")
+	ErrMonthlyJobDays              = fmt.Errorf("gocron: MonthlyJob: daysOfTheMonth must be between 31 and -31 inclusive, and not 0")
+	ErrMonthlyJobHours             = fmt.Errorf("gocron: MonthlyJob: atTimes hours must be between 0 and 23 inclusive")
+	ErrMonthlyJobMinutesSeconds    = fmt.Errorf("gocron: MonthlyJob: atTimes minutes and seconds must be between 0 and 59 inclusive")
 	ErrNewJobTask                  = fmt.Errorf("gocron: NewJob: Task.Function must be of kind reflect.Func")
+	ErrNewJobTaskNil               = fmt.Errorf("gocron: NewJob: Task must not be nil")
 	ErrStopTimedOut                = fmt.Errorf("gocron: timed out waiting for jobs to finish")
 	ErrWithDistributedElector      = fmt.Errorf("gocron: WithDistributedElector: elector must not be nil")
 	ErrWithFakeClockNil            = fmt.Errorf("gocron: WithFakeClock: clock must not be nil")
diff --git a/job.go b/job.go
index 5e9966e..f68fe1f 100644
--- a/job.go
+++ b/job.go
@@ -11,6 +11,7 @@ import (
 	"github.com/google/uuid"
 	"github.com/jonboulle/clockwork"
 	"github.com/robfig/cron/v3"
+	"golang.org/x/exp/slices"
 )
 
 type internalJob struct {
@@ -199,14 +200,134 @@ func DurationRandomJob(minDuration, maxDuration time.Duration, task Task, option
 	}
 }
 
-func DailyJob(interval int, at time.Duration, task Task, options ...JobOption) JobDefinition {
+func DailyJob(interval int, atTimes []time.Duration, task Task, options ...JobOption) JobDefinition {
 	return nil
 }
 
-func WeeklyJob(interval int, daysOfTheWeek []time.Weekday, task Task, options ...JobOption) JobDefinition {
+func WeeklyJob(interval int, daysOfTheWeek []time.Weekday, atTimes []time.Duration, task Task, options ...JobOption) JobDefinition {
 	return nil
 }
 
+var _ JobDefinition = (*monthlyJobDefinition)(nil)
+
+type monthlyJobDefinition struct {
+	interval       uint
+	daysOfTheMonth DaysOfTheMonth
+	atTimes        AtTimes
+	opts           []JobOption
+	tas            Task
+}
+
+func (m monthlyJobDefinition) options() []JobOption {
+	return m.opts
+}
+
+func (m monthlyJobDefinition) setup(j *internalJob, location *time.Location) error {
+	var ms monthlyJob
+
+	if m.daysOfTheMonth != nil {
+		var days, daysEnd []int
+		for _, day := range m.daysOfTheMonth() {
+			if day > 31 || day == 0 || day < -31 {
+				return ErrMonthlyJobDays
+			}
+			if day > 0 {
+				days = append(days, day)
+			} else {
+				daysEnd = append(daysEnd, day)
+			}
+		}
+		slices.Sort(days)
+		slices.Sort(daysEnd)
+		ms.days = days
+		ms.daysFromEnd = daysEnd
+	}
+
+	if m.atTimes != nil {
+		var atTimesDate []time.Time
+		for _, a := range m.atTimes() {
+			if a == nil {
+				continue
+			}
+			at := a()
+			if at.hours > 23 {
+				return ErrMonthlyJobHours
+			} else if at.minutes > 59 || at.seconds > 59 {
+				return ErrMonthlyJobMinutesSeconds
+			}
+			atTimesDate = append(atTimesDate, at.Time(location))
+		}
+		slices.SortStableFunc(atTimesDate, func(a, b time.Time) int {
+			return a.Compare(b)
+		})
+		ms.atTimes = atTimesDate
+	}
+
+	j.jobSchedule = ms
+	return nil
+}
+
+func (m monthlyJobDefinition) task() Task {
+	return m.tas
+}
+
+type DaysOfTheMonth func() []int
+
+func NewDaysOfTheMonth(day int, days ...int) DaysOfTheMonth {
+	return func() []int {
+		days = append(days, day)
+		return days
+	}
+}
+
+type atTime struct {
+	hours, minutes, seconds uint
+}
+
+func (a atTime) Time(location *time.Location) time.Time {
+	return time.Date(0, 0, 0, int(a.hours), int(a.minutes), int(a.seconds), 0, location)
+}
+
+type AtTime func() atTime
+
+func NewAtTime(hours, minutes, seconds uint) AtTime {
+	return func() atTime {
+		return atTime{hours: hours, minutes: minutes, seconds: seconds}
+	}
+}
+
+type AtTimes func() []AtTime
+
+func NewAtTimes(atTime AtTime, atTimes ...AtTime) AtTimes {
+	return func() []AtTime {
+		atTimes = append(atTimes, atTime)
+		return atTimes
+	}
+}
+
+// MonthlyJob runs the job on the interval of months, on the specific days of the month
+// specified, and at the set times. Days of the month can be 1 to 31 or negative (-1 to -31), which
+// count backwards from the end of the month. E.g. -1 is the last day of the month.
+//
+// If no days of the month are set, the job will start on the following month and on the
+// first at time set.
+//
+// If no days or at times are set, the job will start X months from now.
+//
+// If a day of the month is selected that does not exist in all months (e.g. 31st)
+// any month that does not have that day will be skipped.
+//
+// A job can always start per your specification using WithStartAt.
+func MonthlyJob(interval uint, daysOfTheMonth DaysOfTheMonth, atTimes AtTimes, task Task, options ...JobOption) JobDefinition {
+	return monthlyJobDefinition{
+		interval:       interval,
+		daysOfTheMonth: daysOfTheMonth,
+		atTimes:        atTimes,
+		tas:            task,
+		opts:           options,
+	}
+}
+
 // -----------------------------------------------
 // -----------------------------------------------
 // ----------------- Job Options -----------------
@@ -381,6 +502,46 @@ func (j *durationRandomJob) next(lastRun time.Time) time.Time {
 	return lastRun.Add(j.min + time.Duration(r))
 }
 
+var _ jobSchedule = (*monthlyJob)(nil)
+
+type monthlyJob struct {
+	days        []int
+	daysFromEnd []int
+	atTimes     []time.Time
+}
+
+func (m monthlyJob) next(lastRun time.Time) time.Time {
+	days := make([]int, len(m.days))
+	copy(days, m.days)
+	firstDayNextMonth := time.Date(lastRun.Year(), lastRun.Month()+1, 1, 0, 0, 0, 0, lastRun.Location())
+	for _, daySub := range m.daysFromEnd {
+		day := firstDayNextMonth.AddDate(0, 0, daySub).Day()
+		days = append(days, day)
+	}
+	slices.Sort(days)
+
+	next := m.nextMonthDayAtTime(lastRun, days)
+	if !next.IsZero() {
+		return next
+	}
+	return m.nextMonthDayAtTime(firstDayNextMonth, days)
+}
+
+func (m monthlyJob) nextMonthDayAtTime(lastRun time.Time, days []int) time.Time {
+	for _, day := range days {
+		if day >= lastRun.Day() {
+			for _, at := range m.atTimes {
+				atDate := time.Date(lastRun.Year(), lastRun.Month(), day, at.Hour(), at.Minute(), at.Second(), lastRun.Nanosecond(), lastRun.Location())
+				if atDate.After(lastRun) {
+					return atDate
+				}
+			}
+			continue
+		}
+	}
+	return time.Time{}
+}
+
 // -----------------------------------------------
 // -----------------------------------------------
 // ---------------- Job Interface ----------------
diff --git a/scheduler.go b/scheduler.go
index 1f6d161..19e5ac1 100644
--- a/scheduler.go
+++ b/scheduler.go
@@ -327,6 +327,9 @@ func (s *scheduler) addOrUpdateJob(id uuid.UUID, definition JobDefinition) (Job,
 
 	j.ctx, j.cancel = context.WithCancel(s.shutdownCtx)
 
+	if definition.task() == nil {
+		return nil, ErrNewJobTaskNil
+	}
 	tsk := definition.task()()
 	taskFunc := reflect.ValueOf(tsk.function)
 	for taskFunc.Kind() == reflect.Ptr {


Test patch:
diff --git a/job_test.go b/job_test.go
index 24a5400..8f93c33 100644
--- a/job_test.go
+++ b/job_test.go
@@ -4,6 +4,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
@@ -38,3 +39,67 @@ func TestDurationJob(t *testing.T) {
 		})
 	}
 }
+
+func TestMonthlyJob_next(t *testing.T) {
+	americaChicago, err := time.LoadLocation("America/Chicago")
+	require.NoError(t, err)
+
+	tests := []struct {
+		name                      string
+		days                      []int
+		daysFromEnd               []int
+		atTimes                   []time.Time
+		lastRun                   time.Time
+		expectedNextRun           time.Time
+		expectedDurationToNextRun time.Duration
+	}{
+		{
+			"same day",
+			[]int{1},
+			nil,
+			[]time.Time{
+				time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
+			},
+			time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
+			time.Date(2000, 1, 1, 5, 30, 0, 0, time.UTC),
+			5*time.Hour + 30*time.Minute,
+		},
+		{
+			"daylight savings time",
+			[]int{5},
+			nil,
+			[]time.Time{
+				time.Date(0, 0, 0, 5, 30, 0, 0, americaChicago),
+			},
+			time.Date(2023, 11, 1, 0, 0, 0, 0, americaChicago),
+			time.Date(2023, 11, 5, 5, 30, 0, 0, americaChicago),
+			4*24*time.Hour + 6*time.Hour + 30*time.Minute,
+		},
+		{
+			"negative days",
+			nil,
+			[]int{-1, -3, -5},
+			[]time.Time{
+				time.Date(0, 0, 0, 5, 30, 0, 0, time.UTC),
+			},
+			time.Date(2000, 1, 29, 5, 30, 0, 0, time.UTC),
+			time.Date(2000, 1, 31, 5, 30, 0, 0, time.UTC),
+			2 * 24 * time.Hour,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			m := monthlyJob{
+				days:        tt.days,
+				daysFromEnd: tt.daysFromEnd,
+				atTimes:     tt.atTimes,
+			}
+
+			next := m.next(tt.lastRun)
+			assert.Equal(t, tt.expectedNextRun, next)
+			assert.Equal(t, tt.expectedDurationToNextRun, next.Sub(tt.lastRun))
+		})
+	}
+
+}