Repository: go-co-op/gocron (Go)

Issue:
[FEATURE] - Give jobs a unique ID
### Is your feature request related to a problem? Please describe

<!---A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]--->

To better support uniqueness, give jobs a UUID rather than relying on the runtime function name (as this can be duplicated in certain cases, e.g. loops)


### Describe the solution you'd like

<!---A clear and concise description of what you want to happen.--->

- Job struct has a UUID. Prefer a package that supports UUID v4 with a method that returns only a UUID and no error.
- For distributed locking, the default should be to lock based on the user provided name, and then fallback to UUID.
- Expose the job ID to the end user and provide a method to `RemoveByID(id UUID)`
- Store jobs on the scheduler in a map instead of a slice

### Describe alternatives you've considered

<!---A clear and concise description of any alternative solutions or features you've considered.--->

### Additional context

<!---Add any other context about the feature request here.--->



Discussion: By having UUID you could store jobs in a map instead of a list and removal would be much faster. In our case we have 80k jobs which are currently being cleaned up using tags. This iteration process is waaaay too long.



Fix (patch):
diff --git a/go.mod b/go.mod
index 9f5b3105..864b811f 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
 )
 
 require (
+	github.com/google/uuid v1.3.0
 	github.com/kr/pretty v0.3.0 // indirect
 	github.com/rogpeppe/go-internal v1.8.1 // indirect
 	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
diff --git a/go.sum b/go.sum
index b8e806fd..5ecd24f8 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
 github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
diff --git a/gocron.go b/gocron.go
index cb667022..dd159727 100644
--- a/gocron.go
+++ b/gocron.go
@@ -40,6 +40,7 @@ var (
 	ErrNotAFunction                  = errors.New("gocron: only functions can be scheduled into the job queue")
 	ErrNotScheduledWeekday           = errors.New("gocron: job not scheduled weekly on a weekday")
 	ErrJobNotFoundWithTag            = errors.New("gocron: no jobs found with given tag")
+	ErrJobNotFound                   = errors.New("gocron: no job found")
 	ErrUnsupportedTimeFormat         = errors.New("gocron: the given time format is not supported")
 	ErrInvalidInterval               = errors.New("gocron: .Every() interval must be greater than 0")
 	ErrInvalidIntervalType           = errors.New("gocron: .Every() interval must be int, time.Duration, or string")
diff --git a/job.go b/job.go
index d72a0f11..d3f2ff2d 100644
--- a/job.go
+++ b/job.go
@@ -8,6 +8,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/google/uuid"
 	"github.com/robfig/cron/v3"
 	"go.uber.org/atomic"
 )
@@ -47,6 +48,7 @@ type random struct {
 }
 
 type jobFunction struct {
+	id                uuid.UUID          // unique identifier for the job
 	*jobRunTimes                         // tracking all the markers for job run times
 	eventListeners                       // additional functions to allow run 'em during job performing
 	function          interface{}        // task's function
@@ -84,6 +86,7 @@ type jobMutex struct {
 
 func (jf *jobFunction) copy() jobFunction {
 	cp := jobFunction{
+		id:                jf.id,
 		jobRunTimes:       jf.jobRunTimes,
 		eventListeners:    jf.eventListeners,
 		function:          jf.function,
@@ -141,6 +144,7 @@ func newJob(interval int, startImmediately bool, singletonMode bool) *Job {
 		interval: interval,
 		unit:     seconds,
 		jobFunction: jobFunction{
+			id: uuid.New(),
 			jobRunTimes: &jobRunTimes{
 				jobRunTimesMu: &sync.Mutex{},
 				lastRun:       time.Time{},
diff --git a/scheduler.go b/scheduler.go
index 5d1b0cf7..a14c55a7 100644
--- a/scheduler.go
+++ b/scheduler.go
@@ -9,6 +9,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/google/uuid"
 	"github.com/robfig/cron/v3"
 	"go.uber.org/atomic"
 )
@@ -19,7 +20,7 @@ type limitMode int8
 // and implements the sort. any for sorting Jobs, by the time of jobFuncNextRun
 type Scheduler struct {
 	jobsMutex sync.RWMutex
-	jobs      []*Job
+	jobs      map[uuid.UUID]*Job
 
 	locationMutex sync.RWMutex
 	location      *time.Location
@@ -45,7 +46,7 @@ type Scheduler struct {
 	// Update() calls Do(), so really they all end with Do().
 	// This allows the caller to begin with any job related scheduler method
 	// and only with one of [ Every(), EveryRandom(), Cron(), CronWithSeconds(), MonthFirstWeekday() ]
-	inScheduleChain bool
+	inScheduleChain *uuid.UUID
 }
 
 // days in a week
@@ -55,8 +56,7 @@ const allWeekDays = 7
 func NewScheduler(loc *time.Location) *Scheduler {
 	executor := newExecutor()
 
-	return &Scheduler{
-		jobs:       make([]*Job, 0),
+	s := &Scheduler{
 		location:   loc,
 		running:    atomic.NewBool(false),
 		time:       &trueTime{},
@@ -64,6 +64,10 @@ func NewScheduler(loc *time.Location) *Scheduler {
 		tagsUnique: false,
 		timer:      afterFunc,
 	}
+	s.jobsMutex.Lock()
+	s.jobs = map[uuid.UUID]*Job{}
+	s.jobsMutex.Unlock()
+	return s
 }
 
 // SetMaxConcurrentJobs limits how many jobs can be running at the same time.
@@ -104,7 +108,7 @@ func (s *Scheduler) start() {
 	s.runJobs(s.Jobs())
 }
 
-func (s *Scheduler) runJobs(jobs []*Job) {
+func (s *Scheduler) runJobs(jobs map[uuid.UUID]*Job) {
 	for _, job := range jobs {
 		ctx, cancel := context.WithCancel(context.Background())
 		job.mu.Lock()
@@ -125,7 +129,7 @@ func (s *Scheduler) IsRunning() bool {
 }
 
 // Jobs returns the list of Jobs from the Scheduler
-func (s *Scheduler) Jobs() []*Job {
+func (s *Scheduler) Jobs() map[uuid.UUID]*Job {
 	s.jobsMutex.RLock()
 	defer s.jobsMutex.RUnlock()
 	return s.jobs
@@ -141,7 +145,7 @@ func (s *Scheduler) Name(name string) *Scheduler {
 	return s
 }
 
-func (s *Scheduler) setJobs(jobs []*Job) {
+func (s *Scheduler) setJobs(jobs map[uuid.UUID]*Job) {
 	s.jobsMutex.Lock()
 	defer s.jobsMutex.Unlock()
 	s.jobs = jobs
@@ -154,20 +158,6 @@ func (s *Scheduler) Len() int {
 	return len(s.jobs)
 }
 
-// Swap places each job into the other job's position given
-// the provided job indexes.
-func (s *Scheduler) Swap(i, j int) {
-	s.jobsMutex.Lock()
-	defer s.jobsMutex.Unlock()
-	s.jobs[i], s.jobs[j] = s.jobs[j], s.jobs[i]
-}
-
-// Less compares the next run of jobs based on their index.
-// Returns true if the second job is after the first.
-func (s *Scheduler) Less(first, second int) bool {
-	return s.Jobs()[second].NextRun().Unix() >= s.Jobs()[first].NextRun().Unix()
-}
-
 // ChangeLocation changes the default time location
 func (s *Scheduler) ChangeLocation(newLocation *time.Location) {
 	s.locationMutex.Lock()
@@ -512,12 +502,20 @@ func (s *Scheduler) roundToMidnightAndAddDSTAware(t time.Time, d time.Duration)
 // NextRun datetime when the next Job should run.
 func (s *Scheduler) NextRun() (*Job, time.Time) {
 	if len(s.Jobs()) <= 0 {
-		return nil, s.now()
+		return nil, time.Time{}
 	}
 
-	sort.Sort(s)
+	var jobID uuid.UUID
+	var nearestRun time.Time
+	for _, job := range s.Jobs() {
+		nr := job.NextRun()
+		if nr.Before(nearestRun) && s.now().Before(nr) {
+			nearestRun = nr
+			jobID = job.id
+		}
+	}
 
-	return s.Jobs()[0], s.Jobs()[0].NextRun()
+	return s.Jobs()[jobID], nearestRun
 }
 
 // EveryRandom schedules a new period Job that runs at random intervals
@@ -717,10 +715,10 @@ func (s *Scheduler) removeJobsUniqueTags(job *Job) {
 }
 
 func (s *Scheduler) removeByCondition(shouldRemove func(*Job) bool) {
-	retainedJobs := make([]*Job, 0)
+	retainedJobs := make(map[uuid.UUID]*Job, 0)
 	for _, job := range s.Jobs() {
 		if !shouldRemove(job) {
-			retainedJobs = append(retainedJobs, job)
+			retainedJobs[job.id] = job
 		} else {
 			job.stop()
 		}
@@ -767,6 +765,15 @@ func (s *Scheduler) RemoveByTagsAny(tags ...string) error {
 	return errs
 }
 
+// RemoveByID removes the job from the scheduler looking up by id
+func (s *Scheduler) RemoveByID(job *Job) error {
+	if _, ok := s.Jobs()[job.id]; ok {
+		delete(s.Jobs(), job.id)
+		return nil
+	}
+	return ErrJobNotFound
+}
+
 // FindJobsByTag will return a slice of Jobs that match all given tags
 func (s *Scheduler) FindJobsByTag(tags ...string) ([]*Job, error) {
 	var jobs []*Job
@@ -853,13 +860,11 @@ func (s *Scheduler) TaskPresent(j interface{}) bool {
 
 // To avoid the recursive read lock on s.Jobs() and this function,
 // creating this new function and distributing the lock between jobPresent, _jobPresent
-func (s *Scheduler) _jobPresent(j *Job, jobs []*Job) bool {
+func (s *Scheduler) _jobPresent(j *Job, jobs map[uuid.UUID]*Job) bool {
 	s.jobsMutex.RLock()
 	defer s.jobsMutex.RUnlock()
-	for _, job := range jobs {
-		if job == j {
-			return true
-		}
+	if _, ok := jobs[j.id]; ok {
+		return true
 	}
 	return false
 }
@@ -870,10 +875,8 @@ func (s *Scheduler) jobPresent(j *Job) bool {
 
 // Clear clears all Jobs from this scheduler
 func (s *Scheduler) Clear() {
-	for _, job := range s.Jobs() {
-		job.stop()
-	}
-	s.setJobs(make([]*Job, 0))
+	s.stopJobs()
+	s.setJobs(make(map[uuid.UUID]*Job, 0))
 	// If unique tags was enabled, delete all the tags loaded in the tags sync.Map
 	if s.tagsUnique {
 		s.tags.Range(func(key interface{}, value interface{}) bool {
@@ -908,7 +911,7 @@ func (s *Scheduler) stopJobs() {
 
 func (s *Scheduler) doCommon(jobFun interface{}, params ...interface{}) (*Job, error) {
 	job := s.getCurrentJob()
-	s.inScheduleChain = false
+	s.inScheduleChain = nil
 
 	jobUnit := job.getUnit()
 	jobLastRun := job.LastRun()
@@ -1255,17 +1258,19 @@ func (s *Scheduler) Sunday() *Scheduler {
 }
 
 func (s *Scheduler) getCurrentJob() *Job {
-	if !s.inScheduleChain {
+	if s.inScheduleChain == nil {
 		s.jobsMutex.Lock()
-		s.jobs = append(s.jobs, s.newJob(0))
+		j := s.newJob(0)
+		s.jobs[j.id] = j
 		s.jobsMutex.Unlock()
-		s.inScheduleChain = true
+		s.inScheduleChain = &j.id
+		return j
 	}
 
 	s.jobsMutex.RLock()
 	defer s.jobsMutex.RUnlock()
 
-	return s.jobs[len(s.jobs)-1]
+	return s.jobs[*s.inScheduleChain]
 }
 
 func (s *Scheduler) now() time.Time {
@@ -1284,14 +1289,7 @@ func (s *Scheduler) TagsUnique() {
 // of making changes to the job with the scheduler chain
 // and finalized by calling Update()
 func (s *Scheduler) Job(j *Job) *Scheduler {
-	jobs := s.Jobs()
-	for index, job := range jobs {
-		if job == j {
-			// the current job is always last, so put this job there
-			s.Swap(len(jobs)-1, index)
-		}
-	}
-	s.inScheduleChain = true
+	s.inScheduleChain = &j.id
 	s.updateJob = true
 	return s
 }