mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-29 13:52:10 +03:00
Added comments for post scheduling
no issue - jsdoc - inline comments
This commit is contained in:
parent
5e33f0771d
commit
8f76827464
@ -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});
|
||||||
|
@ -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 || {};
|
||||||
|
@ -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}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -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 || {};
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user