Repository: kibertoad/toad-scheduler
Pull request #14 (2021-05-16 20:06:17)

Implement stopping of jobs by id

kibertoad: @jenseneducation-mattias-von-vogelsang Could you please review this?
jenseneducation-mattias-von-vogelsang: Looks perfect. I believe that will work like a charm. Great job, Igor!

Patch:
diff --git a/.eslintrc.json b/.eslintrc.json
index a31ba6b..13c8696 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -20,6 +20,7 @@
     "@typescript-eslint/no-non-null-assertion": "off",
     "@typescript-eslint/no-var-requires": "off",
     "@typescript-eslint/indent": "off",
+    "@typescript-eslint/no-empty-function": "off",
     "@typescript-eslint/no-explicit-any": "off",
     "@typescript-eslint/explicit-function-return-type": "off",
     "@typescript-eslint/member-delimiter-style": [
diff --git a/README.md b/README.md
index 8dfdb5d..c0b0698 100644
--- a/README.md
+++ b/README.md
@@ -63,7 +63,8 @@ Note that in order to avoid memory leaks, it is recommended to use promise chain
 ## API for scheduler
 
 * `addSimpleIntervalJob(job: SimpleIntervalJob): void` - registers and starts a new job;
-* `stop(): void` - stops all jobs, registered in the scheduler.
+* `stop(): void` - stops all jobs, registered in the scheduler;
+* `stopByID(id: string): void` - stops the job with a given id.
 
 [npm-image]: https://img.shields.io/npm/v/toad-scheduler.svg
 [npm-url]: https://npmjs.org/package/toad-scheduler
diff --git a/lib/common/Job.ts b/lib/common/Job.ts
index a6b8980..e571fea 100644
--- a/lib/common/Job.ts
+++ b/lib/common/Job.ts
@@ -1,4 +1,10 @@
 export abstract class Job {
+  public readonly id: string | undefined
+
+  protected constructor(id?: string) {
+    this.id = id
+  }
+
   abstract start(): void
   abstract stop(): void
 }
diff --git a/lib/engines/simple-interval/SimpleIntervalJob.ts b/lib/engines/simple-interval/SimpleIntervalJob.ts
index e09a79e..f8ce5a3 100644
--- a/lib/engines/simple-interval/SimpleIntervalJob.ts
+++ b/lib/engines/simple-interval/SimpleIntervalJob.ts
@@ -9,8 +9,8 @@ export class SimpleIntervalJob extends Job {
   private readonly schedule: SimpleIntervalSchedule
   private readonly task: Task | AsyncTask
 
-  constructor(schedule: SimpleIntervalSchedule, task: Task | AsyncTask) {
-    super()
+  constructor(schedule: SimpleIntervalSchedule, task: Task | AsyncTask, id?: string) {
+    super(id)
     this.schedule = schedule
     this.task = task
   }
diff --git a/lib/toadScheduler.ts b/lib/toadScheduler.ts
index d5322c4..54c1731 100644
--- a/lib/toadScheduler.ts
+++ b/lib/toadScheduler.ts
@@ -1,5 +1,6 @@
 import { SimpleIntervalEngine } from './engines/simple-interval/SimpleIntervalEngine'
 import { SimpleIntervalJob } from './engines/simple-interval/SimpleIntervalJob'
+import { Job } from './common/Job'
 
 type EngineRegistry = {
   simpleIntervalEngine?: SimpleIntervalEngine
@@ -7,9 +8,11 @@ type EngineRegistry = {
 
 export class ToadScheduler {
   private readonly engines: EngineRegistry
+  private readonly jobRegistry: Record<string, Job>
 
   constructor() {
     this.engines = {}
+    this.jobRegistry = {}
   }
 
   addSimpleIntervalJob(job: SimpleIntervalJob): void {
@@ -17,6 +20,13 @@ export class ToadScheduler {
       this.engines.simpleIntervalEngine = new SimpleIntervalEngine()
     }
 
+    if (job.id) {
+      if (this.jobRegistry[job.id]) {
+        throw new Error(`Job with an id ${job.id} is already registered.`)
+      }
+      this.jobRegistry[job.id] = job
+    }
+
     this.engines.simpleIntervalEngine.add(job)
   }
 
@@ -25,4 +35,12 @@ export class ToadScheduler {
       engine?.stop()
     }
   }
+
+  stopByID(id: string): void {
+    const job = this.jobRegistry[id]
+    if (!job) {
+      throw new Error(`Job with an id ${id} is not registered.`)
+    }
+    job.stop()
+  }
 }


Test patch:
diff --git a/test/AsyncTask.spec.ts b/test/AsyncTask.spec.ts
new file mode 100644
index 0000000..e3473c6
--- /dev/null
+++ b/test/AsyncTask.spec.ts
@@ -0,0 +1,75 @@
+import { ToadScheduler } from '../lib/toadScheduler'
+import { SimpleIntervalJob } from '../lib/engines/simple-interval/SimpleIntervalJob'
+import { AsyncTask } from '../lib/common/AsyncTask'
+
+function sleep(ms: number) {
+  return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+describe('ToadScheduler', () => {
+  describe('AsyncTask', () => {
+    it('correctly handles async errors', (done) => {
+      jest.useRealTimers()
+      expect.assertions(1)
+      let error: string
+      const scheduler = new ToadScheduler()
+      const task = new AsyncTask(
+        'async task',
+        () => {
+          return Promise.resolve().then(() => {
+            throw new Error('kaboom')
+          })
+        },
+        (err: Error) => {
+          error = err.message
+        }
+      )
+      const job = new SimpleIntervalJob(
+        {
+          milliseconds: 5,
+        },
+        task
+      )
+
+      scheduler.addSimpleIntervalJob(job)
+
+      sleep(10).then(() => {
+        expect(error).toBe('kaboom')
+        scheduler.stop()
+        done()
+      })
+    })
+
+    it('correctly handles async rejections', (done) => {
+      jest.useRealTimers()
+      expect.assertions(1)
+      let error: string
+      const scheduler = new ToadScheduler()
+      const task = new AsyncTask(
+        'async task',
+        () => {
+          return Promise.resolve().then(() => {
+            return Promise.reject(new Error('kaboom2'))
+          })
+        },
+        (err: Error) => {
+          error = err.message
+        }
+      )
+      const job = new SimpleIntervalJob(
+        {
+          milliseconds: 5,
+        },
+        task
+      )
+
+      scheduler.addSimpleIntervalJob(job)
+
+      sleep(10).then(() => {
+        expect(error).toBe('kaboom2')
+        scheduler.stop()
+        done()
+      })
+    })
+  })
+})
diff --git a/test/SimpleIntervalJob.spec.ts b/test/SimpleIntervalJob.spec.ts
new file mode 100644
index 0000000..0cd1e53
--- /dev/null
+++ b/test/SimpleIntervalJob.spec.ts
@@ -0,0 +1,220 @@
+import { ToadScheduler } from '../lib/toadScheduler'
+import { SimpleIntervalJob } from '../lib/engines/simple-interval/SimpleIntervalJob'
+import { Task } from '../lib/common/Task'
+
+describe('ToadScheduler', () => {
+  beforeEach(() => {
+    jest.useFakeTimers()
+  })
+
+  afterEach(() => {
+    jest.useRealTimers()
+  })
+
+  describe('SimpleIntervalJob', () => {
+    it('correctly processes SimpleIntervalJob', () => {
+      let counter = 0
+      const scheduler = new ToadScheduler()
+      const task = new Task('simple task', () => {
+        counter++
+      })
+      const job = new SimpleIntervalJob(
+        {
+          seconds: 20,
+        },
+        task
+      )
+
+      scheduler.addSimpleIntervalJob(job)
+
+      expect(counter).toBe(0)
+      jest.advanceTimersByTime(19999)
+      expect(counter).toBe(0)
+      jest.advanceTimersByTime(1)
+      expect(counter).toBe(1)
+      jest.advanceTimersByTime(19999)
+      expect(counter).toBe(1)
+      jest.advanceTimersByTime(1)
+      expect(counter).toBe(2)
+
+      scheduler.stop()
+    })
+
+    it('correctly handles milliseconds', () => {
+      let counter = 0
+      const scheduler = new ToadScheduler()
+      const task = new Task('simple task', () => {
+        counter++
+      })
+      const job = new SimpleIntervalJob(
+        {
+          milliseconds: 33,
+        },
+        task
+      )
+
+      scheduler.addSimpleIntervalJob(job)
+
+      expect(counter).toBe(0)
+      jest.advanceTimersByTime(32)
+      expect(counter).toBe(0)
+      jest.advanceTimersByTime(1)
+      expect(counter).toBe(1)
+
+      scheduler.stop()
+    })
+
+    it('correctly handles adding job twice', () => {
+      let counter = 0
+      const scheduler = new ToadScheduler()
+      const task = new Task('simple task', () => {
+        counter++
+      })
+      const job = new SimpleIntervalJob(
+        {
+          milliseconds: 10,
+        },
+        task
+      )
+
+      scheduler.addSimpleIntervalJob(job)
+      scheduler.addSimpleIntervalJob(job)
+
+      expect(counter).toBe(0)
+      jest.advanceTimersByTime(20)
+      expect(counter).toBe(2)
+
+      scheduler.stop()
+    })
+
+    it('correctly stops jobs', () => {
+      let counter = 0
+      const scheduler = new ToadScheduler()
+      const task = new Task('simple task', () => {
+        counter++
+      })
+      const job = new SimpleIntervalJob(
+        {
+          milliseconds: 10,
+        },
+        task
+      )
+
+      scheduler.addSimpleIntervalJob(job)
+
+      expect(counter).toBe(0)
+      jest.advanceTimersByTime(20)
+      expect(counter).toBe(2)
+
+      scheduler.stop()
+      scheduler.stop()
+
+      jest.advanceTimersByTime(20)
+      expect(counter).toBe(2)
+    })
+
+    it('correctly handles minutes', () => {
+      let counter = 0
+      const scheduler = new ToadScheduler()
+      const task = new Task('simple task', () => {
+        counter++
+      })
+      const job = new SimpleIntervalJob(
+        {
+          minutes: 5,
+        },
+        task
+      )
+
+      scheduler.addSimpleIntervalJob(job)
+
+      expect(counter).toBe(0)
+      jest.advanceTimersByTime(299999)
+      expect(counter).toBe(0)
+      jest.advanceTimersByTime(1)
+      expect(counter).toBe(1)
+
+      scheduler.stop()
+    })
+
+    it('correctly handles hours', () => {
+      let counter = 0
+      const scheduler = new ToadScheduler()
+      const task = new Task('simple task', () => {
+        counter++
+      })
+      const job = new SimpleIntervalJob(
+        {
+          hours: 1,
+        },
+        task
+      )
+
+      scheduler.addSimpleIntervalJob(job)
+
+      expect(counter).toBe(0)
+      jest.advanceTimersByTime(3599999)
+      expect(counter).toBe(0)
+      jest.advanceTimersByTime(1)
+      expect(counter).toBe(1)
+
+      scheduler.stop()
+    })
+
+    it('correctly handles days', () => {
+      let counter = 0
+      const scheduler = new ToadScheduler()
+      const task = new Task('simple task', () => {
+        counter++
+      })
+      const job = new SimpleIntervalJob(
+        {
+          days: 1,
+        },
+        task
+      )
+
+      scheduler.addSimpleIntervalJob(job)
+
+      expect(counter).toBe(0)
+      jest.advanceTimersByTime(86399999)
+      expect(counter).toBe(0)
+      jest.advanceTimersByTime(1)
+      expect(counter).toBe(1)
+
+      scheduler.stop()
+    })
+
+    it('correctly handles a combination of time units', () => {
+      let counter = 0
+      const scheduler = new ToadScheduler()
+      const task = new Task('simple task', () => {
+        counter++
+      })
+      const job = new SimpleIntervalJob(
+        {
+          days: 1,
+          hours: 2,
+          minutes: 3,
+          seconds: 4,
+          milliseconds: 5,
+        },
+        task
+      )
+
+      scheduler.addSimpleIntervalJob(job)
+
+      expect(counter).toBe(0)
+      jest.advanceTimersByTime(86_400_000)
+      jest.advanceTimersByTime(3_600_000 * 2)
+      jest.advanceTimersByTime(60_000 * 3)
+      jest.advanceTimersByTime(1_000 * 4)
+      jest.advanceTimersByTime(4)
+      expect(counter).toBe(0)
+      jest.advanceTimersByTime(1)
+      expect(counter).toBe(1)
+
+      scheduler.stop()
+    })
+  })
+})
diff --git a/test/Task.spec.ts b/test/Task.spec.ts
new file mode 100644
index 0000000..822e284
--- /dev/null
+++ b/test/Task.spec.ts
@@ -0,0 +1,41 @@
+import { ToadScheduler } from '../lib/toadScheduler'
+import { SimpleIntervalJob } from '../lib/engines/simple-interval/SimpleIntervalJob'
+import { Task } from '../lib/common/Task'
+
+function sleep(ms: number) {
+  return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+describe('ToadScheduler', () => {
+  describe('Task', () => {
+    it('correctly handles errors', (done) => {
+      jest.useRealTimers()
+      expect.assertions(1)
+      let error: string
+      const scheduler = new ToadScheduler()
+      const task = new Task(
+        'task',
+        () => {
+          throw new Error('kaboomSync')
+        },
+        (err: Error) => {
+          error = err.message
+        }
+      )
+      const job = new SimpleIntervalJob(
+        {
+          milliseconds: 5,
+        },
+        task
+      )
+
+      scheduler.addSimpleIntervalJob(job)
+
+      sleep(5).then(() => {
+        expect(error).toBe('kaboomSync')
+        scheduler.stop()
+        done()
+      })
+    })
+  })
+})
diff --git a/test/toadScheduler.spec.ts b/test/toadScheduler.spec.ts
index 5799ff7..cc0390d 100644
--- a/test/toadScheduler.spec.ts
+++ b/test/toadScheduler.spec.ts
@@ -1,10 +1,11 @@
 import { ToadScheduler } from '../lib/toadScheduler'
 import { SimpleIntervalJob } from '../lib/engines/simple-interval/SimpleIntervalJob'
 import { Task } from '../lib/common/Task'
-import { AsyncTask } from '../lib/common/AsyncTask'
 
-function sleep(ms: number) {
-  return new Promise((resolve) => setTimeout(resolve, ms))
+class NoopTask extends Task {
+  constructor() {
+    super('dummy', () => {})
+  }
 }
 
 describe('ToadScheduler', () => {
@@ -16,321 +17,89 @@ describe('ToadScheduler', () => {
     jest.useRealTimers()
   })
 
-  describe('SimpleIntervalJob', () => {
-    it('correctly processes SimpleIntervalJob', () => {
-      let counter = 0
+  describe('common', () => {
+    it('correctly stops without any jobs', () => {
       const scheduler = new ToadScheduler()
-      const task = new Task('simple task', () => {
-        counter++
-      })
-      const job = new SimpleIntervalJob(
-        {
-          seconds: 20,
-        },
-        task
-      )
-
-      scheduler.addSimpleIntervalJob(job)
-
-      expect(counter).toBe(0)
-      jest.advanceTimersByTime(19999)
-      expect(counter).toBe(0)
-      jest.advanceTimersByTime(1)
-      expect(counter).toBe(1)
-      jest.advanceTimersByTime(19999)
-      expect(counter).toBe(1)
-      jest.advanceTimersByTime(1)
-      expect(counter).toBe(2)
-
       scheduler.stop()
     })
 
-    it('correctly handles milliseconds', () => {
-      let counter = 0
+    it('correctly stops even when some engines are not initialized', () => {
       const scheduler = new ToadScheduler()
-      const task = new Task('simple task', () => {
-        counter++
-      })
-      const job = new SimpleIntervalJob(
-        {
-          milliseconds: 33,
-        },
-        task
-      )
-
-      scheduler.addSimpleIntervalJob(job)
-
-      expect(counter).toBe(0)
-      jest.advanceTimersByTime(32)
-      expect(counter).toBe(0)
-      jest.advanceTimersByTime(1)
-      expect(counter).toBe(1)
 
+      scheduler['engines'].simpleIntervalEngine = undefined
       scheduler.stop()
     })
 
-    it('correctly handles adding job twice', () => {
-      let counter = 0
+    it('throws an error when duplicate id is registered', () => {
       const scheduler = new ToadScheduler()
-      const task = new Task('simple task', () => {
-        counter++
-      })
       const job = new SimpleIntervalJob(
         {
-          milliseconds: 10,
+          milliseconds: 1,
         },
-        task
+        new NoopTask(),
+        'job1'
       )
-
-      scheduler.addSimpleIntervalJob(job)
-      scheduler.addSimpleIntervalJob(job)
-
-      expect(counter).toBe(0)
-      jest.advanceTimersByTime(20)
-      expect(counter).toBe(2)
-
-      scheduler.stop()
-    })
-
-    it('correctly stops jobs', () => {
-      let counter = 0
-      const scheduler = new ToadScheduler()
-      const task = new Task('simple task', () => {
-        counter++
-      })
-      const job = new SimpleIntervalJob(
+      const job2 = new SimpleIntervalJob(
         {
           milliseconds: 10,
         },
-        task
+        new NoopTask(),
+        'job1'
       )
 
       scheduler.addSimpleIntervalJob(job)
-
-      expect(counter).toBe(0)
-      jest.advanceTimersByTime(20)
-      expect(counter).toBe(2)
-
-      scheduler.stop()
-      scheduler.stop()
-
-      jest.advanceTimersByTime(20)
-      expect(counter).toBe(2)
+      expect(() => {
+        scheduler.addSimpleIntervalJob(job2)
+      }).toThrow(/Job with an id job1 is already registered/)
     })
 
-    it('correctly handles minutes', () => {
-      let counter = 0
+    it('throws an error when non-existent id is stopped', () => {
       const scheduler = new ToadScheduler()
-      const task = new Task('simple task', () => {
-        counter++
-      })
-      const job = new SimpleIntervalJob(
-        {
-          minutes: 5,
-        },
-        task
-      )
-
-      scheduler.addSimpleIntervalJob(job)
-
-      expect(counter).toBe(0)
-      jest.advanceTimersByTime(299999)
-      expect(counter).toBe(0)
-      jest.advanceTimersByTime(1)
-      expect(counter).toBe(1)
-
-      scheduler.stop()
+      expect(() => {
+        scheduler.stopByID('dummy')
+      }).toThrow(/Job with an id dummy is not registered./)
     })
 
-    it('correctly handles hours', () => {
+    it('correctly stops job by id', () => {
       let counter = 0
+      let counter2 = 0
       const scheduler = new ToadScheduler()
       const task = new Task('simple task', () => {
         counter++
       })
-      const job = new SimpleIntervalJob(
-        {
-          hours: 1,
-        },
-        task
-      )
-
-      scheduler.addSimpleIntervalJob(job)
-
-      expect(counter).toBe(0)
-      jest.advanceTimersByTime(3599999)
-      expect(counter).toBe(0)
-      jest.advanceTimersByTime(1)
-      expect(counter).toBe(1)
-
-      scheduler.stop()
-    })
-
-    it('correctly handles days', () => {
-      let counter = 0
-      const scheduler = new ToadScheduler()
-      const task = new Task('simple task', () => {
-        counter++
+      const task2 = new Task('simple task2', () => {
+        counter2++
       })
       const job = new SimpleIntervalJob(
         {
-          days: 1,
+          milliseconds: 1,
         },
-        task
+        task,
+        'job1'
       )
-
-      scheduler.addSimpleIntervalJob(job)
-
-      expect(counter).toBe(0)
-      jest.advanceTimersByTime(86399999)
-      expect(counter).toBe(0)
-      jest.advanceTimersByTime(1)
-      expect(counter).toBe(1)
-
-      scheduler.stop()
-    })
-
-    it('correctly handles a combination of time units', () => {
-      let counter = 0
-      const scheduler = new ToadScheduler()
-      const task = new Task('simple task', () => {
-        counter++
-      })
-      const job = new SimpleIntervalJob(
+      const job2 = new SimpleIntervalJob(
         {
-          days: 1,
-          hours: 2,
-          minutes: 3,
-          seconds: 4,
-          milliseconds: 5,
+          milliseconds: 10,
         },
-        task
+        task2,
+        'job2'
       )
 
       scheduler.addSimpleIntervalJob(job)
+      scheduler.addSimpleIntervalJob(job2)
 
       expect(counter).toBe(0)
-      jest.advanceTimersByTime(86_400_000)
-      jest.advanceTimersByTime(3_600_000 * 2)
-      jest.advanceTimersByTime(60_000 * 3)
-      jest.advanceTimersByTime(1_000 * 4)
-      jest.advanceTimersByTime(4)
-      expect(counter).toBe(0)
-      jest.advanceTimersByTime(1)
-      expect(counter).toBe(1)
-
-      scheduler.stop()
-    })
-  })
-
-  describe('Task', () => {
-    it('correctly handles errors', (done) => {
-      jest.useRealTimers()
-      expect.assertions(1)
-      let error: string
-      const scheduler = new ToadScheduler()
-      const task = new Task(
-        'task',
-        () => {
-          throw new Error('kaboomSync')
-        },
-        (err: Error) => {
-          error = err.message
-        }
-      )
-      const job = new SimpleIntervalJob(
-        {
-          milliseconds: 5,
-        },
-        task
-      )
-
-      scheduler.addSimpleIntervalJob(job)
-
-      sleep(5).then(() => {
-        expect(error).toBe('kaboomSync')
-        scheduler.stop()
-        done()
-      })
-    })
-  })
-
-  describe('AsyncTask', () => {
-    it('correctly handles async errors', (done) => {
-      jest.useRealTimers()
-      expect.assertions(1)
-      let error: string
-      const scheduler = new ToadScheduler()
-      const task = new AsyncTask(
-        'async task',
-        () => {
-          return Promise.resolve().then(() => {
-            throw new Error('kaboom')
-          })
-        },
-        (err: Error) => {
-          error = err.message
-        }
-      )
-      const job = new SimpleIntervalJob(
-        {
-          milliseconds: 5,
-        },
-        task
-      )
-
-      scheduler.addSimpleIntervalJob(job)
-
-      sleep(10).then(() => {
-        expect(error).toBe('kaboom')
-        scheduler.stop()
-        done()
-      })
-    })
-
-    it('correctly handles async rejections', (done) => {
-      jest.useRealTimers()
-      expect.assertions(1)
-      let error: string
-      const scheduler = new ToadScheduler()
-      const task = new AsyncTask(
-        'async task',
-        () => {
-          return Promise.resolve().then(() => {
-            return Promise.reject(new Error('kaboom2'))
-          })
-        },
-        (err: Error) => {
-          error = err.message
-        }
-      )
-      const job = new SimpleIntervalJob(
-        {
-          milliseconds: 5,
-        },
-        task
-      )
-
-      scheduler.addSimpleIntervalJob(job)
+      expect(counter2).toBe(0)
 
-      sleep(10).then(() => {
-        expect(error).toBe('kaboom2')
-        scheduler.stop()
-        done()
-      })
-    })
-  })
+      scheduler.stopByID('job2')
 
-  describe('common', () => {
-    it('correctly stops without any jobs', () => {
-      const scheduler = new ToadScheduler()
-      scheduler.stop()
-    })
-
-    it('correctly stops even when some engines are not initialized', () => {
-      const scheduler = new ToadScheduler()
+      jest.advanceTimersByTime(2)
+      expect(counter).toBe(2)
+      expect(counter2).toBe(0)
+      jest.advanceTimersByTime(10)
+      expect(counter).toBe(12)
+      expect(counter2).toBe(0)
 
-      scheduler['engines'].simpleIntervalEngine = undefined
       scheduler.stop()
     })