Repository: kelektiv/node-cron (ts)

Issue:
option to wait for job completion before executing the next one
This can be useful in cases where two ticks running at the same time could lead to conflicts, and while it's not supposed to happen, maybe some undetected errors/bugs in the `onTick` call might lead to one tick being too slow and having multiple ticks running at the same time

~~Considering you already have the `job.running` boolean, I think it could be a great addition whilst not being too hard, to add a new parameter that could prevent the job to fire a new tick, if one is already running.~~
I gave a look at the code, and figured that I misunderstood how `running` was working: It determines whether the **job** is running, not if **one tick** is running. Regardless of that, I still think this could be a nice feature, though I see that it is harder to implement now

### Related
- https://github.com/kelektiv/node-cron/issues/347
- https://github.com/nestjs/schedule/issues/1296
- https://github.com/kelektiv/node-cron/issues/713
option to wait for callbacks completion on job.stop()
### ⭐ Suggestion

Do not stop the jobs if the `onTick` function is being executed. Wait for this function to resolve first and then, resolve `job.stop()`

### 💻 Use Cases

Basically I am trying to gracefully shut down my cron jobs when deploying a new version of my back-end.

That means that ideally, I want to call `job.stop()` on all the ongoing jobs, Then once they are done, I want to close the DB connection and so on and so forth.

The problem is that those jobs are mostly doing async stuff, so if I call a `job.stop()` and my `onTick` function is currently running, then, I would like to wait for the `onTick` function to resolve before the `job.stop(`) function resolves itself so I don't close the DB connection before those jobs are done since they need it.

### Related

- https://github.com/kelektiv/node-cron/issues/556

Pull request description:
feat: support async handling and add CronJob status tracking
## Description
This PR improves async handling in the CronJob class and adds status tracking functionality. 

- Modified the `fireOnTick()` method to return a Promise for better async callback handling
- Added an `isCallbackRunning` flag to track the running state of CronJob instances
- Updated the test suite to use the new async behavior and track the job's running state
- Added `waitForCompletion` functionality to the `job.stop()` method
  -  waits for running jobs to complete before executing the `onComplete` callback

During test case writing, I encountered a type error with sinon.
To resolve this, added `sinon.restore()` to the `afterEach` block.

Reference: https://stackoverflow.com/questions/73232999/sinon-cant-install-fake-timers-twice-on-the-same-global-object

<img width="811" alt="스크린샷 2024-09-03 오후 7 10 58" src="https://github.com/user-attachments/assets/b87deee7-14b2-4407-8fea-a1cb469ef44b">

## Related Issue
Closes #713
Closes #556

## Motivation and Context
These changes allow the CronJob class to handle asynchronous callbacks more effectively and provide a way to track the running state of jobs.

## How Has This Been Tested?
- Updated existing test suite to verify the new async behavior
- Adjusted test timeouts to use the `tickAsync` method
- Added new test cases to check for proper waiting of running callbacks before stopping

## Types of changes
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:
- [x] My code follows the code style of this project.
- [x] My change requires a change to the documentation.
- [ ] I have updated the documentation accordingly.
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.
- [ ] If my change introduces a breaking change, I have added a `!` after the type/scope in the title (see the Conventional Commits standard).

Patch:
diff --git a/README.md b/README.md
index 35bebef..21b395a 100644
--- a/README.md
+++ b/README.md
@@ -194,6 +194,8 @@ day of week    0-7 (0 or 7 is Sunday, or use names)
 
 - `unrefTimeout`: [OPTIONAL] - Useful for controlling event loop behavior. More details [here](https://nodejs.org/api/timers.html#timers_timeout_unref).
 
+- `waitForCompletion`: [OPTIONAL] - If `true`, no additional instances of the `onTick` callback function will run until the current onTick callback has completed. Any new scheduled executions that occur while the current callback is running will be skipped entirely. Default is `false`.
+
 #### Methods
 
 - `from` (static): Create a new CronJob object providing arguments as an object. See argument names and descriptions above.
@@ -214,6 +216,23 @@ day of week    0-7 (0 or 7 is Sunday, or use names)
 
 - `addCallback`: Permits addition of `onTick` callbacks.
 
+#### Properties
+
+- `isCallbackRunning`: [READ-ONLY] Indicates if a callback is currently executing.
+
+  ```javascript
+  const job = new CronJob('* * * * * *', async () => {
+  	console.log(job.isCallbackRunning); // true during callback execution
+  	await someAsyncTask();
+  	console.log(job.isCallbackRunning); // still true until callback completes
+  });
+
+  console.log(job.isCallbackRunning); // false
+  job.start();
+  console.log(job.running); // true (schedule is active)
+  console.log(job.isCallbackRunning); // false (no callback executing)
+  ```
+
 ### CronTime Class
 
 #### Constructor
diff --git a/src/job.ts b/src/job.ts
index b1d8be7..59555ca 100644
--- a/src/job.ts
+++ b/src/job.ts
@@ -21,10 +21,16 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 	onComplete?: WithOnComplete<OC> extends true
 		? CronOnCompleteCallback
 		: undefined;
+	waitForCompletion = false;
 
+	private _isCallbackRunning = false;
 	private _timeout?: NodeJS.Timeout;
 	private _callbacks: CronCallback<C, WithOnComplete<OC>>[] = [];
 
+	get isCallbackRunning() {
+		return this._isCallbackRunning;
+	}
+
 	constructor(
 		cronTime: CronJobParams<OC, C>['cronTime'],
 		onTick: CronJobParams<OC, C>['onTick'],
@@ -34,7 +40,8 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 		context?: CronJobParams<OC, C>['context'],
 		runOnInit?: CronJobParams<OC, C>['runOnInit'],
 		utcOffset?: null,
-		unrefTimeout?: CronJobParams<OC, C>['unrefTimeout']
+		unrefTimeout?: CronJobParams<OC, C>['unrefTimeout'],
+		waitForCompletion?: CronJobParams<OC, C>['waitForCompletion']
 	);
 	constructor(
 		cronTime: CronJobParams<OC, C>['cronTime'],
@@ -45,7 +52,8 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 		context?: CronJobParams<OC, C>['context'],
 		runOnInit?: CronJobParams<OC, C>['runOnInit'],
 		utcOffset?: CronJobParams<OC, C>['utcOffset'],
-		unrefTimeout?: CronJobParams<OC, C>['unrefTimeout']
+		unrefTimeout?: CronJobParams<OC, C>['unrefTimeout'],
+		waitForCompletion?: CronJobParams<OC, C>['waitForCompletion']
 	);
 	constructor(
 		cronTime: CronJobParams<OC, C>['cronTime'],
@@ -56,9 +64,11 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 		context?: CronJobParams<OC, C>['context'],
 		runOnInit?: CronJobParams<OC, C>['runOnInit'],
 		utcOffset?: CronJobParams<OC, C>['utcOffset'],
-		unrefTimeout?: CronJobParams<OC, C>['unrefTimeout']
+		unrefTimeout?: CronJobParams<OC, C>['unrefTimeout'],
+		waitForCompletion?: CronJobParams<OC, C>['waitForCompletion']
 	) {
 		this.context = (context ?? this) as CronContext<C>;
+		this.waitForCompletion = Boolean(waitForCompletion);
 
 		// runtime check for JS users
 		if (timeZone != null && utcOffset != null) {
@@ -92,7 +102,7 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 
 		if (runOnInit) {
 			this.lastExecution = new Date();
-			this.fireOnTick();
+			void this.fireOnTick();
 		}
 
 		if (start) this.start();
@@ -117,7 +127,8 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 				params.context,
 				params.runOnInit,
 				params.utcOffset,
-				params.unrefTimeout
+				params.unrefTimeout,
+				params.waitForCompletion
 			);
 		} else if (params.utcOffset != null) {
 			return new CronJob<OC, C>(
@@ -129,7 +140,8 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 				params.context,
 				params.runOnInit,
 				params.utcOffset,
-				params.unrefTimeout
+				params.unrefTimeout,
+				params.waitForCompletion
 			);
 		} else {
 			return new CronJob<OC, C>(
@@ -141,7 +153,8 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 				params.context,
 				params.runOnInit,
 				params.utcOffset,
-				params.unrefTimeout
+				params.unrefTimeout,
+				params.waitForCompletion
 			);
 		}
 	}
@@ -193,14 +206,26 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 		return this.cronTime.sendAt();
 	}
 
-	fireOnTick() {
-		for (const callback of this._callbacks) {
-			void callback.call(
-				this.context,
-				this.onComplete as WithOnComplete<OC> extends true
-					? CronOnCompleteCallback
-					: never
-			);
+	async fireOnTick() {
+		if (!this.waitForCompletion && this._isCallbackRunning) return;
+
+		this._isCallbackRunning = true;
+
+		try {
+			for (const callback of this._callbacks) {
+				const result = callback.call(
+					this.context,
+					this.onComplete as WithOnComplete<OC> extends true
+						? CronOnCompleteCallback
+						: never
+				);
+
+				if (this.waitForCompletion) await result;
+			}
+		} catch (error) {
+			console.error('[Cron] error in callback', error);
+		} finally {
+			this._isCallbackRunning = false;
 		}
 	}
 
@@ -209,9 +234,7 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 	}
 
 	start() {
-		if (this.running) {
-			return;
-		}
+		if (this.running) return;
 
 		const MAXDELAY = 2147483647; // The maximum number of milliseconds setTimeout will wait.
 		let timeout = this.cronTime.getTimeout();
@@ -262,11 +285,9 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 				this.running = false;
 
 				// start before calling back so the callbacks have the ability to stop the cron job
-				if (!this.runOnce) {
-					this.start();
-				}
+				if (!this.runOnce) this.start();
 
-				this.fireOnTick();
+				void this.fireOnTick();
 			}
 		};
 
@@ -290,14 +311,37 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 		return this.lastExecution;
 	}
 
+	private async _executeOnComplete() {
+		if (typeof this.onComplete !== 'function') return;
+
+		try {
+			await this.onComplete.call(this.context);
+		} catch (error) {
+			console.error('[Cron] error in onComplete callback:', error);
+		}
+	}
+
+	private async _waitForJobCompletion() {
+		while (this._isCallbackRunning) {
+			await new Promise(resolve => setTimeout(resolve, 100));
+		}
+	}
+
 	/**
 	 * Stop the cronjob.
 	 */
 	stop() {
 		if (this._timeout) clearTimeout(this._timeout);
 		this.running = false;
-		if (typeof this.onComplete === 'function') {
-			void this.onComplete.call(this.context);
+
+		if (!this.waitForCompletion) {
+			void this._executeOnComplete();
+			return;
 		}
+
+		void Promise.resolve().then(async () => {
+			await this._waitForJobCompletion();
+			await this._executeOnComplete();
+		});
 	}
 }
diff --git a/src/types/cron.types.ts b/src/types/cron.types.ts
index 8604044..4df8f09 100644
--- a/src/types/cron.types.ts
+++ b/src/types/cron.types.ts
@@ -15,6 +15,7 @@ interface BaseCronJobParams<
 	context?: C;
 	runOnInit?: boolean | null;
 	unrefTimeout?: boolean | null;
+	waitForCompletion?: boolean | null;
 }
 
 export type CronJobParams<


Test patch:
diff --git a/tests/cron.test.ts b/tests/cron.test.ts
index 0aef3ba..c11e8f6 100644
--- a/tests/cron.test.ts
+++ b/tests/cron.test.ts
@@ -3,27 +3,35 @@ import sinon from 'sinon';
 import { CronJob, CronTime } from '../src';
 
 describe('cron', () => {
-	// eslint-disable-next-line jest/no-standalone-expect
+	let callback: jest.Mock;
+
+	beforeEach(() => {
+		callback = jest.fn();
+	});
+
+	afterAll(() => {
+		jest.clearAllMocks();
+	});
+
 	afterEach(() => {
+		// eslint-disable-next-line jest/no-standalone-expect
 		expect.hasAssertions();
+		sinon.restore();
 	});
 
 	describe('with seconds', () => {
 		it('should run every second (* * * * * *)', () => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 			const job = new CronJob('* * * * * *', callback, null, true);
 
 			expect(callback).not.toHaveBeenCalled();
 			clock.tick(1000);
 			job.stop();
-			clock.restore();
 			expect(callback).toHaveBeenCalledTimes(1);
 		});
 
 		it('should run second with onComplete (* * * * * *)', done => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 
 			const job = new CronJob(
 				'* * * * * *',
@@ -37,12 +45,10 @@ describe('cron', () => {
 
 			clock.tick(1000);
 			job.stop();
-			clock.restore();
 		});
 
 		it('should use standard cron no-seconds syntax (* * * * *)', () => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 			const job = new CronJob('* * * * *', callback, null, true);
 
 			clock.tick(1000); // tick second
@@ -50,24 +56,22 @@ describe('cron', () => {
 			clock.tick(59 * 1000); // tick minute
 
 			job.stop();
-			clock.restore();
-
 			expect(callback).toHaveBeenCalledTimes(1);
 		});
 
 		it('should run every second for 5 seconds (* * * * * *)', () => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 			const job = new CronJob('* * * * * *', callback, null, true);
-			for (let i = 0; i < 5; i++) clock.tick(1000);
+			for (let i = 0; i < 5; i++) {
+				clock.tick(1000);
+			}
 			job.stop();
-			clock.restore();
 			expect(callback).toHaveBeenCalledTimes(5);
 		});
 
 		it('should run every second for 5 seconds with onComplete (* * * * * *)', done => {
-			const callback = jest.fn();
 			const clock = sinon.useFakeTimers();
+
 			const job = new CronJob(
 				'* * * * * *',
 				callback,
@@ -77,44 +81,37 @@ describe('cron', () => {
 				},
 				true
 			);
+
 			for (let i = 0; i < 5; i++) clock.tick(1000);
 			job.stop();
-			clock.restore();
 		});
 
 		it('should run every second for 5 seconds (*/1 * * * * *)', () => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 			const job = new CronJob('*/1 * * * * *', callback, null, true);
 			for (let i = 0; i < 5; i++) clock.tick(1000);
 			job.stop();
-			clock.restore();
 			expect(callback).toHaveBeenCalledTimes(5);
 		});
 
 		it('should run every 2 seconds for 1 seconds (*/2 * * * * *)', () => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 			const job = new CronJob('*/2 * * * * *', callback, null, true);
 			clock.tick(1000);
 			job.stop();
-			clock.restore();
 			expect(callback).toHaveBeenCalledTimes(0);
 		});
 
 		it('should run every 2 seconds for 5 seconds (*/2 * * * * *)', () => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 			const job = new CronJob('*/2 * * * * *', callback, null, true);
 			for (let i = 0; i < 5; i++) clock.tick(1000);
 			job.stop();
-			clock.restore();
 			expect(callback).toHaveBeenCalledTimes(2);
 		});
 
 		it('should run every second for 5 seconds with onComplete (*/1 * * * * *)', done => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 			const job = new CronJob(
 				'*/1 * * * * *',
 				callback,
@@ -126,22 +123,18 @@ describe('cron', () => {
 			);
 			for (let i = 0; i < 5; i++) clock.tick(1000);
 			job.stop();
-			clock.restore();
 		});
 
 		it('should run every second for a range ([start]-[end] * * * * *)', () => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 			const job = new CronJob('0-8 * * * * *', callback, null, true);
 			clock.tick(10000);
 			job.stop();
-			clock.restore();
 			expect(callback).toHaveBeenCalledTimes(8);
 		});
 
 		it('should run every second for a range ([start]-[end] * * * * *) with onComplete', done => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 			const job = new CronJob(
 				'0-8 * * * * *',
 				callback,
@@ -153,12 +146,10 @@ describe('cron', () => {
 			);
 			clock.tick(10000);
 			job.stop();
-			clock.restore();
 		});
 
 		it('should default to full range when upper range not provided (1/2 * * * * *)', done => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 			const job = new CronJob(
 				'1/2 * * * * *',
 				callback,
@@ -170,12 +161,10 @@ describe('cron', () => {
 			);
 			clock.tick(1000 * 60);
 			job.stop();
-			clock.restore();
 		});
 
 		it('should run every second (* * * * * *) using the object constructor', () => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 			const job = CronJob.from({
 				cronTime: '* * * * * *',
 				onTick: callback,
@@ -183,13 +172,11 @@ describe('cron', () => {
 			});
 			clock.tick(1000);
 			job.stop();
-			clock.restore();
 			expect(callback).toHaveBeenCalledTimes(1);
 		});
 
 		it('should run every second with onComplete (* * * * * *) using the object constructor', done => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 			const job = CronJob.from({
 				cronTime: '* * * * * *',
 				onTick: callback,
@@ -201,7 +188,6 @@ describe('cron', () => {
 			});
 			clock.tick(1000);
 			job.stop();
-			clock.restore();
 		});
 	});
 
@@ -225,22 +211,18 @@ describe('cron', () => {
 			expect(l.every(i => i % 30 === 0)).toBe(true);
 
 			job.stop();
-			clock.restore();
 		});
 
 		it('should run every 45 minutes for 2 hours (0 */45 * * * *)', () => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 			const job = new CronJob('0 */45 * * * *', callback, null, true);
 			for (let i = 0; i < 2; i++) clock.tick(60 * 60 * 1000);
 			job.stop();
-			clock.restore();
 			expect(callback).toHaveBeenCalledTimes(4);
 		});
 
 		it('should run every 45 minutes for 2 hours (0 */45 * * * *) with onComplete', done => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 			const job = new CronJob(
 				'0 */45 * * * *',
 				callback,
@@ -252,13 +234,11 @@ describe('cron', () => {
 			);
 			for (let i = 0; i < 2; i++) clock.tick(60 * 60 * 1000);
 			job.stop();
-			clock.restore();
 		});
 	});
 
 	it('should start and stop job from outside', done => {
 		const clock = sinon.useFakeTimers();
-		const callback = jest.fn();
 		const job = new CronJob(
 			'* * * * * *',
 			function () {
@@ -277,7 +257,6 @@ describe('cron', () => {
 
 	it('should start and stop job from inside (default context)', done => {
 		const clock = sinon.useFakeTimers();
-		const callback = jest.fn();
 		new CronJob(
 			'* * * * * *',
 			function () {
@@ -300,7 +279,6 @@ describe('cron', () => {
 			const clock = sinon.useFakeTimers(d.getTime());
 			const s = d.getSeconds() + 1;
 			d.setSeconds(s);
-			const callback = jest.fn();
 			const job = new CronJob(
 				d,
 				() => {
@@ -312,7 +290,6 @@ describe('cron', () => {
 				true
 			);
 			clock.tick(1000);
-			clock.restore();
 			job.stop();
 			expect(callback).toHaveBeenCalledTimes(1);
 		});
@@ -321,7 +298,6 @@ describe('cron', () => {
 			const d = new Date();
 			const clock = sinon.useFakeTimers(d.getTime());
 			d.setSeconds(d.getSeconds() + 1);
-			const callback = jest.fn();
 
 			await new Promise<void>(resolve => {
 				const job = new CronJob(
@@ -338,7 +314,6 @@ describe('cron', () => {
 					true
 				);
 				clock.tick(1000);
-				clock.restore();
 				job.stop();
 			});
 
@@ -350,7 +325,6 @@ describe('cron', () => {
 			const d = new Date();
 			const clock = sinon.useFakeTimers(d.getTime());
 			d.setSeconds(d.getSeconds() + 1);
-			const callback = jest.fn();
 
 			await new Promise<void>(resolve => {
 				const job = CronJob.from({
@@ -367,7 +341,6 @@ describe('cron', () => {
 					start: true
 				});
 				clock.tick(1000);
-				clock.restore();
 				job.stop();
 			});
 
@@ -403,12 +376,10 @@ describe('cron', () => {
 			);
 			clock.tick(1000);
 			job.stop();
-			clock.restore();
 		});
 
 		it('should wait and not fire immediately', () => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 
 			const d = new Date().getTime() + 31 * 86400 * 1000;
 
@@ -417,14 +388,12 @@ describe('cron', () => {
 
 			clock.tick(1000);
 
-			clock.restore();
 			job.stop();
 			expect(callback).toHaveBeenCalledTimes(0);
 		});
 
 		it('should wait but fire on init', () => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 
 			const d = new Date().getTime() + 31 * 86400 * 1000;
 
@@ -440,14 +409,12 @@ describe('cron', () => {
 
 			clock.tick(1000);
 
-			clock.restore();
 			job.stop();
 			expect(callback).toHaveBeenCalledTimes(1);
 		});
 
 		it('should fire on init but not run until started', () => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 
 			const job = CronJob.from({
 				cronTime: '* * * * * *',
@@ -461,7 +428,6 @@ describe('cron', () => {
 
 			clock.tick(3500);
 
-			clock.restore();
 			job.stop();
 			expect(callback).toHaveBeenCalledTimes(4);
 		});
@@ -470,7 +436,6 @@ describe('cron', () => {
 	describe('with timezone', () => {
 		it('should run a job using cron syntax with a timezone', () => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 			let zone = 'America/Chicago';
 			// New Orleans time
 			let t = DateTime.local().setZone(zone);
@@ -500,14 +465,12 @@ describe('cron', () => {
 			);
 
 			clock.tick(1000);
-			clock.restore();
 			job.stop();
 			expect(callback).toHaveBeenCalledTimes(1);
 		});
 
 		it('should run a job using cron syntax with a "UTC+HH:mm" offset as timezone', () => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 
 			// Current time
 			const d = DateTime.local();
@@ -539,7 +502,6 @@ describe('cron', () => {
 			);
 
 			clock.tick(1000);
-			clock.restore();
 			job.stop();
 			expect(callback).toHaveBeenCalledTimes(1);
 		});
@@ -560,10 +522,8 @@ describe('cron', () => {
 			expect(d.hour).not.toBe(t.hour);
 			d = d.plus({ seconds: 1 });
 			const clock = sinon.useFakeTimers(d.valueOf());
-			const callback = jest.fn();
 			const job = new CronJob(d.toJSDate(), callback, null, true, zone);
 			clock.tick(1000);
-			clock.restore();
 			job.stop();
 			expect(callback).toHaveBeenCalledTimes(1);
 		});
@@ -595,7 +555,6 @@ describe('cron', () => {
 
 			clock.tick(1000);
 
-			clock.restore();
 			job.stop();
 		});
 
@@ -613,7 +572,6 @@ describe('cron', () => {
 
 			clock.tick(1000);
 
-			clock.restore();
 			job.stop();
 		});
 
@@ -634,7 +592,6 @@ describe('cron', () => {
 
 			clock.tick(1000);
 
-			clock.restore();
 			job.stop();
 		});
 
@@ -653,7 +610,6 @@ describe('cron', () => {
 
 			clock.tick(1000);
 
-			clock.restore();
 			job.stop();
 		});
 	});
@@ -683,7 +639,6 @@ describe('cron', () => {
 	});
 
 	it('should test start of month', () => {
-		const callback = jest.fn();
 		const d = new Date('12/31/2014');
 		d.setSeconds(59);
 		d.setMinutes(59);
@@ -706,7 +661,6 @@ describe('cron', () => {
 	});
 
 	it('should not fire if time was adjusted back', () => {
-		const callback = jest.fn();
 		const clock = sinon.useFakeTimers({
 			toFake: ['setTimeout']
 		});
@@ -716,12 +670,10 @@ describe('cron', () => {
 		clock.tick(60000);
 		expect(callback).toHaveBeenCalledTimes(0);
 
-		clock.restore();
 		job.stop();
 	});
 
 	it('should run every day', () => {
-		const callback = jest.fn();
 		const d = new Date('12/31/2014');
 		d.setSeconds(59);
 		d.setMinutes(59);
@@ -738,13 +690,11 @@ describe('cron', () => {
 		const twoWeeks = 14 * 24 * 60 * 60 * 1000;
 		clock.tick(twoWeeks);
 
-		clock.restore();
 		job.stop();
 		expect(callback).toHaveBeenCalledTimes(14);
 	});
 
 	it('should run every 2 hours between hours', () => {
-		const callback = jest.fn();
 		const d = new Date('12/31/2014');
 		d.setSeconds(0);
 		d.setMinutes(0);
@@ -765,13 +715,11 @@ describe('cron', () => {
 		expect(callback).toHaveBeenCalledTimes(3);
 		clock.tick(2 * 60 * 1000);
 
-		clock.restore();
 		job.stop();
 		expect(callback).toHaveBeenCalledTimes(3);
 	});
 
 	it('should run every minute', () => {
-		const callback = jest.fn();
 		const d = new Date('12/31/2014');
 		d.setSeconds(0);
 		d.setMinutes(0);
@@ -789,13 +737,11 @@ describe('cron', () => {
 		clock.tick(60 * 1000);
 		expect(callback).toHaveBeenCalledTimes(2);
 
-		clock.restore();
 		job.stop();
 		expect(callback).toHaveBeenCalledTimes(2);
 	});
 
 	it('should run every day at 12:30', () => {
-		const callback = jest.fn();
 		const d = new Date('12/31/2014');
 		d.setSeconds(0);
 		d.setMinutes(0);
@@ -817,13 +763,11 @@ describe('cron', () => {
 		expect(callback).toHaveBeenCalledTimes(3);
 		clock.tick(5 * day);
 
-		clock.restore();
 		job.stop();
 		expect(callback).toHaveBeenCalledTimes(8);
 	});
 
 	it('should trigger onTick at midnight', () => {
-		const callback = jest.fn();
 		const d = new Date('12/31/2014');
 		d.setSeconds(59);
 		d.setMinutes(59);
@@ -840,13 +784,11 @@ describe('cron', () => {
 		clock.tick(1000); // move clock 1 second
 		expect(callback).toHaveBeenCalledTimes(1);
 
-		clock.restore();
 		job.stop();
 		expect(callback).toHaveBeenCalledTimes(1);
 	});
 
 	it('should run every day UTC', () => {
-		const callback = jest.fn();
 		const d = new Date('12/31/2014');
 		d.setSeconds(0);
 		d.setMinutes(0);
@@ -869,14 +811,12 @@ describe('cron', () => {
 		expect(callback).toHaveBeenCalledTimes(3);
 		clock.tick(5 * day);
 
-		clock.restore();
 		job.stop();
 		expect(callback).toHaveBeenCalledTimes(8);
 	});
 
 	// from https://github.com/kelektiv/node-cron/issues/180#issuecomment-154108131
 	it('should run once not double', () => {
-		const callback = jest.fn();
 		const d = new Date(2015, 1, 1, 1, 1, 41, 0);
 		const clock = sinon.useFakeTimers(d.getTime());
 
@@ -889,7 +829,6 @@ describe('cron', () => {
 		const minute = 60 * 1000;
 		clock.tick(minute);
 		expect(callback).toHaveBeenCalledTimes(1);
-		clock.restore();
 		job.stop();
 		expect(callback).toHaveBeenCalledTimes(1);
 	});
@@ -901,7 +840,6 @@ describe('cron', () => {
 	 * source: https://github.com/cronie-crond/cronie/blob/0d669551680f733a4bdd6bab082a0b3d6d7f089c/src/cronnext.c#L401-L403
 	 */
 	it('should work correctly for max match interval', () => {
-		const callback = jest.fn();
 		const d = new Date(2096, 2, 1);
 		const clock = sinon.useFakeTimers(d.getTime());
 
@@ -918,7 +856,6 @@ describe('cron', () => {
 
 		// tick by 1 day
 		clock.tick(24 * 60 * 60 * 1000);
-		clock.restore();
 		job.stop();
 		expect(callback).toHaveBeenCalledTimes(1);
 	});
@@ -926,7 +863,6 @@ describe('cron', () => {
 	describe('with utcOffset', () => {
 		it('should run a job using cron syntax with number format utcOffset', () => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 			// Current time
 			const t = DateTime.local();
 			// UTC Offset decreased by an hour
@@ -948,14 +884,12 @@ describe('cron', () => {
 			expect(callback).toHaveBeenCalledTimes(0);
 
 			clock.tick(1);
-			clock.restore();
 			job.stop();
 			expect(callback).toHaveBeenCalledTimes(1);
 		});
 
 		it('should run a job using cron syntax with numeric format utcOffset with minute support', () => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 			// Current time
 			const t = DateTime.local();
 
@@ -977,14 +911,12 @@ describe('cron', () => {
 			expect(callback).toHaveBeenCalledTimes(0);
 
 			clock.tick(1);
-			clock.restore();
 			job.stop();
 			expect(callback).toHaveBeenCalledTimes(1);
 		});
 
 		it('should run a job using cron syntax with number format utcOffset that is 0', () => {
 			const clock = sinon.useFakeTimers();
-			const callback = jest.fn();
 
 			const job = new CronJob(
 				'* * * * * *',
@@ -1001,7 +933,6 @@ describe('cron', () => {
 			expect(callback).toHaveBeenCalledTimes(0);
 
 			clock.tick(1);
-			clock.restore();
 			job.stop();
 			expect(callback).toHaveBeenCalledTimes(1);
 		});
@@ -1015,7 +946,6 @@ describe('cron', () => {
 
 	describe('setTime', () => {
 		it('should start, change time, start again', () => {
-			const callback = jest.fn();
 			const clock = sinon.useFakeTimers();
 
 			const job = new CronJob('* * * * * *', callback);
@@ -1028,13 +958,11 @@ describe('cron', () => {
 
 			clock.tick(4000);
 
-			clock.restore();
 			job.stop();
 			expect(callback).toHaveBeenCalledTimes(3);
 		});
 
 		it('should start, stop, change time, not start again', () => {
-			const callback = jest.fn();
 			const clock = sinon.useFakeTimers();
 
 			const job = new CronJob('* * * * * *', callback);
@@ -1048,13 +976,11 @@ describe('cron', () => {
 
 			clock.tick(4000);
 
-			clock.restore();
 			job.stop();
 			expect(callback).toHaveBeenCalledTimes(1);
 		});
 
 		it('should setTime with invalid object', () => {
-			const callback = jest.fn();
 			const job = new CronJob('* * * * * *', callback);
 			expect(() => {
 				// @ts-expect-error time parameter cannot be undefined
@@ -1063,7 +989,6 @@ describe('cron', () => {
 		});
 
 		it('should start, change time, exception', () => {
-			const callback = jest.fn();
 			const clock = sinon.useFakeTimers();
 
 			const job = new CronJob('* * * * * *', callback);
@@ -1078,13 +1003,11 @@ describe('cron', () => {
 				job.setTime(time);
 			}).toThrow();
 
-			clock.restore();
 			job.stop();
 			expect(callback).toHaveBeenCalledTimes(1);
 		});
 
 		it('should create recurring job, setTime with actual date, start and run once (#739)', () => {
-			const callback = jest.fn();
 			const clock = sinon.useFakeTimers();
 
 			const job = new CronJob('0 0 20 * * *', callback);
@@ -1100,8 +1023,6 @@ describe('cron', () => {
 
 			clock.tick(60000);
 
-			clock.restore();
-
 			expect(callback).toHaveBeenCalledTimes(1);
 			expect(job.running).toBe(false);
 		});
@@ -1109,7 +1030,6 @@ describe('cron', () => {
 
 	describe('nextDate(s)', () => {
 		it('should give the next date to run at', () => {
-			const callback = jest.fn();
 			const clock = sinon.useFakeTimers();
 			const job = new CronJob('* * * * * *', callback);
 			const d = Date.now();
@@ -1120,7 +1040,6 @@ describe('cron', () => {
 		});
 
 		it('should give the next 5 dates to run at', () => {
-			const callback = jest.fn();
 			const clock = sinon.useFakeTimers();
 			const job = new CronJob('* * * * * *', callback);
 			const d = Date.now();
@@ -1137,7 +1056,6 @@ describe('cron', () => {
 		});
 
 		it('should give an empty array when called without argument', () => {
-			const callback = jest.fn();
 			const clock = sinon.useFakeTimers();
 			const job = new CronJob('* * * * * *', callback);
 
@@ -1148,7 +1066,6 @@ describe('cron', () => {
 	});
 
 	it('should automatically setup a new timeout if we roll past the max timeout delay', () => {
-		const callback = jest.fn();
 		const clock = sinon.useFakeTimers();
 		const d = new Date();
 		d.setMilliseconds(2147485647 * 2); // MAXDELAY in `job.js` + 2000.
@@ -1159,11 +1076,9 @@ describe('cron', () => {
 		clock.tick(2147489648);
 		expect(callback).toHaveBeenCalledTimes(1);
 		job.stop();
-		clock.restore();
 	});
 
 	it('should give the correct last execution date', () => {
-		const callback = jest.fn();
 		const clock = sinon.useFakeTimers();
 		const job = new CronJob('* * * * * *', callback);
 		job.start();
@@ -1171,11 +1086,9 @@ describe('cron', () => {
 		expect(callback).toHaveBeenCalledTimes(1);
 		expect(job.lastDate()?.getTime()).toBe(1000);
 		job.stop();
-		clock.restore();
 	});
 
 	it('should give the correct last execution date for intervals greater than 25 days (#710)', () => {
-		const callback = jest.fn();
 		const clock = sinon.useFakeTimers();
 
 		const job = new CronJob('0 0 0 1 * *', callback); // At 00:00 on day-of-month 1.
@@ -1188,7 +1101,6 @@ describe('cron', () => {
 		expect(job.lastDate()?.getTime()).toBeUndefined();
 
 		job.stop();
-		clock.restore();
 	});
 
 	it('should throw when providing both exclusive parameters timeZone and utcOffset', () => {
@@ -1221,7 +1133,6 @@ describe('cron', () => {
 
 	it('should support async callback', () => {
 		const clock = sinon.useFakeTimers();
-		const callback = jest.fn();
 		const job = new CronJob(
 			'* * * * * *',
 			async function () {
@@ -1237,7 +1148,152 @@ describe('cron', () => {
 		);
 		clock.tick(1500);
 		job.stop();
-		clock.restore();
 		expect(callback).toHaveBeenCalledTimes(1);
 	});
+
+	describe('waitForCompletion and job status tracking', () => {
+		it('should wait for async job completion when waitForCompletion is true', async () => {
+			const clock = sinon.useFakeTimers();
+			let isJobCompleted = false;
+
+			const job = new CronJob(
+				'*/2 * * * * *',
+				async () => {
+					expect(job.isCallbackRunning).toBe(true);
+					await new Promise<void>(resolve => {
+						setTimeout(() => {
+							callback();
+							isJobCompleted = true;
+							resolve();
+						}, 1000);
+					});
+				},
+				null,
+				true,
+				null,
+				null,
+				false,
+				null,
+				false,
+				true // waitForCompletion: true
+			);
+
+			expect(job.isCallbackRunning).toBe(false);
+
+			// First execution
+			await clock.tickAsync(2000);
+			expect(job.isCallbackRunning).toBe(true);
+
+			// Wait for job completion
+			await clock.tickAsync(500);
+			expect(isJobCompleted).toBe(false);
+			expect(job.isCallbackRunning).toBe(true);
+
+			await clock.tickAsync(1000);
+			expect(isJobCompleted).toBe(true);
+			expect(job.isCallbackRunning).toBe(false);
+
+			job.stop();
+		});
+
+		it('should not wait for job completion when waitForCompletion is false', () => {
+			const clock = sinon.useFakeTimers();
+			let isJobCompleted = false;
+
+			const job = new CronJob(
+				'* * * * * *',
+				() => {
+					setTimeout(() => {
+						callback();
+						isJobCompleted = true;
+					}, 500);
+				},
+				null,
+				true,
+				null,
+				null,
+				false,
+				null,
+				false,
+				false // waitForCompletion: false
+			);
+
+			expect(job.isCallbackRunning).toBe(false);
+
+			// First execution
+			clock.tick(1000);
+			expect(isJobCompleted).toBe(false);
+			expect(job.isCallbackRunning).toBe(false);
+
+			job.stop();
+		});
+
+		it('should track multiple running jobs correctly', async () => {
+			const clock = sinon.useFakeTimers();
+			const executionOrder: number[] = [];
+
+			const job = new CronJob(
+				'* * * * * *',
+				async () => {
+					await new Promise<void>(resolve => {
+						setTimeout(() => {
+							callback();
+							resolve();
+						}, 1500);
+					});
+					const jobNumber = executionOrder.length + 1;
+					executionOrder.push(jobNumber);
+				},
+				null,
+				true,
+				null,
+				null,
+				false,
+				null,
+				false,
+				true // waitForCompletion: true
+			);
+
+			await clock.tickAsync(3500);
+
+			expect(executionOrder).toEqual([1, 2]);
+			job.stop();
+		});
+
+		it('should handle stop() correctly while job is running', async () => {
+			const clock = sinon.useFakeTimers();
+			let isJobCompleted = false;
+
+			const job = new CronJob(
+				'* * * * * *',
+				async () => {
+					await new Promise<void>(resolve => {
+						setTimeout(() => {
+							callback();
+							isJobCompleted = true;
+							resolve();
+						}, 500);
+					});
+				},
+				null,
+				true,
+				null,
+				null,
+				false,
+				null,
+				false,
+				true // waitForCompletion: true
+			);
+
+			await clock.tickAsync(1000);
+			expect(job.isCallbackRunning).toBe(true);
+
+			job.stop();
+			await clock.tickAsync(500);
+
+			expect(isJobCompleted).toBe(true);
+			expect(job.isCallbackRunning).toBe(false);
+			expect(job.running).toBe(false);
+		});
+	});
 });