2020-11-19 07:58:41 +03:00
|
|
|
const path = require('path');
|
2022-07-27 20:04:19 +03:00
|
|
|
const util = require('util');
|
|
|
|
const setTimeoutPromise = util.promisify(setTimeout);
|
2020-08-11 23:13:31 +03:00
|
|
|
const fastq = require('fastq');
|
2020-11-05 07:36:29 +03:00
|
|
|
const later = require('@breejs/later');
|
2020-11-19 07:58:41 +03:00
|
|
|
const Bree = require('bree');
|
2020-08-11 23:13:31 +03:00
|
|
|
const pWaitFor = require('p-wait-for');
|
2021-11-30 14:14:50 +03:00
|
|
|
const {UnhandledJobError, IncorrectUsageError} = require('@tryghost/errors');
|
2021-12-02 15:48:48 +03:00
|
|
|
const logging = require('@tryghost/logging');
|
2020-11-05 07:36:29 +03:00
|
|
|
const isCronExpression = require('./is-cron-expression');
|
2020-11-19 07:58:41 +03:00
|
|
|
const assembleBreeJob = require('./assemble-bree-job');
|
2022-07-21 21:39:54 +03:00
|
|
|
const JobsRepository = require('./jobs-repository');
|
2020-08-11 23:13:31 +03:00
|
|
|
|
|
|
|
const worker = async (task, callback) => {
|
|
|
|
try {
|
|
|
|
let result = await task();
|
2022-07-26 20:14:12 +03:00
|
|
|
await callback(null, result);
|
2020-08-11 23:13:31 +03:00
|
|
|
} catch (error) {
|
2022-07-26 20:14:12 +03:00
|
|
|
await callback(error);
|
2020-08-11 23:13:31 +03:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2022-07-28 16:25:46 +03:00
|
|
|
const ALL_STATUSES = {
|
|
|
|
started: 'started',
|
|
|
|
finished: 'finished',
|
|
|
|
failed: 'failed',
|
|
|
|
queued: 'queued'
|
|
|
|
};
|
|
|
|
|
2020-08-11 23:13:31 +03:00
|
|
|
class JobManager {
|
2022-11-29 13:15:19 +03:00
|
|
|
#domainEvents;
|
2022-12-01 15:43:49 +03:00
|
|
|
#completionPromises = new Map();
|
2022-11-29 13:15:19 +03:00
|
|
|
|
2020-12-07 06:50:41 +03:00
|
|
|
/**
|
|
|
|
* @param {Object} options
|
|
|
|
* @param {Function} [options.errorHandler] - custom job error handler
|
2021-02-22 09:10:47 +03:00
|
|
|
* @param {Function} [options.workerMessageHandler] - custom message handler coming from workers
|
2022-07-21 21:39:54 +03:00
|
|
|
* @param {Object} [options.JobModel] - a model which can persist job data in the storage
|
2022-11-29 13:15:19 +03:00
|
|
|
* @param {Object} [options.domainEvents] - domain events emitter
|
2020-12-07 06:50:41 +03:00
|
|
|
*/
|
2022-11-29 13:15:19 +03:00
|
|
|
constructor({errorHandler, workerMessageHandler, JobModel, domainEvents}) {
|
2020-08-11 23:13:31 +03:00
|
|
|
this.queue = fastq(this, worker, 1);
|
2022-07-22 18:43:08 +03:00
|
|
|
this._jobMessageHandler = this._jobMessageHandler.bind(this);
|
|
|
|
this._jobErrorHandler = this._jobErrorHandler.bind(this);
|
2022-11-29 13:15:19 +03:00
|
|
|
this.#domainEvents = domainEvents;
|
2022-07-22 18:43:08 +03:00
|
|
|
|
|
|
|
const combinedMessageHandler = workerMessageHandler
|
|
|
|
? ({name, message}) => {
|
|
|
|
workerMessageHandler({name, message});
|
|
|
|
this._jobMessageHandler({name, message});
|
|
|
|
}
|
|
|
|
: this._jobMessageHandler;
|
|
|
|
|
|
|
|
const combinedErrorHandler = errorHandler
|
|
|
|
? (error, workerMeta) => {
|
|
|
|
errorHandler(error, workerMeta);
|
|
|
|
this._jobErrorHandler(error, workerMeta);
|
|
|
|
}
|
|
|
|
: this._jobErrorHandler;
|
2020-11-19 07:58:41 +03:00
|
|
|
|
|
|
|
this.bree = new Bree({
|
|
|
|
root: false, // set this to `false` to prevent requiring a root directory of jobs
|
2021-02-22 03:11:11 +03:00
|
|
|
hasSeconds: true, // precision is needed to avoid task overlaps after immediate execution
|
2020-11-19 08:15:52 +03:00
|
|
|
outputWorkerMetadata: true,
|
2020-12-07 06:50:41 +03:00
|
|
|
logger: logging,
|
2022-07-22 18:43:08 +03:00
|
|
|
errorHandler: combinedErrorHandler,
|
|
|
|
workerMessageHandler: combinedMessageHandler
|
2020-11-19 07:58:41 +03:00
|
|
|
});
|
2022-07-21 21:39:54 +03:00
|
|
|
|
2022-07-22 18:43:08 +03:00
|
|
|
this.bree.on('worker created', (name) => {
|
2022-07-28 16:25:46 +03:00
|
|
|
this._jobMessageHandler({name, message: ALL_STATUSES.started});
|
2022-07-22 18:44:28 +03:00
|
|
|
});
|
2022-07-22 18:43:08 +03:00
|
|
|
|
2022-07-26 20:00:40 +03:00
|
|
|
if (JobModel) {
|
|
|
|
this._jobsRepository = new JobsRepository({JobModel});
|
|
|
|
}
|
2020-08-11 23:13:31 +03:00
|
|
|
}
|
|
|
|
|
2022-07-26 20:14:12 +03:00
|
|
|
inlineJobHandler(jobName) {
|
|
|
|
return async (error, result) => {
|
|
|
|
if (error) {
|
|
|
|
await this._jobErrorHandler(error, {
|
|
|
|
name: jobName
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
await this._jobMessageHandler({
|
|
|
|
name: jobName,
|
|
|
|
message: 'done'
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2022-10-07 13:41:21 +03:00
|
|
|
// Can potentially standardize the result here
|
2022-07-26 20:14:12 +03:00
|
|
|
return result;
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2022-07-22 18:43:08 +03:00
|
|
|
async _jobMessageHandler({name, message}) {
|
2022-12-01 15:43:49 +03:00
|
|
|
if (name) {
|
2022-07-28 16:25:46 +03:00
|
|
|
if (message === ALL_STATUSES.started) {
|
2022-12-01 15:43:49 +03:00
|
|
|
if (this._jobsRepository) {
|
|
|
|
const job = await this._jobsRepository.read(name);
|
|
|
|
|
|
|
|
if (job) {
|
|
|
|
await this._jobsRepository.update(job.id, {
|
|
|
|
status: ALL_STATUSES.started,
|
|
|
|
started_at: new Date()
|
|
|
|
});
|
|
|
|
}
|
2022-07-26 20:00:40 +03:00
|
|
|
}
|
|
|
|
} else if (message === 'done') {
|
2022-12-01 15:43:49 +03:00
|
|
|
if (this._jobsRepository) {
|
|
|
|
const job = await this._jobsRepository.read(name);
|
|
|
|
|
|
|
|
if (job) {
|
|
|
|
await this._jobsRepository.update(job.id, {
|
|
|
|
status: ALL_STATUSES.finished,
|
|
|
|
finished_at: new Date()
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
2022-07-22 18:43:08 +03:00
|
|
|
|
2022-12-01 15:43:49 +03:00
|
|
|
// Check completion listeners
|
|
|
|
if (this.#completionPromises.has(name)) {
|
|
|
|
for (const listeners of this.#completionPromises.get(name)) {
|
|
|
|
listeners.resolve();
|
|
|
|
}
|
|
|
|
// Clear the listeners
|
|
|
|
this.#completionPromises.delete(name);
|
2022-07-26 20:00:40 +03:00
|
|
|
}
|
2022-11-29 13:15:19 +03:00
|
|
|
} else {
|
|
|
|
if (typeof message === 'object' && this.#domainEvents) {
|
|
|
|
// Is this an event?
|
|
|
|
if (message.event) {
|
|
|
|
this.#domainEvents.dispatchRaw(message.event.type, message.event.data);
|
|
|
|
}
|
|
|
|
}
|
2022-07-22 18:43:08 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-07-26 20:00:40 +03:00
|
|
|
async _jobErrorHandler(error, jobMeta) {
|
2022-10-07 13:41:11 +03:00
|
|
|
if (this._jobsRepository && jobMeta.name) {
|
2022-07-26 20:00:40 +03:00
|
|
|
const job = await this._jobsRepository.read(jobMeta.name);
|
2022-07-22 18:43:08 +03:00
|
|
|
|
2022-07-26 20:00:40 +03:00
|
|
|
if (job) {
|
|
|
|
await this._jobsRepository.update(job.id, {
|
2022-07-28 16:25:46 +03:00
|
|
|
status: ALL_STATUSES.failed
|
2022-07-26 20:00:40 +03:00
|
|
|
});
|
|
|
|
}
|
2022-07-22 18:43:08 +03:00
|
|
|
}
|
2022-12-01 15:43:49 +03:00
|
|
|
|
|
|
|
// Check completion listeners and call them with error
|
|
|
|
if (this.#completionPromises.has(jobMeta.name)) {
|
|
|
|
for (const listeners of this.#completionPromises.get(jobMeta.name)) {
|
|
|
|
listeners.reject(error);
|
|
|
|
}
|
|
|
|
// Clear the listeners
|
|
|
|
this.#completionPromises.delete(jobMeta.name);
|
|
|
|
}
|
2022-07-22 18:43:08 +03:00
|
|
|
}
|
|
|
|
|
2020-11-05 06:41:16 +03:00
|
|
|
/**
|
2021-01-06 03:45:23 +03:00
|
|
|
* By default schedules an "offloaded" job. If `offloaded: true` parameter is set,
|
|
|
|
* puts an "inline" immediate job into the queue.
|
2020-11-05 06:41:16 +03:00
|
|
|
*
|
2021-01-06 03:45:23 +03:00
|
|
|
* @param {Object} GhostJob - job options
|
|
|
|
* @prop {Function | String} GhostJob.job - function or path to a module defining a job
|
|
|
|
* @prop {String} [GhostJob.name] - unique job name, if not provided takes function name or job script filename
|
|
|
|
* @prop {String | Date} [GhostJob.at] - Date, cron or human readable schedule format. Manage will do immediate execution if not specified. Not supported for "inline" jobs
|
|
|
|
* @prop {Object} [GhostJob.data] - data to be passed into the job
|
|
|
|
* @prop {Boolean} [GhostJob.offloaded] - creates an "offloaded" job running in a worker thread by default. If set to "false" runs an "inline" job on the same event loop
|
2020-11-05 06:41:16 +03:00
|
|
|
*/
|
2022-07-21 20:56:17 +03:00
|
|
|
addJob({name, at, job, data, offloaded = true}) {
|
2021-01-06 03:45:23 +03:00
|
|
|
if (offloaded) {
|
2021-12-02 15:48:48 +03:00
|
|
|
logging.info('Adding offloaded job to the queue');
|
2021-01-06 03:45:23 +03:00
|
|
|
let schedule;
|
|
|
|
|
|
|
|
if (!name) {
|
|
|
|
if (typeof job === 'string') {
|
|
|
|
name = path.parse(job).name;
|
2020-11-10 07:11:24 +03:00
|
|
|
} else {
|
2021-09-22 12:50:21 +03:00
|
|
|
throw new IncorrectUsageError({
|
|
|
|
message: 'Name parameter should be present if job is a function'
|
|
|
|
});
|
2020-11-10 07:11:24 +03:00
|
|
|
}
|
2020-11-10 06:32:47 +03:00
|
|
|
}
|
2020-08-11 23:13:31 +03:00
|
|
|
|
2021-01-06 03:45:23 +03:00
|
|
|
if (at && !(at instanceof Date)) {
|
|
|
|
if (isCronExpression(at)) {
|
|
|
|
schedule = later.parse.cron(at, true);
|
|
|
|
} else {
|
|
|
|
schedule = later.parse.text(at);
|
|
|
|
}
|
2020-11-05 07:36:29 +03:00
|
|
|
|
2021-01-06 03:45:23 +03:00
|
|
|
if ((schedule.error && schedule.error !== -1) || schedule.schedules.length === 0) {
|
2021-09-22 12:50:21 +03:00
|
|
|
throw new IncorrectUsageError({
|
|
|
|
message: 'Invalid schedule format'
|
|
|
|
});
|
2021-01-06 03:45:23 +03:00
|
|
|
}
|
2020-11-19 07:58:41 +03:00
|
|
|
|
2021-12-02 15:48:48 +03:00
|
|
|
logging.info(`Scheduling job ${name} at ${at}. Next run on: ${later.schedule(schedule).next()}`);
|
2021-01-06 03:45:23 +03:00
|
|
|
} else if (at !== undefined) {
|
2021-12-02 15:48:48 +03:00
|
|
|
logging.info(`Scheduling job ${name} at ${at}`);
|
2020-12-02 11:26:15 +03:00
|
|
|
} else {
|
2021-12-02 15:48:48 +03:00
|
|
|
logging.info(`Scheduling job ${name} to run immediately`);
|
2020-12-02 11:26:15 +03:00
|
|
|
}
|
2020-11-05 07:36:29 +03:00
|
|
|
|
2021-01-06 03:45:23 +03:00
|
|
|
const breeJob = assembleBreeJob(at, job, data, name);
|
|
|
|
this.bree.add(breeJob);
|
|
|
|
return this.bree.start(name);
|
2020-12-09 09:11:11 +03:00
|
|
|
} else {
|
2021-12-02 15:48:48 +03:00
|
|
|
logging.info('Adding one off inline job to the queue');
|
2021-01-06 03:45:23 +03:00
|
|
|
|
|
|
|
this.queue.push(async () => {
|
|
|
|
try {
|
2022-07-26 20:14:12 +03:00
|
|
|
// NOTE: setting the status here otherwise it is impossible to
|
|
|
|
// distinguish between states when the job fails immediately
|
|
|
|
await this._jobMessageHandler({
|
|
|
|
name: name,
|
2022-07-28 16:25:46 +03:00
|
|
|
message: ALL_STATUSES.started
|
2022-07-26 20:14:12 +03:00
|
|
|
});
|
|
|
|
|
2021-01-06 03:45:23 +03:00
|
|
|
if (typeof job === 'function') {
|
|
|
|
await job(data);
|
|
|
|
} else {
|
|
|
|
await require(job)(data);
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
// NOTE: each job should be written in a safe way and handle all errors internally
|
2022-10-07 13:41:21 +03:00
|
|
|
// if the error is caught here jobs implementation should be changed
|
2021-12-02 15:48:48 +03:00
|
|
|
logging.error(new UnhandledJobError({
|
2021-01-06 03:45:23 +03:00
|
|
|
context: (typeof job === 'function') ? 'function' : job,
|
|
|
|
err
|
|
|
|
}));
|
|
|
|
|
|
|
|
throw err;
|
|
|
|
}
|
2022-07-26 20:14:12 +03:00
|
|
|
}, this.inlineJobHandler(name));
|
2020-11-05 07:36:29 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-07-21 21:39:54 +03:00
|
|
|
/**
|
2022-07-28 21:04:42 +03:00
|
|
|
* Adds a job that could ever be executed once. In case the job fails
|
|
|
|
* can be "added" again, effectively restarting the failed job.
|
2022-07-21 21:39:54 +03:00
|
|
|
*
|
|
|
|
* @param {Object} GhostJob - job options
|
|
|
|
* @prop {Function | String} GhostJob.job - function or path to a module defining a job
|
2022-07-26 17:12:44 +03:00
|
|
|
* @prop {String} GhostJob.name - unique job name, if not provided takes function name or job script filename
|
2022-07-21 21:39:54 +03:00
|
|
|
* @prop {String | Date} [GhostJob.at] - Date, cron or human readable schedule format. Manage will do immediate execution if not specified. Not supported for "inline" jobs
|
|
|
|
* @prop {Object} [GhostJob.data] - data to be passed into the job
|
|
|
|
* @prop {Boolean} [GhostJob.offloaded] - creates an "offloaded" job running in a worker thread by default. If set to "false" runs an "inline" job on the same event loop
|
|
|
|
*/
|
|
|
|
async addOneOffJob({name, job, data, offloaded = true}) {
|
2022-07-26 17:12:44 +03:00
|
|
|
if (!name) {
|
|
|
|
throw new IncorrectUsageError({
|
|
|
|
message: `The name parameter is required for a one off job.`
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2022-07-21 21:39:54 +03:00
|
|
|
const persistedJob = await this._jobsRepository.read(name);
|
|
|
|
|
2022-07-28 21:04:42 +03:00
|
|
|
if (persistedJob && (persistedJob.get('status') !== ALL_STATUSES.failed)) {
|
2022-07-21 21:39:54 +03:00
|
|
|
throw new IncorrectUsageError({
|
|
|
|
message: `A "${name}" one off job has already been executed.`
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2022-07-28 21:04:42 +03:00
|
|
|
if (persistedJob && (persistedJob.get('status') === ALL_STATUSES.failed)) {
|
|
|
|
await this._jobsRepository.update(persistedJob.id, {
|
|
|
|
status: ALL_STATUSES.queued
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
await this._jobsRepository.add({
|
|
|
|
name,
|
|
|
|
status: ALL_STATUSES.queued
|
|
|
|
});
|
|
|
|
}
|
2022-07-21 21:39:54 +03:00
|
|
|
|
2022-07-28 21:04:42 +03:00
|
|
|
// NOTE: there's a assumption the job with the same name failed while
|
|
|
|
// running under different instance of job manager (bree).
|
|
|
|
// For example, it failed and the process was restarted.
|
|
|
|
// If we want to be able to restart within the same instance,
|
|
|
|
// we'd need to handle job restart/removal in Bree first
|
2022-07-21 21:39:54 +03:00
|
|
|
this.addJob({name, job, data, offloaded});
|
|
|
|
}
|
|
|
|
|
2022-07-27 20:04:19 +03:00
|
|
|
/**
|
2022-07-28 19:51:59 +03:00
|
|
|
* Checks if the one-off job has ever been executed successfully.
|
2022-07-27 20:04:19 +03:00
|
|
|
* @param {String} name one-off job name
|
|
|
|
*/
|
2022-07-28 19:51:59 +03:00
|
|
|
async hasExecutedSuccessfully(name) {
|
2022-07-27 20:04:19 +03:00
|
|
|
if (this._jobsRepository) {
|
|
|
|
const persistedJob = await this._jobsRepository.read(name);
|
2022-07-28 19:51:59 +03:00
|
|
|
|
|
|
|
if (!persistedJob) {
|
|
|
|
return false;
|
|
|
|
} else {
|
|
|
|
return (persistedJob.get('status') !== ALL_STATUSES.failed);
|
|
|
|
}
|
2022-07-27 20:04:19 +03:00
|
|
|
} else {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2022-12-01 15:43:49 +03:00
|
|
|
* Awaits completion of the offloaded one-off job.
|
2022-07-28 15:30:30 +03:00
|
|
|
* CAUTION: it might take a long time to resolve!
|
2022-07-27 20:04:19 +03:00
|
|
|
* @param {String} name one-off job name
|
2022-07-28 15:30:30 +03:00
|
|
|
* @returns resolves with a Job model at current state
|
2022-07-27 20:04:19 +03:00
|
|
|
*/
|
2022-12-01 15:43:49 +03:00
|
|
|
async awaitOneOffCompletion(name) {
|
2022-07-27 20:04:19 +03:00
|
|
|
const persistedJob = await this._jobsRepository.read({
|
|
|
|
name
|
|
|
|
});
|
|
|
|
|
2022-07-28 16:25:46 +03:00
|
|
|
if (!persistedJob || ![ALL_STATUSES.finished, ALL_STATUSES.failed].includes(persistedJob.get('status'))) {
|
2022-07-27 20:04:19 +03:00
|
|
|
// NOTE: can implement exponential backoff here if that's ever needed
|
|
|
|
await setTimeoutPromise(500);
|
|
|
|
|
2022-12-01 15:43:49 +03:00
|
|
|
return this.awaitOneOffCompletion(name);
|
2022-07-27 20:04:19 +03:00
|
|
|
}
|
|
|
|
|
2022-07-28 15:30:30 +03:00
|
|
|
return persistedJob;
|
2022-07-27 20:04:19 +03:00
|
|
|
}
|
|
|
|
|
2022-12-01 15:43:49 +03:00
|
|
|
/***
|
|
|
|
* Create this promise before you add the job you want to listen for. Then await the returned promise.
|
|
|
|
* Resolves if the job has been executed successfully.
|
|
|
|
* Throws an error if the job has failed execution.
|
|
|
|
*/
|
|
|
|
async awaitCompletion(name) {
|
|
|
|
const promise = new Promise((resolve, reject) => {
|
|
|
|
this.#completionPromises.set(name, [
|
|
|
|
...(this.#completionPromises.get(name) ?? []),
|
|
|
|
{resolve, reject}
|
|
|
|
]);
|
|
|
|
});
|
|
|
|
|
|
|
|
return promise;
|
|
|
|
}
|
|
|
|
|
2020-12-08 06:43:18 +03:00
|
|
|
/**
|
2021-01-06 03:45:23 +03:00
|
|
|
* Removes an "offloaded" job from scheduled jobs queue.
|
|
|
|
* It's NOT yet possible to remove "inline" jobs (will be possible when scheduling is added https://github.com/breejs/bree/issues/68).
|
2020-12-08 06:43:18 +03:00
|
|
|
* The method will throw an Error if job with provided name does not exist.
|
|
|
|
*
|
|
|
|
* NOTE: current implementation does not guarante running job termination
|
|
|
|
* for details see https://github.com/breejs/bree/pull/64
|
|
|
|
*
|
|
|
|
* @param {String} name - job name
|
|
|
|
*/
|
|
|
|
async removeJob(name) {
|
|
|
|
await this.bree.remove(name);
|
|
|
|
}
|
|
|
|
|
2020-11-05 07:36:29 +03:00
|
|
|
/**
|
|
|
|
* @param {import('p-wait-for').Options} [options]
|
|
|
|
*/
|
2020-08-11 23:13:31 +03:00
|
|
|
async shutdown(options) {
|
2020-11-19 07:58:41 +03:00
|
|
|
await this.bree.stop();
|
2020-11-05 07:36:29 +03:00
|
|
|
|
2020-08-11 23:13:31 +03:00
|
|
|
if (this.queue.idle()) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-12-02 15:48:48 +03:00
|
|
|
logging.warn('Waiting for busy job queue');
|
2020-08-11 23:13:31 +03:00
|
|
|
|
|
|
|
await pWaitFor(() => this.queue.idle() === true, options);
|
|
|
|
|
2021-12-02 15:48:48 +03:00
|
|
|
logging.warn('Job queue finished');
|
2020-08-11 23:13:31 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = JobManager;
|