UNPKG

node-busmq

Version:

A high performance, highly-available and scalable, message bus and queueing system for node.js backed by Redis

543 lines (479 loc) 16 kB
var events = require("events"); var util = require("util"); var crypto = require("crypto"); var stream = require("stream"); var Queue = require("./queue"); function _noop() {} /** * Creates a new service endpoint utilizing message queues * @param bus * @param name the service name * @constructor */ function Service(bus, name) { events.EventEmitter.call(this); this.bus = bus; this.name = name; this.logger = bus.logger.withTag(name); this.type = "service"; this.id = "service:" + name; this.replyTo = this.id + ":replyTo:" + crypto.randomBytes(8).toString("hex"); this.pendingReplies = {}; this.requesters = {}; this.inflight = 0; } util.inherits(Service, events.EventEmitter); /** * Connect to the service queue and setup listeners * @param {*} options * @param {*} cb * @private */ Service.prototype._connect = function(options, cb) { var _this = this; this.options = util._extend({ reqTimeout: 1000 }, options); // force unreliable message delivery since multiple service providers could be // serving the endpoint, and reliable delivery is not well defined when there are multiple // consumers on a single queue this.options.reliable = false; this.options.remove = true; this.options.max = -1; this.qService = new Queue(this.bus, this.id); var detachedCount = 1; var _detached = function() { if (--detachedCount === 0) { _this.emit("disconnect"); _attached = undefined; _detached = undefined; } }; var onServiceError = function(err) { if (_this.qService) { _this.emit("error", err); } }; var onServiceAttached = function() { if (_this.qService && !_this.qReplyTo) { cb && cb(); cb = null; } }; var onServiceMessage = function(msg, id) { if (_this.qService) { _this._handleRequest(msg, id); } }; var onServiceConsuming = function(state) { state && _this.emit("serving"); }; var onServiceDetached = function() { // register an empty error listener so errors don't throw exceptions _this.qService.on("error", _noop); _this.qService.removeListener("error", onServiceError); onServiceError = undefined; _this.qService.removeListener("attached", onServiceAttached); onServiceAttached = undefined; _this.qService.removeListener("message", onServiceMessage); onServiceMessage = undefined; _this.qService.removeListener("detached", onServiceDetached); onServiceDetached = undefined; _this.qService.removeListener("consuming", onServiceConsuming); onServiceConsuming = undefined; _this.qService = null; _detached(); }; this.qService.on("error", onServiceError); this.qService.on("message", onServiceMessage); this.qService.on("attached", onServiceAttached); this.qService.on("detached", onServiceDetached); this.qService.on("consuming", onServiceConsuming); this.qService.attach(this.options); if (this.qReplyTo) { ++detachedCount; var onReplyToError = function(err) { if (_this.qReplyTo) { _this.emit("error", err); } }; var onReplyToAttached = function() { if (_this.qReplyTo) { cb && cb(); cb = null; } }; var onReplyToMessage = function(msg, id) { if (_this.qReplyTo) { _this._handleReply(msg, id); } }; var onReplyToConsuming = function(state) { state && _this.emit("connected", state); }; var onReplyToDetached = function() { // register an empty error listener so errors don't throw exceptions _this.qReplyTo.on("error", _noop); _this.qReplyTo.removeListener("error", onReplyToError); onReplyToError = undefined; _this.qReplyTo.removeListener("attached", onReplyToAttached); onReplyToAttached = undefined; _this.qReplyTo.removeListener("message", onReplyToMessage); onReplyToMessage = undefined; _this.qReplyTo.removeListener("detached", onReplyToDetached); onReplyToDetached = undefined; _this.qReplyTo.removeListener("consuming", onReplyToConsuming); onReplyToConsuming = undefined; _this.qReplyTo = null; _detached(); }; this.qReplyTo.on("error", onReplyToError); this.qReplyTo.on("message", onReplyToMessage); this.qReplyTo.on("attached", onReplyToAttached); this.qReplyTo.on("detached", onReplyToDetached); this.qReplyTo.on("consuming", onReplyToConsuming); this.qReplyTo.attach(this.options); } }; /** * Listens for requests to the service. Whever a new message arrives designated to the service, * a 'request' event will be emitted. * Events: * serving - emitted when the service will start receiving requests * @param options message consumption options (same as Queue#consume) */ Service.prototype.serve = function(options, cb) { if (this.isServing()) { this.emit("error", "already serving"); return; } if (this.isConnected()) { this.emit("error", "already connected"); return; } if (typeof options === "function") { cb = options; options = undefined; } var _this = this; if (options) { this.serveMax = options.max; options.max = -1; } this._connect(options, function() { cb && _this.once("serving", function() { cb(); }); _this.qService.consume(options); }); }; /** * Connect to the service as a service consumer. * Events: * connected - connected to the service. after this event requests can be sent to the service * @param options message consumption options (same as Queue#consume) */ Service.prototype.connect = function(options, cb) { if (this.isServing()) { this.emit("error", "already serving"); return; } if (this.isConnected()) { this.emit("error", "already connected"); return; } if (typeof options === "function") { cb = options; options = undefined; } this.qReplyTo = new Queue(this.bus, this.replyTo); var _this = this; this._connect(options, function() { cb && _this.once("connected", function() { cb(); }); _this.qReplyTo.consume(options); }); }; /** * */ Service.prototype.disconnected = function() { return !(this.qReplyTo && this.qReplyTo.isAttached()) && !(this.qService && this.qService.isAttached()); }; /** * returns whether we have been asked to disconnect but haven't done so yet */ Service.prototype.disconnecting = function() { return !!this.graceTimer; }; /** * shutdown the connection to the service and cleanup */ Service.prototype._shutdown = function(force) { // if we are not forced to shutdown, // and we either have requests in flight or disconnect was not called // then we can ignore the shutdown if (!force && (!this.disconnecting() || this.inflight > 0)) { return; } clearTimeout(this.graceTimer); this.graceTimer = undefined; this.qService && this.qService.detach(); this.qReplyTo && this.qReplyTo.detach(); // clear the requesters cache var _this = this; Object.keys(this.requesters).forEach(function(replyTo) { _this.requesters[replyTo].q.detach(); clearTimeout(_this.requesters[replyTo].timer); }); this.requesters = {}; this.pendingReplies = {}; }; /** * Disconnect from the service. Used both by a service provider and service consumer * @param gracePeriod number of milliseconds to wait for all in-flight requests to be handled. if undefined or negative, * the disconnect will not be graceful. default is 0. */ Service.prototype.disconnect = function(gracePeriod) { if (this.disconnected()) { return; } // regardless of grace period, stop listening for new requests this.qService && this.qService.stop(); gracePeriod = Math.max(gracePeriod || 0, 0); var _this = this; this.graceTimer = setTimeout(function() { _this._shutdown(true); }, gracePeriod); this._shutdown(); }; /** * Returns whether we are serving requests */ Service.prototype.isServing = function() { return this.qService && this.qService.isConsuming(); }; /** * Returns whether we are connected to the service for sending requests */ Service.prototype.isConnected = function() { return !!this.qReplyTo; }; /** * Send a request to the service. Service#connect must have been called before sending requests * @param request the request to send. can be either a string or an object. * @param options options for the request: * 'reqTimeout' - milliseconds to wait for a response. On timeout, the reply cb will be invoked with an error * @param cb invoked with '(err, reply)'. ommiting the cb indicates that no reply is necessary. */ Service.prototype.request = function(message, options, cb) { if (!this.isConnected()) { return; } if (typeof options === "function") { cb = options; options = {}; } options = options || {}; options.reqTimeout = options.reqTimeout || this.options.reqTimeout; if (options.streamReply) { options.allowPartial = true; } var _this = this; var reqId = Date.now() + "" + Math.random(); var replyTo = undefined; if (cb) { ++this.inflight; replyTo = this.replyTo; this.pendingReplies[reqId] = { cb: cb, timeout: setTimeout(function() { _this._handleReply({ reqId: reqId, err: "timeout" }); }, options.reqTimeout), options: options }; } return this.qService.push({ data: message, replyTo: replyTo, reqId: reqId, expires: Date.now() + options.reqTimeout }); }; /** * Mark a requester as still alive so we keep it in the cache for another hour * @param {*} replyTo the address of the requester */ Service.prototype._touchRequester = function(replyTo) { var _this = this; var requester = this.requesters[replyTo]; if (!requester) { return; } clearTimeout(requester.timer); requester.timer = setTimeout(function() { // if the timer fires, it means this specific requester hasn't made any request in the // last hour, so we clean it out of the cache requester.q.detach(); _this.requesters[replyTo] = undefined; }, 60 * 60 * 1000); // 1 hour }; /** * Handle an incoming request * @param request the incoming request * @param id id of the received message * @private */ Service.prototype._handleRequest = function(request, id) { var _this = this; try { request = JSON.parse(request); // if the incoming request is not an object, silently discard it if (typeof request !== "object") { return; } // check if the request ttl has expired, and if so, silently discard it. // (the requester will also receive a timeout error) if (Date.now() > request.expires) { return; } if (this.serveMax && this.serveMax > 0 && --this.serveMax === 0) { this.qService.stop(); } // mark that we are handling an additional request ++this.inflight; // the request handling is done, it's possible we need to shutdown var _reply = undefined; var _done = function() { --_this.inflight; _this._shutdown(); // if we have not been asked to disconnect, this will do nothing _done = undefined; _reply = undefined; }; // if the requster is expecting a reply if (request.replyTo) { // we may have the requestor reply queue already cached var requester = this.requesters[request.replyTo]; if (!requester) { // we don't have it cached requester = { q: new Queue(this.bus, request.replyTo) }; requester.q.on("error", function(err) { _this.emit("error", err); }); // attach to the requester queue for the duration of the cache requester.q.attach(); this.requesters[request.replyTo] = requester; } // mark this requester as still alive this._touchRequester(request.replyTo); // sends the reply back to the requester _reply = function(err, reply, partial) { if (err instanceof Error) { err = Object.keys(err).reduce( function(res, prop) { res[prop] = err[prop]; return res; }, { message: err.message, stack: err.stack } ); } requester.q.push({ err: err, reply: reply, reqId: request.reqId, partial: partial }); if (!partial) { requester = undefined; _done(); } }; // Create a write stream to stream data back to the requester _reply.createWriteStream = function() { var st = new stream.PassThrough({objectMode: true}); st.on("data", function(data) { requester.q.push({ err: null, reply: data, reqId: request.reqId, partial: true }); }); st.on("end", function() { requester.q.push({ err: null, reqId: request.reqId }); st = undefined; }); return st; }; _reply.replyTo = request.replyTo; } else { // we don't need to send a reply _reply = _done; } this.emit("request", request.data, _reply); } catch (e) { this.logger.warning("failed to handle incoming request: " + e.message); } }; /** * Handle a reply * @param reply the reply from the service * @param id id of the received message * @private */ Service.prototype._handleReply = function(reply, id) { // lookup the request id that this reply is for try { if (typeof reply === "string") { reply = JSON.parse(reply); } // if we can't find the corresponding request, silently discard the reply var data = this.pendingReplies[reply.reqId]; if (!data) { return; } // disable the timeout clearTimeout(data.timeout); // invoke the callback if (data.options.streamReply) { // if streaming a reply if (!data.options._stream) { // create the read stream just once at the beginning data.options._stream = new stream.PassThrough({objectMode: true}); data.cb.call(null, reply.err, data.options._stream); } data.options._stream.write(reply.reply); if (!reply.partial) { // end the stream if this is the last message data.options._stream.end(); data.options._stream = undefined; } } else { // not using a stream data.cb.call(null, reply.err, reply.reply, reply.partial || false); } if (!data.options.allowPartial || !reply.partial) { delete this.pendingReplies[reply.reqId]; --this.inflight; this._shutdown(); // if we have not been asked to disconnect, this will do nothing } else { var _this = this; // reset the timeout data.timeout = setTimeout(function() { _this._handleReply({ reqId: reply.reqId, err: "timeout" }); }, data.options.reqTimeout); } } catch (e) { this.logger.warning("failed to handle incoming reply: " + e.message); } }; /** * Convert all eligible methods into promise based methods instead of callback based methods */ Service.prototype.promisify = function() { return this.bus.promisify(this, ["serve", "connect", "request"]); }; /** * Tells the federation object which methods save state that need to be restored upon * reconnecting over a dropped websocket connection * @private */ Service.prototype._federationState = function() { return [{ save: "connect", unsave: "disconnect" }]; }; exports = module.exports = Service;