diff --git a/errors.go b/errors.go index f7fbc9a..c3ce4b7 100644 --- a/errors.go +++ b/errors.go @@ -59,6 +59,7 @@ var ( ErrStartTimeLaterThanEndTime = errors.New("gocron: WithStartDateTime: start must not be later than end") ErrStopTimeEarlierThanStartTime = errors.New("gocron: WithStopDateTime: end must not be earlier than start") ErrWithStopTimeoutZeroOrNegative = errors.New("gocron: WithStopTimeout: timeout must be greater than 0") + ErrSchedulerMonitorNil = errors.New("gocron: scheduler monitor cannot be nil") ) // internal errors diff --git a/scheduler.go b/scheduler.go index 274f1e2..0d7ff78 100644 --- a/scheduler.go +++ b/scheduler.go @@ -7,6 +7,7 @@ import ( "runtime" "slices" "strings" + "sync" "sync/atomic" "time" @@ -100,6 +101,11 @@ type scheduler struct { removeJobCh chan uuid.UUID // requests from the client to remove jobs by tags are received here removeJobsByTagsCh chan []string + + // scheduler monitor from which metrics can be collected + schedulerMonitor SchedulerMonitor + // mutex to protect access to the scheduler monitor + schedulerMonitorMu sync.RWMutex } type newJobIn struct { @@ -815,8 +821,13 @@ func (s *scheduler) RemoveJob(id uuid.UUID) error { func (s *scheduler) Start() { select { case <-s.shutdownCtx.Done(): + // Scheduler already shut down, don't notify + return case s.startCh <- struct{}{}: - <-s.startedCh + <-s.startedCh // Wait for scheduler to actually start + + // Scheduler has started + s.notifySchedulerStarted() } } @@ -849,6 +860,9 @@ func (s *scheduler) Shutdown() error { select { case err := <-s.stopErrCh: t.Stop() + + // notify monitor that scheduler stopped + s.notifySchedulerStopped() return err case <-t.C: return ErrStopSchedulerTimedOut @@ -1054,3 +1068,34 @@ func WithMonitorStatus(monitor MonitorStatus) SchedulerOption { return nil } } + +// WithSchedulerMonitor sets a monitor that will be called with scheduler-level events. +func WithSchedulerMonitor(monitor SchedulerMonitor) SchedulerOption { + return func(s *scheduler) error { + if monitor == nil { + return ErrSchedulerMonitorNil + } + s.schedulerMonitorMu.Lock() + defer s.schedulerMonitorMu.Unlock() + s.schedulerMonitor = monitor + return nil + } +} + +// notifySchedulerStarted notifies the monitor that scheduler has started +func (s *scheduler) notifySchedulerStarted() { + s.schedulerMonitorMu.RLock() + defer s.schedulerMonitorMu.RUnlock() + if s.schedulerMonitor != nil { + s.schedulerMonitor.SchedulerStarted() + } +} + +// notifySchedulerStopped notifies the monitor that scheduler has stopped +func (s *scheduler) notifySchedulerStopped() { + s.schedulerMonitorMu.RLock() + defer s.schedulerMonitorMu.RUnlock() + if s.schedulerMonitor != nil { + s.schedulerMonitor.SchedulerStopped() + } +} diff --git a/scheduler_monitor.go b/scheduler_monitor.go new file mode 100644 index 0000000..ab788c8 --- /dev/null +++ b/scheduler_monitor.go @@ -0,0 +1,11 @@ +package gocron + +// SchedulerMonitor is called by the Scheduler to provide scheduler-level +// metrics and events. +type SchedulerMonitor interface { + // SchedulerStarted is called when Start() is invoked on the scheduler. + SchedulerStarted() + + // SchedulerStopped is called when Shutdown() completes successfully. + SchedulerStopped() +} diff --git a/scheduler_monitor_test.go b/scheduler_monitor_test.go new file mode 100644 index 0000000..1b69a5b --- /dev/null +++ b/scheduler_monitor_test.go @@ -0,0 +1,148 @@ +package gocron + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockSchedulerMonitor is a test implementation +type mockSchedulerMonitor struct { + mu sync.Mutex + schedulerStartedCount int + schedulerStoppedCount int +} + +// SchedulerStarted increments the started count +func (m *mockSchedulerMonitor) SchedulerStarted() { + m.mu.Lock() + defer m.mu.Unlock() + m.schedulerStartedCount++ +} + +// SchedulerStopped increments the stopped count +func (m *mockSchedulerMonitor) SchedulerStopped() { + m.mu.Lock() + defer m.mu.Unlock() + m.schedulerStoppedCount++ +} + +// getSchedulerStartedCount returns the count of SchedulerStarted calls +func (m *mockSchedulerMonitor) getSchedulerStartedCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.schedulerStartedCount +} + +// getSchedulerStoppedCount returns the count of SchedulerStopped calls +func (m *mockSchedulerMonitor) getSchedulerStoppedCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.schedulerStoppedCount +} + +// Test that SchedulerStopped is called +func TestSchedulerMonitor_SchedulerStopped(t *testing.T) { + monitor := &mockSchedulerMonitor{} + s, err := NewScheduler(WithSchedulerMonitor(monitor)) + require.NoError(t, err) + + s.Start() + time.Sleep(10 * time.Millisecond) + + err = s.Shutdown() + require.NoError(t, err) + + // verify SchedulerStopped was called exactly once + assert.Equal(t, 1, monitor.getSchedulerStoppedCount()) +} + +// Test multiple start/stop cycles +func TestSchedulerMonitor_MultipleStartStopCycles(t *testing.T) { + monitor := &mockSchedulerMonitor{} + s, err := NewScheduler(WithSchedulerMonitor(monitor)) + require.NoError(t, err) + + // first cycle + s.Start() + time.Sleep(10 * time.Millisecond) + err = s.Shutdown() + require.NoError(t, err) + + // second cycle + s.Start() + time.Sleep(10 * time.Millisecond) + err = s.Shutdown() + require.NoError(t, err) + + // verifying counts + assert.Equal(t, 2, monitor.getSchedulerStartedCount()) + assert.Equal(t, 2, monitor.getSchedulerStoppedCount()) +} + +// Test that start and stop are called in correct order +func TestSchedulerMonitor_StartStopOrder(t *testing.T) { + events := []string{} + var mu sync.Mutex + + monitor := &orderTrackingMonitor{ + onStart: func() { + mu.Lock() + events = append(events, "started") + mu.Unlock() + }, + onStop: func() { + mu.Lock() + events = append(events, "stopped") + mu.Unlock() + }, + } + + s, err := NewScheduler(WithSchedulerMonitor(monitor)) + require.NoError(t, err) + + s.Start() + time.Sleep(10 * time.Millisecond) + s.Shutdown() + time.Sleep(10 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, []string{"started", "stopped"}, events) +} + +// Helper monitor for tracking order +type orderTrackingMonitor struct { + onStart func() + onStop func() +} + +func (m *orderTrackingMonitor) SchedulerStarted() { + if m.onStart != nil { + m.onStart() + } +} + +func (m *orderTrackingMonitor) SchedulerStopped() { + if m.onStop != nil { + m.onStop() + } +} + +// Test that stopped is not called if scheduler wasn't started +func TestSchedulerMonitor_StoppedNotCalledIfNotStarted(t *testing.T) { + monitor := &mockSchedulerMonitor{} + s, err := NewScheduler(WithSchedulerMonitor(monitor)) + require.NoError(t, err) + err = s.Shutdown() + if err != nil { + t.Logf("Shutdown returned error as expected: %v", err) + } + + // Depending on implementation, this might return an error + // But stopped should not be called + assert.Equal(t, 0, monitor.getSchedulerStoppedCount()) +}