Repository: kelektiv/node-cron (ts)

Issue:
Jobs stopping unexpectedly
Supersedes the following issues:
- #805
- #514
- #232
- #231

## Bug description

Under certain conditions, cron jobs stop executing unexpectedly without any error or log output. This behavior has been observed in jobs running at high frequency (e.g., every second or every few seconds) as well as jobs scheduled less frequently. The job ceases operation after a variable period (from as little as 15 minutes to several days) and does not resume until the application or container is restarted. In some cases, the issue appears to coincide with low available machine resources or system time adjustments, while in others it occurs without any apparent external trigger.

## Root cause analysis


The root cause of these issues is the way the library calculates the time until the next scheduled execution. The process involves two separate fetches of the current time within the `sendAt` function, leading to a potential race condition:

### 1. Initial time fetch

When `sendAt` starts, it fetches the current time to calculate the next execution time based on the cron expression <sup>([source](https://github.com/kelektiv/node-cron/blob/8e509f3ad7dcd5d250e75a41417830b9b01c2bdb/src/time.ts#L87C2-L91))</sup>.

```ts
sendAt(i?: number): DateTime | DateTime[] {
  let date =
    this.realDate && this.source instanceof DateTime
      ? this.source
      : DateTime.local();
```

### 2. Timeout calculation

After `sendAt` completes, the library fetches the current time again to compute the remaining delay <sup>([source](https://github.com/kelektiv/node-cron/blob/8e509f3ad7dcd5d250e75a41417830b9b01c2bdb/src/time.ts#L140-L142))</sup>.

```ts
getTimeout() {
  return Math.max(-1, this.sendAt().toMillis() - DateTime.local().toMillis());
}
```

If the delay between these two time fetches is sufficiently large (due to execution delays or a system time change), the computed timeout can become negative. In such cases, the job is stopped immediately <sup>([source](https://github.com/kelektiv/node-cron/blob/8e509f3ad7dcd5d250e75a41417830b9b01c2bdb/src/job.ts#L309-L322))</sup>.

```ts
if (timeout >= 0) {
  // ...
  setCronTimeout(timeout);
} else {
  this.stop();
}
```

## Reproduction

The bug can be reproduced by stubbing `Date.now()` to simulate either a prolonged execution of `sendAt` or a system time jump between the initial time fetch and the subsequent calculation. This controlled manipulation forces `getTimeout` to compute a negative timeout, which triggers the job to stop.

See the [implemented test case](https://github.com/kelektiv/node-cron/pull/963/commits/7a81971abd0b431f23c4816c5a4971831f94aa09#diff-29a4a49c4a4e84d0dd0917be4f7921905d0122a2bafa1d9d2749557359de4bb7R22-R45).

## Proposed fix

Introduce a configurable threshold with sensible default to distinguish between minor delays and significant timing discrepancies. When a negative timeout is detected, if the negative timeout is:

- **Within threshold:** schedule the job immediately (and log a warning)
- **Outside threshold:** skip the current execution (and log a warning)

This solution combines the strengths of either always executing immediately or always skipping execution while mitigating their drawbacks:

- **Balanced execution:** in real-world scenarios, minor delays (for example, those under 500ms/1000ms) are often acceptable. Executing the job immediately in these cases prevents unnecessary stoppages and keeps high-frequency tasks running with minimal interruption.

- **Flexibility:** making the threshold configurable allows developers to tailor the behavior based on the criticality and frequency of their cron jobs.

- **Observability:** logging warnings when a large negative timeout is encountered provides critical insight into timing issues. This transparency aids in diagnostics and helps developers fine-tune the threshold settings based on observed behavior in production.

Pull request description:
fix: prevent jobs from stopping unexpectedly
## Description

See #962.

## Related Issue

Closes #962.

## Motivation and Context

See #962.

## How Has This Been Tested?

New test case added.

## Types of changes

- [X] Bug fix (non-breaking change which fixes an issue)
- [ ] 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.
- [x] 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 efa49ed..b4a8795 100644
--- a/README.md
+++ b/README.md
@@ -195,7 +195,7 @@ day of week    0-7 (0 or 7 is Sunday, or use names)
 
 #### Constructor
 
-`constructor(cronTime, onTick, onComplete, start, timeZone, context, runOnInit, utcOffset, unrefTimeout)`:
+`constructor(cronTime, onTick, onComplete, start, timeZone, context, runOnInit, utcOffset, unrefTimeout, waitForCompletion, errorHandler, name, threshold)`:
 
 - `cronTime`: [REQUIRED] - The time to fire off your job. Can be cron syntax, a JS [`Date`](https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Date) object or a Luxon [`DateTime`](https://moment.github.io/luxon/api-docs/index.html#datetime) object.
 
@@ -219,6 +219,10 @@ day of week    0-7 (0 or 7 is Sunday, or use names)
 
 - `errorHandler`: [OPTIONAL] - Function to handle any exceptions that occur in the `onTick` method.
 
+- `name`: [OPTIONAL] - Name of the job. Useful for identifying jobs in logs.
+
+- `threshold`: [OPTIONAL] - Threshold in ms to control whether to execute or skip missed execution deadlines caused by slow or busy hardware. Execution delays within threshold will be executed immediately, and otherwise will be skipped. In both cases a warning will be printed to the console with the job name and cron expression. See [issue #962](https://github.com/kelektiv/node-cron/issues/962) for more information. Default is `250`.
+
 #### Methods
 
 - `from` (static): Create a new CronJob object providing arguments as an object. See argument names and descriptions above.
diff --git a/src/job.ts b/src/job.ts
index bf5b743..8beca71 100644
--- a/src/job.ts
+++ b/src/job.ts
@@ -22,6 +22,8 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 		: undefined;
 	waitForCompletion = false;
 	errorHandler?: CronJobParams<OC, C>['errorHandler'];
+	name?: string; // optional job name for identification
+	threshold = 250; // default threshold in ms
 
 	private _isActive = false;
 	private _isCallbackRunning = false;
@@ -47,7 +49,9 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 		utcOffset?: null,
 		unrefTimeout?: CronJobParams<OC, C>['unrefTimeout'],
 		waitForCompletion?: CronJobParams<OC, C>['waitForCompletion'],
-		errorHandler?: CronJobParams<OC, C>['errorHandler']
+		errorHandler?: CronJobParams<OC, C>['errorHandler'],
+		name?: CronJobParams<OC, C>['name'],
+		threshold?: CronJobParams<OC, C>['threshold']
 	);
 	constructor(
 		cronTime: CronJobParams<OC, C>['cronTime'],
@@ -60,7 +64,9 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 		utcOffset?: CronJobParams<OC, C>['utcOffset'],
 		unrefTimeout?: CronJobParams<OC, C>['unrefTimeout'],
 		waitForCompletion?: CronJobParams<OC, C>['waitForCompletion'],
-		errorHandler?: CronJobParams<OC, C>['errorHandler']
+		errorHandler?: CronJobParams<OC, C>['errorHandler'],
+		name?: CronJobParams<OC, C>['name'],
+		threshold?: CronJobParams<OC, C>['threshold']
 	);
 	constructor(
 		cronTime: CronJobParams<OC, C>['cronTime'],
@@ -73,7 +79,9 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 		utcOffset?: CronJobParams<OC, C>['utcOffset'],
 		unrefTimeout?: CronJobParams<OC, C>['unrefTimeout'],
 		waitForCompletion?: CronJobParams<OC, C>['waitForCompletion'],
-		errorHandler?: CronJobParams<OC, C>['errorHandler']
+		errorHandler?: CronJobParams<OC, C>['errorHandler'],
+		name?: CronJobParams<OC, C>['name'],
+		threshold?: CronJobParams<OC, C>['threshold']
 	) {
 		this.context = (context ?? this) as CronContext<C>;
 		this.waitForCompletion = Boolean(waitForCompletion);
@@ -104,6 +112,14 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 			) as WithOnComplete<OC> extends true ? CronOnCompleteCallback : undefined;
 		}
 
+		if (threshold != null) {
+			this.threshold = Math.abs(threshold);
+		}
+
+		if (name != null) {
+			this.name = name;
+		}
+
 		if (this.cronTime.realDate) {
 			this.runOnce = true;
 		}
@@ -139,7 +155,9 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 				params.utcOffset,
 				params.unrefTimeout,
 				params.waitForCompletion,
-				params.errorHandler
+				params.errorHandler,
+				params.name,
+				params.threshold
 			);
 		} else if (params.utcOffset != null) {
 			return new CronJob<OC, C>(
@@ -153,7 +171,9 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 				params.utcOffset,
 				params.unrefTimeout,
 				params.waitForCompletion,
-				params.errorHandler
+				params.errorHandler,
+				params.name,
+				params.threshold
 			);
 		} else {
 			return new CronJob<OC, C>(
@@ -167,7 +187,9 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 				params.utcOffset,
 				params.unrefTimeout,
 				params.waitForCompletion,
-				params.errorHandler
+				params.errorHandler,
+				params.name,
+				params.threshold
 			);
 		}
 	}
@@ -250,6 +272,7 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 
 	start() {
 		if (this._isActive) return;
+		this._isActive = true;
 
 		const MAXDELAY = 2147483647; // the maximum number of milliseconds setTimeout will wait.
 		let timeout = this.cronTime.getTimeout();
@@ -307,8 +330,6 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 		};
 
 		if (timeout >= 0) {
-			this._isActive = true;
-
 			// don't try to sleep more than MAXDELAY ms at a time.
 
 			if (timeout > MAXDELAY) {
@@ -318,7 +339,26 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 
 			setCronTimeout(timeout);
 		} else {
-			this.stop();
+			// handle negative timeout
+			const absoluteTimeout = Math.abs(timeout);
+
+			const message = `[Cron] Missed execution deadline by ${absoluteTimeout}ms for job${this.name ? ` "${this.name}"` : ''} with cron expression '${String(this.cronTime.source)}'`;
+
+			if (absoluteTimeout <= this.threshold) {
+				// execute immediately if within threshold
+				console.warn(`${message}. Executing immediately.`);
+
+				this.lastExecution = new Date();
+				void this.fireOnTick();
+			} else {
+				// skip job if beyond threshold
+				console.warn(
+					`${message}. Skipping execution as it exceeds threshold (${this.threshold}ms).`
+				);
+			}
+
+			timeout = this.cronTime.getTimeout();
+			setCronTimeout(timeout);
 		}
 	}
 
diff --git a/src/time.ts b/src/time.ts
index 20c49f3..137ea42 100644
--- a/src/time.ts
+++ b/src/time.ts
@@ -169,9 +169,18 @@ export class CronTime {
 
 	/**
 	 * get the number of milliseconds in the future at which to fire our callbacks.
+	 *
+	 * Can return a negative value when `sendAt` took too long to execute.
+	 * This is then handled in `CronJob` to execute the job immediately or skip
+	 * this execution based on the `threshold` option.
+	 *
+	 * We could instead call DateTime.local before `sendAt` to get the current time, but
+	 * then the calculated timeout would be offset by the time it takes to execute `sendAt`.
+	 *
+	 * As such it is better to handle negative timeouts by executing the job immediately.
 	 */
 	getTimeout() {
-		return Math.max(-1, this.sendAt().toMillis() - DateTime.utc().toMillis());
+		return this.sendAt().toMillis() - DateTime.local().toMillis();
 	}
 
 	/**
diff --git a/src/types/cron.types.ts b/src/types/cron.types.ts
index dda5b34..b9c14a5 100644
--- a/src/types/cron.types.ts
+++ b/src/types/cron.types.ts
@@ -17,6 +17,8 @@ interface BaseCronJobParams<
 	unrefTimeout?: boolean | null;
 	waitForCompletion?: boolean | null;
 	errorHandler?: ((error: unknown) => void) | null;
+	threshold?: number | null;
+	name?: string | null;
 }
 
 export type CronJobParams<


Test patch:
diff --git a/tests/cron.test.ts b/tests/cron.test.ts
index 195ad83..ec360e9 100644
--- a/tests/cron.test.ts
+++ b/tests/cron.test.ts
@@ -18,6 +18,39 @@ describe('cron', () => {
 		sinon.restore();
 	});
 
+	it('should not stop job if sendAt takes time to complete (#962)', () => {
+		const EVERY = 5;
+		const TICK = EVERY * 1000;
+		const DELAY = 350;
+
+		const job = CronJob.from({
+			cronTime: `*/${EVERY} * * * * *`,
+			onTick: callback,
+			start: false,
+			threshold: 350
+		});
+
+		sinon
+			.stub(job.cronTime, 'getTimeout')
+			.onCall(0)
+			.returns(TICK)
+			.onCall(1)
+			.returns(-DELAY);
+
+		const clock = sinon.useFakeTimers();
+
+		// mock console.warn to avoid poluting tests with the warning
+		const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
+
+		job.start();
+
+		clock.tick(TICK);
+		expect(job.isActive).toBe(true);
+
+		job.stop();
+		warnSpy.mockRestore();
+	});
+
 	describe('with seconds', () => {
 		it('should run every second (* * * * * *)', () => {
 			const clock = sinon.useFakeTimers();
diff --git a/tests/threshold.test.ts b/tests/threshold.test.ts
new file mode 100644
index 0000000..74951d9
--- /dev/null
+++ b/tests/threshold.test.ts
@@ -0,0 +1,249 @@
+import sinon from 'sinon';
+import { CronJob } from '../src';
+
+describe('threshold behavior', () => {
+	let callback: jest.Mock;
+	let warnSpy: jest.SpyInstance;
+
+	beforeEach(() => {
+		callback = jest.fn();
+		warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
+	});
+
+	afterEach(() => {
+		sinon.restore();
+		warnSpy.mockRestore();
+	});
+
+	it('should call the callback the correct amount of times', () => {
+		const EVERY = 5;
+		const TICK = EVERY * 1000;
+		const DELAY = 350;
+
+		const job = CronJob.from({
+			cronTime: `*/${EVERY} * * * * *`,
+			onTick: callback,
+			start: false,
+			threshold: 350
+		});
+
+		sinon
+			.stub(job.cronTime, 'getTimeout')
+			.onCall(0)
+			.returns(TICK)
+			.onCall(1)
+			.returns(-DELAY)
+			// => 1st assertion (delay simulated)
+			.onCall(2)
+			.returns(TICK)
+			.onCall(3)
+			.returns(TICK)
+			// => 2nd assertion (no delay simulated)
+			.onCall(4)
+			.returns(-DELAY)
+			.onCall(5)
+			.returns(TICK);
+		// => 3rd assertion (delay simulated)
+		// => 4th assertion (scheduled during the previous iteration)
+
+		const clock = sinon.useFakeTimers();
+
+		job.start();
+
+		clock.tick(TICK);
+		// 2 calls: 1 from the initial scheduled execution, 1 from the immediate execution
+		expect(callback).toHaveBeenCalledTimes(2);
+
+		clock.tick(TICK);
+		// 1 call: no delay simulated
+		expect(callback).toHaveBeenCalledTimes(3);
+
+		clock.tick(TICK);
+		// 2 calls: 1 from the scheduled execution, 1 from the immediate execution
+		expect(callback).toHaveBeenCalledTimes(5);
+
+		clock.tick(TICK);
+		// 1 call: no delay simulated
+		expect(callback).toHaveBeenCalledTimes(6);
+
+		expect(job.isActive).toBe(true);
+		job.stop();
+	});
+
+	it('should execute job immediately on start if negative timeout is within threshold', () => {
+		const job = CronJob.from({
+			cronTime: '*/5 * * * * *',
+			onTick: callback,
+			start: false,
+			threshold: 300,
+			name: 'test-job'
+		});
+
+		sinon
+			.stub(job.cronTime, 'getTimeout')
+			.onCall(0)
+			.returns(-100)
+			.onCall(1)
+			.returns(5000);
+		sinon.stub(job.cronTime, 'source').value('test-cron');
+
+		const clock = sinon.useFakeTimers();
+		job.start();
+		clock.tick(1000);
+
+		expect(callback).toHaveBeenCalledTimes(1);
+		const message = warnSpy.mock.calls[0][0];
+		expect(message).toContain('Missed execution deadline by 100ms');
+		expect(message).toContain('Executing immediately');
+
+		job.stop();
+	});
+
+	it('should execute job immediately if negative timeout is within threshold', () => {
+		const job = CronJob.from({
+			cronTime: '* * * * * *',
+			onTick: callback,
+			start: false,
+			threshold: 300,
+			name: 'test-job'
+		});
+
+		sinon
+			.stub(job.cronTime, 'getTimeout')
+			.onCall(0)
+			.returns(1000)
+			.onCall(1)
+			.returns(-50);
+		sinon.stub(job.cronTime, 'source').value('test-cron');
+
+		const clock = sinon.useFakeTimers();
+		job.start();
+		clock.tick(1000);
+
+		// 2 calls: 1 from the initial scheduled execution, 1 from the immediate execution
+		expect(callback).toHaveBeenCalledTimes(2);
+		const message = warnSpy.mock.calls[0][0];
+		expect(message).toContain('Missed execution deadline by 50ms');
+		expect(message).toContain('Executing immediately');
+
+		job.stop();
+	});
+
+	it('should skip immediate execution if negative timeout exceeds threshold', () => {
+		const job = CronJob.from({
+			cronTime: '* * * * * *',
+			onTick: callback,
+			start: false,
+			threshold: 100,
+			name: 'test-job'
+		});
+
+		sinon
+			.stub(job.cronTime, 'getTimeout')
+			.onCall(0)
+			.returns(1000)
+			.onCall(1)
+			.returns(-200);
+		sinon.stub(job.cronTime, 'source').value('test-cron');
+
+		const clock = sinon.useFakeTimers();
+		job.start();
+		clock.tick(1000);
+
+		// 1 call from the initial scheduled execution
+		expect(callback).toHaveBeenCalledTimes(1);
+		const message = warnSpy.mock.calls[0][0];
+		expect(message).toContain('Missed execution deadline by 200ms');
+		expect(message).toContain('Skipping execution as it exceeds threshold');
+
+		job.stop();
+	});
+
+	it('should use 250ms as default threshold', () => {
+		const job = CronJob.from({
+			cronTime: '* * * * * *',
+			onTick: callback,
+			start: false,
+			name: 'test-job'
+		});
+
+		sinon
+			.stub(job.cronTime, 'getTimeout')
+			.onCall(0)
+			.returns(1000)
+			.onCall(1)
+			.returns(-250);
+		sinon.stub(job.cronTime, 'source').value('test-cron-expression');
+
+		const clock = sinon.useFakeTimers();
+		job.start();
+		clock.tick(1000);
+
+		expect(callback).toHaveBeenCalledTimes(2);
+		const message = warnSpy.mock.calls[0][0];
+		expect(message).toContain('Missed execution deadline by 250ms');
+		expect(message).toContain('Executing immediately');
+
+		job.stop();
+	});
+
+	it('should properly identify named jobs in logs', () => {
+		const jobName = 'test-named-job';
+		const job = CronJob.from({
+			cronTime: '* * * * * *',
+			onTick: callback,
+			start: false,
+			threshold: 250,
+			name: jobName
+		});
+
+		sinon
+			.stub(job.cronTime, 'getTimeout')
+			.onCall(0)
+			.returns(1000)
+			.onCall(1)
+			.returns(-500);
+		sinon.stub(job.cronTime, 'source').value('test-cron-expression');
+
+		const clock = sinon.useFakeTimers();
+		job.start();
+		clock.tick(1000);
+
+		expect(callback).toHaveBeenCalledTimes(1);
+		const message = warnSpy.mock.calls[0][0];
+		expect(message).toContain('Missed execution deadline by 500ms');
+		expect(message).toContain(`job "${jobName}" with cron expression`);
+		expect(message).toContain('test-cron-expression');
+
+		job.stop();
+	});
+
+	it('should properly identify unnamed jobs in logs', () => {
+		const job = CronJob.from({
+			cronTime: '* * * * * *',
+			onTick: callback,
+			start: false,
+			threshold: 250
+		});
+
+		sinon
+			.stub(job.cronTime, 'getTimeout')
+			.onCall(0)
+			.returns(1000)
+			.onCall(1)
+			.returns(-500);
+		sinon.stub(job.cronTime, 'source').value('test-cron-expression');
+
+		const clock = sinon.useFakeTimers();
+		job.start();
+		clock.tick(1000);
+
+		expect(callback).toHaveBeenCalledTimes(1);
+		const message = warnSpy.mock.calls[0][0];
+		expect(message).toContain('Missed execution deadline by 500ms');
+		expect(message).toContain('for job with cron expression');
+		expect(message).toContain('test-cron-expression');
+
+		job.stop();
+	});
+});