Repository: go-co-op/gocron
Pull request #460 (2023-04-22 16:34:28)

fix(job): provide access to job context
### What does this do?

This PR provides access to a `Job`'s `ctx` field. This comes very handy when we've got a long running job that we want to kill when the `Job` is stopped. By using the context `Done()` channel, we can exit immediately, just as the `Job` has been stopped.

This is in line with the standard practice in Go, for example https://pkg.go.dev/net/http#Request.Context.

Without this change, the user of the library has to implement quite significant code to work around this issue. Mainly maintaining a list of `context`es and/or `cancel` functions associated to each `Job`.  However, this seems redundant since all this functionality is already implemented in the library itself.

Changes:
* [main change] added a getter for the `ctx`: https://github.com/go-co-op/gocron/pull/460/files#diff-d25200def3804ad5276ed9f6b2e57f0681afe30e7b1929ccd2dacda060b920c2R297
* added test asserting the getter returns the context
* added test asserting that the cancel function is called when a job is stopped

### 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
This PR does not modify or break any new functionality, it just exposes the `ctx` of a `Job`.

### Have you included tests for your changes?
Yes

### Did you document any new/modified functionality?

- [x] Updated `example_test.go`
- [x] Updated `README.md`
- [x] Update documentation of `Remove` method to reflect that there is a new easy way to find out whether the job has been removed/canceled

### Notes



Patch:
diff --git a/README.md b/README.md
index 2364b76..e0d83ca 100644
--- a/README.md
+++ b/README.md
@@ -117,6 +117,50 @@ s.RunByTag("tag")
 
 - Q: I've removed my job from the scheduler, but how can I stop a long-running job that has already been triggered?
   - A: We recommend using a means of canceling your job, e.g. a `context.WithCancel()`.
+  - A2: You can listen to the job context Done channel to know when the job has been canceled
+    ```golang
+    task := func(in string, job gocron.Job) {
+        fmt.Printf("this job's last run: %s this job's next run: %s\n", job.LastRun(), job.NextRun())
+        fmt.Printf("in argument is %s\n", in)
+
+        ticker := time.NewTicker(100 * time.Millisecond)
+        defer ticker.Stop()
+
+        for {
+            select {
+            case <-job.Context().Done():
+                fmt.Printf("function has been canceled, performing cleanup and exiting gracefully\n")
+                return
+            case <-ticker.C:
+                fmt.Printf("performing a hard job that takes a long time that I want to kill whenever I want\n")
+            }
+        }
+    }
+
+    var err error
+    s := gocron.NewScheduler(time.UTC)
+    s.SingletonModeAll()
+    j, err := s.Every(1).Hour().Tag("myJob").DoWithJobDetails(task, "foo")
+    if err != nil {
+        log.Fatalln("error scheduling job", err)
+    }
+
+    s.StartAsync()
+
+    // Simulate some more work
+    time.Sleep(time.Second)
+
+    // I want to stop the job, together with the underlying goroutine
+    fmt.Printf("now I want to kill the job\n")
+    err = s.RemoveByTag("myJob")
+    if err != nil {
+        log.Fatalln("error removing job by tag", err)
+    }
+
+    // Wait a bit so that we can see that the job is exiting gracefully
+    time.Sleep(time.Second)
+    fmt.Printf("Job: %#v, Error: %#v", j, err)
+    ```
 
 ---
 
diff --git a/job.go b/job.go
index b476ea3..d177ae6 100644
--- a/job.go
+++ b/job.go
@@ -293,6 +293,11 @@ func (j *Job) Error() error {
 	return j.error
 }
 
+// Context returns the job's context. The context controls cancellation.
+func (j *Job) Context() context.Context {
+	return j.ctx
+}
+
 // Tag allows you to add arbitrary labels to a Job that do not
 // impact the functionality of the Job
 func (j *Job) Tag(tags ...string) {
diff --git a/scheduler.go b/scheduler.go
index e085428..f4a3009 100644
--- a/scheduler.go
+++ b/scheduler.go
@@ -649,10 +649,14 @@ func (s *Scheduler) RunByTagWithDelay(tag string, d time.Duration) error {
 // Remove specific Job by function
 //
 // Removing a job stops that job's timer. However, if a job has already
-// been started by by the job's timer before being removed, there is no way to stop
-// it through gocron as https://pkg.go.dev/time#Timer.Stop explains.
-// The job function would need to have implemented a means of
-// stopping, e.g. using a context.WithCancel().
+// been started by the job's timer before being removed, the only way to stop
+// it through gocron is to use DoWithJobDetails and access the job's Context which
+// informs you when the job has been canceled.
+//
+// Alternatively, the job function would need to have implemented a means of
+// stopping, e.g. using a context.WithCancel() passed as params to Do method.
+//
+// The above are based on what the underlying library suggests https://pkg.go.dev/time#Timer.Stop.
 func (s *Scheduler) Remove(job any) {
 	fName := getFunctionName(job)
 	j := s.findJobByTaskName(fName)


Test patch:
diff --git a/example_test.go b/example_test.go
index 02594ac..5fa3f78 100644
--- a/example_test.go
+++ b/example_test.go
@@ -324,13 +324,46 @@ func ExampleScheduler_Do() {
 
 func ExampleScheduler_DoWithJobDetails() {
 	task := func(in string, job gocron.Job) {
-		fmt.Printf("this job's last run: %s\nthis job's next run: %s", job.LastRun(), job.NextRun())
+		fmt.Printf("this job's last run: %s this job's next run: %s\n", job.LastRun(), job.NextRun())
+		fmt.Printf("in argument is %s\n", in)
+
+		ticker := time.NewTicker(100 * time.Millisecond)
+		defer ticker.Stop()
+
+		for {
+			select {
+			case <-job.Context().Done():
+				fmt.Printf("function has been canceled, performing cleanup and exiting gracefully\n")
+				return
+			case <-ticker.C:
+				fmt.Printf("performing a hard job that takes a long time that I want to kill whenever I want\n")
+			}
+		}
 	}
 
+	var err error
 	s := gocron.NewScheduler(time.UTC)
-	j, err := s.Every(1).Second().DoWithJobDetails(task, "foo")
+	s.SingletonModeAll()
+	j, err := s.Every(1).Hour().Tag("myJob").DoWithJobDetails(task, "foo")
+	if err != nil {
+		log.Fatalln("error scheduling job", err)
+	}
+
 	s.StartAsync()
-	fmt.Printf("Job: %v, Error: %v", j, err)
+
+	// Simulate some more work
+	time.Sleep(time.Second)
+
+	// I want to stop the job, together with the underlying goroutine
+	fmt.Printf("now I want to kill the job\n")
+	err = s.RemoveByTag("myJob")
+	if err != nil {
+		log.Fatalln("error removing job by tag", err)
+	}
+
+	// Wait a bit so that we can see that the job is exiting gracefully
+	time.Sleep(time.Second)
+	fmt.Printf("Job: %#v, Error: %#v", j, err)
 }
 
 func ExampleScheduler_Every() {
diff --git a/job_test.go b/job_test.go
index 1abbb88..4ab80ad 100644
--- a/job_test.go
+++ b/job_test.go
@@ -1,6 +1,8 @@
 package gocron
 
 import (
+	"context"
+	"errors"
 	"sync"
 	"sync/atomic"
 	"testing"
@@ -259,3 +261,27 @@ func TestJob_SetEventListeners(t *testing.T) {
 		assert.True(t, afterCallbackPassed)
 	})
 }
+
+func TestJob_Context(t *testing.T) {
+	t.Run("Context returns the job's context", func(t *testing.T) {
+		s := NewScheduler(time.UTC)
+		job, err := s.Tag("tag1").Every("100ms").Do(func() {})
+		require.NoError(t, err)
+		assert.NotNil(t, job.Context())
+		assert.Equal(t, job.ctx, job.Context())
+	})
+}
+
+func TestJob_Stop(t *testing.T) {
+	t.Run("stop calls the cancel function", func(t *testing.T) {
+		s := NewScheduler(time.UTC)
+		job, err := s.Tag("tag1").Every("100ms").Do(func() {})
+		require.NoError(t, err)
+
+		jobCtx := job.ctx
+		assert.Nil(t, jobCtx.Err())
+
+		job.stop()
+		assert.True(t, errors.Is(jobCtx.Err(), context.Canceled))
+	})
+}