Skip to content

Commit 69d2ef5

Browse files
authored
fix: prevent jobs from stopping unexpectedly (#963)
1 parent fe6b815 commit 69d2ef5

File tree

6 files changed

+348
-11
lines changed

6 files changed

+348
-11
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ day of week 0-7 (0 or 7 is Sunday, or use names)
195195

196196
#### Constructor
197197

198-
`constructor(cronTime, onTick, onComplete, start, timeZone, context, runOnInit, utcOffset, unrefTimeout)`:
198+
`constructor(cronTime, onTick, onComplete, start, timeZone, context, runOnInit, utcOffset, unrefTimeout, waitForCompletion, errorHandler, name, threshold)`:
199199

200200
- `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.
201201

@@ -219,6 +219,10 @@ day of week 0-7 (0 or 7 is Sunday, or use names)
219219

220220
- `errorHandler`: [OPTIONAL] - Function to handle any exceptions that occur in the `onTick` method.
221221

222+
- `name`: [OPTIONAL] - Name of the job. Useful for identifying jobs in logs.
223+
224+
- `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`.
225+
222226
#### Methods
223227

224228
- `from` (static): Create a new CronJob object providing arguments as an object. See argument names and descriptions above.

src/job.ts

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
2222
: undefined;
2323
waitForCompletion = false;
2424
errorHandler?: CronJobParams<OC, C>['errorHandler'];
25+
name?: string; // optional job name for identification
26+
threshold = 250; // default threshold in ms
2527

2628
private _isActive = false;
2729
private _isCallbackRunning = false;
@@ -47,7 +49,9 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
4749
utcOffset?: null,
4850
unrefTimeout?: CronJobParams<OC, C>['unrefTimeout'],
4951
waitForCompletion?: CronJobParams<OC, C>['waitForCompletion'],
50-
errorHandler?: CronJobParams<OC, C>['errorHandler']
52+
errorHandler?: CronJobParams<OC, C>['errorHandler'],
53+
name?: CronJobParams<OC, C>['name'],
54+
threshold?: CronJobParams<OC, C>['threshold']
5155
);
5256
constructor(
5357
cronTime: CronJobParams<OC, C>['cronTime'],
@@ -60,7 +64,9 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
6064
utcOffset?: CronJobParams<OC, C>['utcOffset'],
6165
unrefTimeout?: CronJobParams<OC, C>['unrefTimeout'],
6266
waitForCompletion?: CronJobParams<OC, C>['waitForCompletion'],
63-
errorHandler?: CronJobParams<OC, C>['errorHandler']
67+
errorHandler?: CronJobParams<OC, C>['errorHandler'],
68+
name?: CronJobParams<OC, C>['name'],
69+
threshold?: CronJobParams<OC, C>['threshold']
6470
);
6571
constructor(
6672
cronTime: CronJobParams<OC, C>['cronTime'],
@@ -73,7 +79,9 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
7379
utcOffset?: CronJobParams<OC, C>['utcOffset'],
7480
unrefTimeout?: CronJobParams<OC, C>['unrefTimeout'],
7581
waitForCompletion?: CronJobParams<OC, C>['waitForCompletion'],
76-
errorHandler?: CronJobParams<OC, C>['errorHandler']
82+
errorHandler?: CronJobParams<OC, C>['errorHandler'],
83+
name?: CronJobParams<OC, C>['name'],
84+
threshold?: CronJobParams<OC, C>['threshold']
7785
) {
7886
this.context = (context ?? this) as CronContext<C>;
7987
this.waitForCompletion = Boolean(waitForCompletion);
@@ -104,6 +112,14 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
104112
) as WithOnComplete<OC> extends true ? CronOnCompleteCallback : undefined;
105113
}
106114

115+
if (threshold != null) {
116+
this.threshold = Math.abs(threshold);
117+
}
118+
119+
if (name != null) {
120+
this.name = name;
121+
}
122+
107123
if (this.cronTime.realDate) {
108124
this.runOnce = true;
109125
}
@@ -139,7 +155,9 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
139155
params.utcOffset,
140156
params.unrefTimeout,
141157
params.waitForCompletion,
142-
params.errorHandler
158+
params.errorHandler,
159+
params.name,
160+
params.threshold
143161
);
144162
} else if (params.utcOffset != null) {
145163
return new CronJob<OC, C>(
@@ -153,7 +171,9 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
153171
params.utcOffset,
154172
params.unrefTimeout,
155173
params.waitForCompletion,
156-
params.errorHandler
174+
params.errorHandler,
175+
params.name,
176+
params.threshold
157177
);
158178
} else {
159179
return new CronJob<OC, C>(
@@ -167,7 +187,9 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
167187
params.utcOffset,
168188
params.unrefTimeout,
169189
params.waitForCompletion,
170-
params.errorHandler
190+
params.errorHandler,
191+
params.name,
192+
params.threshold
171193
);
172194
}
173195
}
@@ -250,6 +272,7 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
250272

251273
start() {
252274
if (this._isActive) return;
275+
this._isActive = true;
253276

254277
const MAXDELAY = 2147483647; // the maximum number of milliseconds setTimeout will wait.
255278
let timeout = this.cronTime.getTimeout();
@@ -307,8 +330,6 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
307330
};
308331

309332
if (timeout >= 0) {
310-
this._isActive = true;
311-
312333
// don't try to sleep more than MAXDELAY ms at a time.
313334

314335
if (timeout > MAXDELAY) {
@@ -318,7 +339,26 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
318339

319340
setCronTimeout(timeout);
320341
} else {
321-
this.stop();
342+
// handle negative timeout
343+
const absoluteTimeout = Math.abs(timeout);
344+
345+
const message = `[Cron] Missed execution deadline by ${absoluteTimeout}ms for job${this.name ? ` "${this.name}"` : ''} with cron expression '${String(this.cronTime.source)}'`;
346+
347+
if (absoluteTimeout <= this.threshold) {
348+
// execute immediately if within threshold
349+
console.warn(`${message}. Executing immediately.`);
350+
351+
this.lastExecution = new Date();
352+
void this.fireOnTick();
353+
} else {
354+
// skip job if beyond threshold
355+
console.warn(
356+
`${message}. Skipping execution as it exceeds threshold (${this.threshold}ms).`
357+
);
358+
}
359+
360+
timeout = this.cronTime.getTimeout();
361+
setCronTimeout(timeout);
322362
}
323363
}
324364

src/time.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,18 @@ export class CronTime {
169169

170170
/**
171171
* get the number of milliseconds in the future at which to fire our callbacks.
172+
*
173+
* Can return a negative value when `sendAt` took too long to execute.
174+
* This is then handled in `CronJob` to execute the job immediately or skip
175+
* this execution based on the `threshold` option.
176+
*
177+
* We could instead call DateTime.local before `sendAt` to get the current time, but
178+
* then the calculated timeout would be offset by the time it takes to execute `sendAt`.
179+
*
180+
* As such it is better to handle negative timeouts by executing the job immediately.
172181
*/
173182
getTimeout() {
174-
return Math.max(-1, this.sendAt().toMillis() - DateTime.utc().toMillis());
183+
return this.sendAt().toMillis() - DateTime.local().toMillis();
175184
}
176185

177186
/**

src/types/cron.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ interface BaseCronJobParams<
1717
unrefTimeout?: boolean | null;
1818
waitForCompletion?: boolean | null;
1919
errorHandler?: ((error: unknown) => void) | null;
20+
threshold?: number | null;
21+
name?: string | null;
2022
}
2123

2224
export type CronJobParams<

tests/cron.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,39 @@ describe('cron', () => {
1818
sinon.restore();
1919
});
2020

21+
it('should not stop job if sendAt takes time to complete (#962)', () => {
22+
const EVERY = 5;
23+
const TICK = EVERY * 1000;
24+
const DELAY = 350;
25+
26+
const job = CronJob.from({
27+
cronTime: `*/${EVERY} * * * * *`,
28+
onTick: callback,
29+
start: false,
30+
threshold: 350
31+
});
32+
33+
sinon
34+
.stub(job.cronTime, 'getTimeout')
35+
.onCall(0)
36+
.returns(TICK)
37+
.onCall(1)
38+
.returns(-DELAY);
39+
40+
const clock = sinon.useFakeTimers();
41+
42+
// mock console.warn to avoid poluting tests with the warning
43+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
44+
45+
job.start();
46+
47+
clock.tick(TICK);
48+
expect(job.isActive).toBe(true);
49+
50+
job.stop();
51+
warnSpy.mockRestore();
52+
});
53+
2154
describe('with seconds', () => {
2255
it('should run every second (* * * * * *)', () => {
2356
const clock = sinon.useFakeTimers();

0 commit comments

Comments
 (0)