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');
/**
* 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) {
SchedulingBase.call(this, options);
// NOTE: How often should the scheduler wake up?
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.beforePingInMs = -50;
this.retryTimeoutInMs = 1000 * 5;
// NOTE: Each scheduler implementation can decide whether to load scheduled posts on bootstrap or not.
this.rescheduleOnBoot = true;
// NOTE: A sorted list of all scheduled jobs.
this.allJobs = {};
this.deletedJobs = {};
this.isRunning = false;
}
@ -25,15 +40,40 @@ function SchedulingDefault(options) {
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) {
this._addJob(object);
};
/**
* remove from list
* add to list
* @description Remove & schedule a job.
*
* 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) {
this._deleteJob({time: object.extra.oldTime, url: object.url});
@ -41,22 +81,36 @@ SchedulingDefault.prototype.reschedule = function (object) {
};
/**
* remove from list
* deletion happens right before execution
* @description Unschedule a job.
*
* 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) {
this._deleteJob(object);
};
/**
* check if there are new jobs which needs to be published in the next x minutes
* because allJobs is a sorted list, we don't have to iterate over all jobs, just until the offset is too big
* @description "run" is executed from outside (see post-scheduling module)
*
* 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 () {
const self = this;
let timeout = null,
recursiveRun;
// NOTE: Ensure the scheduler never runs twice.
if (this.isRunning) {
return;
}
@ -68,6 +122,7 @@ SchedulingDefault.prototype.run = function () {
const times = Object.keys(self.allJobs),
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) {
if (moment(Number(time)).diff(moment(), 'minutes') <= self.offsetInMinutes) {
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) {
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
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];
this._execute(instantJob);
@ -126,6 +183,15 @@ SchedulingDefault.prototype._addJob = function (object) {
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) {
const {url, time} = object;
@ -144,9 +210,20 @@ SchedulingDefault.prototype._deleteJob = function (object) {
};
/**
* ping jobs
* 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
* @description The "execute" function will receive the next jobs which need execution.
*
* 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) {
const keys = Object.keys(jobs),
@ -156,7 +233,7 @@ SchedulingDefault.prototype._execute = function (jobs) {
let timeout = null,
diff = moment(Number(timestamp)).diff(moment());
// awake a little before
// NOTE: awake a little before...
timeout = setTimeout(function () {
clearTimeout(timeout);
@ -164,6 +241,7 @@ SchedulingDefault.prototype._execute = function (jobs) {
let immediate = setImmediate(function () {
clearImmediate(immediate);
// CASE: It's not the time yet...
if (moment().diff(moment(Number(timestamp))) <= self.beforePingInMs) {
return retry();
}
@ -171,10 +249,12 @@ SchedulingDefault.prototype._execute = function (jobs) {
const toExecute = jobs[timestamp];
delete jobs[timestamp];
// CASE: each timestamp can have multiple jobs
toExecute.forEach(function (job) {
const {url, time} = job;
const deleteKey = `${url}_${moment(time).valueOf()}`;
// CASE: Was the job already deleted in the meanwhile...?
if (self.deletedJobs[deleteKey]) {
if (self.deletedJobs[deleteKey].length === 1) {
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) {
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 (httpMethod === 'GET') {
// @todo: rename to searchParams when updating to Got v10
// @TODO: rename to searchParams when updating to Got v10
options.query = 'force=true';
} else {
options.body = JSON.stringify({force: true});

View File

@ -1,8 +1,20 @@
const postScheduling = require(__dirname + '/post-scheduling');
/**
* scheduling modules:
* - post scheduling: publish posts/pages when scheduled
* @description Initialise all scheduler modules.
*
* 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) {
options = options || {};

View File

@ -6,23 +6,40 @@ const Promise = require('bluebird'),
urlService = require('../../../services/url'),
_private = {};
/**
* @description Normalize model data into scheduler notation.
* @param {Object} options
* @return {Object}
*/
_private.normalize = function normalize(options) {
const {object, apiUrl, client} = options;
const {model, apiUrl, client} = options;
return {
time: moment(object.get('published_at')).valueOf(),
url: `${urlService.utils.urlJoin(apiUrl, 'schedules', 'posts', object.get('id'))}?client_id=${client.get('slug')}&client_secret=${client.get('secret')}`,
// NOTE: The scheduler expects a unix timestmap.
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: {
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() {
return models.Client.findOne({slug: 'ghost-scheduler'}, {columns: ['slug', 'secret']});
};
/**
* @description Load all scheduled posts from database.
* @return {Promise}
*/
_private.loadScheduledPosts = function () {
const api = require('../../../api');
return api.schedules.getScheduledPosts()
@ -31,6 +48,11 @@ _private.loadScheduledPosts = function () {
});
};
/**
* @description Initialise post scheduling.
* @param {Object} options
* @return {*}
*/
exports.init = function init(options = {}) {
const {apiUrl} = options;
let adapter = null,
@ -51,9 +73,11 @@ exports.init = function init(options = {}) {
})
.then((_adapter) => {
adapter = _adapter;
if (!adapter.rescheduleOnBoot) {
return [];
}
return _private.loadScheduledPosts();
})
.then((scheduledPosts) => {
@ -61,8 +85,8 @@ exports.init = function init(options = {}) {
return;
}
scheduledPosts.forEach((object) => {
adapter.reschedule(_private.normalize({object, apiUrl, client}));
scheduledPosts.forEach((model) => {
adapter.reschedule(_private.normalize({model, apiUrl, client}));
});
})
.then(() => {
@ -72,22 +96,22 @@ exports.init = function init(options = {}) {
common.events.onMany([
'post.scheduled',
'page.scheduled'
], (object) => {
adapter.schedule(_private.normalize({object, apiUrl, client}));
], (model) => {
adapter.schedule(_private.normalize({model, apiUrl, client}));
});
common.events.onMany([
'post.rescheduled',
'page.rescheduled'
], (object) => {
adapter.reschedule(_private.normalize({object, apiUrl, client}));
], (model) => {
adapter.reschedule(_private.normalize({model, apiUrl, client}));
});
common.events.onMany([
'post.unscheduled',
'page.unscheduled'
], (object) => {
adapter.unschedule(_private.normalize({object, apiUrl, client}));
], (model) => {
adapter.unschedule(_private.normalize({model, apiUrl, client}));
});
});
};

View File

@ -4,6 +4,21 @@ const _ = require('lodash'),
common = require('../../lib/common'),
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) {
options = options || {};