Skip to content

Commit 2a68c95

Browse files
committed
feat: allow to fork jobs in isolated sub process
experimental new feature: this allows to run a job in a seperate forked child.
1 parent b086096 commit 2a68c95

7 files changed

+169
-34
lines changed

package-lock.json

+35
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@
5656
"debug": "~4.3.4",
5757
"human-interval": "~2.0.1",
5858
"luxon": "^2.3.1",
59-
"mongodb": "^4.3.1"
59+
"mongodb": "^4.3.1",
60+
"get-function-location": "^2.0.0"
6061
},
6162
"devDependencies": {
6263
"eslint": "^8.11.0",

src/Job.ts

+99-26
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import * as date from 'date.js';
22
import * as debug from 'debug';
33
import { ObjectId } from 'mongodb';
4+
import { fork } from 'child_process';
5+
import * as getFunctionLocation from 'get-function-location';
46
import type { Agenda } from './index';
57
import type { DefinitionProcessor } from './types/JobDefinition';
68
import { IJobParameters, datefields, TJobDatefield } from './types/JobParameters';
79
import { JobPriority, parsePriority } from './utils/priority';
810
import { computeFromInterval, computeFromRepeatAt } from './utils/nextRunAt';
911

12+
const controller = new AbortController();
13+
const { signal } = controller;
14+
1015
const log = debug('agenda:job');
1116

1217
/**
@@ -15,6 +20,8 @@ const log = debug('agenda:job');
1520
export class Job<DATA = unknown | void> {
1621
readonly attrs: IJobParameters<DATA>;
1722

23+
static functionLocationCache: { [key: string]: string } = {};
24+
1825
/** this flag is set to true, if a job got canceled (e.g. due to a timeout or other exception),
1926
* you can use it for long running tasks to periodically check if canceled is true,
2027
* also touch will check if and throws that the job got canceled
@@ -209,7 +216,7 @@ export class Job<DATA = unknown | void> {
209216
* @returns Whether or not job is running at the moment (true for running)
210217
*/
211218
async isRunning(): Promise<boolean> {
212-
if (!this.byJobProcessor) {
219+
if (!this.byJobProcessor || this.attrs.fork) {
213220
// we have no job definition, therfore we are not the job processor, but a client call
214221
// so we get the real state from database
215222
await this.fetchStatus();
@@ -237,6 +244,10 @@ export class Job<DATA = unknown | void> {
237244
* Saves a job to database
238245
*/
239246
async save(): Promise<Job> {
247+
if (this.agenda.forkedWorker) {
248+
console.warn('calling save() on a Job during a forkedWorker has no effect!');
249+
return this as Job;
250+
}
240251
// ensure db connection is ready
241252
await this.agenda.ready;
242253
return this.agenda.db.saveJob(this as Job);
@@ -250,7 +261,7 @@ export class Job<DATA = unknown | void> {
250261
}
251262

252263
async isDead(): Promise<boolean> {
253-
if (!this.byJobProcessor) {
264+
if (!this.byJobProcessor || this.attrs.fork) {
254265
// we have no job definition, therfore we are not the job processor, but a client call
255266
// so we get the real state from database
256267
await this.fetchStatus();
@@ -259,7 +270,13 @@ export class Job<DATA = unknown | void> {
259270
return this.isExpired();
260271
}
261272

262-
isExpired(): boolean {
273+
async isExpired(): Promise<boolean> {
274+
if (!this.byJobProcessor || this.attrs.fork) {
275+
// we have no job definition, therfore we are not the job processor, but a client call
276+
// so we get the real state from database
277+
await this.fetchStatus();
278+
}
279+
263280
const definition = this.agenda.definitions[this.attrs.name];
264281

265282
const lockDeadline = new Date(Date.now() - definition.lockLifetime);
@@ -317,8 +334,6 @@ export class Job<DATA = unknown | void> {
317334
}
318335

319336
async run(): Promise<void> {
320-
const definition = this.agenda.definitions[this.attrs.name];
321-
322337
this.attrs.lastRunAt = new Date();
323338
log(
324339
'[%s:%s] setting lastRunAt to: %s',
@@ -333,33 +348,58 @@ export class Job<DATA = unknown | void> {
333348
this.agenda.emit('start', this);
334349
this.agenda.emit(`start:${this.attrs.name}`, this);
335350
log('[%s:%s] starting job', this.attrs.name, this.attrs._id);
336-
if (!definition) {
337-
log('[%s:%s] has no definition, can not run', this.attrs.name, this.attrs._id);
338-
throw new Error('Undefined job');
339-
}
340351

341-
if (definition.fn.length === 2) {
342-
log('[%s:%s] process function being called', this.attrs.name, this.attrs._id);
352+
if (this.attrs.fork && this.agenda.forkHelper) {
353+
const { forkHelper } = this.agenda;
354+
const location =
355+
Job.functionLocationCache[this.attrs.name] ||
356+
(await getFunctionLocation(this.agenda.definitions[this.attrs.name].fn)).source.replace(
357+
/^file:\/\//,
358+
''
359+
);
360+
361+
if (!Job.functionLocationCache[this.attrs.name]) {
362+
Job.functionLocationCache[this.attrs.name] = location;
363+
}
364+
// console.log('location', location);
365+
343366
await new Promise<void>((resolve, reject) => {
344-
try {
345-
const result = definition.fn(this as Job, error => {
346-
if (error) {
347-
reject(error);
348-
return;
349-
}
367+
let stillRunning = true;
368+
const child = fork(forkHelper, [this.attrs.name, this.attrs._id!.toString(), location], {
369+
signal
370+
});
371+
372+
child.on('close', code => {
373+
console.log(`child process exited with code ${code}`);
374+
stillRunning = false;
375+
if (code) {
376+
reject(code);
377+
} else {
350378
resolve();
351-
});
352-
353-
if (this.isPromise(result)) {
354-
result.catch((error: Error) => reject(error));
355379
}
356-
} catch (error) {
357-
reject(error);
358-
}
380+
});
381+
child.on('message', message => {
382+
console.log(`Message from child.js: ${message}`, JSON.stringify(message));
383+
if (typeof message === 'string') {
384+
reject(JSON.parse(message));
385+
} else {
386+
reject(message);
387+
}
388+
});
389+
390+
// check if job is still alive
391+
const checkCancel = () =>
392+
setTimeout(() => {
393+
if (this.canceled) {
394+
controller.abort(); // Stops the child process
395+
} else if (stillRunning) {
396+
setTimeout(checkCancel, 10000);
397+
}
398+
});
399+
checkCancel();
359400
});
360401
} else {
361-
log('[%s:%s] process function being called', this.attrs.name, this.attrs._id);
362-
await (definition.fn as DefinitionProcessor<DATA, void>)(this);
402+
await this.runJob();
363403
}
364404

365405
this.attrs.lastFinishedAt = new Date();
@@ -397,6 +437,39 @@ export class Job<DATA = unknown | void> {
397437
}
398438
}
399439

440+
async runJob() {
441+
const definition = this.agenda.definitions[this.attrs.name];
442+
443+
if (!definition) {
444+
log('[%s:%s] has no definition, can not run', this.attrs.name, this.attrs._id);
445+
throw new Error('Undefined job');
446+
}
447+
448+
if (definition.fn.length === 2) {
449+
log('[%s:%s] process function being called', this.attrs.name, this.attrs._id);
450+
await new Promise<void>((resolve, reject) => {
451+
try {
452+
const result = definition.fn(this as Job, error => {
453+
if (error) {
454+
reject(error);
455+
return;
456+
}
457+
resolve();
458+
});
459+
460+
if (this.isPromise(result)) {
461+
result.catch((error: Error) => reject(error));
462+
}
463+
} catch (error) {
464+
reject(error);
465+
}
466+
});
467+
} else {
468+
log('[%s:%s] process function being called', this.attrs.name, this.attrs._id);
469+
await (definition.fn as DefinitionProcessor<DATA, void>)(this);
470+
}
471+
}
472+
400473
private isPromise(value: unknown): value is Promise<void> {
401474
return !!(value && typeof (value as Promise<void>).then === 'function');
402475
}

src/JobDbRepository.ts

+4
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ export class JobDbRepository {
5454
return !!(connectOptions as IDatabaseOptions)?.db?.address;
5555
}
5656

57+
async getJobById(id: string) {
58+
return this.collection.findOne({ _id: new ObjectId(id) });
59+
}
60+
5761
async getJobs(
5862
query: Filter<IJobParameters>,
5963
sort: Sort = {},

src/JobProcessor.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ export class JobProcessor {
374374
* handledJobs keeps list of already processed jobs
375375
* @returns {undefined}
376376
*/
377-
private jobProcessing(handledJobs: IJobParameters['_id'][] = []) {
377+
private async jobProcessing(handledJobs: IJobParameters['_id'][] = []) {
378378
// Ensure we have jobs
379379
if (this.jobQueue.length === 0) {
380380
return;
@@ -395,7 +395,7 @@ export class JobProcessor {
395395

396396
this.jobQueue.remove(job);
397397

398-
if (!job.isExpired()) {
398+
if (!(await job.isExpired())) {
399399
// check if job has expired (and therefore probably got picked up again by another queue in the meantime)
400400
// before it even has started to run
401401

@@ -513,7 +513,7 @@ export class JobProcessor {
513513
return;
514514
}
515515

516-
if (job.isExpired()) {
516+
if (await job.isExpired()) {
517517
reject(
518518
new Error(
519519
`execution of '${job.attrs.name}' canceled, execution took more than ${

src/index.ts

+23-4
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ const DefaultOptions = {
2323
defaultLockLimit: 0,
2424
lockLimit: 0,
2525
defaultLockLifetime: 10 * 60 * 1000,
26-
sort: { nextRunAt: 1, priority: -1 } as const
26+
sort: { nextRunAt: 1, priority: -1 } as const,
27+
forkHelper: 'dist/childWorker.js'
2728
};
2829

2930
/**
@@ -32,9 +33,11 @@ const DefaultOptions = {
3233
export class Agenda extends EventEmitter {
3334
readonly attrs: IAgendaConfig & IDbConfig;
3435

36+
public readonly forkedWorker?: boolean;
37+
public readonly forkHelper?: string;
38+
3539
db: JobDbRepository;
36-
// eslint-disable-next-line default-param-last
37-
// private jobQueue: JobProcessingQueue;
40+
3841
// internally used
3942
on(event: 'processJob', listener: (job: JobWithId) => void): this;
4043

@@ -47,6 +50,10 @@ export class Agenda extends EventEmitter {
4750
on(event: 'ready', listener: () => void): this;
4851
on(event: 'error', listener: (error: Error) => void): this;
4952
on(event: string, listener: (...args) => void): this {
53+
if (this.forkedWorker) {
54+
console.warn('calling on() during a forkedWorker has no effect!');
55+
return this;
56+
}
5057
return super.on(event, listener);
5158
}
5259

@@ -62,6 +69,15 @@ export class Agenda extends EventEmitter {
6269
return !!this.jobProcessor;
6370
}
6471

72+
async runForkedJob(name: string, jobId: string) {
73+
const jobData = await this.db.getJobById(jobId);
74+
if (!jobData) {
75+
throw new Error('db entry not found');
76+
}
77+
const job = new Job(this, jobData);
78+
await job.runJob();
79+
}
80+
6581
async getRunningStats(fullDetails = false): Promise<IAgendaStatus> {
6682
if (!this.jobProcessor) {
6783
throw new Error('agenda not running!');
@@ -84,7 +100,7 @@ export class Agenda extends EventEmitter {
84100
defaultLockLifetime?: number;
85101
// eslint-disable-next-line @typescript-eslint/ban-types
86102
} & (IDatabaseOptions | IMongoOptions | {}) &
87-
IDbConfig = DefaultOptions,
103+
IDbConfig & { forkHelper?: string; forkedWorker?: boolean } = DefaultOptions,
88104
cb?: (error?: Error) => void
89105
) {
90106
super();
@@ -100,6 +116,9 @@ export class Agenda extends EventEmitter {
100116
sort: config.sort || DefaultOptions.sort
101117
};
102118

119+
this.forkedWorker = config.forkedWorker;
120+
this.forkHelper = config.forkHelper;
121+
103122
this.ready = new Promise(resolve => {
104123
this.once('ready', resolve);
105124
});

src/types/JobParameters.ts

+3
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export interface IJobParameters<DATA = unknown | void> {
3232
};
3333

3434
lastModifiedBy?: string;
35+
36+
/** forks a new node sub process for executing this job */
37+
fork?: boolean;
3538
}
3639

3740
export type TJobDatefield = keyof Pick<

0 commit comments

Comments
 (0)