dbwrkr
Version:
A pub-sub-scheduler system for NodeJS
728 lines (601 loc) • 20.6 kB
JavaScript
'use strict';
const assert = require('assert');
const events = require('events');
const util = require('util');
const debug = require('debug')('dbwrkr');
const flw = require('flw');
const lruCache = require('lru-cache');
// Valid event keys just before we store them
const validEventKeys = [
'name', 'tid', 'payload',
'parent',
'when',
'queue',
'retryCount',
'__blocked'
];
/**
* Create new DBWrkr object
* @constructor
* @param {Object} opt options
* @param {Object} opt.storage - Storage engine to use
*/
function DBWrkr(opt) {
if (!(this instanceof DBWrkr)) {
return new DBWrkr(opt);
}
assert(typeof opt === 'object', 'opt must be object');
assert(opt.storage, 'need storage argument');
this.storage = opt.storage;
this.listenQueues = {};
this.middleware = [];
this.eventQueueCacheEnabled = true;
this.eventQueueCache = new lruCache({
max: 20, // different eventNames
maxAge: 2000, // cache time in ms
});
}
util.inherits(DBWrkr, events.EventEmitter);
/**
* Install a middleware function
* @param {function} fn function to use as middleware
*/
DBWrkr.prototype.use = function use(fn) {
assert(typeof fn === 'function', 'fn must be a function');
this.middleware.push(fn);
};
/**
* Clear the current list of middleware
*/
DBWrkr.prototype.clearMiddleware = function () {
this.middleware = [];
};
/**
* Get a representation of a Queue to receive it's events
*/
DBWrkr.prototype.queue = function queue(queueName, opt, done) {
if (done === undefined && typeof opt === 'function') {
done = opt; opt = {};
}
assert(typeof queueName === 'string', 'queueName must be a string');
assert(typeof opt === 'object', 'opt must be an object');
assert(typeof done === 'function', 'done must be a function');
if (this.listenQueues[queueName]) {
throw new Error('queueAlreadRegistered');
}
debug('queue', {
name: queueName,
opt: opt
});
this.listenQueues[queueName] = DBQueue(queueName, opt, this);
return done(null, this.listenQueues[queueName]);
};
/**
* Connect to backend storage
* @param {function} done callback
* @see disconnect
*/
DBWrkr.prototype.connect = function connect(done) {
assert(typeof done === 'function', 'done must be a function');
debug('connecting to storage', {storage: this.storage.constructor.name});
this.storage.connect(function (err) {
if (err) return done(err);
debug('connected to storage');
return done(null);
});
};
/**
* Disconnect from the backend storage
* @param {function} done callback
* @see connect
*/
DBWrkr.prototype.disconnect = function disconnect(done) {
assert(typeof done === 'function', 'done must be a function');
const self = this;
return flw.series([
stopListen,
disconnect,
], done);
function stopListen(c, cb) {
debug('disconnect, stopListen');
self.stopListen(cb);
}
function disconnect(c, cb) {
debug('disconnecting from storage', {storage: self.storage.constructor.name});
self.storage.disconnect(function (err) {
if (err) return cb(err);
debug('disconnected from storage');
return cb(null);
});
}
};
/**
* Start processing events
* @param {function} done callback
*/
DBWrkr.prototype.listen = function wrkrListen(done) {
assert(typeof done === 'function', 'done must be a function');
const self = this;
const queues = Object.keys(this.listenQueues);
return flw.each(queues, startQueue, done);
function startQueue(queueName, cb) {
const q = self.listenQueues[queueName];
q.__listen(cb);
}
};
/**
* Stop processing events
* @param {function} done callback
*/
DBWrkr.prototype.stopListen = function wrkrStopListen(done) {
assert(typeof done === 'function', 'done must be a function');
const self = this;
const queues = Object.keys(this.listenQueues);
return flw.each(queues, stopQueue, done);
function stopQueue(queueName, cb) {
const q = self.listenQueues[queueName];
q.__stopListen(cb);
}
};
/**
* Subscribe an event to a queue
* @param {string} eventName name of the event to subscribe
* @param {string} queueName name of the queue to receive the events
* @param {function} done callback
* @see unsubscribe subscriptions
*/
DBWrkr.prototype.subscribe = function subscribe(eventName, queueName, done) {
assert(eventName && typeof eventName === 'string', 'eventName must be a string');
assert(queueName && typeof queueName === 'string', 'queueName must be a string');
assert(typeof done === 'function', 'done must be a function');
debug('subscribing eventName ' +eventName+ 'to queue '+queueName);
this.storage.subscribe(eventName, queueName, function (err) {
if (err) return done(err);
debug('subscribed eventName '+eventName+' to queue '+queueName);
return done(null);
});
};
/**
* Unsubscribe an event from a queue
* @param {string} eventName event to unsubscribe
* @param {string} queueName queue to remove
* @param {function} done callback
* @see subscribe subscriptions
*/
DBWrkr.prototype.unsubscribe = function unsubscribe(eventName, queueName, done) {
assert(typeof eventName === 'string', 'eventName must be a string');
assert(typeof queueName === 'string', 'queueName must be a string');
assert(typeof done === 'function', 'done must be a function');
debug('unsubscribing eventName '+eventName+' from queue '+queueName);
this.storage.unsubscribe(eventName, queueName, function (err) {
if (err) return done(err);
debug('unsubscribed eventName '+eventName+' from queue '+queueName);
return done(null);
});
};
/**
* Get queueNames for given events
* @param {string} eventName event to get the queueNames for
* @param {function} done callback
* @returns {Array} Array with queueNames
* @see subscribe unsubscribe
*/
DBWrkr.prototype.subscriptions = function subscriptions(eventName, done) {
assert(typeof eventName === 'string', 'eventName must be a string');
assert(typeof done === 'function', 'done must be a function');
const self = this;
if (self.eventQueueCacheEnabled) {
const cachedQueues = self.eventQueueCache.get(eventName);
if (cachedQueues) {
return setImmediate(done, null, cachedQueues);
}
}
this.storage.subscriptions(eventName, function (err, queues) {
if (err) return done(err);
if (self.eventQueueCacheEnabled) {
self.eventQueueCache.set(eventName, queues);
}
debug('subscriptions for eventName ' + eventName, queues);
return done(null, queues);
});
};
/**
* Publish event(s) to subscribes queues
* @param {Object} events event(s) to publish
* @param {string} event.name eventName (required)
* @param {string} event.tid targetId, e.g. userId (optional)
* @param {Date} event.when when the event must be processed (default: direct)
* @param {Object} event.payload payload to send with the event (optional)
* @param {function} done callback
* @returns {Array} Array with id's of created events
* @see followUp retry
*/
DBWrkr.prototype.publish = function publish(events, done) {
assert(typeof done === 'function', 'done must be a function');
const self = this;
const eventsToProcess = (Array.isArray(events) ? events : [events]).map(_mapFormat);
const eventsToStore = [];
return flw.series([
processEvents,
storeEvents,
], (err, c) => {
if (err) return done(err);
return done(null, c.createdIds);
});
function processEvents(c, cb) {
debug('publish events', eventsToProcess);
return flw.each(eventsToProcess, processEvent, cb);
}
function storeEvents(c, cb) {
debug('publish - storing events', eventsToStore);
if (eventsToStore.length === 0) {
c.createdIds = [];
return cb();
}
self.storage.publish(eventsToStore, (err, createdIds) => {
if (err) return cb(err);
assert(
eventsToStore.length === createdIds.length,
'created eventIds returned'
);
c.createdIds = createdIds;
return cb();
});
}
function processEvent(event, cb) {
return flw.series([
runMiddleware,
checkValidEventKeys,
makeQueueEvents
], cb);
function runMiddleware(c, cb) {
return flw.each(self.middleware, runFn, cb);
function runFn(fn, cb) {
return fn(event, self, cb);
}
}
function checkValidEventKeys(c, cb) {
const errs = [];
if (!event.name) errs.push('missingKey: name');
Object.keys(event).forEach((k) => {
if (validEventKeys.indexOf(k) === -1) errs.push('invalidKey:' + k);
});
return cb(errs.length ? new Error(errs.join(',')) : null);
}
function makeQueueEvents(c, cb) {
if (event.__blocked) return cb();
// get subscriptions for queue and create events
self.subscriptions(event.name, function (err, qNames) {
if (err) return cb(err);
if (qNames.length === 0) {
debug('publish - no queues for event', event.name);
return cb();
}
qNames.forEach(function (qName) {
const newEvent = {
name: event.name,
queue: qName,
when: event.when || new Date(),
created: new Date(),
payload: event.payload || {},
retryCount: event.retryCount || 0
};
if (event.tid) newEvent.tid = event.tid;
if (event.parent) newEvent.parent = event.parent;
eventsToStore.push(newEvent);
});
return cb();
});
}
}
};
/**
* Find queueItems based on criteria
* @param {Object} criteria Object with searchProperties (id:xxx, name:xxx, etc)
* @param {function} done callback
* @returns {Array} Array with found events
* @see remove
*/
DBWrkr.prototype.find = function find(criteria, done) {
assert(typeof done === 'function', 'done must be a function');
const mappedCriteria = _mapFormat(criteria);
const criteriaErrors = _validCriteria(mappedCriteria);
if (criteriaErrors) return done(criteriaErrors);
debug('finding', mappedCriteria);
this.storage.find(mappedCriteria, function (err, items) {
if (err) return done(err);
const mappedItems = items.map(_mapFormat);
debug('found', mappedItems);
return done(null, mappedItems);
});
};
/**
* Remove queueItems based on criteria
* @param {Object} criteria Object with searchProperties (id:xxx, name:xxx, etc)
* @param {function} done callback
* @see find
*/
DBWrkr.prototype.remove = function remove(criteria, done) {
assert(typeof done === 'function', 'done must be a function');
const mappedCriteria = _mapFormat(criteria);
const criteriaErrors = _validCriteria(mappedCriteria);
if (criteriaErrors) return done(new Error(criteriaErrors));
this.storage.remove(mappedCriteria, function (err) {
return done(err || null);
});
};
/**
* Followup event with a new event, will publish the new events with the .parent
* property set to the originalEvent. Useful for introspection.
* (show event-tree of generated events)
* @param {Object} originalEvent originalEvent that is being processed
* @param {Object} events Object with new event data
* @param {function} done callback
*/
DBWrkr.prototype.followUp = function followUp(originalEvent, events, done) {
assert(typeof originalEvent === 'object', 'originalEvent must be an object');
assert(typeof done === 'function', 'done must be a function');
const publishEvents = Array.isArray(events) ? events : [events];
publishEvents.forEach(function (e) {
e.parent = originalEvent.id;
});
return this.publish(publishEvents, done);
};
/**
* Retry an event, will publish a new event with an increased retry-counter
* After 20 retries (approx 54 hour, an Error will be returned)
* @param {Object} event event that need to be retried
* @param {Date} when when it should be retried (default: incremental retry-timer)
* @param {function} done callback
* @returns {Array} id's of generated events
*/
DBWrkr.prototype.retry = function retry(event, when, done) {
if (done === undefined && typeof (when) === 'function') {
done = when;
when = undefined;
}
assert(typeof event === 'object', 'event must be an object');
assert(typeof done === 'function', 'done must be a function');
// get the next retryCounter
const nc = !isNaN(event.retryCount) ? event.retryCount+1 : 1;
if (nc > 20) {
return done(new Error('tooMuchRetries'));
}
// Auto retryTimer mechanism
// Start with 10 seconds, increase slowly, reaches 57 hours in 20 retries
if (when === undefined) {
const nextSeconds = Math.round(10 + (nc * (nc / 5)) * (nc * 150));
when = nextSeconds * 1000;
}
// Re-publishes the events (new qitems are created)
const newEvent = {
name: event.name,
queue: event.queue,
parent: event.id,
when: event.when,
retryCount: nc,
};
if (event.tid) newEvent.tid = event.tid;
if (event.payload) newEvent.payload = event.payload;
return this.publish(newEvent, done);
};
/**
* Will be called by the listen mechanism to dispatch the next event
* @private
* @param {DBQueue} Queue queue you want the next item processed for
* @param {function} done callback
* @returns {Object} the processed event
*/
DBWrkr.prototype.__processNext = function processext(q, done) {
assert(q instanceof DBQueue, 'queue must be a DBQueue');
assert(typeof done === 'function', 'done must be a function');
const self = this;
debug('processNext', {queue: q.queueName});
self.__fetchNext(q.queueName, function (err, event) {
if (err) return done(err);
q._stats.lastPollTime = new Date();
if (event) {
q._stats.lastEventId = event.id;
q._stats.lastEventName = event.name;
q._stats.lastEventTid = event.tid;
q._stats.lastEventTime = new Date();
}
if (!event) return done(null, undefined);
let handled = q.emit(event.name, event, dispatchDone);
if (!handled) {
handled = q.emit('*', event, dispatchDone);
}
if (!handled) {
self.emit('error', 'noHandler', null, event);
return done(null, event);
}
function dispatchDone(err) {
if (err) {
self.emit('error', 'dispatchError', err, event);
}
// don't return the error that was related to the event
// we continue processing now
return done(null, event);
}
});
};
/**
* Fetch the next qitem from the storage engine
* (does not send events, mainly used for testing)
* @private
* @param {string} Queue queue you want the next queueItem for
* @param {function} done callback
* @returns {Object} event (or undefined)
*/
DBWrkr.prototype.__fetchNext = function fetchNext(queueName, done) {
assert(typeof queueName === 'string', 'queue must be a string');
assert(typeof done === 'function', 'done must be a function');
debug('fetchNext', queueName);
this.storage.fetchNext(queueName, (err, qitem) => {
if (err) return done(err);
if (!qitem) return done(null, undefined);
const mappedQitem = _mapFormat(qitem);
return done(null, mappedQitem);
});
};
/**
* Representation of a queue
* @param {string} queueName name of the queue
* @param {Object} opt misc options
* @param {DBWrkr} wrkr object
*/
function DBQueue(queueName, opt, wrkr) {
if (!(this instanceof DBQueue)) {
return new DBQueue(queueName, opt, wrkr);
}
assert(typeof queueName === 'string', 'queueName must be a string');
assert(typeof opt === 'object', 'opt must be an object');
assert(wrkr instanceof DBWrkr, 'wrkr must be instance of DBWrkr');
this.wrkr = wrkr;
this.queueName = queueName;
this.opt = opt;
if (!opt.idleTimer) opt.idleTimer = 500;
if (!opt.busyTimer) opt.busyTimer = 10;
this.listenStatus = 'stopped'; // started,stopping,stopped
this._stats = {
lastPollTime: null,
lastEventId: null,
lastEventName: null,
lastEventTid: null,
lastEventTime: null,
};
}
util.inherits(DBQueue, events.EventEmitter);
DBQueue.prototype.statistics = function queueStatistics(done) {
// Not really neccesary to have a callback here,
// but could be usefull for later if we want to add stuff
setImmediate(done, null, this._stats);
};
DBQueue.prototype.subscribe = function queueSubscribe(eventName, done) {
assert(typeof eventName === 'string', 'eventName must be a string');
this.wrkr.subscribe(eventName, this.queueName, done);
};
DBQueue.prototype.unsubscribe = function queueUnsubscribe(eventName, done) {
assert(typeof eventName === 'string', 'eventName must be a string');
this.wrkr.unsubscribe(eventName, this.queueName, done);
};
/**
* Starts receiving qitems for the given queue
* @private
* @param {string} Queue queue you want start listen on
* @param {Object} opt options
* @param {number} opt.idleTimer pollTimer when idle (no events on last check) Default: 500ms
* @param {number} opt.busyTimer pollTimer when busy (got event on last check) Default: 10ms
* @param {function} done callback
*/
DBQueue.prototype.__listen = function queueListen(done) {
assert(typeof done === 'function', 'done must be a function');
const self = this;
const logContext = {
queue: this.queueName,
};
if (self.listenStatus === 'started') {
return done(new Error('alreadyStarted'));
}
debug('listen - started', logContext);
self.listenStatus = 'started';
setImmediate(fetchNext, this.opt.idleTimer);
return done(null);
function fetchNext() {
debug('listen - fetchNext()', logContext);
self.wrkr.__processNext(self, function (err, event) {
if (err) self.wrkr.emit('error', 'listen', err, null);
if (self.listenStatus === 'started') {
const nextTimeOut = event ? self.opt.busyTimer : self.opt.idleTimer;
debug('listen - next fetchNext() in ' + nextTimeOut + 'ms', logContext);
setTimeout(fetchNext, nextTimeOut);
}
else if (self.listenStatus === 'stopping') {
debug('listen - stopping', logContext);
self.listenStatus = 'stopped';
} else {
debug('listen - unknown status: '+self.listenStatus, logContext);
}
});
}
};
/**
* Stops receiving qitems for the given queue
* @param {DBQueue} Queue queue to stop receiving events
* @param {function} done callback
*/
DBQueue.prototype.__stopListen = function queueStopListen(done) {
assert(typeof done === 'function', 'done must be a function');
const self = this;
const logContext = {
queue: this.queueName,
};
if (self.listenStatus === 'stopped') {
debug('stopListen - already stopped', logContext);
return done(null);
}
if (self.listenStatus === 'started') {
debug('stopListen - stopping', logContext);
self.listenStatus = 'stopping';
}
setImmediate(checkStopped);
function checkStopped() {
if (self.listenStatus === 'stopped') {
debug('stopListen - stopped', logContext);
return done();
}
debug('stoplisten - waiting for \'stopped\' state, now: '+self.listenStatus, logContext);
setTimeout(checkStopped, 250);
}
};
/**
* Checks criteria object for valid options/arguments
* @private
* @param {Object} criteria object with find/remove spec
*/
function _validCriteria(criteria) {
const validFindKeys = ['id', 'name', 'tid', 'queue'];
const criteriaKeys = Object.keys(criteria);
const errMsgs = [];
if (typeof criteria !== 'object') {
errMsgs.push('criteria must be an object');
}
if (!criteriaKeys.length) {
errMsgs.push('no keys in criteria');
}
criteriaKeys.forEach(k => {
if (validFindKeys.indexOf(k) === -1) {
errMsgs.push(`invalid key: ${k}`);
}
if (k === 'id') {
const ids = Array.isArray(criteria[k]) ? criteria[k] : [criteria[k]];
ids.forEach(i => {
if (typeof i !== 'string') {
errMsgs.push('id(s) must be strings');
}
});
} else {
if (typeof criteria[k] !== 'string' && typeof criteria[k] !== 'number') {
errMsgs.push(`${k} must be a string`);
}
}
});
if (errMsgs.length) {
debug('criteria errors', criteria, errMsgs);
}
return errMsgs.join(',');
}
/**
* Maps the internal qitem format to a uniform format to be stored
* by the storage engine
* @param {qitem} qitem the qitem to store
*/
function _mapFormat(qitem) {
const sqitem = Object.assign({}, qitem);
if (qitem.id) sqitem.id = String(qitem.id);
if (qitem.parent) sqitem.parent = String(qitem.parent);
if (qitem.tid) sqitem.tid = String(qitem.tid);
return sqitem;
}
// Main export
module.exports = DBWrkr;