gocron/scheduler_test.go

1748 lines
32 KiB
Go
Raw Normal View History

2023-11-08 17:11:42 +00:00
package gocron
import (
"context"
"fmt"
"sync"
2023-11-08 17:11:42 +00:00
"testing"
"time"
"github.com/google/uuid"
2023-11-08 17:11:42 +00:00
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
)
func newTestScheduler(t *testing.T, options ...SchedulerOption) Scheduler {
2023-11-08 17:11:42 +00:00
// default test options
out := []SchedulerOption{
2023-11-09 15:49:21 +00:00
WithLogger(NewLogger(LogLevelDebug)),
2023-11-08 17:11:42 +00:00
WithStopTimeout(time.Second),
}
// append any additional options 2nd to override defaults if needed
out = append(out, options...)
s, err := NewScheduler(out...)
require.NoError(t, err)
return s
2023-11-08 17:11:42 +00:00
}
func TestScheduler_OneSecond_NoOptions(t *testing.T) {
defer goleak.VerifyNone(t)
cronNoOptionsCh := make(chan struct{}, 10)
durationNoOptionsCh := make(chan struct{}, 10)
tests := []struct {
name string
ch chan struct{}
jd JobDefinition
tsk Task
}{
{
"cron",
cronNoOptionsCh,
CronJob(
"* * * * * *",
true,
),
NewTask(
func() {
cronNoOptionsCh <- struct{}{}
},
),
},
{
"duration",
durationNoOptionsCh,
DurationJob(
time.Second,
),
NewTask(
func() {
durationNoOptionsCh <- struct{}{}
},
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t)
2023-11-08 17:11:42 +00:00
_, err := s.NewJob(tt.jd, tt.tsk)
2023-11-08 17:11:42 +00:00
require.NoError(t, err)
s.Start()
startTime := time.Now()
var runCount int
for runCount < 1 {
<-tt.ch
runCount++
}
require.NoError(t, s.Shutdown())
2023-11-08 17:11:42 +00:00
stopTime := time.Now()
select {
case <-tt.ch:
t.Fatal("job ran after scheduler was stopped")
case <-time.After(time.Millisecond * 50):
}
runDuration := stopTime.Sub(startTime)
assert.GreaterOrEqual(t, runDuration, time.Millisecond)
assert.LessOrEqual(t, runDuration, 1500*time.Millisecond)
})
}
}
func TestScheduler_LongRunningJobs(t *testing.T) {
defer goleak.VerifyNone(t)
durationCh := make(chan struct{}, 10)
durationSingletonCh := make(chan struct{}, 10)
tests := []struct {
name string
ch chan struct{}
jd JobDefinition
tsk Task
opts []JobOption
options []SchedulerOption
expectedRuns int
}{
{
"duration",
durationCh,
DurationJob(
time.Millisecond * 500,
),
NewTask(
func() {
time.Sleep(1 * time.Second)
durationCh <- struct{}{}
},
),
nil,
[]SchedulerOption{WithStopTimeout(time.Second * 2)},
3,
},
{
"duration singleton",
durationSingletonCh,
DurationJob(
time.Millisecond * 500,
),
NewTask(
func() {
time.Sleep(1 * time.Second)
durationSingletonCh <- struct{}{}
},
),
[]JobOption{WithSingletonMode(LimitModeWait)},
[]SchedulerOption{WithStopTimeout(time.Second * 5)},
2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t, tt.options...)
2023-11-08 17:11:42 +00:00
_, err := s.NewJob(tt.jd, tt.tsk, tt.opts...)
2023-11-08 17:11:42 +00:00
require.NoError(t, err)
s.Start()
time.Sleep(1600 * time.Millisecond)
require.NoError(t, s.Shutdown())
2023-11-08 17:11:42 +00:00
var runCount int
timeout := make(chan struct{})
go func() {
time.Sleep(2 * time.Second)
close(timeout)
}()
Outer:
for {
select {
case <-tt.ch:
runCount++
case <-timeout:
break Outer
}
}
assert.Equal(t, tt.expectedRuns, runCount)
})
}
}
func TestScheduler_Update(t *testing.T) {
defer goleak.VerifyNone(t)
durationJobCh := make(chan struct{})
tests := []struct {
name string
initialJob JobDefinition
updateJob JobDefinition
tsk Task
ch chan struct{}
runCount int
updateAfterCount int
expectedMinTime time.Duration
expectedMaxRunTime time.Duration
}{
{
"duration, updated to another duration",
DurationJob(
time.Millisecond * 500,
),
DurationJob(
time.Second,
),
NewTask(
func() {
durationJobCh <- struct{}{}
},
),
durationJobCh,
2,
1,
time.Second * 1,
time.Second * 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t)
2023-11-08 17:11:42 +00:00
j, err := s.NewJob(tt.initialJob, tt.tsk)
require.NoError(t, err)
startTime := time.Now()
s.Start()
var runCount int
for runCount < tt.runCount {
select {
case <-tt.ch:
runCount++
if runCount == tt.updateAfterCount {
_, err = s.Update(j.ID(), tt.updateJob, tt.tsk)
require.NoError(t, err)
}
default:
}
}
require.NoError(t, s.Shutdown())
2023-11-08 17:11:42 +00:00
stopTime := time.Now()
select {
case <-tt.ch:
t.Fatal("job ran after scheduler was stopped")
case <-time.After(time.Millisecond * 50):
}
runDuration := stopTime.Sub(startTime)
assert.GreaterOrEqual(t, runDuration, tt.expectedMinTime)
assert.LessOrEqual(t, runDuration, tt.expectedMaxRunTime)
})
}
}
func TestScheduler_StopTimeout(t *testing.T) {
defer goleak.VerifyNone(t)
tests := []struct {
name string
jd JobDefinition
f any
opts []JobOption
}{
{
"duration",
DurationJob(
time.Millisecond * 100,
),
func(testDoneCtx context.Context) {
select {
case <-time.After(1 * time.Second):
case <-testDoneCtx.Done():
}
},
nil,
},
{
"duration singleton",
DurationJob(
time.Millisecond * 100,
),
func(testDoneCtx context.Context) {
select {
case <-time.After(1 * time.Second):
case <-testDoneCtx.Done():
}
},
[]JobOption{WithSingletonMode(LimitModeWait)},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testDoneCtx, cancel := context.WithCancel(context.Background())
s := newTestScheduler(t,
WithStopTimeout(time.Millisecond*100),
2023-11-08 17:11:42 +00:00
)
_, err := s.NewJob(tt.jd, NewTask(tt.f, testDoneCtx), tt.opts...)
2023-11-08 17:11:42 +00:00
require.NoError(t, err)
s.Start()
assert.ErrorIs(t, err, s.Shutdown())
2023-11-08 17:11:42 +00:00
cancel()
time.Sleep(2 * time.Second)
})
}
}
func TestScheduler_Shutdown(t *testing.T) {
goleak.VerifyNone(t)
t.Run("start, stop, start, shutdown", func(t *testing.T) {
s := newTestScheduler(t,
2023-11-08 17:11:42 +00:00
WithStopTimeout(time.Second),
)
_, err := s.NewJob(
2023-11-08 17:11:42 +00:00
DurationJob(
50*time.Millisecond,
),
NewTask(
func() {},
),
WithStartAt(
WithStartImmediately(),
),
)
require.NoError(t, err)
s.Start()
require.NoError(t, s.StopJobs())
s.Start()
require.NoError(t, s.Shutdown())
})
t.Run("calling Job methods after shutdown errors", func(t *testing.T) {
s := newTestScheduler(t,
2023-11-08 17:11:42 +00:00
WithStopTimeout(time.Second),
)
j, err := s.NewJob(
DurationJob(
100*time.Millisecond,
),
NewTask(
func() {},
),
WithStartAt(
WithStartImmediately(),
),
)
require.NoError(t, err)
s.Start()
require.NoError(t, s.Shutdown())
_, err = j.LastRun()
assert.ErrorIs(t, err, ErrJobNotFound)
_, err = j.NextRun()
assert.ErrorIs(t, err, ErrJobNotFound)
})
}
func TestScheduler_NewJob(t *testing.T) {
goleak.VerifyNone(t)
tests := []struct {
name string
jd JobDefinition
tsk Task
opts []JobOption
}{
{
"cron with timezone",
CronJob(
"CRON_TZ=America/Chicago * * * * * *",
true,
),
NewTask(
func() {},
),
nil,
},
{
"cron with timezone, no seconds",
CronJob(
"CRON_TZ=America/Chicago * * * * *",
false,
),
NewTask(
func() {},
),
nil,
},
{
"random duration",
DurationRandomJob(
time.Second,
time.Second*5,
),
NewTask(
func() {},
),
nil,
},
{
"daily",
DailyJob(
1,
NewAtTimes(
NewAtTime(1, 0, 0),
),
),
NewTask(
func() {},
),
nil,
},
{
"weekly",
WeeklyJob(
1,
NewWeekdays(time.Monday),
NewAtTimes(
NewAtTime(1, 0, 0),
),
),
NewTask(
func() {},
),
nil,
},
{
"monthly",
MonthlyJob(
1,
NewDaysOfTheMonth(1, -1),
NewAtTimes(
NewAtTime(1, 0, 0),
),
),
NewTask(
func() {},
),
nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t)
2023-11-08 17:11:42 +00:00
_, err := s.NewJob(tt.jd, tt.tsk, tt.opts...)
2023-11-08 17:11:42 +00:00
require.NoError(t, err)
s.Start()
require.NoError(t, s.Shutdown())
})
}
}
func TestScheduler_NewJobErrors(t *testing.T) {
goleak.VerifyNone(t)
tests := []struct {
name string
jd JobDefinition
opts []JobOption
err error
}{
{
"cron with timezone",
CronJob(
"bad cron",
true,
),
nil,
ErrCronJobParse,
},
{
"random with bad min/max",
DurationRandomJob(
time.Second*5,
time.Second,
),
nil,
ErrDurationRandomJobMinMax,
},
{
"daily job at times nil",
DailyJob(
1,
nil,
),
nil,
ErrDailyJobAtTimesNil,
},
{
"daily job at time nil",
DailyJob(
1,
NewAtTimes(nil),
),
nil,
ErrDailyJobAtTimeNil,
},
{
"daily job hours out of range",
DailyJob(
1,
NewAtTimes(
NewAtTime(100, 0, 0),
),
),
nil,
ErrDailyJobHours,
},
{
"daily job minutes out of range",
DailyJob(
1,
NewAtTimes(
NewAtTime(1, 100, 0),
),
),
nil,
ErrDailyJobMinutesSeconds,
},
{
"daily job seconds out of range",
DailyJob(
1,
NewAtTimes(
NewAtTime(1, 0, 100),
),
),
nil,
ErrDailyJobMinutesSeconds,
},
{
"weekly job at times nil",
WeeklyJob(
1,
NewWeekdays(time.Monday),
nil,
),
nil,
ErrWeeklyJobAtTimesNil,
},
{
"weekly job at time nil",
WeeklyJob(
1,
NewWeekdays(time.Monday),
NewAtTimes(nil),
),
nil,
ErrWeeklyJobAtTimeNil,
},
{
"weekly job weekdays nil",
WeeklyJob(
1,
nil,
NewAtTimes(
NewAtTime(1, 0, 0),
),
),
nil,
ErrWeeklyJobDaysOfTheWeekNil,
},
{
"weekly job hours out of range",
WeeklyJob(
1,
NewWeekdays(time.Monday),
NewAtTimes(
NewAtTime(100, 0, 0),
),
),
nil,
ErrWeeklyJobHours,
},
{
"weekly job minutes out of range",
WeeklyJob(
1,
NewWeekdays(time.Monday),
NewAtTimes(
NewAtTime(1, 100, 0),
),
),
nil,
ErrWeeklyJobMinutesSeconds,
},
{
"weekly job seconds out of range",
WeeklyJob(
1,
NewWeekdays(time.Monday),
NewAtTimes(
NewAtTime(1, 0, 100),
),
),
nil,
ErrWeeklyJobMinutesSeconds,
},
{
"monthly job at times nil",
MonthlyJob(
1,
NewDaysOfTheMonth(1),
nil,
),
nil,
ErrMonthlyJobAtTimesNil,
},
{
"monthly job at time nil",
MonthlyJob(
1,
NewDaysOfTheMonth(1),
NewAtTimes(nil),
),
nil,
ErrMonthlyJobAtTimeNil,
},
{
"monthly job days out of range",
MonthlyJob(
1,
NewDaysOfTheMonth(0),
NewAtTimes(
NewAtTime(1, 0, 0),
),
),
nil,
ErrMonthlyJobDays,
},
{
"monthly job days out of range",
MonthlyJob(
1,
nil,
NewAtTimes(
NewAtTime(1, 0, 0),
),
),
nil,
ErrMonthlyJobDaysNil,
},
{
"monthly job hours out of range",
MonthlyJob(
1,
NewDaysOfTheMonth(1),
NewAtTimes(
NewAtTime(100, 0, 0),
),
),
nil,
ErrMonthlyJobHours,
},
{
"monthly job minutes out of range",
MonthlyJob(
1,
NewDaysOfTheMonth(1),
NewAtTimes(
NewAtTime(1, 100, 0),
),
),
nil,
ErrMonthlyJobMinutesSeconds,
},
{
"monthly job seconds out of range",
MonthlyJob(
1,
NewDaysOfTheMonth(1),
NewAtTimes(
NewAtTime(1, 0, 100),
),
),
nil,
ErrMonthlyJobMinutesSeconds,
},
{
"WithName no name",
DurationJob(
time.Second,
),
[]JobOption{WithName("")},
ErrWithNameEmpty,
},
{
"WithStartDateTime is zero",
DurationJob(
time.Second,
),
[]JobOption{WithStartAt(WithStartDateTime(time.Time{}))},
ErrWithStartDateTimePast,
},
{
"WithStartDateTime is in the past",
DurationJob(
time.Second,
),
[]JobOption{WithStartAt(WithStartDateTime(time.Now().Add(-time.Second)))},
ErrWithStartDateTimePast,
},
{
"oneTimeJob start at is zero",
OneTimeJob(OneTimeJobStartDateTime(time.Time{})),
nil,
ErrOneTimeJobStartDateTimePast,
},
{
"oneTimeJob start at is in past",
OneTimeJob(OneTimeJobStartDateTime(time.Now().Add(-time.Second))),
nil,
ErrOneTimeJobStartDateTimePast,
},
2023-11-08 17:11:42 +00:00
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t,
WithStopTimeout(time.Millisecond*50),
2023-11-08 17:11:42 +00:00
)
_, err := s.NewJob(tt.jd, NewTask(func() {}), tt.opts...)
2023-11-08 17:11:42 +00:00
assert.ErrorIs(t, err, tt.err)
require.NoError(t, s.Shutdown())
})
t.Run(tt.name+" global", func(t *testing.T) {
s := newTestScheduler(t,
WithStopTimeout(time.Millisecond*50),
WithGlobalJobOptions(tt.opts...),
)
_, err := s.NewJob(tt.jd, NewTask(func() {}))
assert.ErrorIs(t, err, tt.err)
require.NoError(t, s.Shutdown())
})
}
}
func TestScheduler_NewJobTask(t *testing.T) {
goleak.VerifyNone(t)
testFuncPtr := func() {}
testFuncWithParams := func(one, two string) {}
testStruct := struct{}{}
tests := []struct {
name string
tsk Task
err error
}{
{
"task nil",
nil,
ErrNewJobTaskNil,
},
{
"task not func - nil",
NewTask(nil),
ErrNewJobTaskNotFunc,
},
{
"task not func - string",
NewTask("not a func"),
ErrNewJobTaskNotFunc,
},
{
"task func is pointer",
NewTask(&testFuncPtr),
nil,
},
{
"parameter number does not match",
NewTask(testFuncWithParams, "one"),
ErrNewJobWrongNumberOfParameters,
},
{
"parameter type does not match",
NewTask(testFuncWithParams, "one", 2),
ErrNewJobWrongTypeOfParameters,
},
{
"parameter number does not match - ptr",
NewTask(&testFuncWithParams, "one"),
ErrNewJobWrongNumberOfParameters,
},
{
"parameter type does not match - ptr",
NewTask(&testFuncWithParams, "one", 2),
ErrNewJobWrongTypeOfParameters,
},
{
"all good struct",
NewTask(func(one struct{}) {}, struct{}{}),
nil,
},
{
"all good interface",
NewTask(func(one interface{}) {}, struct{}{}),
nil,
},
{
"all good any",
NewTask(func(one any) {}, struct{}{}),
nil,
},
{
"all good slice",
NewTask(func(one []struct{}) {}, []struct{}{}),
nil,
},
{
"all good chan",
NewTask(func(one chan struct{}) {}, make(chan struct{})),
nil,
},
{
"all good pointer",
NewTask(func(one *struct{}) {}, &testStruct),
nil,
},
{
"all good map",
NewTask(func(one map[string]struct{}) {}, make(map[string]struct{})),
nil,
},
{
"all good",
NewTask(&testFuncWithParams, "one", "two"),
nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t)
_, err := s.NewJob(DurationJob(time.Second), tt.tsk)
assert.ErrorIs(t, err, tt.err)
require.NoError(t, s.Shutdown())
})
}
}
func TestScheduler_WithOptionsErrors(t *testing.T) {
goleak.VerifyNone(t)
tests := []struct {
name string
opt SchedulerOption
err error
}{
{
"WithClock nil",
WithClock(nil),
ErrWithClockNil,
},
{
"WithDistributedElector nil",
WithDistributedElector(nil),
ErrWithDistributedElectorNil,
},
{
"WithDistributedLocker nil",
WithDistributedLocker(nil),
ErrWithDistributedLockerNil,
},
{
"WithLimitConcurrentJobs limit 0",
WithLimitConcurrentJobs(0, LimitModeWait),
ErrWithLimitConcurrentJobsZero,
},
{
"WithLocation nil",
WithLocation(nil),
ErrWithLocationNil,
},
{
"WithLogger nil",
WithLogger(nil),
ErrWithLoggerNil,
},
{
"WithStopTimeout 0",
WithStopTimeout(0),
ErrWithStopTimeoutZeroOrNegative,
},
{
"WithStopTimeout -1",
WithStopTimeout(-1),
ErrWithStopTimeoutZeroOrNegative,
},
{
"WithMonitorer nil",
WithMonitor(nil),
ErrWithMonitorNil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewScheduler(tt.opt)
assert.ErrorIs(t, err, tt.err)
2023-11-08 17:11:42 +00:00
})
}
}
func TestScheduler_Singleton(t *testing.T) {
goleak.VerifyNone(t)
tests := []struct {
name string
duration time.Duration
limitMode LimitMode
runCount int
expectedMin time.Duration
expectedMax time.Duration
}{
{
"singleton mode reschedule",
time.Millisecond * 100,
LimitModeReschedule,
3,
time.Millisecond * 600,
time.Millisecond * 1100,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
jobRanCh := make(chan struct{}, 10)
s := newTestScheduler(t,
WithStopTimeout(1*time.Second),
WithLocation(time.Local),
2023-11-08 17:11:42 +00:00
)
_, err := s.NewJob(
2023-11-08 17:11:42 +00:00
DurationJob(
tt.duration,
),
NewTask(func() {
time.Sleep(tt.duration * 2)
jobRanCh <- struct{}{}
}),
WithSingletonMode(tt.limitMode),
)
require.NoError(t, err)
start := time.Now()
s.Start()
var runCount int
for runCount < tt.runCount {
select {
case <-jobRanCh:
runCount++
case <-time.After(time.Second):
t.Fatalf("timed out waiting for jobs to run")
}
}
stop := time.Now()
require.NoError(t, s.Shutdown())
assert.GreaterOrEqual(t, stop.Sub(start), tt.expectedMin)
assert.LessOrEqual(t, stop.Sub(start), tt.expectedMax)
})
}
}
func TestScheduler_LimitMode(t *testing.T) {
goleak.VerifyNone(t)
tests := []struct {
name string
numJobs int
limit uint
limitMode LimitMode
duration time.Duration
expectedMin time.Duration
expectedMax time.Duration
}{
{
"limit mode reschedule",
10,
2,
LimitModeReschedule,
time.Millisecond * 100,
time.Millisecond * 400,
time.Millisecond * 700,
},
{
"limit mode wait",
10,
2,
LimitModeWait,
time.Millisecond * 100,
time.Millisecond * 200,
time.Millisecond * 500,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t,
2023-11-08 17:11:42 +00:00
WithLimitConcurrentJobs(tt.limit, tt.limitMode),
WithStopTimeout(2*time.Second),
)
jobRanCh := make(chan struct{}, 20)
for i := 0; i < tt.numJobs; i++ {
_, err := s.NewJob(
2023-11-08 17:11:42 +00:00
DurationJob(tt.duration),
NewTask(func() {
time.Sleep(tt.duration / 2)
jobRanCh <- struct{}{}
}),
)
require.NoError(t, err)
}
start := time.Now()
s.Start()
var runCount int
for runCount < tt.numJobs {
select {
case <-jobRanCh:
runCount++
case <-time.After(time.Second):
t.Fatalf("timed out waiting for jobs to run")
}
}
stop := time.Now()
require.NoError(t, s.Shutdown())
2023-11-08 17:11:42 +00:00
assert.GreaterOrEqual(t, stop.Sub(start), tt.expectedMin)
assert.LessOrEqual(t, stop.Sub(start), tt.expectedMax)
})
}
}
func TestScheduler_LimitModeAndSingleton(t *testing.T) {
goleak.VerifyNone(t)
tests := []struct {
name string
numJobs int
limit uint
limitMode LimitMode
singletonMode LimitMode
duration time.Duration
expectedMin time.Duration
expectedMax time.Duration
}{
{
"limit mode reschedule",
10,
2,
LimitModeReschedule,
LimitModeReschedule,
time.Millisecond * 100,
time.Millisecond * 400,
time.Millisecond * 700,
},
{
"limit mode wait",
10,
2,
LimitModeWait,
LimitModeWait,
time.Millisecond * 100,
time.Millisecond * 200,
time.Millisecond * 500,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t,
WithLimitConcurrentJobs(tt.limit, tt.limitMode),
WithStopTimeout(2*time.Second),
)
jobRanCh := make(chan int, 20)
for i := 0; i < tt.numJobs; i++ {
jobNum := i
_, err := s.NewJob(
DurationJob(tt.duration),
NewTask(func() {
time.Sleep(tt.duration / 2)
jobRanCh <- jobNum
}),
WithSingletonMode(tt.singletonMode),
)
require.NoError(t, err)
}
start := time.Now()
s.Start()
jobsRan := make(map[int]int)
var runCount int
for runCount < tt.numJobs {
select {
case jobNum := <-jobRanCh:
runCount++
jobsRan[jobNum]++
case <-time.After(time.Second):
t.Fatalf("timed out waiting for jobs to run")
}
}
stop := time.Now()
require.NoError(t, s.Shutdown())
assert.GreaterOrEqual(t, stop.Sub(start), tt.expectedMin)
assert.LessOrEqual(t, stop.Sub(start), tt.expectedMax)
for _, count := range jobsRan {
if tt.singletonMode == LimitModeWait {
assert.Equal(t, 1, count)
} else {
assert.LessOrEqual(t, count, 5)
}
}
})
}
}
var _ Elector = (*testElector)(nil)
type testElector struct {
mu sync.Mutex
leaderElected bool
}
func (t *testElector) IsLeader(ctx context.Context) error {
select {
case <-ctx.Done():
return fmt.Errorf("done")
default:
}
t.mu.Lock()
defer t.mu.Unlock()
if t.leaderElected {
return fmt.Errorf("already elected leader")
}
t.leaderElected = true
return nil
}
var _ Locker = (*testLocker)(nil)
type testLocker struct {
mu sync.Mutex
jobLocked bool
}
func (t *testLocker) Lock(_ context.Context, _ string) (Lock, error) {
t.mu.Lock()
defer t.mu.Unlock()
if t.jobLocked {
return nil, fmt.Errorf("job already locked")
}
2023-11-14 17:06:16 +00:00
t.jobLocked = true
return &testLock{}, nil
}
var _ Lock = (*testLock)(nil)
type testLock struct{}
func (t testLock) Unlock(_ context.Context) error {
return nil
}
func TestScheduler_WithDistributed(t *testing.T) {
goleak.VerifyNone(t)
tests := []struct {
name string
count int
opt SchedulerOption
}{
{
"3 schedulers with elector",
3,
WithDistributedElector(&testElector{}),
},
{
"3 schedulers with locker",
3,
WithDistributedLocker(&testLocker{}),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
jobsRan := make(chan struct{}, 20)
ctx, cancel := context.WithCancel(context.Background())
schedulersDone := make(chan struct{}, tt.count)
for i := tt.count; i > 0; i-- {
s := newTestScheduler(t,
2023-11-14 21:09:02 +00:00
tt.opt,
)
2023-11-14 21:09:02 +00:00
go func() {
s.Start()
_, err := s.NewJob(
DurationJob(
time.Second,
),
NewTask(
func() {
jobsRan <- struct{}{}
},
),
WithStartAt(
WithStartImmediately(),
),
)
require.NoError(t, err)
<-ctx.Done()
err = s.Shutdown()
require.NoError(t, err)
schedulersDone <- struct{}{}
}()
}
var runCount int
select {
case <-jobsRan:
cancel()
runCount++
case <-time.After(time.Second):
cancel()
t.Error("timed out waiting for job to run")
}
var doneCount int
timeout := time.Now().Add(3 * time.Second)
for doneCount < tt.count && time.Now().After(timeout) {
select {
case <-schedulersDone:
doneCount++
default:
}
}
close(jobsRan)
for range jobsRan {
runCount++
}
assert.Equal(t, 1, runCount)
})
}
}
func TestScheduler_RemoveJob(t *testing.T) {
goleak.VerifyNone(t)
tests := []struct {
name string
addJob bool
err error
}{
{
"success",
true,
nil,
},
{
"job not found",
false,
ErrJobNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t)
var id uuid.UUID
if tt.addJob {
j, err := s.NewJob(DurationJob(time.Second), NewTask(func() {}))
require.NoError(t, err)
id = j.ID()
} else {
id = uuid.New()
}
err := s.RemoveJob(id)
assert.ErrorIs(t, err, err)
require.NoError(t, s.Shutdown())
})
}
}
func TestScheduler_WithEventListeners(t *testing.T) {
goleak.VerifyNone(t)
listenerRunCh := make(chan error, 1)
testErr := fmt.Errorf("test error")
tests := []struct {
name string
tsk Task
el EventListener
expectRun bool
expectErr error
}{
{
"AfterJobRuns",
NewTask(func() {}),
AfterJobRuns(func(_ uuid.UUID, _ string) {
listenerRunCh <- nil
}),
true,
nil,
},
{
"AfterJobRunsWithError - error",
NewTask(func() error { return testErr }),
AfterJobRunsWithError(func(_ uuid.UUID, _ string, err error) {
listenerRunCh <- err
}),
true,
testErr,
},
2023-11-22 12:43:50 +00:00
{
"AfterJobRunsWithError - multiple return values, including error",
NewTask(func() (bool, error) { return false, testErr }),
AfterJobRunsWithError(func(_ uuid.UUID, _ string, err error) {
listenerRunCh <- err
}),
true,
testErr,
},
{
"AfterJobRunsWithError - no error",
NewTask(func() error { return nil }),
AfterJobRunsWithError(func(_ uuid.UUID, _ string, err error) {
listenerRunCh <- err
}),
false,
nil,
},
{
"BeforeJobRuns",
NewTask(func() {}),
BeforeJobRuns(func(_ uuid.UUID, _ string) {
listenerRunCh <- nil
}),
true,
nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t)
_, err := s.NewJob(
DurationJob(time.Minute*10),
tt.tsk,
WithStartAt(
WithStartImmediately(),
),
WithEventListeners(tt.el),
WithLimitedRuns(1),
)
require.NoError(t, err)
s.Start()
if tt.expectRun {
select {
case err = <-listenerRunCh:
assert.ErrorIs(t, err, tt.expectErr)
case <-time.After(time.Second):
t.Fatal("timed out waiting for listener to run")
}
} else {
select {
case <-listenerRunCh:
t.Fatal("listener ran when it shouldn't have")
case <-time.After(time.Millisecond * 100):
}
}
require.NoError(t, s.Shutdown())
})
}
}
func TestScheduler_ManyJobs(t *testing.T) {
s := newTestScheduler(t)
jobsRan := make(chan struct{}, 20000)
for i := 1; i <= 1000; i++ {
_, err := s.NewJob(
DurationJob(
time.Millisecond*100,
),
NewTask(
func() {
jobsRan <- struct{}{}
},
),
WithStartAt(WithStartImmediately()),
)
require.NoError(t, err)
}
s.Start()
time.Sleep(1 * time.Second)
require.NoError(t, s.Shutdown())
close(jobsRan)
var count int
for range jobsRan {
count++
}
assert.GreaterOrEqual(t, count, 9900)
assert.LessOrEqual(t, count, 11000)
}
func TestScheduler_RunJobNow(t *testing.T) {
chDuration := make(chan struct{}, 10)
chMonthly := make(chan struct{}, 10)
chDurationImmediate := make(chan struct{}, 10)
chDurationSingleton := make(chan struct{}, 10)
chOneTime := make(chan struct{}, 10)
tests := []struct {
name string
ch chan struct{}
j JobDefinition
fun any
opts []JobOption
expectedDiff func() time.Duration
expectedRuns int
}{
{
"duration job",
chDuration,
DurationJob(time.Second * 10),
func() {
chDuration <- struct{}{}
},
nil,
func() time.Duration {
return 0
},
1,
},
{
"monthly job",
chMonthly,
MonthlyJob(1, NewDaysOfTheMonth(1), NewAtTimes(NewAtTime(0, 0, 0))),
func() {
chMonthly <- struct{}{}
},
nil,
func() time.Duration {
return 0
},
1,
},
{
"duration job - start immediately",
chDurationImmediate,
DurationJob(time.Second * 10),
func() {
chDurationImmediate <- struct{}{}
},
[]JobOption{
WithStartAt(
WithStartImmediately(),
),
},
func() time.Duration {
return 10 * time.Second
},
2,
},
{
"duration job - singleton",
chDurationSingleton,
DurationJob(time.Second * 10),
func() {
chDurationSingleton <- struct{}{}
time.Sleep(200 * time.Millisecond)
},
[]JobOption{
WithStartAt(
WithStartImmediately(),
),
WithSingletonMode(LimitModeReschedule),
},
func() time.Duration {
return 10 * time.Second
},
1,
},
{
"one time job",
chOneTime,
OneTimeJob(OneTimeJobStartImmediately()),
func() {
chOneTime <- struct{}{}
},
nil,
nil,
2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t)
j, err := s.NewJob(tt.j, NewTask(tt.fun), tt.opts...)
require.NoError(t, err)
s.Start()
var nextRunBefore time.Time
if tt.expectedDiff != nil {
for ; nextRunBefore.IsZero() || err != nil; nextRunBefore, err = j.NextRun() { //nolint:revive
}
}
assert.NoError(t, err)
time.Sleep(100 * time.Millisecond)
require.NoError(t, j.RunNow())
var runCount int
select {
case <-tt.ch:
runCount++
case <-time.After(time.Second):
t.Fatal("timed out waiting for job to run")
}
timeout := time.Now().Add(time.Second)
for time.Now().Before(timeout) {
select {
case <-tt.ch:
runCount++
default:
}
}
assert.Equal(t, tt.expectedRuns, runCount)
nextRunAfter, err := j.NextRun()
if tt.expectedDiff != nil && tt.expectedDiff() > 0 {
for ; nextRunBefore.IsZero() || nextRunAfter.Equal(nextRunBefore); nextRunAfter, err = j.NextRun() { //nolint:revive
}
}
assert.NoError(t, err)
assert.NoError(t, s.Shutdown())
if tt.expectedDiff != nil {
assert.Equal(t, tt.expectedDiff(), nextRunAfter.Sub(nextRunBefore))
}
})
}
}
func TestScheduler_OneTimeJob(t *testing.T) {
tests := []struct {
name string
startAt func() OneTimeJobStartAtOption
}{
{
"start now",
func() OneTimeJobStartAtOption {
return OneTimeJobStartImmediately()
},
},
{
"start in 100 ms",
func() OneTimeJobStartAtOption {
return OneTimeJobStartDateTime(time.Now().Add(100 * time.Millisecond))
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
jobRan := make(chan struct{}, 2)
s := newTestScheduler(t)
j, err := s.NewJob(
OneTimeJob(tt.startAt()),
NewTask(func() {
jobRan <- struct{}{}
}),
)
require.NoError(t, err)
s.Start()
select {
case <-jobRan:
case <-time.After(500 * time.Millisecond):
t.Fatal("timed out waiting for job to run")
}
var nextRun time.Time
for ; nextRun.IsZero(); nextRun, err = j.NextRun() { //nolint:revive
}
assert.NoError(t, err)
assert.True(t, nextRun.Before(time.Now()))
assert.NoError(t, s.Shutdown())
})
}
}
func TestScheduler_Jobs(t *testing.T) {
tests := []struct {
name string
}{
{
"order is equal",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestScheduler(t)
for i := 0; i <= 20; i++ {
_, err := s.NewJob(
DurationJob(time.Second),
NewTask(func() {}),
)
require.NoError(t, err)
}
jobsFirst := s.Jobs()
jobsSecond := s.Jobs()
assert.Equal(t, jobsFirst, jobsSecond)
assert.NoError(t, s.Shutdown())
})
}
}
type testMonitor struct {
mu sync.Mutex
counter map[string]int
time map[string][]time.Duration
}
func newTestMonitor() *testMonitor {
return &testMonitor{
counter: make(map[string]int),
time: make(map[string][]time.Duration),
}
}
func (t *testMonitor) IncrementJob(_ uuid.UUID, name string, _ []string, _ JobStatus) {
t.mu.Lock()
defer t.mu.Unlock()
_, ok := t.counter[name]
if !ok {
t.counter[name] = 0
}
t.counter[name]++
}
func (t *testMonitor) RecordJobTiming(startTime, endTime time.Time, _ uuid.UUID, name string, _ []string) {
t.mu.Lock()
defer t.mu.Unlock()
_, ok := t.time[name]
if !ok {
t.time[name] = make([]time.Duration, 0)
}
t.time[name] = append(t.time[name], endTime.Sub(startTime))
}
func TestScheduler_WithMonitor(t *testing.T) {
goleak.VerifyNone(t)
tests := []struct {
name string
jd JobDefinition
jobName string
}{
{
"scheduler with monitorer",
DurationJob(time.Millisecond * 50),
"job",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ch := make(chan struct{}, 20)
monitor := newTestMonitor()
s := newTestScheduler(t, WithMonitor(monitor))
opt := []JobOption{
WithName(tt.jobName),
WithStartAt(
WithStartImmediately(),
),
}
_, err := s.NewJob(
tt.jd,
NewTask(func() {
ch <- struct{}{}
}),
opt...,
)
require.NoError(t, err)
s.Start()
time.Sleep(150 * time.Millisecond)
require.NoError(t, s.Shutdown())
close(ch)
expectedCount := 0
for range ch {
expectedCount++
}
got := monitor.counter[tt.jobName]
if got != expectedCount {
t.Fatalf("job %q counter expected %d, got %d", tt.jobName, expectedCount, got)
}
})
}
}