@tryghost/job-manager 0.9.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2013-2022 Ghost Foundation
3
+ Copyright (c) 2013-2025 Ghost Foundation
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,33 +1,70 @@
1
1
  # Job Manager
2
2
 
3
- A manager for jobs (aka tasks) that have to be performed asynchronously, optionally recurring, scheduled or one-off in their nature. The job queue is manage in memory without additional dependencies.
3
+ A manager for background jobs in Ghost, supporting one-off tasks and recurring jobs.
4
4
 
5
- ## Install
5
+ ## Table of Contents
6
+ - [Quick Start](#quick-start)
7
+ - [Job Types](#job-types)
8
+ - [Background Job Requirements](#background-job-requirements)
9
+ - [Advanced Usage](#advanced-usage)
10
+ - [Technical Details](#technical-details)
6
11
 
7
- `npm install @tryghost/job-manager --save`
12
+ ## Quick Start
8
13
 
9
- or
14
+ ```js
15
+ const JobManager = require('@tryghost/job-manager');
16
+ const jobManager = new JobManager({JobModel: models.Job});
17
+
18
+ // Simple one-off job
19
+ jobManager.addJob({
20
+ name: 'hello-world',
21
+ job: () => console.log('Hello World'),
22
+ offloaded: false
23
+ });
24
+
25
+ // Recurring job every 5 minutes
26
+ jobManager.addJob({
27
+ name: 'check-emails',
28
+ at: 'every 5 minutes',
29
+ job: './jobs/check-emails.js'
30
+ });
31
+ ```
32
+
33
+ ## Job Types
10
34
 
11
- `yarn add @tryghost/job-manager`
35
+ Ghost supports two types of jobs:
12
36
 
37
+ 1. **Inline Jobs**
38
+ - Run in the main Ghost process
39
+ - Best for quick, simple tasks
40
+ - Cannot be scheduled or recurring
13
41
 
14
- ## Usage
42
+ 2. **Offloaded Jobs**
43
+ - Run in a separate process
44
+ - Good for CPU-intensive tasks
45
+ - Can be scheduled or recurring
15
46
 
16
- Below is a sample code to wire up job manger and initialize jobs:
47
+ ## Background Job Requirements
48
+
49
+ Offloaded jobs must:
50
+
51
+ - Have a unique name
52
+ - Be idempotent (safe to run multiple times)
53
+ - Use minimal parameters (prefer using IDs rather than full objects)
54
+ - Import their own dependencies
55
+ - Primarily use DB or API calls
56
+
57
+ ## Advanced Usage
58
+
59
+ Below is a sample code to wire up job manger and initialize jobs. This is the simplest way to interact with the job manager - these jobs do not persist after reboot:
17
60
  ```js
18
61
  const JobManager = require('@tryghost/job-manager');
19
62
 
20
- const logging = {
21
- info: console.log,
22
- warn: console.log,
23
- error: console.error
24
- };
25
-
26
- const jobManager = new JobManager(logging);
63
+ const jobManager = new JobManager({JobModel: models.Job});
27
64
 
28
65
  // register a job "function" with queued execution in current event loop
29
66
  jobManager.addJob({
30
- job: printWord(word) => console.log(word),
67
+ job: (word) => console.log(word),
31
68
  name: 'hello',
32
69
  offloaded: false
33
70
  });
@@ -55,82 +92,60 @@ jobManager.addJob({
55
92
  name: 'email-checker-cron'
56
93
  });
57
94
 
58
- // register a job to un immediately running outside of current even loop
95
+ // register a job to run immediately running outside of current even loop
59
96
  jobManager.addJob({
60
97
  job: './path/to/jobs/check-emails.js',
61
98
  name: 'email-checker-now'
62
99
  });
63
- ```
64
100
 
65
- For more examples of JobManager initialization check [test/examples](https://github.com/TryGhost/Utils/tree/master/packages/job-manager/test/examples) directory.
66
-
67
- ### Job types and definitions
68
-
69
- There are two types of jobs distinguished based on purpose and environment they run in:
70
- - **"inline"** - job which is run in the same even loop as the caller. Should be used in situations when there is no even loop blocking operations and no need to manage memory leaks in sandboxed way. Sometimes
71
- - **"offloaded"** - job which is executed in separate to caller's event loop. For Node >v12 clients it spawns a [Worker thread](https://nodejs.org/dist/latest-v12.x/docs/api/worker_threads.html#worker_threads_new_worker_filename_options), for older Node runtimes it is executed in separate process through [child_process](https://nodejs.org/docs/latest-v10.x/api/child_process.html). Comparing to **inline** jobs, **offloaded** jobs are safer to execute as they are run on a dedicated thread (or process) acting like a sandbox. These jobs also give better utilization of multi-core CPUs. This type of jobs is useful when there are heavy computations needed to be done blocking the event loop or need a sandboxed environment to run in safely. Example jobs would be: statistical information processing, memory intensive computations (e.g. recursive algorithms), processing that requires blocking I/O operations etc.
72
-
73
- Job manager's instance registers jobs through `addJob` method. The `offloaded` parameter controls if the job is **inline** (executed in the same event loop) or is **offloaded** (executed in worker thread/separate process). By default `offloaded` is set to `true` - creates an "offloaded" job.
101
+ // register a one-off job to be executed immediately within current event loop
102
+ jobManager.addOneOffJob({
103
+ name: 'members-migrations',
104
+ offloaded: false,
105
+ job: stripeService.migrations.execute.bind(stripeService.migrations)
106
+ });
74
107
 
75
- When `offloaded: false` parameter is passed into `addJob` method, job manager registers an **inline** function for execution in FIFO queue. The job should not be computationally intensive and should have small amount of asynchronous operations. The developer should always account that the function will be executed on the **same event loop, thread and process as caller's process**. **inline** jobs should be [JavaScript functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function) or a path to a module that exports a function as default. Note, at the moment it's not possible to defined scheduled or recurring **inline** job.
108
+ // register a one-off job to be executed immediately outside of current event loop
109
+ jobManager.addOneOffJob({
110
+ name: 'generate-backup-2022-09-15',
111
+ job: './path/to/jobs/backup.js',
112
+ });
76
113
 
77
- When skipped or `offloaded: true` parameter is passed into `addJob` method, job manager registers execution of an **offloaded** job. The job can be scheduled to run immediately, in the future, or in recurring manner (through `at` parameter). Jobs created this way are managed by [bree](https://github.com/breejs/bree) job scheduling library. For examples of job scripts check out [this section](https://github.com/breejs/bree#nodejs-email-queue-job-scheduling-example) of bree's documentation, test [job examples](https://github.com/TryGhost/Utils/tree/master/packages/job-manager/test/jobs).
114
+ // optionally await completion of the one-off job in case
115
+ // there are state changes expected to execute the rest of the process
116
+ // NOTE: if multiple jobs are submitted using the same name, the first completion will resolve
117
+ await jobManager.awaitOneOffCompletion('members-migrations');
118
+
119
+ // check if previously registered one-off job has been executed
120
+ // successfully - it exists and doesn't have a "failed" state.
121
+ // NOTE: this is stored in memory and cleared on reboot
122
+ const backupSuccessful = await jobManager.hasExecutedSuccessfully('generate-backup-2022-09-15');
123
+
124
+ if (!backupSuccessful) {
125
+ // One-off jobs with "failed" state can be rescheduled
126
+ jobManager.addOneOffJob({
127
+ name: 'generate-backup-2022-09-15',
128
+ job: './path/to/jobs/backup.js',
129
+ });
130
+ }
131
+ ```
78
132
 
133
+ For more examples of JobManager initialization check [test/examples](https://github.com/TryGhost/Utils/tree/master/packages/job-manager/test/examples) directory.
79
134
 
80
- ### Offloaded jobs rules of thumb
81
- To prevent complications around failed job retries and and handling of specific job states here are some rules that should be followed for all scheduled jobs:
82
- 1. Jobs are **self contained** - meaning job manager should be able to run the job with the state information included within the job's parameters. Job script should look up for the rest of needed information from somewhere else, like a database, API, or file.
83
- 2. Jobs should be [idempotent](https://en.wikipedia.org/wiki/Idempotence) - consequent job executions should be safe.
84
- 3. Job **parameters** should be **kept to the minimum**. When passing large amounts of data around performance can suffer from slow JSON serialization. Also, storage size restrictions that can arise if there is a need to store parameters in the future.Job parameters should be kept to only information that is needed to retrieve the rest of information from somewhere else. For example, it's recommended to pass in only an *id* of the resource that could be fetched from the data storage during job execution or pass in a file path which could be read during execution.
85
- 4. Scheduled **job execution time should not overlap**. It's up to the registering service to assure job execution time does not ecceed time between subsequent scheduled jobs. For example, if job is scheduled to run every 5 minutes it should always run under 5 minutes, otherwise next scheduled job would fail to start.
135
+ ## Technical Details
86
136
 
87
- ### Offloaded jobs lifecycle
137
+ ### Process Isolation
138
+ Background jobs run in separate processes, meaning:
139
+ - No shared memory with Ghost
140
+ - Must import own dependencies
141
+ - Should avoid heavy dependencies
142
+ - Should handle graceful shutdown
88
143
 
89
- Offloaded jobs are running on dedicated worker threads which makes their lifecycle a bit different to inline jobs:
90
- 1. When **starting** a job it's only sharing ENV variables with it's parent process. The job itself is run on an independent JavaScript execution thread. The script has to re-initialize any modules it will use. For example it should take care of: model layer initialization, cache initialization, etc.
144
+ ### Job Lifecycle
145
+ Offloaded jobs are running on dedicated worker threads which makes their lifecycle a bit different from inline jobs:
146
+ 1. When **starting** a job it's only sharing ENV variables with its parent process. The job itself is run on an independent JavaScript execution thread. The script has to re-initialize any modules it will use. For example, it should take care of: model layer initialization, cache initialization, etc.
91
147
  2. When **finishing** work in a job prefer to signal successful termination by sending 'done' message to the parent thread: `parentPort.postMessage('done')` ([example use](https://github.com/TryGhost/Utils/blob/0e423f6c5c69b08d81d470f49de95654d8cc90e3/packages/job-manager/test/jobs/graceful.js#L33-L37)). Finishing work this way terminates the thread through [worker.terminate()]((https://nodejs.org/dist/latest-v14.x/docs/api/worker_threads.html#worker_threads_worker_terminate)), which logs termination in parent process and flushes any pipes opened in thread.
92
148
  3. Jobs that have iterative nature, or need cleanup before interrupting work should allow for **graceful shutdown** by listening on `'cancel'` message coming from parent thread ([example use](https://github.com/TryGhost/Utils/blob/0e423f6c5c69b08d81d470f49de95654d8cc90e3/packages/job-manager/test/jobs/graceful.js#L12-L16)).
93
149
  4. When **exceptions** happen and expected outcome is to terminate current job, leave the exception unhandled allowing it to bubble up to the job manager. Unhandled exceptions [terminate current thread](https://nodejs.org/dist/latest-v14.x/docs/api/worker_threads.html#worker_threads_event_error) and allow for next scheduled job execution to happen.
94
150
 
95
151
  For more nuances on job structure best practices check [bree documentation](https://github.com/breejs/bree#writing-jobs-with-promises-and-async-await).
96
-
97
- ### Offloaded job script quirks
98
-
99
- ⚠️ to ensure worker thread back compatibility and correct inter-thread communication use [btrheads](https://github.com/chjj/bthreads) polyfill instead of native [worker_threads](https://nodejs.org/api/worker_threads.html#worker_threads) module in job scripts.
100
-
101
- Instead of:
102
- ```js
103
- const {isMainThread, parentPort} = require('worker_threads');
104
- ```
105
- use
106
- ```js
107
- const {isMainThread, parentPort} = require('bthreads');
108
- ```
109
-
110
- It should be possible to use native `worker_threads` module once Node v10 [hits EOL](https://nodejs.org/en/about/releases/) (2021-04-30).
111
-
112
- ## Develop
113
-
114
- This is a mono repository, managed with [lerna](https://lernajs.io/).
115
-
116
- Follow the instructions for the top-level repo.
117
- 1. `git clone` this repo & `cd` into it as usual
118
- 2. Run `yarn` to install top-level dependencies.
119
-
120
-
121
- ## Run
122
-
123
- - `yarn dev`
124
-
125
-
126
- ## Test
127
-
128
- - `yarn lint` run just eslint
129
- - `yarn test` run lint and tests
130
-
131
-
132
-
133
-
134
- # Copyright & License
135
-
136
- Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE).
package/index.js CHANGED
@@ -1 +1 @@
1
- module.exports = require('./lib/job-manager');
1
+ module.exports = require('./lib/JobManager');
@@ -0,0 +1,427 @@
1
+ const path = require('path');
2
+ const util = require('util');
3
+ const setTimeoutPromise = util.promisify(setTimeout);
4
+ const fastq = require('fastq');
5
+ const later = require('@breejs/later');
6
+ const Bree = require('bree');
7
+ const pWaitFor = require('p-wait-for');
8
+ const {UnhandledJobError, IncorrectUsageError} = require('@tryghost/errors');
9
+ const logging = require('@tryghost/logging');
10
+ const isCronExpression = require('./is-cron-expression');
11
+ const assembleBreeJob = require('./assemble-bree-job');
12
+ const JobsRepository = require('./JobsRepository');
13
+
14
+ const worker = async (task, callback) => {
15
+ try {
16
+ let result = await task();
17
+ await callback(null, result);
18
+ } catch (error) {
19
+ await callback(error);
20
+ }
21
+ };
22
+
23
+ const ALL_STATUSES = {
24
+ started: 'started',
25
+ finished: 'finished',
26
+ failed: 'failed',
27
+ queued: 'queued'
28
+ };
29
+
30
+ /**
31
+ * @typedef {Object} ScheduledJob
32
+ * @property {Function | string} job - Function or path to a module defining a job
33
+ * @property {string} [name] - Unique job name, if not provided takes function name or job script filename
34
+ * @property {string | Date} [at] - Date, cron or human readable schedule format
35
+ * @property {Object} [data] - Data to be passed into the job
36
+ * @property {boolean} [offloaded=true] - If true, creates an "offloaded" job running in a worker thread. If false, runs an "inline" job on the same event loop
37
+ */
38
+ class JobManager {
39
+ #domainEvents;
40
+ #completionPromises = new Map();
41
+ #config;
42
+ #JobModel;
43
+ #events;
44
+
45
+ /**
46
+ * @param {Object} options
47
+ * @param {Function} [options.errorHandler] - custom job error handler
48
+ * @param {Function} [options.workerMessageHandler] - custom message handler coming from workers
49
+ * @param {Object} [options.JobModel] - a model which can persist job data in the storage
50
+ * @param {Object} [options.domainEvents] - domain events emitter
51
+ * @param {Object} [options.config] - config
52
+ * @param {Object} [options.events] - events instance (for testing)
53
+ */
54
+ constructor({errorHandler, workerMessageHandler, JobModel, domainEvents, config, events = null}) {
55
+ this.inlineQueue = fastq(this, worker, 3);
56
+ this._jobMessageHandler = this._jobMessageHandler.bind(this);
57
+ this._jobErrorHandler = this._jobErrorHandler.bind(this);
58
+ this.#domainEvents = domainEvents;
59
+ this.#config = config;
60
+ this.#JobModel = JobModel;
61
+ this.#events = events;
62
+
63
+ const combinedMessageHandler = workerMessageHandler
64
+ ? ({name, message}) => {
65
+ workerMessageHandler({name, message});
66
+ this._jobMessageHandler({name, message});
67
+ }
68
+ : this._jobMessageHandler;
69
+
70
+ const combinedErrorHandler = errorHandler
71
+ ? (error, workerMeta) => {
72
+ errorHandler(error, workerMeta);
73
+ this._jobErrorHandler(error, workerMeta);
74
+ }
75
+ : this._jobErrorHandler;
76
+
77
+ this.bree = new Bree({
78
+ root: false, // set this to `false` to prevent requiring a root directory of jobs
79
+ hasSeconds: true, // precision is needed to avoid task overlaps after immediate execution
80
+ outputWorkerMetadata: true,
81
+ logger: logging,
82
+ errorHandler: combinedErrorHandler,
83
+ workerMessageHandler: combinedMessageHandler
84
+ });
85
+
86
+ this.bree.on('worker created', (name) => {
87
+ this._jobMessageHandler({name, message: ALL_STATUSES.started});
88
+ });
89
+
90
+ if (JobModel) {
91
+ this._jobsRepository = new JobsRepository({JobModel});
92
+ }
93
+ }
94
+
95
+ inlineJobHandler(jobName) {
96
+ return async (error, result) => {
97
+ if (error) {
98
+ await this._jobErrorHandler(error, {
99
+ name: jobName
100
+ });
101
+ } else {
102
+ await this._jobMessageHandler({
103
+ name: jobName,
104
+ message: 'done'
105
+ });
106
+ }
107
+
108
+ // Can potentially standardize the result here
109
+ return result;
110
+ };
111
+ }
112
+
113
+ async _jobMessageHandler({name, message}) {
114
+ if (name) {
115
+ if (message === ALL_STATUSES.started) {
116
+ if (this._jobsRepository) {
117
+ const job = await this._jobsRepository.read(name);
118
+
119
+ if (job) {
120
+ await this._jobsRepository.update(job.id, {
121
+ status: ALL_STATUSES.started,
122
+ started_at: new Date()
123
+ });
124
+ }
125
+ }
126
+ } else if (message === 'done') {
127
+ if (this._jobsRepository) {
128
+ const job = await this._jobsRepository.read(name);
129
+
130
+ if (job) {
131
+ await this._jobsRepository.update(job.id, {
132
+ status: ALL_STATUSES.finished,
133
+ finished_at: new Date()
134
+ });
135
+ }
136
+ }
137
+
138
+ // Check completion listeners
139
+ if (this.#completionPromises.has(name)) {
140
+ for (const listeners of this.#completionPromises.get(name)) {
141
+ listeners.resolve();
142
+ }
143
+ // Clear the listeners
144
+ this.#completionPromises.delete(name);
145
+ }
146
+
147
+ if (this.inlineQueue.length() <= 1) {
148
+ if (this.#completionPromises.has('all')) {
149
+ for (const listeners of this.#completionPromises.get('all')) {
150
+ listeners.resolve();
151
+ }
152
+ // Clear the listeners
153
+ this.#completionPromises.delete('all');
154
+ }
155
+ }
156
+ } else {
157
+ if (typeof message === 'object' && this.#domainEvents) {
158
+ // Is this an event?
159
+ if (message.event) {
160
+ this.#domainEvents.dispatchRaw(message.event.type, message.event.data);
161
+ }
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ async _jobErrorHandler(error, jobMeta) {
168
+ if (this._jobsRepository && jobMeta.name) {
169
+ const job = await this._jobsRepository.read(jobMeta.name);
170
+
171
+ if (job) {
172
+ await this._jobsRepository.update(job.id, {
173
+ status: ALL_STATUSES.failed
174
+ });
175
+ }
176
+ }
177
+
178
+ // Check completion listeners and call them with error
179
+ if (this.#completionPromises.has(jobMeta.name)) {
180
+ for (const listeners of this.#completionPromises.get(jobMeta.name)) {
181
+ listeners.reject(error);
182
+ }
183
+ // Clear the listeners
184
+ this.#completionPromises.delete(jobMeta.name);
185
+ }
186
+
187
+ if (this.inlineQueue.length() <= 1) {
188
+ if (this.#completionPromises.has('all')) {
189
+ for (const listeners of this.#completionPromises.get('all')) {
190
+ listeners.reject(error);
191
+ }
192
+ // Clear the listeners
193
+ this.#completionPromises.delete('all');
194
+ }
195
+ }
196
+ }
197
+
198
+ /**
199
+ * By default schedules an "offloaded" job. If `offloaded: true` parameter is set,
200
+ * puts an "inline" immediate job into the inlineQueue.
201
+ *
202
+ * @param {Object} GhostJob - job options
203
+ * @prop {Function | String} GhostJob.job - function or path to a module defining a job
204
+ * @prop {String} [GhostJob.name] - unique job name, if not provided takes function name or job script filename
205
+ * @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
206
+ * @prop {Object} [GhostJob.data] - data to be passed into the job
207
+ * @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
208
+ */
209
+ addJob({name, at, job, data, offloaded = true}) {
210
+ if (offloaded) {
211
+ logging.info('Adding offloaded job to the inline job queue');
212
+ let schedule;
213
+
214
+ if (!name) {
215
+ if (typeof job === 'string') {
216
+ name = path.parse(job).name;
217
+ } else {
218
+ throw new IncorrectUsageError({
219
+ message: 'Name parameter should be present if job is a function'
220
+ });
221
+ }
222
+ }
223
+
224
+ if (at && !(at instanceof Date)) {
225
+ if (isCronExpression(at)) {
226
+ schedule = later.parse.cron(at, true);
227
+ } else {
228
+ schedule = later.parse.text(at);
229
+ }
230
+
231
+ if ((schedule.error && schedule.error !== -1) || schedule.schedules.length === 0) {
232
+ throw new IncorrectUsageError({
233
+ message: 'Invalid schedule format'
234
+ });
235
+ }
236
+
237
+ logging.info(`Scheduling job ${name} at ${at}. Next run on: ${later.schedule(schedule).next()}`);
238
+ } else if (at !== undefined) {
239
+ logging.info(`Scheduling job ${name} at ${at}`);
240
+ } else {
241
+ logging.info(`Scheduling job ${name} to run immediately`);
242
+ }
243
+
244
+ const breeJob = assembleBreeJob(at, job, data, name);
245
+ this.bree.add(breeJob);
246
+ return this.bree.start(name);
247
+ } else {
248
+ logging.info(`Adding one-off job to inlineQueue with current length = ${this.inlineQueue.length()} called '${name || 'anonymous'}'`);
249
+
250
+ this.inlineQueue.push(async () => {
251
+ try {
252
+ // NOTE: setting the status here otherwise it is impossible to
253
+ // distinguish between states when the job fails immediately
254
+ await this._jobMessageHandler({
255
+ name: name,
256
+ message: ALL_STATUSES.started
257
+ });
258
+
259
+ if (typeof job === 'function') {
260
+ await job(data);
261
+ } else {
262
+ await require(job)(data);
263
+ }
264
+ } catch (err) {
265
+ // NOTE: each job should be written in a safe way and handle all errors internally
266
+ // if the error is caught here jobs implementation should be changed
267
+ logging.error(new UnhandledJobError({
268
+ context: (typeof job === 'function') ? 'function' : job,
269
+ err
270
+ }));
271
+
272
+ throw err;
273
+ }
274
+ }, this.inlineJobHandler(name));
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Adds a job that could ever be executed once. In case the job fails
280
+ * can be "added" again, effectively restarting the failed job.
281
+ *
282
+ * @param {Object} GhostJob - job options
283
+ * @prop {Function | String} GhostJob.job - function or path to a module defining a job
284
+ * @prop {String} GhostJob.name - unique job name, if not provided takes function name or job script filename
285
+ * @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
286
+ * @prop {Object} [GhostJob.data] - data to be passed into the job
287
+ * @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
288
+ */
289
+ async addOneOffJob({name, job, data, offloaded = true}) {
290
+ if (!name) {
291
+ throw new IncorrectUsageError({
292
+ message: `The name parameter is required for a one off job.`
293
+ });
294
+ }
295
+
296
+ const persistedJob = await this._jobsRepository.read(name);
297
+
298
+ if (persistedJob && (persistedJob.get('status') !== ALL_STATUSES.failed)) {
299
+ throw new IncorrectUsageError({
300
+ message: `A "${name}" one off job has already been executed.`
301
+ });
302
+ }
303
+
304
+ if (persistedJob && (persistedJob.get('status') === ALL_STATUSES.failed)) {
305
+ await this._jobsRepository.update(persistedJob.id, {
306
+ status: ALL_STATUSES.queued
307
+ });
308
+ } else {
309
+ await this._jobsRepository.add({
310
+ name,
311
+ status: ALL_STATUSES.queued
312
+ });
313
+ }
314
+
315
+ // NOTE: there's a assumption the job with the same name failed while
316
+ // running under different instance of job manager (bree).
317
+ // For example, it failed and the process was restarted.
318
+ // If we want to be able to restart within the same instance,
319
+ // we'd need to handle job restart/removal in Bree first
320
+ this.addJob({name, job, data, offloaded});
321
+ }
322
+
323
+ /**
324
+ * Checks if the one-off job has ever been executed successfully.
325
+ * @param {String} name one-off job name
326
+ */
327
+ async hasExecutedSuccessfully(name) {
328
+ if (this._jobsRepository) {
329
+ const persistedJob = await this._jobsRepository.read(name);
330
+
331
+ if (!persistedJob) {
332
+ return false;
333
+ } else {
334
+ return (persistedJob.get('status') !== ALL_STATUSES.failed);
335
+ }
336
+ } else {
337
+ return false;
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Awaits completion of the offloaded one-off job.
343
+ * CAUTION: it might take a long time to resolve!
344
+ * @param {string} name one-off job name
345
+ * @returns resolves with a Job model at current state
346
+ */
347
+ async awaitOneOffCompletion(name) {
348
+ const persistedJob = await this._jobsRepository.read(name);
349
+
350
+ if (!persistedJob || ![ALL_STATUSES.finished, ALL_STATUSES.failed].includes(persistedJob.get('status'))) {
351
+ // NOTE: can implement exponential backoff here if that's ever needed
352
+ await setTimeoutPromise(500);
353
+
354
+ return this.awaitOneOffCompletion(name);
355
+ }
356
+
357
+ return persistedJob;
358
+ }
359
+
360
+ /***
361
+ * Create this promise before you add the job you want to listen for. Then await the returned promise.
362
+ * Resolves if the job has been executed successfully.
363
+ * Throws an error if the job has failed execution.
364
+ */
365
+ async awaitCompletion(name) {
366
+ const promise = new Promise((resolve, reject) => {
367
+ this.#completionPromises.set(name, [
368
+ ...(this.#completionPromises.get(name) ?? []),
369
+ {resolve, reject}
370
+ ]);
371
+ });
372
+
373
+ return promise;
374
+ }
375
+
376
+ /**
377
+ * Wait for all inline jobs to be completed.
378
+ */
379
+ async allSettled() {
380
+ const name = 'all';
381
+
382
+ return new Promise((resolve, reject) => {
383
+ if (this.inlineQueue.idle()) {
384
+ resolve();
385
+ return;
386
+ }
387
+
388
+ this.#completionPromises.set(name, [
389
+ ...(this.#completionPromises.get(name) ?? []),
390
+ {resolve, reject}
391
+ ]);
392
+ });
393
+ }
394
+
395
+ /**
396
+ * Removes an "offloaded" job from scheduled jobs inlineQueue.
397
+ * It's NOT yet possible to remove "inline" jobs (will be possible when scheduling is added https://github.com/breejs/bree/issues/68).
398
+ * The method will throw an Error if job with provided name does not exist.
399
+ *
400
+ * NOTE: current implementation does not guarante running job termination
401
+ * for details see https://github.com/breejs/bree/pull/64
402
+ *
403
+ * @param {String} name - job name
404
+ */
405
+ async removeJob(name) {
406
+ await this.bree.remove(name);
407
+ }
408
+
409
+ /**
410
+ * @param {import('p-wait-for').Options} [options]
411
+ */
412
+ async shutdown(options) {
413
+ await this.bree.stop();
414
+
415
+ if (this.inlineQueue.idle()) {
416
+ return;
417
+ }
418
+
419
+ logging.warn('Waiting for busy job in inline job queue');
420
+
421
+ await pWaitFor(() => this.inlineQueue.idle() === true, options);
422
+
423
+ logging.warn('Inline job queue finished');
424
+ }
425
+ }
426
+
427
+ module.exports = JobManager;
@@ -0,0 +1,71 @@
1
+ const logging = require('@tryghost/logging');
2
+
3
+ /**
4
+ * @class JobsRepository
5
+ * @description Repository class for managing job-related operations.
6
+ */
7
+ class JobsRepository {
8
+ /**
9
+ * @constructor
10
+ * @param {Object} options - The options object.
11
+ * @param {Object} options.JobModel - The Job model for database operations.
12
+ */
13
+ constructor({JobModel}) {
14
+ // NOTE: We ought to clean this up. We want to use bookshelf models for all db operations,
15
+ // but we use knex directly in a few places still largely for performance reasons.
16
+ this._JobModel = JobModel;
17
+ }
18
+
19
+ /**
20
+ * @method add
21
+ * @async
22
+ * @description Adds a new job to the database.
23
+ * @param {Object} data - The job data to be added.
24
+ * @returns {Promise<Object>} The added job object.
25
+ */
26
+ async add(data) {
27
+ const job = await this._JobModel.add(data);
28
+ return job;
29
+ }
30
+
31
+ /**
32
+ * @method read
33
+ * @async
34
+ * @description Reads a job from the database by name.
35
+ * @param {string} name - The name of the job to read.
36
+ * @returns {Promise<Object|null>} The job object if found, null otherwise.
37
+ */
38
+ async read(name) {
39
+ const job = await this._JobModel.findOne({name});
40
+ return job;
41
+ }
42
+
43
+ /**
44
+ * @method update
45
+ * @async
46
+ * @description Updates a job in the database.
47
+ * @param {string} id - The ID of the job to update.
48
+ * @param {Object} data - The updated job data.
49
+ * @returns {Promise<void>}
50
+ */
51
+ async update(id, data) {
52
+ await this._JobModel.edit(data, {id});
53
+ }
54
+
55
+ /**
56
+ * @method delete
57
+ * @async
58
+ * @description Deletes a job from the database.
59
+ * @param {string} id - The ID of the job to delete.
60
+ * @returns {Promise<void>}
61
+ */
62
+ async delete(id) {
63
+ try {
64
+ await this._JobModel.destroy({id});
65
+ } catch (error) {
66
+ logging.error(`Error deleting job ${id}:`, error);
67
+ }
68
+ }
69
+ }
70
+
71
+ module.exports = JobsRepository;
package/package.json CHANGED
@@ -1,39 +1,48 @@
1
1
  {
2
2
  "name": "@tryghost/job-manager",
3
- "version": "0.9.0",
4
- "repository": "https://github.com/TryGhost/Utils/tree/main/packages/job-manager",
3
+ "version": "1.0.1",
5
4
  "author": "Ghost Foundation",
6
5
  "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
7
9
  "main": "index.js",
8
10
  "scripts": {
9
11
  "dev": "echo \"Implement me!\"",
10
- "test": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura mocha './test/**/*.test.js'",
11
- "lint": "eslint . --ext .js --cache",
12
- "posttest": "yarn lint"
12
+ "test:unit": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura -- mocha --reporter dot './test/**/*.test.js'",
13
+ "test": "yarn test:unit",
14
+ "lint:code": "eslint *.js lib/ --ext .js --cache",
15
+ "lint": "yarn lint:code && yarn lint:test",
16
+ "lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache"
13
17
  },
14
18
  "files": [
15
19
  "index.js",
16
20
  "lib"
17
21
  ],
18
- "publishConfig": {
19
- "access": "public"
20
- },
21
22
  "devDependencies": {
22
- "@sinonjs/fake-timers": "9.1.2",
23
- "c8": "7.12.0",
24
- "date-fns": "2.29.1",
23
+ "@sinonjs/fake-timers": "11.3.1",
24
+ "c8": "8.0.1",
25
+ "date-fns": "2.30.0",
25
26
  "delay": "5.0.0",
26
- "mocha": "10.0.0",
27
+ "mocha": "10.8.2",
28
+ "rewire": "7.0.0",
27
29
  "should": "13.2.3",
28
- "sinon": "14.0.0"
30
+ "sinon": "15.2.0"
29
31
  },
30
32
  "dependencies": {
31
- "@breejs/later": "^4.0.2",
32
- "@tryghost/logging": "^2.0.0",
33
- "bree": "^6.2.0",
34
- "cron-validate": "^1.4.3",
35
- "fastq": "^1.11.0",
36
- "p-wait-for": "^3.2.0"
33
+ "@breejs/later": "4.2.0",
34
+ "@tryghost/errors": "^1.3.8",
35
+ "@tryghost/logging": "^2.4.22",
36
+ "bree": "6.5.0",
37
+ "cron-validate": "1.4.5",
38
+ "fastq": "1.19.1",
39
+ "p-wait-for": "3.2.0",
40
+ "workerpool": "9.2.0"
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/TryGhost/framework.git",
45
+ "directory": "packages/job-manager"
37
46
  },
38
- "gitHead": "2ce0a263975cf6e0f34233caf19404fd8ab5e7a5"
47
+ "gitHead": "e395f1d79edee4aecc36e572bb402016a8ba9273"
39
48
  }
@@ -1,236 +0,0 @@
1
- const path = require('path');
2
- const fastq = require('fastq');
3
- const later = require('@breejs/later');
4
- const Bree = require('bree');
5
- const pWaitFor = require('p-wait-for');
6
- const {UnhandledJobError, IncorrectUsageError} = require('@tryghost/errors');
7
- const logging = require('@tryghost/logging');
8
- const isCronExpression = require('./is-cron-expression');
9
- const assembleBreeJob = require('./assemble-bree-job');
10
- const JobsRepository = require('./jobs-repository');
11
-
12
- const worker = async (task, callback) => {
13
- try {
14
- let result = await task();
15
- callback(null, result);
16
- } catch (error) {
17
- callback(error);
18
- }
19
- };
20
-
21
- const handler = (error, result) => {
22
- if (error) {
23
- // TODO: this handler should not be throwing as this blocks the queue
24
- // throw error;
25
- }
26
- // Can potentially standardise the result here
27
- return result;
28
- };
29
-
30
- class JobManager {
31
- /**
32
- * @param {Object} options
33
- * @param {Function} [options.errorHandler] - custom job error handler
34
- * @param {Function} [options.workerMessageHandler] - custom message handler coming from workers
35
- * @param {Object} [options.JobModel] - a model which can persist job data in the storage
36
- */
37
- constructor({errorHandler, workerMessageHandler, JobModel}) {
38
- this.queue = fastq(this, worker, 1);
39
- this._jobMessageHandler = this._jobMessageHandler.bind(this);
40
- this._jobErrorHandler = this._jobErrorHandler.bind(this);
41
-
42
- const combinedMessageHandler = workerMessageHandler
43
- ? ({name, message}) => {
44
- workerMessageHandler({name, message});
45
- this._jobMessageHandler({name, message});
46
- }
47
- : this._jobMessageHandler;
48
-
49
- const combinedErrorHandler = errorHandler
50
- ? (error, workerMeta) => {
51
- errorHandler(error, workerMeta);
52
- this._jobErrorHandler(error, workerMeta);
53
- }
54
- : this._jobErrorHandler;
55
-
56
- this.bree = new Bree({
57
- root: false, // set this to `false` to prevent requiring a root directory of jobs
58
- hasSeconds: true, // precision is needed to avoid task overlaps after immediate execution
59
- outputWorkerMetadata: true,
60
- logger: logging,
61
- errorHandler: combinedErrorHandler,
62
- workerMessageHandler: combinedMessageHandler
63
- });
64
-
65
- this.bree.on('worker created', (name) => {
66
- this._jobMessageHandler({name, message: 'started'});
67
- });
68
-
69
- this._jobsRepository = new JobsRepository({JobModel});
70
- }
71
-
72
- async _jobMessageHandler({name, message}) {
73
- if (message === 'started') {
74
- const job = await this._jobsRepository.read(name);
75
-
76
- if (job) {
77
- await this._jobsRepository.update(job.id, {
78
- status: 'started',
79
- started_at: new Date()
80
- });
81
- }
82
- } else if (message === 'done') {
83
- const job = await this._jobsRepository.read(name);
84
-
85
- if (job) {
86
- await this._jobsRepository.update(job.id, {
87
- status: 'finished',
88
- finished_at: new Date()
89
- });
90
- }
91
- }
92
- }
93
-
94
- async _jobErrorHandler(error, workerMeta) {
95
- const job = await this._jobsRepository.read(workerMeta.name);
96
-
97
- if (job) {
98
- await this._jobsRepository.update(job.id, {
99
- status: 'failed'
100
- });
101
- }
102
- }
103
-
104
- /**
105
- * By default schedules an "offloaded" job. If `offloaded: true` parameter is set,
106
- * puts an "inline" immediate job into the queue.
107
- *
108
- * @param {Object} GhostJob - job options
109
- * @prop {Function | String} GhostJob.job - function or path to a module defining a job
110
- * @prop {String} [GhostJob.name] - unique job name, if not provided takes function name or job script filename
111
- * @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
112
- * @prop {Object} [GhostJob.data] - data to be passed into the job
113
- * @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
114
- */
115
- addJob({name, at, job, data, offloaded = true}) {
116
- if (offloaded) {
117
- logging.info('Adding offloaded job to the queue');
118
- let schedule;
119
-
120
- if (!name) {
121
- if (typeof job === 'string') {
122
- name = path.parse(job).name;
123
- } else {
124
- throw new IncorrectUsageError({
125
- message: 'Name parameter should be present if job is a function'
126
- });
127
- }
128
- }
129
-
130
- if (at && !(at instanceof Date)) {
131
- if (isCronExpression(at)) {
132
- schedule = later.parse.cron(at, true);
133
- } else {
134
- schedule = later.parse.text(at);
135
- }
136
-
137
- if ((schedule.error && schedule.error !== -1) || schedule.schedules.length === 0) {
138
- throw new IncorrectUsageError({
139
- message: 'Invalid schedule format'
140
- });
141
- }
142
-
143
- logging.info(`Scheduling job ${name} at ${at}. Next run on: ${later.schedule(schedule).next()}`);
144
- } else if (at !== undefined) {
145
- logging.info(`Scheduling job ${name} at ${at}`);
146
- } else {
147
- logging.info(`Scheduling job ${name} to run immediately`);
148
- }
149
-
150
- const breeJob = assembleBreeJob(at, job, data, name);
151
- this.bree.add(breeJob);
152
- return this.bree.start(name);
153
- } else {
154
- logging.info('Adding one off inline job to the queue');
155
-
156
- this.queue.push(async () => {
157
- try {
158
- if (typeof job === 'function') {
159
- await job(data);
160
- } else {
161
- await require(job)(data);
162
- }
163
- } catch (err) {
164
- // NOTE: each job should be written in a safe way and handle all errors internally
165
- // if the error is caught here jobs implementaton should be changed
166
- logging.error(new UnhandledJobError({
167
- context: (typeof job === 'function') ? 'function' : job,
168
- err
169
- }));
170
-
171
- throw err;
172
- }
173
- }, handler);
174
- }
175
- }
176
-
177
- /**
178
- * Adds a job that could ever be executed once.
179
- *
180
- * @param {Object} GhostJob - job options
181
- * @prop {Function | String} GhostJob.job - function or path to a module defining a job
182
- * @prop {String} [GhostJob.name] - unique job name, if not provided takes function name or job script filename
183
- * @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
184
- * @prop {Object} [GhostJob.data] - data to be passed into the job
185
- * @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
186
- */
187
- async addOneOffJob({name, job, data, offloaded = true}) {
188
- const persistedJob = await this._jobsRepository.read(name);
189
-
190
- if (persistedJob) {
191
- throw new IncorrectUsageError({
192
- message: `A "${name}" one off job has already been executed.`
193
- });
194
- }
195
-
196
- await this._jobsRepository.add({
197
- name,
198
- status: 'queued'
199
- });
200
-
201
- this.addJob({name, job, data, offloaded});
202
- }
203
-
204
- /**
205
- * Removes an "offloaded" job from scheduled jobs queue.
206
- * It's NOT yet possible to remove "inline" jobs (will be possible when scheduling is added https://github.com/breejs/bree/issues/68).
207
- * The method will throw an Error if job with provided name does not exist.
208
- *
209
- * NOTE: current implementation does not guarante running job termination
210
- * for details see https://github.com/breejs/bree/pull/64
211
- *
212
- * @param {String} name - job name
213
- */
214
- async removeJob(name) {
215
- await this.bree.remove(name);
216
- }
217
-
218
- /**
219
- * @param {import('p-wait-for').Options} [options]
220
- */
221
- async shutdown(options) {
222
- await this.bree.stop();
223
-
224
- if (this.queue.idle()) {
225
- return;
226
- }
227
-
228
- logging.warn('Waiting for busy job queue');
229
-
230
- await pWaitFor(() => this.queue.idle() === true, options);
231
-
232
- logging.warn('Job queue finished');
233
- }
234
- }
235
-
236
- module.exports = JobManager;
@@ -1,23 +0,0 @@
1
- class JobsRepository {
2
- constructor({JobModel}) {
3
- this._JobModel = JobModel;
4
- }
5
-
6
- async add(data) {
7
- const job = await this._JobModel.add(data);
8
-
9
- return job;
10
- }
11
-
12
- async read(name) {
13
- const job = await this._JobModel.findOne({name});
14
-
15
- return job;
16
- }
17
-
18
- async update(id, data) {
19
- await this._JobModel.edit(data, {id});
20
- }
21
- }
22
-
23
- module.exports = JobsRepository;