UNPKG

ghost

Version:

The professional publishing platform

261 lines (229 loc) 9.58 kB
const debug = require('@tryghost/debug')('services:url:queue'); const EventEmitter = require('events').EventEmitter; const _ = require('lodash'); const logging = require('@tryghost/logging'); const errors = require('@tryghost/errors'); /** * ### Purpose of this queue * * Ghost fetches the resources as early as possible from the database. The simple reason is: we * want to know all URLs as soon as possible. * * Parallel to this, the routes are read/prepared and registered in express. So the challenge is * to handle both resource availability and route registration. If you start an event, all subscribers * of it are executed in a sequence. The queue will wait until the current subscriber has finished its work. * * The URL service must ensure that each URL in the system exists only once. The order of * subscribers defines who will possibly own an URL first. * * If an event has finished, the subscribers of this event still remain in the queue. That means: * - you can re-run an event * - you can add more subscribers to an existing queue * - you can order subscribers (helpful if you want to order routers) * * Each subscriber represents one instance of the URL generator. One URL generator represents one router. * * ### Tolerance option * * You can define a `tolerance` value per event. This allows you to wait an amount of time until you think * all subscribers have registered. * * ### Some examples to understand cases * * e.g. * - resources have been loaded, event has started * - no subscribers yet, we need to wait, express still initialises * - okay, routes are coming in * - we notify the subscribers * * e.g. * - resources are in the progress of fetching from the database * - routes are already waiting for the resources * * e.g. * - resources are in the progress of fetching from the database * - 2 subscribers are already registered * - resources finished, event starts * - 2 more subscribers are coming in * ### Events * - unique events e.g. added, updated, init, all * - has subscribers * - we remember the subscriber * * ### Actions * - one event can have multiple actions * - unique actions e.g. add post 1, add post 2 * - one event might only allow a single action to avoid collisions e.g. you initialise data twice * - if an event has no action, the name of the action is the name of the event * - in this case the event can only run once at a time * - makes use of `toNotify` to remember who was notified already */ class Queue extends EventEmitter { /** @type {Record<string, {tolerance: number, requiredSubscriberCount: number, subscribers: Array<Function>}>} */ queue = {}; /** @type {Record<string, {event: string, timeoutInMS: number, notified: Array<Function>, timeout?: NodeJS.Timeout | null}>} */ toNotify = {}; /** * @description Register a subscriber for this queue. * * Also initializes the queue for this event if it hasn't already. * * The tolerance is for the event on the queue (milliseconds): * - 0: don't wait for more subscribers [default] * - 100: wait long enough till all subscribers have registered (e.g. bootstrap) * * @param {Object} options * @param {string} options.event * @param {number} [options.tolerance] * @param {number} [options.requiredSubscriberCount] * @param {Function} fn */ register(options, fn) { if (!Object.prototype.hasOwnProperty.call(options, 'tolerance')) { options.tolerance = 0; } // CASE: nobody has initialised the queue event yet if (!Object.prototype.hasOwnProperty.call(this.queue, options.event)) { this.queue[options.event] = { tolerance: options.tolerance, requiredSubscriberCount: options.requiredSubscriberCount || 0, subscribers: [] }; } debug('register', options.event, options.tolerance); this.queue[options.event].subscribers.push(fn); } /** * @description The queue runs & executes subscribers one by one (sequentially) * @param {Object} options * @param {string} options.event * @param {string} options.action * @param {Object} [options.eventData] */ run(options) { const {event, action, eventData} = options; clearTimeout(this.toNotify[action].timeout); this.toNotify[action].timeout = null; const subscribers = this.queue[event].subscribers; const notified = this.toNotify[action].notified; debug('run', action, event, subscribers.length, notified.length); if (subscribers.length && subscribers.length !== notified.length) { const fn = subscribers[notified.length]; debug('run.execute', action, event, notified.length); /** * @NOTE: Currently no async operations happen in the subscribers functions. * We can trigger the functions sync. */ try { fn(eventData); debug('run.executed', action, event, notified.length); this.toNotify[action].notified.push(fn); this.run(options); } catch (err) { debug('error', err.message); logging.error(new errors.InternalServerError({ message: 'Something bad happened.', code: 'SERVICES_URL_QUEUE', err: err })); // @NOTE: The url service stays in maintenance mode. There is nothing we can do if an url generator fails. } } else { // CASE 1: zero tolerance, kill run fn // CASE 2: okay, i was tolerant enough, kill me // CASE 3: wait for more subscribers, i am still tolerant if (this.queue[event].tolerance === 0) { delete this.toNotify[action]; debug('run.ended (zero tolerance)', event, action); this.emit('ended', event); } else if (subscribers.length >= this.queue[event].requiredSubscriberCount && this.toNotify[action].timeoutInMS > this.queue[event].tolerance) { delete this.toNotify[action]; debug('run.ended (tolerance and subscribers)', event, action); this.emit('ended', event); } else { debug('run.retry', event, action, this.toNotify[action].timeoutInMS); this.toNotify[action].timeoutInMS = this.toNotify[action].timeoutInMS * 1.1; this.toNotify[action].timeout = setTimeout(() => { this.run(options); }, this.toNotify[action].timeoutInMS); } } } /** * @description Start the queue from outside. * * CASE: * - resources were fetched from database on bootstrap * - resource was added * * @param {Object} options * @param {string} options.event * @param {number} [options.tolerance] * @param {number} [options.timeoutInMS] * @param {number} [options.requiredSubscriberCount] * @param {string} [options.action] */ start(options) { debug('start'); // CASE: nobody is in the event queue waiting yet // e.g. all resources are fetched already, but no subscribers (bootstrap) // happens only for high tolerant events if (!Object.prototype.hasOwnProperty.call(this.queue, options.event)) { this.queue[options.event] = { tolerance: options.tolerance || 0, requiredSubscriberCount: options.requiredSubscriberCount || 0, subscribers: [] }; } // an event doesn't need an action if (!options.action) { options.action = options.event; } // CASE 1: the queue supports killing an event e.g. resource edit is triggered twice very fast // CASE 2: is the action already running, stop it, because e.g. configuration has changed if (this.toNotify[options.action]) { // CASE: timeout was registered, kill it, this will stop the run function of this action if (this.toNotify[options.action].timeout) { clearTimeout(this.toNotify[options.action].timeout); this.toNotify[options.action].timeout = null; } else { debug('ignore. is already running', options.event, options.action); return; } } // @NOTE: reset who was already notified this.toNotify[options.action] = { event: options.event, timeoutInMS: options.timeoutInMS || 50, notified: [] }; this.emit('started', options.event); this.run(options); } /** * @description Hard reset queue from outside. * * Reset usually only happens if you e.g. switch the api version. */ reset() { this.queue = {}; _.each(this.toNotify, (obj) => { clearTimeout(obj.timeout); }); this.toNotify = {}; } /** * @description Soft reset queue from outside. * * A soft reset does NOT clear the subscribers! * Only used for test env currently. */ softReset() { _.each(this.toNotify, (obj) => { clearTimeout(obj.timeout); }); this.toNotify = {}; } } module.exports = Queue;