Ghost/ghost/job-manager/test/job-manager.test.js
Hannah Wolfe 6161f94910
Updated to use assert/strict everywhere (#17047)
refs: https://github.com/TryGhost/Toolbox/issues/595

We're rolling out new rules around the node assert library, the first of which is enforcing the use of assert/strict. This means we don't need to use the strict version of methods, as the standard version will work that way by default.

This caught some gotchas in our existing usage of assert where the lack of strict mode had unexpected results:
- Url matching needs to be done on `url.href` see aa58b354a4
- Null and undefined are not the same thing,  there were a few cases of this being confused
- Particularly questionable changes in [PostExporter tests](c1a468744b) tracked [here](https://github.com/TryGhost/Team/issues/3505).
- A typo see eaac9c293a

Moving forward, using assert strict should help us to catch unexpected behaviour, particularly around nulls and undefineds during implementation.
2023-06-21 09:56:59 +01:00

705 lines
26 KiB
JavaScript

// Switch these lines once there are useful utils
// const testUtils = require('./utils');
require('./utils');
const assert = require('assert/strict');
const path = require('path');
const sinon = require('sinon');
const delay = require('delay');
const FakeTimers = require('@sinonjs/fake-timers');
const logging = require('@tryghost/logging');
const JobManager = require('../index');
const sandbox = sinon.createSandbox();
const jobModelInstance = {
id: 'unique',
get: (field) => {
if (field === 'status') {
return 'finished';
}
}
};
describe('Job Manager', function () {
beforeEach(function () {
sandbox.stub(logging, 'info');
sandbox.stub(logging, 'warn');
sandbox.stub(logging, 'error');
});
afterEach(function () {
sandbox.restore();
});
it('public interface', function () {
const jobManager = new JobManager({});
should.exist(jobManager.addJob);
should.exist(jobManager.hasExecutedSuccessfully);
should.exist(jobManager.awaitOneOffCompletion);
});
describe('Add a job', function () {
describe('Inline jobs', function () {
it('adds a job to a queue', async function () {
const spy = sinon.spy();
const jobManager = new JobManager({
JobModel: sinon.stub().resolves()
});
jobManager.addJob({
job: spy,
data: 'test data',
offloaded: false
});
should(jobManager.queue.idle()).be.false();
// give time to execute the job
await delay(1);
should(jobManager.queue.idle()).be.true();
should(spy.called).be.true();
should(spy.args[0][0]).equal('test data');
});
it('handles failed job gracefully', async function () {
const spy = sinon.stub().throws();
const jobModelSpy = {
findOne: sinon.spy()
};
const jobManager = new JobManager({
JobModel: jobModelSpy
});
jobManager.addJob({
job: spy,
data: 'test data',
offloaded: false
});
should(jobManager.queue.idle()).be.false();
// give time to execute the job
await delay(1);
should(jobManager.queue.idle()).be.true();
should(spy.called).be.true();
should(spy.args[0][0]).equal('test data');
should(logging.error.called).be.true();
// a one-off job without a name should not have persistance
should(jobModelSpy.findOne.called).be.false();
});
});
describe('Offloaded jobs', function () {
it('fails to schedule for invalid scheduling expression', function () {
const jobManager = new JobManager({});
try {
jobManager.addJob({
at: 'invalid expression',
name: 'jobName'
});
} catch (err) {
err.message.should.equal('Invalid schedule format');
}
});
it('fails to schedule for no job name', function () {
const jobManager = new JobManager({});
try {
jobManager.addJob({
at: 'invalid expression',
job: () => {}
});
} catch (err) {
err.message.should.equal('Name parameter should be present if job is a function');
}
});
it('schedules a job using date format', async function () {
const jobManager = new JobManager({});
const timeInTenSeconds = new Date(Date.now() + 10);
const jobPath = path.resolve(__dirname, './jobs/simple.js');
const clock = FakeTimers.install({now: Date.now()});
jobManager.addJob({
at: timeInTenSeconds,
job: jobPath,
name: 'job-in-ten'
});
should(jobManager.bree.timeouts['job-in-ten']).type('object');
should(jobManager.bree.workers['job-in-ten']).type('undefined');
// allow to run the job and start the worker
await clock.nextAsync();
should(jobManager.bree.workers['job-in-ten']).type('object');
const promise = new Promise((resolve, reject) => {
jobManager.bree.workers['job-in-ten'].on('error', reject);
jobManager.bree.workers['job-in-ten'].on('exit', (code) => {
should(code).equal(0);
resolve();
});
});
// allow job to finish execution and exit
clock.next();
await promise;
should(jobManager.bree.workers['job-in-ten']).type('undefined');
clock.uninstall();
});
it('schedules a job to run immediately', async function () {
const jobManager = new JobManager({});
const clock = FakeTimers.install({now: Date.now()});
const jobPath = path.resolve(__dirname, './jobs/simple.js');
jobManager.addJob({
job: jobPath,
name: 'job-now'
});
should(jobManager.bree.timeouts['job-now']).type('object');
// allow scheduler to pick up the job
clock.tick(1);
should(jobManager.bree.workers['job-now']).type('object');
const promise = new Promise((resolve, reject) => {
jobManager.bree.workers['job-now'].on('error', reject);
jobManager.bree.workers['job-now'].on('exit', (code) => {
should(code).equal(0);
resolve();
});
});
await promise;
should(jobManager.bree.workers['job-now']).type('undefined');
clock.uninstall();
});
it('fails to schedule a job with the same name to run immediately one after another', async function () {
const jobManager = new JobManager({});
const clock = FakeTimers.install({now: Date.now()});
const jobPath = path.resolve(__dirname, './jobs/simple.js');
jobManager.addJob({
job: jobPath,
name: 'job-now'
});
should(jobManager.bree.timeouts['job-now']).type('object');
// allow scheduler to pick up the job
clock.tick(1);
should(jobManager.bree.workers['job-now']).type('object');
const promise = new Promise((resolve, reject) => {
jobManager.bree.workers['job-now'].on('error', reject);
jobManager.bree.workers['job-now'].on('exit', (code) => {
should(code).equal(0);
resolve();
});
});
await promise;
should(jobManager.bree.workers['job-now']).type('undefined');
(() => {
jobManager.addJob({
job: jobPath,
name: 'job-now'
});
}).should.throw('Job #1 has a duplicate job name of job-now');
clock.uninstall();
});
it('uses custom error handler when job fails', async function (){
let job = function namedJob() {
throw new Error('job error');
};
const spyHandler = sinon.spy();
const jobManager = new JobManager({errorHandler: spyHandler});
const completion = jobManager.awaitCompletion('will-fail');
jobManager.addJob({
job,
name: 'will-fail'
});
await assert.rejects(completion, /job error/);
should(spyHandler.called).be.true();
should(spyHandler.args[0][0].message).equal('job error');
should(spyHandler.args[0][1].name).equal('will-fail');
});
it('uses worker message handler when job sends a message', async function (){
const workerMessageHandlerSpy = sinon.spy();
const jobManager = new JobManager({workerMessageHandler: workerMessageHandlerSpy});
const completion = jobManager.awaitCompletion('will-send-msg');
jobManager.addJob({
job: path.resolve(__dirname, './jobs/message.js'),
name: 'will-send-msg'
});
jobManager.bree.run('will-send-msg');
await delay(100);
jobManager.bree.workers['will-send-msg'].postMessage('hello from Ghost!');
await completion;
should(workerMessageHandlerSpy.called).be.true();
should(workerMessageHandlerSpy.args[0][0].name).equal('will-send-msg');
should(workerMessageHandlerSpy.args[0][0].message).equal('Worker received: hello from Ghost!');
});
});
});
describe('Add one off job', function () {
it('throws if name parameter is not provided', async function () {
const jobManager = new JobManager({});
try {
await jobManager.addOneOffJob({
job: () => {}
});
throw new Error('should have thrown');
} catch (err) {
should.equal(err.message, 'The name parameter is required for a one off job.');
}
});
describe('Inline jobs', function () {
it('adds job to the queue when it is a unique one', async function () {
const spy = sinon.spy();
const JobModel = {
findOne: sinon.stub().resolves(undefined),
add: sinon.stub().resolves()
};
const jobManager = new JobManager({JobModel});
await jobManager.addOneOffJob({
job: spy,
name: 'unique name',
data: 'test data',
offloaded: false
});
assert.equal(JobModel.add.called, true);
});
it('does not add a job to the queue when it already exists', async function () {
const spy = sinon.spy();
const JobModel = {
findOne: sinon.stub().resolves(jobModelInstance),
add: sinon.stub().throws('should not be called')
};
const jobManager = new JobManager({JobModel});
try {
await jobManager.addOneOffJob({
job: spy,
name: 'I am the only one',
data: 'test data',
offloaded: false
});
throw new Error('should not reach this point');
} catch (error) {
assert.equal(error.message, 'A "I am the only one" one off job has already been executed.');
}
});
it('sets a finished state on an inline job', async function () {
const JobModel = {
findOne: sinon.stub()
.onCall(0)
.resolves(null)
.resolves({id: 'unique', name: 'successful-oneoff'}),
add: sinon.stub().resolves({name: 'successful-oneoff'}),
edit: sinon.stub().resolves({name: 'successful-oneoff'})
};
const jobManager = new JobManager({JobModel});
const completion = jobManager.awaitCompletion('successful-oneoff');
jobManager.addOneOffJob({
job: async () => {
return await delay(10);
},
name: 'successful-oneoff',
offloaded: false
});
await completion;
// tracks the job queued
should(JobModel.add.args[0][0].status).equal('queued');
should(JobModel.add.args[0][0].name).equal('successful-oneoff');
// tracks the job started
should(JobModel.edit.args[0][0].status).equal('started');
should(JobModel.edit.args[0][0].started_at).not.equal(undefined);
should(JobModel.edit.args[0][1].id).equal('unique');
// tracks the job finish
should(JobModel.edit.args[1][0].status).equal('finished');
should(JobModel.edit.args[1][0].finished_at).not.equal(undefined);
should(JobModel.edit.args[1][1].id).equal('unique');
});
it('sets a failed state on a job', async function () {
const JobModel = {
findOne: sinon.stub()
.onCall(0)
.resolves(null)
.resolves({id: 'unique', name: 'failed-oneoff'}),
add: sinon.stub().resolves({name: 'failed-oneoff'}),
edit: sinon.stub().resolves({name: 'failed-oneoff'})
};
let job = function namedJob() {
throw new Error('job error');
};
const spyHandler = sinon.spy();
const jobManager = new JobManager({errorHandler: spyHandler, JobModel});
const completion = jobManager.awaitCompletion('failed-oneoff');
await jobManager.addOneOffJob({
job,
name: 'failed-oneoff',
offloaded: false
});
await assert.rejects(completion, /job error/);
// tracks the job start
should(JobModel.edit.args[0][0].status).equal('started');
should(JobModel.edit.args[0][0].started_at).not.equal(undefined);
should(JobModel.edit.args[0][1].id).equal('unique');
// tracks the job failure
should(JobModel.edit.args[1][0].status).equal('failed');
should(JobModel.edit.args[1][1].id).equal('unique');
});
it('adds job to the queue after failing', async function () {
const JobModel = {
findOne: sinon.stub()
.onCall(0)
.resolves(null)
.onCall(1)
.resolves({id: 'unique'})
.resolves({
id: 'unique',
get: (field) => {
if (field === 'status') {
return 'failed';
}
}
}),
add: sinon.stub().resolves({}),
edit: sinon.stub().resolves()
};
let job = function namedJob() {
throw new Error('job error');
};
const spyHandler = sinon.spy();
const jobManager = new JobManager({errorHandler: spyHandler, JobModel});
const completion1 = jobManager.awaitCompletion('failed-oneoff');
await jobManager.addOneOffJob({
job,
name: 'failed-oneoff',
offloaded: false
});
// give time to execute the job and fail
await assert.rejects(completion1, /job error/);
should(JobModel.edit.args[1][0].status).equal('failed');
// simulate process restart and "fresh" slate to add the job
jobManager.removeJob('failed-oneoff');
const completion2 = jobManager.awaitCompletion('failed-oneoff');
await jobManager.addOneOffJob({
job,
name: 'failed-oneoff',
offloaded: false
});
// give time to execute the job and fail AGAIN
await assert.rejects(completion2, /job error/);
should(JobModel.edit.args[3][0].status).equal('started');
should(JobModel.edit.args[4][0].status).equal('failed');
});
});
describe('Offloaded jobs', function () {
it('adds job to the queue when it is a unique one', async function () {
const spy = sinon.spy();
const JobModel = {
findOne: sinon.stub().resolves(undefined),
add: sinon.stub().resolves()
};
const jobManager = new JobManager({JobModel});
await jobManager.addOneOffJob({
job: spy,
name: 'unique name',
data: 'test data'
});
assert.equal(JobModel.add.called, true);
});
it('does not add a job to the queue when it already exists', async function () {
const spy = sinon.spy();
const JobModel = {
findOne: sinon.stub().resolves(jobModelInstance),
add: sinon.stub().throws('should not be called')
};
const jobManager = new JobManager({JobModel});
try {
await jobManager.addOneOffJob({
job: spy,
name: 'I am the only one',
data: 'test data'
});
throw new Error('should not reach this point');
} catch (error) {
assert.equal(error.message, 'A "I am the only one" one off job has already been executed.');
}
});
it('sets a finished state on a job', async function () {
const JobModel = {
findOne: sinon.stub()
.onCall(0)
.resolves(null)
.resolves({id: 'unique', name: 'successful-oneoff'}),
add: sinon.stub().resolves({name: 'successful-oneoff'}),
edit: sinon.stub().resolves({name: 'successful-oneoff'})
};
const jobManager = new JobManager({JobModel});
const jobCompletion = jobManager.awaitCompletion('successful-oneoff');
await jobManager.addOneOffJob({
job: path.resolve(__dirname, './jobs/message.js'),
name: 'successful-oneoff'
});
// allow job to get picked up and executed
await delay(100);
jobManager.bree.workers['successful-oneoff'].postMessage('be done!');
// allow the message to be passed around
await jobCompletion;
// tracks the job start
should(JobModel.edit.args[0][0].status).equal('started');
should(JobModel.edit.args[0][0].started_at).not.equal(undefined);
should(JobModel.edit.args[0][1].id).equal('unique');
// tracks the job finish
should(JobModel.edit.args[1][0].status).equal('finished');
should(JobModel.edit.args[1][0].finished_at).not.equal(undefined);
should(JobModel.edit.args[1][1].id).equal('unique');
});
it('handles a failed job', async function () {
const JobModel = {
findOne: sinon.stub()
.onCall(0)
.resolves(null)
.resolves(jobModelInstance),
add: sinon.stub().resolves({name: 'failed-oneoff'}),
edit: sinon.stub().resolves({name: 'failed-oneoff'})
};
let job = function namedJob() {
throw new Error('job error');
};
const spyHandler = sinon.spy();
const jobManager = new JobManager({errorHandler: spyHandler, JobModel});
const completion = jobManager.awaitCompletion('failed-oneoff');
await jobManager.addOneOffJob({
job,
name: 'failed-oneoff'
});
await assert.rejects(completion, /job error/);
// still calls the original error handler
should(spyHandler.called).be.true();
should(spyHandler.args[0][0].message).equal('job error');
should(spyHandler.args[0][1].name).equal('failed-oneoff');
// tracks the job start
should(JobModel.edit.args[0][0].status).equal('started');
should(JobModel.edit.args[0][0].started_at).not.equal(undefined);
should(JobModel.edit.args[0][1].id).equal('unique');
// tracks the job failure
should(JobModel.edit.args[1][0].status).equal('failed');
should(JobModel.edit.args[1][1].id).equal('unique');
});
});
});
describe('Job execution progress', function () {
it('checks if job has ever been executed', async function () {
const JobModel = {
findOne: sinon.stub()
.withArgs('solovei')
.onCall(0)
.resolves(null)
.onCall(1)
.resolves({
id: 'unique',
get: (field) => {
if (field === 'status') {
return 'finished';
}
}
})
.onCall(2)
.resolves({
id: 'unique',
get: (field) => {
if (field === 'status') {
return 'failed';
}
}
})
};
const jobManager = new JobManager({JobModel});
let executed = await jobManager.hasExecutedSuccessfully('solovei');
should.equal(executed, false);
executed = await jobManager.hasExecutedSuccessfully('solovei');
should.equal(executed, true);
executed = await jobManager.hasExecutedSuccessfully('solovei');
should.equal(executed, false);
});
it('can wait for job completion', async function () {
const spy = sinon.spy();
let status = 'queued';
const jobWithDelay = async () => {
await delay(80);
status = 'finished';
spy();
};
const JobModel = {
findOne: sinon.stub()
// first call when adding a job
.withArgs('solovei')
.onCall(0)
// first call when adding a job
.resolves(null)
.onCall(1)
.resolves(null)
.resolves({
id: 'unique',
get: () => status
}),
add: sinon.stub().resolves()
};
const jobManager = new JobManager({JobModel});
await jobManager.addOneOffJob({
job: jobWithDelay,
name: 'solovei',
offloaded: false
});
should.equal(spy.called, false);
await jobManager.awaitOneOffCompletion('solovei');
should.equal(spy.called, true);
});
});
describe('Remove a job', function () {
it('removes a scheduled job from the queue', async function () {
const jobManager = new JobManager({});
const timeInTenSeconds = new Date(Date.now() + 10);
const jobPath = path.resolve(__dirname, './jobs/simple.js');
jobManager.addJob({
at: timeInTenSeconds,
job: jobPath,
name: 'job-in-ten'
});
jobManager.bree.config.jobs[0].name.should.equal('job-in-ten');
await jobManager.removeJob('job-in-ten');
should(jobManager.bree.config.jobs[0]).be.undefined;
});
});
describe('Shutdown', function () {
it('gracefully shuts down an inline jobs', async function () {
const jobManager = new JobManager({});
jobManager.addJob({
job: require('./jobs/timed-job'),
data: 200,
offloaded: false
});
should(jobManager.queue.idle()).be.false();
await jobManager.shutdown();
should(jobManager.queue.idle()).be.true();
});
it('gracefully shuts down an interval job', async function () {
const jobManager = new JobManager({});
jobManager.addJob({
at: 'every 5 seconds',
job: path.resolve(__dirname, './jobs/graceful.js')
});
await delay(1); // let the job execution kick in
should(Object.keys(jobManager.bree.workers).length).equal(0);
should(Object.keys(jobManager.bree.timeouts).length).equal(0);
should(Object.keys(jobManager.bree.intervals).length).equal(1);
await jobManager.shutdown();
should(Object.keys(jobManager.bree.intervals).length).equal(0);
});
});
});