// 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); }); }); });