keuss
Version:
Enterprise-grade Job Queues for node.js backed by redis, MongoDB or PostgreSQL
771 lines (610 loc) • 23.7 kB
JavaScript
var async = require ('async');
var _ = require ('lodash');
var uuid = require ('uuid');
var debug = require('debug')('keuss:Queue:base');
class Queue {
//////////////////////////////////////////////
constructor (name, factory, opts, orig_opts) {
//////////////////////////////////////////////
if (!name) {
throw new Error ('provide a queue name');
}
this._opts = opts || {};
this._orig_opts = orig_opts || {};
this._name = name;
this._factory = factory;
// most mature
// values:
// * null: unknown, triggers read this.next_t() from backend
// * 0: known to be no element in queue, accepts values from insertNotifs
// * <non-zero int>: a know value, to be trusted. accepts values from insertNotifs if lower
this._next_mature_t = null;
// defaults
this._pollInterval = this._opts.pollInterval || 60000;
// map of active consumers
this._consumers_by_tid = new Map();
this._signaller = factory._signaller_factory.signal (this, this._opts.signaller.opts);
this._stats = factory._stats_factory.stats (factory.name(), this.name (), this._opts.stats.opts);
// save original options
this._stats.opts (orig_opts || {}, () => {});
this._stats.incr ('get', 0);
this._stats.incr ('put', 0);
if (factory.capabilities().reserve) {
this._stats.incr ('reserve', 0);
this._stats.incr ('commit', 0);
this._stats.incr ('rollback', 0);
this._stats.incr ('deadletter', 0);
}
// if true, queue is being drained just before shutdown
this._in_drain = false;
// if true, queue has been drained and is now not usable
this._drained = false;
debug ('created queue %s with opts %o', this._name, this._opts);
}
////////////////////////////////////////////////////////////////////////////
// expected redefinitions on subclasses
// add element to queue
insert (entry, callback) {callback (null, null);}
// get element from queue
get (callback) {callback (null, {mature: 0, payload: null, tries: 0});}
// reserve element: call cb (err, pl) where pl has an id
reserve (callback) {callback (null, {mature: 0, payload: null, tries: 0}, null);}
// commit previous reserve, by p.id: call cb (err, true|false), true if element committed
commit (id, callback) {callback (null, false);}
// rollback previous reserve, by p.id: call cb (err, true|false), true if element rolled back
rollback (id, next_t, callback) {callback (null, false);}
// pipeline: atomically passes to next queue a previously reserved element, by id
pl_step (id, next_queue, opts, callback) {callback (null, false);}
// queue size including non-mature elements
totalSize (callback) {callback (null, 0);}
// queue size NOT including non-mature elements
size (callback) {callback (null, 0);}
// queue size of non-mature elements only
schedSize (callback) {callback (null, 0);}
// queue size of reserved elements only
resvSize (callback) {callback (null, null);}
// Date of next
next_t (callback) {callback (null, null);}
// remove (and return if possible) by id
remove (id, callback) {callback(null, null);}
// extra information & status
extra_info (cb) {cb (null, {});}
// end of expected redefinitions on subclasses
////////////////////////////////////////////////////////////////////////////
stats (cb) {this._stats.values (cb);}
paused (cb) {this._stats.paused (cb);}
// placeholder methods
name () {return this._name;}
ns () {return this._factory.name();}
type () {return 'queue:base';}
// capabilities
capabilities () {
return this._factory.capabilities ();
}
info (cb) {
async.parallel ({
size: cb => this.size (cb),
totalSize: cb => this.totalSize (cb),
schedSize: cb => this.schedSize (cb),
resvSize: cb => this.resvSize (cb),
next_t: cb => this.next_t (cb),
stats: cb => this.stats (cb),
paused: cb => this.paused (cb),
extra: cb => this.extra_info (cb),
}, (err, res) => {
if (err) return cb (err);
res.name = this.name();
res.ns = this.ns();
res.type = this.type();
res.capabilities = this.capabilities();
cb (null, res);
});
}
// T of next mature
nextMatureDate () {return this._next_mature_t;}
static now () {return (new Date ());}
static nowPlusSecs (secs) {return (new Date (Date.now () + secs * 1000));}
/////////////////////////////////////////
consumers () {
/////////////////////////////////////////
let r = [];
this._consumers_by_tid.forEach ((value, key) => {
r.push ({
tid: value.tid,
since: value.since,
callback: (value.callback ? 'set' : 'unset'),
cleanup_timeout: (value.cleanup_timeout ? 'set' : 'unset'),
wakeup_timeout: (value.wakeup_timeout ? 'set' : 'unset')
});
});
return r;
}
/////////////////////////////////////////
nConsumers () {
/////////////////////////////////////////
return this._consumers_by_tid.size;
}
/////////////////////////////////////////
// called when an insertion has been signaled
signalInsertion (mature, cb) {
// ignore if paused
if (this._local_paused) {
if (cb) return cb ();
else return;
}
if (_.isNull (this._next_mature_t)) {
// totally ignore it, let pop() get it via next_t()
debug ('%s: signalInsertion received with mature %s. _next_mature_t is null, ignoring', this._name, mature.toISOString ());
if (cb) return cb ();
else return;
}
else if (this._next_mature_t == 0) {
// next_t() forced a read and the result was 'empty': trust the notif
debug ('%s: signalInsertion received with mature %s. _next_mature_t is 0, trusting notif', this._name, mature.toISOString ());
this._next_mature_t = mature.getTime ();
}
else if (this._next_mature_t <= mature.getTime ()) {
// _next_mature_t is numeric and non-null, so an active wait is happening. ignore it
debug ('%s: signalInsertion received with mature %s. _next_mature_t (%s) is lower, ignoring', this._name, mature.toISOString (), new Date(this._next_mature_t).toISOString());
if (cb) return cb ();
else return;
}
else {
// _next_mature_t is numeric and non-null, so an active wait is happening. ignore it
debug ('%s: signalInsertion received with mature %s. _next_mature_t (%s) is higher, trusting notif', this._name, mature.toISOString (), new Date(this._next_mature_t).toISOString());
this._next_mature_t = mature.getTime ();
}
this._nextDelta (delta_ms => {
// run a wakeup on all consumers with the wakeup timer set
debug ('%s: signalInsertion: waking up all consumers with wakeup already set', this._name);
this._consumers_by_tid.forEach ((consumer, tid) => {
debug ('%s: signalInsertion: waking up consumer %d', this._name, tid);
if (consumer.wakeup_timeout) {
clearTimeout (consumer.wakeup_timeout);
consumer.wakeup_timeout = null;
if (delta_ms > 0) {
consumer.wakeup_timeout = setTimeout (() => {
consumer.wakeup_timeout = null;
this._onetime_pop (consumer);
}, delta_ms);
}
else {
setImmediate (() => this._onetime_pop (consumer));
}
}
});
});
if (cb) cb ();
}
/////////////////////////////////////////
// called when a pause/resume has been signaled
signalPaused (paused, cb) {
debug ('%s: signalPaused: received pause notif %s', this._name, paused ? 'true' : 'false');
this._local_paused = paused;
if (paused == false) {
// run a wakeup on all consumers with or without wakeup timer set
debug ('%s: signaPaused: waking up all consumers', this._name);
this._consumers_by_tid.forEach ((consumer, tid) => {
debug ('%s: signalPaused: waking up consumer %s', this._name, tid);
if (consumer.wakeup_timeout) {
clearTimeout (consumer.wakeup_timeout);
consumer.wakeup_timeout = null;
}
setImmediate (() => this._onetime_pop (consumer));
});
}
else {
this._consumers_by_tid.forEach ((consumer_data, tid) => {
debug ('%s: pausing tid %s (cid %s)', this._name, tid, consumer_data.cid);
if (consumer_data.wakeup_timeout) {
clearTimeout (consumer_data.wakeup_timeout);
consumer_data.wakeup_timeout = null;
}
});
}
if (cb) cb ();
}
////////////////////////////////////////
// pause/resume
pause (v) {
if (v == undefined) v = true;
this._stats.paused (v, () => this._signaller.signalPaused (v));
}
//////////////////////////////////
// empty local buffers
drain (callback) {
debug ('%s: draining queue', this._name);
this._in_drain = false;
this._drained = true;
this.cancel ();
setImmediate (callback);
}
/////////////////////////////////////////
// add element to queue
push (payload, opts, callback) {
/////////////////////////////////////////
if (!callback) {
callback = opts;
opts = {};
}
if (this._in_drain) return setImmediate (() => {
debug ('%s: push while in drain, return error', this._name);
callback ('drain');
});
// get delay from either params or config
var mature = null;
if (opts.mature) {
mature = new Date (opts.mature * 1000);
}
else {
var delay = opts.delay || this.delay;
mature = delay ? Queue.nowPlusSecs (delay) : Queue.now ();
}
// build payload
var msg = {
mature: mature,
payload: payload,
tries: opts.tries || 0,
hdrs: opts.hdrs || {}
};
debug ('%s: about to insert %o', this._name, msg);
// insert into queue
this.insert (msg, (err, result) => {
if (err) {
debug ('%s: push : error when pushing elem: %o', this._name, err);
return callback (err);
}
this._stats.incr ('put');
this._signal_insertion (mature);
debug ('%s: elem pushed, given id %s, signalled insertion with mature %s', this._name, result, mature.toISOString ());
callback (null, result);
})
}
//////////////////////////////////
// obtain element from queue
pop (cid, opts, callback) {
if (this._drained) return setImmediate (() => {
debug ('%s: pop while drained, return error', this._name);
if (callback) callback ('drain');
});
if (!callback) {
callback = opts;
opts = {};
}
if (!opts) {
opts = {};
}
var tid = opts.tid || uuid.v4 ();
var consumer_data = {
tid: tid, // unique id for this transaction
cid: cid,
since: new Date (), // timestamp of creation of the consumer
reserve: opts.reserve, // boolean, is a reserve or a plain pop?
callback: callback, // final, outbound callback upon read/timeout
cleanup_timeout: null, // timer for timeout cancellation
wakeup_timeout: null // timer for rearming, awaiting data available
};
if (opts.timeout) {
consumer_data.cleanup_timeout = setTimeout (() => {
debug ('%s: pop: timed out %d msecs on transaction %s', this._name, opts.timeout, tid);
consumer_data.cleanup_timeout = null;
if (consumer_data.wakeup_timeout) {
clearTimeout (consumer_data.wakeup_timeout);
consumer_data.wakeup_timeout = null;
}
this._consumers_by_tid.delete (consumer_data.tid);
if (consumer_data.callback) consumer_data.callback ({
timeout: true,
tid: consumer_data.tid,
since: consumer_data.since
});
}, opts.timeout);
}
this._consumers_by_tid.set (tid, consumer_data);
// end here if paused
if (this._local_paused == undefined) {
debug ('%s: local_paused undefined, read it from stats', this._name);
this._stats.paused ((err, v) => {
this._local_paused = v;
debug ('%s: local_paused undefined, read paused status from stats:', this._name, v);
if (!this._local_paused) {
// attempt a read
debug ('%s: pop: calling initial onetime_pop on cid %s, tid %s', this._name, cid, tid);
this._onetime_pop (consumer_data);
}
else {
debug ('%s: pop: queue paused for cid %s, tid %s', this._name, cid, tid);
}
});
return tid;
}
else {
if (!this._local_paused) {
// attempt a read
debug ('%s: pop: calling initial onetime_pop on cid %s, tid %s', this._name, cid, tid);
this._onetime_pop (consumer_data);
}
else {
debug ('%s: pop: queue paused for cid %s, tid %s', this._name, cid, tid);
}
return tid;
}
}
//////////////////////////////////
// high level commit
ok (obj, cb) {
// allow commit with either _id of full object
const id = (obj._id ? obj._id : obj);
this.commit (id, (err, res) => {
if (!err){
debug ('%s: ok : committed ok tid %s', this._name, id);
this._stats.incr ('commit');
}
else {
debug ('%s: ok : error when committing tid %s: %o', this._name, id, err);
}
cb (err, res);
}, obj);
}
//////////////////////////////////
// high level rollback
ko (obj, next_t, cb) {
if (_.isFunction (next_t)) {
cb = next_t;
next_t = null;
}
// allow rollback with either _id of full object
let id = (obj._id ? obj._id : obj);
if (
(obj.tries) && // only if we got tries
(this._factory.deadletter_queue ()) && // AND the factory has a deadletter queue
(this._factory.max_ko ()) && // AND there's a max ko attempts
(obj.tries > this._factory.max_ko ()) && // AND we got enough tries
(this.name () != '__deadletter__') // and this queue is not deadletter already
) {
debug ('%s: too many retries (%d), moving to deadletter', obj._id, obj.tries);
this._move_to_deadletter (obj, cb);
}
else {
this.rollback (id, next_t, (err, res) => {
if (err) {
debug ('%s: ko : error when rolling back tid %s: %o', this._name, id, err);
return cb (err);
}
if (res) {
var t = new Date (next_t || null);
debug ('%s: ko : tid %s rolled back, new mature is %s', this._name, id, t);
if (!err) this._stats.incr ('rollback');
this._signal_insertion (t);
}
cb (null, res);
});
}
}
//////////////////////////////////
// cancel a waiting consumer
cancel (tid) {
debug ('%s: cancelling tid %s', this._name, tid);
if (tid) {
let consumer_data = this._consumers_by_tid.get (tid);
if (!consumer_data) {
// tid does not point to valid consume, NOOP
return;
}
if (consumer_data.callback) {
// call callback with error-cancelled
consumer_data.callback ('cancel');
// mark cancelled by deleting callback
consumer_data.callback = null;
}
// clear timeout if present
if (consumer_data.cleanup_timeout) {
clearTimeout (consumer_data.cleanup_timeout);
consumer_data.cleanup_timeout = null;
}
if (consumer_data.wakeup_timeout) {
clearTimeout (consumer_data.wakeup_timeout);
consumer_data.wakeup_timeout = null;
}
// remove from map
this._consumers_by_tid.delete (tid);
}
else {
// cancel all pending stuff
this._consumers_by_tid.forEach ((consumer_data, tid) => {
debug ('%s: cancelling tid %s (cid %s): start', this._name, tid, consumer_data.cid);
if (consumer_data.callback) {
// call callback with error-cancelled
consumer_data.callback ('cancel');
// mark cancelled by deleting callback
consumer_data.callback = null;
debug ('%s: cancelling tid %s (cid %s): callback called and removed', this._name, tid, consumer_data.cid);
}
if (consumer_data.cleanup_timeout) {
clearTimeout (consumer_data.cleanup_timeout);
consumer_data.cleanup_timeout = null;
debug ('%s: cancelling tid %s (cid %s): cleanup timeout removed', this._name, tid, consumer_data.cid);
}
if (consumer_data.wakeup_timeout) {
clearTimeout (consumer_data.wakeup_timeout);
consumer_data.wakeup_timeout = null;
debug ('%s: cancelling tid %s (cid %s): wakeup timeout removed', this._name, tid, consumer_data.cid);
}
debug ('%s: cancelling tid %s (cid %s): end', this._name, tid, consumer_data.cid);
});
this._consumers_by_tid.clear();
}
}
//////////////////////////////////
status (cb) {
//////////////////////////////////
async.parallel ({
type: cb => cb (null, this.type()),
capabilities: cb => cb (null, this.capabilities ()),
factory: cb => cb (null, this._factory.to_descriptor_obj ()),
stats: cb => this.stats (cb),
paused: cb => this.paused (cb),
next_mature_t: cb => this.next_t (cb),
size: cb => this.size (cb),
totalSize: cb => this.totalSize (cb),
schedSize: cb => this.schedSize (cb),
resvSize: cb => this.resvSize (cb)
}, cb);
}
///////////////////////////////////////////////////////////////////////////////
// private parts
_onetime_pop (consumer) {
var getOrReserve_cb = (err, result) => {
debug ('%s - tid %s: called getOrReserve_cb : err %o, result %o', this._name, consumer.tid, err, result);
if (!consumer.callback) {
// consumer was cancelled mid-flight
// do not reinsert if it's using reserve
if (!(consumer.reserve)) {
return this._reinsert (result);
}
}
if (err) {
// get/reserve in error
debug ('%s - tid %s: getOrReserve_cb in error: err %o', this._name, consumer.tid, err);
// clean timeout timer
if (consumer.cleanup_timeout) {
clearTimeout (consumer.cleanup_timeout);
consumer.cleanup_timeout = null;
}
// remove consumer from map
this._consumers_by_tid.delete (consumer.tid);
// call final callback
if (consumer.callback) consumer.callback (err);
return;
}
if (!result) {
// queue is empty or non-mature: put us to sleep, to-rearm-in-future
debug ('%s - tid %s: getOrReserve_cb no result, setting wakeup', this._name, consumer.tid);
// clear this._next_mature_t if it's in the past
if (this._next_mature_t && (this._next_mature_t < new Date().getTime ())) {
debug ('%s - tid %s: getOrReserve_cb no result, and this._next_mature_t is in the past, clearing it', this._name, consumer.tid);
this._next_mature_t = null;
}
// obtain time to sleep (capped)
this._nextDelta (delta_ms => {
// TODO cancel previous wakeup_timeout if not null?
debug ('%s - tid %s: getOrReserve_cb : set wakeup in %d ms', this._name, consumer.tid, delta_ms);
consumer.wakeup_timeout = setTimeout (() => {
debug ('%s - tid %s: wakey wakey... calling onetime_pop', this._name, consumer.tid);
consumer.wakeup_timeout = null;
this._onetime_pop (consumer);
}, delta_ms);
});
return;
}
// got an element
this._next_mature_t = null;
this._stats.incr (consumer.reserve ? 'reserve': 'get');
debug ('%s - tid %s: getOrReserve_cb : got result (%s) %j', this._name, consumer.tid, (consumer.reserve ? 'reserve': 'get'), result);
// clean timeout timer
if (consumer.cleanup_timeout) {
clearTimeout (consumer.cleanup_timeout);
consumer.cleanup_timeout = null;
}
// remove consumer from map
this._consumers_by_tid.delete (consumer.tid);
// call final callback
if (consumer.callback) consumer.callback (null, result);
return;
};
if (consumer.reserve) {
this.reserve (getOrReserve_cb);
}
else {
this.get (getOrReserve_cb);
}
}
/////////////////////////////
_nextDelta (cb) {
/////////////////////////////
if (this._next_mature_t == 0) {
debug ('%s: _nextDelta: _next_mature_t is zero, serving default', this._name);
return cb (this._pollInterval);
}
if (_.isNull (this._next_mature_t)) {
// there's no precalculated value, get it from backend
debug ('%s: _nextDelta: null _next_mature_t, getting it from backend',this._name);
this.next_t ((err, res) => {
if (err) {
debug ('%s: _nextDelta error from backend, serving default', this._name);
return cb (this._pollInterval);
}
if (res) {
// got a res, use it
this._next_mature_t = res;
var delta = res - Queue.now ().getTime();
if (delta > this._pollInterval) delta = this._pollInterval;
debug ('%s: _nextDelta: _next_mature_t from backend is %d, calc delta is %d', this._name, res, delta);
return cb (delta);
}
else {
// no res, set _next_mature_t to 0, serve default
debug ('%s: _nextDelta: no _next_mature_t from backend, use default', this._name);
this._next_mature_t = 0;
return cb (this._pollInterval);
}
});
}
else {
// _next_mature_t is non-zero numeric, use it
var delta = this._next_mature_t - Queue.now ().getTime();
if (delta > this._pollInterval) delta = this._pollInterval;
if (delta < 0) delta = 0;
debug ('%s: _nextDelta: using _next_mature_t %s, calc delta is %d', this._name, new Date(this._next_mature_t).toISOString (), delta);
return cb (delta);
}
}
///////////////////////////////////////////////////////////
_reinsert (result) {
if (result) {
debug ('%s: reinserting element', this._name);
this.insert (result, (err, r) => {
if (err) {
debug ('%s: error while reinserting elem: %o', this._name, err);
}
else {
this._signal_insertion (result.mature);
}
});
}
}
////////////////////////////////////////
_signal_insertion (t) {
this._signaller.signalInsertion (t);
}
/////////////////////////////////////////////
_move_to_deadletter (obj, cb) {
// commit and move to deadletter
// commit element in origin queue, push in deadletter afterwards
const opts = {
hdrs: _.clone (obj.hdrs || {})
};
// add some extra x-dl-* headers
opts.hdrs['x-dl-from-queue'] = this.name ();
opts.hdrs['x-dl-t'] = new Date().toISOString ();
opts.hdrs['x-dl-tries'] = obj.tries;
this.commit (obj._id, err => {
if (err) {
debug ('while committing %s prior to moving to deadletter: %j', obj._id, err);
return cb (err);
}
this._factory.deadletter_queue ().push (obj.payload, opts, (err, res) => {
if (err) {
debug ('while moving %s to deadletter: %j', obj._id, err);
return cb (err, 'deadletter');
}
else {
debug ('moved %s to deadletter with _id %s', obj._id, res);
this._stats.incr ('deadletter');
return cb (null, 'deadletter');
}
});
}, obj);
}
}
module.exports = Queue;