Added comments for post scheduling

no issue

- jsdoc
- inline comments
This commit is contained in:
kirrg001 2019-05-01 22:05:42 +02:00
parent 5e33f0771d
commit 8f76827464
4 changed files with 165 additions and 30 deletions

View File

@ -6,18 +6,33 @@ const common = require('../../lib/common');
const request = require('../../lib/request'); const request = require('../../lib/request');
/** /**
* allJobs is a sorted list by time attribute * @description Default post scheduling implementation.
*
* The default scheduler is used for all self-hosted blogs.
* It is implemented with pure javascript (timers).
*
* "node-cron" did not perform well enough and we really just needed a simple time management.
* @param {Objec} options
* @constructor
*/ */
function SchedulingDefault(options) { function SchedulingDefault(options) {
SchedulingBase.call(this, options); SchedulingBase.call(this, options);
// NOTE: How often should the scheduler wake up?
this.runTimeoutInMs = 1000 * 60 * 5; this.runTimeoutInMs = 1000 * 60 * 5;
// NOTE: An offset between now and past, which helps us choosing jobs which need to be executed soon.
this.offsetInMinutes = 10; this.offsetInMinutes = 10;
this.beforePingInMs = -50; this.beforePingInMs = -50;
this.retryTimeoutInMs = 1000 * 5; this.retryTimeoutInMs = 1000 * 5;
// NOTE: Each scheduler implementation can decide whether to load scheduled posts on bootstrap or not.
this.rescheduleOnBoot = true; this.rescheduleOnBoot = true;
// NOTE: A sorted list of all scheduled jobs.
this.allJobs = {}; this.allJobs = {};
this.deletedJobs = {}; this.deletedJobs = {};
this.isRunning = false; this.isRunning = false;
} }
@ -25,15 +40,40 @@ function SchedulingDefault(options) {
util.inherits(SchedulingDefault, SchedulingBase); util.inherits(SchedulingDefault, SchedulingBase);
/** /**
* add to list * @description Add a new job to the scheduler.
*
* A new job get's added when the post scheduler module receives a new model event e.g. "post.scheduled".
*
* @param {Object} object
* {
* time: [Number] A unix timestamp
* url: [String] The full post/page API url to publish it.
* extra: {
* httpMethod: [String] The method of the target API endpoint.
* oldTime: [Number] The previous published time.
* }
* }
*/ */
SchedulingDefault.prototype.schedule = function (object) { SchedulingDefault.prototype.schedule = function (object) {
this._addJob(object); this._addJob(object);
}; };
/** /**
* remove from list * @description Remove & schedule a job.
* add to list *
* This function is useful if the model layer detects a rescheduling event.
* Rescheduling means: scheduled -> update published at.
* To be able to delete the previous job we need the old published time.
*
* @param {Object} object
* {
* time: [Number] A unix timestamp
* url: [String] The full post/page API url to publish it.
* extra: {
* httpMethod: [String] The method of the target API endpoint.
* oldTime: [Number] The previous published time.
* }
* }
*/ */
SchedulingDefault.prototype.reschedule = function (object) { SchedulingDefault.prototype.reschedule = function (object) {
this._deleteJob({time: object.extra.oldTime, url: object.url}); this._deleteJob({time: object.extra.oldTime, url: object.url});
@ -41,22 +81,36 @@ SchedulingDefault.prototype.reschedule = function (object) {
}; };
/** /**
* remove from list * @description Unschedule a job.
* deletion happens right before execution *
* Unscheduling means: scheduled -> draft.
*
* @param {Object} object
* {
* time: [Number] A unix timestamp
* url: [String] The full post/page API url to publish it.
* extra: {
* httpMethod: [String] The method of the target API endpoint.
* oldTime: [Number] The previous published time.
* }
* }
*/ */
SchedulingDefault.prototype.unschedule = function (object) { SchedulingDefault.prototype.unschedule = function (object) {
this._deleteJob(object); this._deleteJob(object);
}; };
/** /**
* check if there are new jobs which needs to be published in the next x minutes * @description "run" is executed from outside (see post-scheduling module)
* because allJobs is a sorted list, we don't have to iterate over all jobs, just until the offset is too big *
* This function will ensure that the scheduler will be kept alive while the blog is running.
* It will run recursively and checks if there are new jobs which need to be executed in the next X minutes.
*/ */
SchedulingDefault.prototype.run = function () { SchedulingDefault.prototype.run = function () {
const self = this; const self = this;
let timeout = null, let timeout = null,
recursiveRun; recursiveRun;
// NOTE: Ensure the scheduler never runs twice.
if (this.isRunning) { if (this.isRunning) {
return; return;
} }
@ -68,6 +122,7 @@ SchedulingDefault.prototype.run = function () {
const times = Object.keys(self.allJobs), const times = Object.keys(self.allJobs),
nextJobs = {}; nextJobs = {};
// CASE: We stop till the offset is too big. We are only interested in jobs which need get executed soon.
times.every(function (time) { times.every(function (time) {
if (moment(Number(time)).diff(moment(), 'minutes') <= self.offsetInMinutes) { if (moment(Number(time)).diff(moment(), 'minutes') <= self.offsetInMinutes) {
nextJobs[time] = self.allJobs[time]; nextJobs[time] = self.allJobs[time];
@ -90,7 +145,9 @@ SchedulingDefault.prototype.run = function () {
}; };
/** /**
* each timestamp key entry can have multiple jobs * @description Add the actual job to "allJobs".
* @param {Object} object
* @private
*/ */
SchedulingDefault.prototype._addJob = function (object) { SchedulingDefault.prototype._addJob = function (object) {
let timestamp = moment(object.time).valueOf(), let timestamp = moment(object.time).valueOf(),
@ -101,7 +158,7 @@ SchedulingDefault.prototype._addJob = function (object) {
// CASE: should have been already pinged or should be pinged soon // CASE: should have been already pinged or should be pinged soon
if (moment(timestamp).diff(moment(), 'minutes') < this.offsetInMinutes) { if (moment(timestamp).diff(moment(), 'minutes') < this.offsetInMinutes) {
debug('Imergency job', object.url, moment(object.time).format('YYYY-MM-DD HH:mm:ss')); debug('Emergency job', object.url, moment(object.time).format('YYYY-MM-DD HH:mm:ss'));
instantJob[timestamp] = [object]; instantJob[timestamp] = [object];
this._execute(instantJob); this._execute(instantJob);
@ -126,6 +183,15 @@ SchedulingDefault.prototype._addJob = function (object) {
this.allJobs = sortedJobs; this.allJobs = sortedJobs;
}; };
/**
* @description Delete the job.
*
* Keep a list of deleted jobs because it can happen that a job is already part of the next execution list,
* but it got deleted meanwhile.
*
* @param {Object} object
* @private
*/
SchedulingDefault.prototype._deleteJob = function (object) { SchedulingDefault.prototype._deleteJob = function (object) {
const {url, time} = object; const {url, time} = object;
@ -144,9 +210,20 @@ SchedulingDefault.prototype._deleteJob = function (object) {
}; };
/** /**
* ping jobs * @description The "execute" function will receive the next jobs which need execution.
* setTimeout is not accurate, but we can live with that fact and use setImmediate feature to qualify *
* we don't want to use process.nextTick, this would block any I/O operation * Based on "offsetInMinutes" we figure out which jobs need execution and the "execute" function will
* ensure that
*
* The advantage of having a two step system (a general runner and an executor) is:
* - accuracy
* - setTimeout is limited to 24,3 days
*
* The execution of "setTimeout" is never guaranteed, therefor we've optimised the execution by using "setImmediate".
* The executor will put each job to sleep using `setTimeout` with a threshold of 70ms. And "setImmediate" is then
* used to detect the correct moment to trigger the URL.
* We can't use "process.nextTick" otherwise we will block I/O operations.
*/ */
SchedulingDefault.prototype._execute = function (jobs) { SchedulingDefault.prototype._execute = function (jobs) {
const keys = Object.keys(jobs), const keys = Object.keys(jobs),
@ -156,7 +233,7 @@ SchedulingDefault.prototype._execute = function (jobs) {
let timeout = null, let timeout = null,
diff = moment(Number(timestamp)).diff(moment()); diff = moment(Number(timestamp)).diff(moment());
// awake a little before // NOTE: awake a little before...
timeout = setTimeout(function () { timeout = setTimeout(function () {
clearTimeout(timeout); clearTimeout(timeout);
@ -164,6 +241,7 @@ SchedulingDefault.prototype._execute = function (jobs) {
let immediate = setImmediate(function () { let immediate = setImmediate(function () {
clearImmediate(immediate); clearImmediate(immediate);
// CASE: It's not the time yet...
if (moment().diff(moment(Number(timestamp))) <= self.beforePingInMs) { if (moment().diff(moment(Number(timestamp))) <= self.beforePingInMs) {
return retry(); return retry();
} }
@ -171,10 +249,12 @@ SchedulingDefault.prototype._execute = function (jobs) {
const toExecute = jobs[timestamp]; const toExecute = jobs[timestamp];
delete jobs[timestamp]; delete jobs[timestamp];
// CASE: each timestamp can have multiple jobs
toExecute.forEach(function (job) { toExecute.forEach(function (job) {
const {url, time} = job; const {url, time} = job;
const deleteKey = `${url}_${moment(time).valueOf()}`; const deleteKey = `${url}_${moment(time).valueOf()}`;
// CASE: Was the job already deleted in the meanwhile...?
if (self.deletedJobs[deleteKey]) { if (self.deletedJobs[deleteKey]) {
if (self.deletedJobs[deleteKey].length === 1) { if (self.deletedJobs[deleteKey].length === 1) {
delete self.deletedJobs[deleteKey]; delete self.deletedJobs[deleteKey];
@ -194,7 +274,10 @@ SchedulingDefault.prototype._execute = function (jobs) {
}; };
/** /**
* - if we detect to publish a post in the past (case blog is down), we add a force flag * @description Ping the job URL.
* @param {Object} object
* @return {Promise}
* @private
*/ */
SchedulingDefault.prototype._pingUrl = function (object) { SchedulingDefault.prototype._pingUrl = function (object) {
const {url, time} = object; const {url, time} = object;
@ -214,9 +297,10 @@ SchedulingDefault.prototype._pingUrl = function (object) {
} }
}; };
// CASE: If we detect to publish a post in the past (case blog is down), we add a force flag
if (moment(time).isBefore(moment())) { if (moment(time).isBefore(moment())) {
if (httpMethod === 'GET') { if (httpMethod === 'GET') {
// @todo: rename to searchParams when updating to Got v10 // @TODO: rename to searchParams when updating to Got v10
options.query = 'force=true'; options.query = 'force=true';
} else { } else {
options.body = JSON.stringify({force: true}); options.body = JSON.stringify({force: true});

View File

@ -1,8 +1,20 @@
const postScheduling = require(__dirname + '/post-scheduling'); const postScheduling = require(__dirname + '/post-scheduling');
/** /**
* scheduling modules: * @description Initialise all scheduler modules.
* - post scheduling: publish posts/pages when scheduled *
* We currently only support post-scheduling: publish posts/pages when scheduled.
*
* @param {Object} options
* {
* schedulerUrl: [String] Remote scheduler domain.
* active: [String] Name of the custom scheduler.
* apiUrl: [String] Target Ghost API url.
* internalPath: [String] Folder path where to find the default scheduler.
* contentPath: [String] Folder path where to find custom schedulers.
* }
*
* @TODO: Simplify the passed in options.
*/ */
exports.init = function init(options) { exports.init = function init(options) {
options = options || {}; options = options || {};

View File

@ -6,23 +6,40 @@ const Promise = require('bluebird'),
urlService = require('../../../services/url'), urlService = require('../../../services/url'),
_private = {}; _private = {};
/**
* @description Normalize model data into scheduler notation.
* @param {Object} options
* @return {Object}
*/
_private.normalize = function normalize(options) { _private.normalize = function normalize(options) {
const {object, apiUrl, client} = options; const {model, apiUrl, client} = options;
return { return {
time: moment(object.get('published_at')).valueOf(), // NOTE: The scheduler expects a unix timestmap.
url: `${urlService.utils.urlJoin(apiUrl, 'schedules', 'posts', object.get('id'))}?client_id=${client.get('slug')}&client_secret=${client.get('secret')}`, time: moment(model.get('published_at')).valueOf(),
// @TODO: We are still using API v0.1
url: `${urlService.utils.urlJoin(apiUrl, 'schedules', 'posts', model.get('id'))}?client_id=${client.get('slug')}&client_secret=${client.get('secret')}`,
extra: { extra: {
httpMethod: 'PUT', httpMethod: 'PUT',
oldTime: object.previous('published_at') ? moment(object.previous('published_at')).valueOf() : null oldTime: model.previous('published_at') ? moment(model.previous('published_at')).valueOf() : null
} }
}; };
}; };
/**
* @description Load the client credentials for v0.1 API.
*
* @TODO: Remove when we drop v0.1. API v2 uses integrations.
* @return {Promise}
*/
_private.loadClient = function loadClient() { _private.loadClient = function loadClient() {
return models.Client.findOne({slug: 'ghost-scheduler'}, {columns: ['slug', 'secret']}); return models.Client.findOne({slug: 'ghost-scheduler'}, {columns: ['slug', 'secret']});
}; };
/**
* @description Load all scheduled posts from database.
* @return {Promise}
*/
_private.loadScheduledPosts = function () { _private.loadScheduledPosts = function () {
const api = require('../../../api'); const api = require('../../../api');
return api.schedules.getScheduledPosts() return api.schedules.getScheduledPosts()
@ -31,6 +48,11 @@ _private.loadScheduledPosts = function () {
}); });
}; };
/**
* @description Initialise post scheduling.
* @param {Object} options
* @return {*}
*/
exports.init = function init(options = {}) { exports.init = function init(options = {}) {
const {apiUrl} = options; const {apiUrl} = options;
let adapter = null, let adapter = null,
@ -51,9 +73,11 @@ exports.init = function init(options = {}) {
}) })
.then((_adapter) => { .then((_adapter) => {
adapter = _adapter; adapter = _adapter;
if (!adapter.rescheduleOnBoot) { if (!adapter.rescheduleOnBoot) {
return []; return [];
} }
return _private.loadScheduledPosts(); return _private.loadScheduledPosts();
}) })
.then((scheduledPosts) => { .then((scheduledPosts) => {
@ -61,8 +85,8 @@ exports.init = function init(options = {}) {
return; return;
} }
scheduledPosts.forEach((object) => { scheduledPosts.forEach((model) => {
adapter.reschedule(_private.normalize({object, apiUrl, client})); adapter.reschedule(_private.normalize({model, apiUrl, client}));
}); });
}) })
.then(() => { .then(() => {
@ -72,22 +96,22 @@ exports.init = function init(options = {}) {
common.events.onMany([ common.events.onMany([
'post.scheduled', 'post.scheduled',
'page.scheduled' 'page.scheduled'
], (object) => { ], (model) => {
adapter.schedule(_private.normalize({object, apiUrl, client})); adapter.schedule(_private.normalize({model, apiUrl, client}));
}); });
common.events.onMany([ common.events.onMany([
'post.rescheduled', 'post.rescheduled',
'page.rescheduled' 'page.rescheduled'
], (object) => { ], (model) => {
adapter.reschedule(_private.normalize({object, apiUrl, client})); adapter.reschedule(_private.normalize({model, apiUrl, client}));
}); });
common.events.onMany([ common.events.onMany([
'post.unscheduled', 'post.unscheduled',
'page.unscheduled' 'page.unscheduled'
], (object) => { ], (model) => {
adapter.unschedule(_private.normalize({object, apiUrl, client})); adapter.unschedule(_private.normalize({model, apiUrl, client}));
}); });
}); });
}; };

View File

@ -4,6 +4,21 @@ const _ = require('lodash'),
common = require('../../lib/common'), common = require('../../lib/common'),
cache = {}; cache = {};
/**
* @description Create the scheduling adapter.
*
* This utility helps us to:
*
* - validate the scheduling config
* - cache the target adapter to ensure singletons
* - ensure the adapter can be instantiated
* - have a centralised error handling
* - detect if the adapter is inherited from the base adapter
* - detect if the adapter has implemented the required functions
*
* @param {Object} options
* @return {Promise}
*/
exports.createAdapter = function (options) { exports.createAdapter = function (options) {
options = options || {}; options = options || {};