UNPKG

@gasbuddy/configured-rabbitmq-client

Version:

A configuration driven RabbitMQ client

477 lines (386 loc) 15.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.MockRabbitClient = exports.default = void 0; var _events = require("events"); var _assert = _interopRequireDefault(require("assert")); var _fooFooMq = _interopRequireDefault(require("foo-foo-mq")); var _lodash = _interopRequireDefault(require("lodash")); var _exchangeGroups = require("./exchangeGroups"); var _WrappedMessage = require("./WrappedMessage"); var _boleLogger = _interopRequireDefault(require("./boleLogger")); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } const exchangeErrorRE = /Failed to create exchange '(.*)' on connection 'default'/; const ORIGINAL_ARGS = Symbol('Original rabbitmq options'); function isDev() { return process.env.NODE_ENV === 'development' || !process.env.NODE_ENV; } async function delay(ms) { return new Promise(accept => setTimeout(accept, ms)); } function finalConfigFromConfig(context, opts, mqConnectionConfig) { let exchangeGroups = opts.config && opts.config.exchangeGroups || {}; exchangeGroups = (0, _exchangeGroups.normalizeExchangeGroups)(exchangeGroups); const exchangeGroupConfig = (0, _exchangeGroups.fooFooConfigFromExchangeGroups)(exchangeGroups); const finalConfig = Object.assign({}, opts.config); delete finalConfig.exchangeGroups; finalConfig.exchanges = (finalConfig.exchanges || []).concat(exchangeGroupConfig.exchanges); finalConfig.queues = (finalConfig.queues || []).concat(exchangeGroupConfig.queues); finalConfig.bindings = (finalConfig.bindings || []).concat(exchangeGroupConfig.bindings); finalConfig.connection = Object.assign({}, finalConfig.connection, mqConnectionConfig); let dependencies = opts.config && opts.config.dependencies || []; const dependencyAttribs = {}; // Create dependencies in test mode. if (process.env.NODE_ENV !== 'test') { dependencyAttribs.passive = true; } dependencies = _lodash.default.map(dependencies, d => Object.assign({ name: d }, dependencyAttribs)); finalConfig.exchanges = finalConfig.exchanges.concat(dependencies); if (opts.logging) { _fooFooMq.default.log((0, _boleLogger.default)(context.logger, Array.isArray(opts.logging) ? opts.logging : undefined)); } finalConfig.exchangeGroups = exchangeGroups; return finalConfig; } class FooFooClient extends _events.EventEmitter { constructor(context, opts) { super(); (0, _assert.default)(opts, 'configured-rabbitmq-client must be passed arguments'); (0, _assert.default)(opts.username, 'configured-rabbitmq-client missing username setting'); (0, _assert.default)(opts.password, 'configured-rabbitmq-client missing password setting'); const hostname = Array.isArray(opts.hostname) ? opts.hostname.join(',') : opts.hostname; context.logger.info('Initializing RabbitMQ client', { user: opts.username, host: hostname || 'rabbitmq' }); const mqConnectionConfig = { // In our usage, we've found that a short timeout causes trouble // on startup with spurious connection errors just because the box // is busy. So we default to a higher initial timeout timeout: 15000, // We don't typically use reply queues and they tend to cruft up // the server, so disable by default replyQueue: false, ...opts.connectionOptions, user: opts.username, pass: opts.password, host: hostname || 'rabbitmq', port: opts.port || 5672, vhost: opts.basePath || '/' }; // Event handlers need to be cleaned up afterwards... this.connSubscription = _fooFooMq.default.on('connected', () => { context.logger.info('RabbitMQ connection established.'); }); this[ORIGINAL_ARGS] = { opts, mqConnectionConfig }; this.finalConfig = finalConfigFromConfig(context, opts, mqConnectionConfig); this.exchangeGroups = this.finalConfig.exchangeGroups; delete this.finalConfig.exchangeGroups; this.originalContext = context; this.contextFunction = opts.contextFunction; this.startedCalled = false; this.subs = []; this.client = _fooFooMq.default; this.subscriptions = opts.subscriptions; } // Function should recieve a queue message and return a gb-services style context. publish(...args) { return this.client.publish(...args); } request(...args) { return this.client.request(...args); } bulkPublish(...args) { return this.client.bulkPublish(...args); } async start(context) { (0, _assert.default)(!this.startCalled, 'start called multiple times on configured-rabbitmq-client instance'); this.startCalled = true; const maxRetries = 5; for (let retries = maxRetries; retries >= 0; retries -= 1) { try { context.logger.info('Configuring RabbitMQ client'); // eslint-disable-next-line no-await-in-loop await _fooFooMq.default.configure(this.finalConfig); break; } catch (stringError) { if (process.env.MQ_MAKE_EXCHANGES && exchangeErrorRE.test(stringError.message)) { const [, failedQueue] = stringError.message.match(exchangeErrorRE); const { opts, mqConnectionConfig } = this[ORIGINAL_ARGS]; _lodash.default.pull(opts.config.dependencies, failedQueue); opts.config.exchangeGroups = opts.config.exchangeGroups || {}; opts.config.exchangeGroups[failedQueue] = { keys: '*' }; this.finalConfig = finalConfigFromConfig(context, opts, mqConnectionConfig); this.exchangeGroups = this.finalConfig.exchangeGroups; delete this.finalConfig.exchangeGroups; retries += 1; // this one doesn't count // Unforunately, foofoomq seems to need time to sort its sh** out. context.logger.info('Queue setup failed. Waiting 2.5s then creating queue', { name: failedQueue }); // eslint-disable-next-line no-await-in-loop await delay(2500); } if (retries) { // This comparison basically is only false when we've done the auto-create thing above if (retries <= maxRetries) { context.logger.warn(`Queue configuration failed, retrying ${retries} more times`, stringError); if (isDev() && exchangeErrorRE.test(stringError)) { context.logger.info(` *-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-* Exchanges are missing. Typically this comes from dependent services which you are not running. In order to create these queues with default characteristics, set the MQ_MAKE_EXCHANGES environment variable and restart. *-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*`); } } try { // eslint-disable-next-line no-await-in-loop await _fooFooMq.default.shutdown(); _fooFooMq.default.reset(); context.logger.info('RabbitMQ cleanup complete'); } catch (inner) { context.logger.warn('RabbitMQ cleanup failed', { error: inner.message, stack: inner.stack }); } if (retries <= maxRetries) { // eslint-disable-next-line no-await-in-loop await delay((1 + (maxRetries - retries)) * 2000); } } else { if (typeof stringError === 'string') { throw new Error(stringError); } throw stringError; } } } _fooFooMq.default.nackUnhandled(); _fooFooMq.default.nackOnError(); if (typeof this.subscriptions === 'object') { for (const [, sub] of Object.entries(this.subscriptions)) { this.subscribe(sub.queue, sub.type, sub.handler); } } this.closeSubscription = _fooFooMq.default.on('closed', () => { if (!this.shuttingDown) { context.logger.error('RabbitMQ connection was closed.'); } }); this.unreachSubscription = _fooFooMq.default.on('unreachable', () => { // TODO shutdown the process? context.logger.error('RabbitMQ connection has failed permanently.'); this.emit('unreachable'); }); this.failSubscription = _fooFooMq.default.onReturned('failed', e => { context.logger.error('RabbitMQ connection has failed.', { error: e.message, stack: e.stack }); }); return this; } async stop(context) { (0, _assert.default)(this.startCalled, 'stop called multiple times on configured-rabbitmq-client instance'); context.logger.info('Closing RabbitMQ connection'); await Promise.all(this.subs.map(s => { s[0].remove(); return FooFooClient.gracefulQueueShutdown(s[1]); })); this.shuttingDown = true; this.connSubscription.unsubscribe(); delete this.connSubscription; if (this.closeSubscription) { this.closeSubscription.unsubscribe(); delete this.closeSubscription; } if (this.unreachSubscription) { this.unreachSubscription.unsubscribe(); delete this.unreachSubscription; } await _fooFooMq.default.shutdown(); _fooFooMq.default.reset(); this.startCalled = false; } async subscribe(exchangeGroupOrQueue, type, handler) { if (process.env.DISABLE_RABBITMQ_SUBSCRIPTIONS === 'true') { return; } if (process.env.DISABLE_RABBITMQ_SUBSCRIPTIONS) { const disabled = process.env.DISABLE_RABBITMQ_SUBSCRIPTIONS.split(','); if (disabled.includes(exchangeGroupOrQueue) || disabled.includes(`${exchangeGroupOrQueue}$${type}`)) { return; } } let wrappedHandler = async rmqMessage => { const message = new _WrappedMessage.WrappedMessage(this, rmqMessage); if (handler.length === 2) { const context = this.contextFunction && (await this.contextFunction(this.originalContext, message)); await handler(context, message); } else { await handler(message); } }; let finalQueueName = exchangeGroupOrQueue; const exchangeGroup = this.exchangeGroups[exchangeGroupOrQueue]; if (exchangeGroup) { finalQueueName = exchangeGroup.queue.name; if (exchangeGroup.retries) { wrappedHandler = async rmqMessage => { const message = new _WrappedMessage.WrappedMessage(this, rmqMessage); let context; try { if (handler.length === 2) { context = this.contextFunction && (await this.contextFunction(this.originalContext, message)); await handler(context, message); } else { await handler(message); } } catch (e) { const headers = message.properties.headers || {}; const retryCount = (headers.retryCount || 0) + 1; headers.retryCount = retryCount; headers.error = e.message; // Error logging if (context) { const loggingMetadata = context.gb.wrapError(e); loggingMetadata.queueName = finalQueueName; loggingMetadata.type = message.type; loggingMetadata.routingKey = message.fields.routingKey; loggingMetadata.retryCount = retryCount; context.gb.logger.error('Error handling queue message.', loggingMetadata); } if (retryCount > exchangeGroup.retries || e.noRetry) { message.reject(); } else { const messageOptions = { type: message.type, body: message.body, routingKey: message.fields.routingKey, correlationId: message.properties.correlationId, timestamp: message.properties.timestamp, expiresAfter: exchangeGroup.retryQueue.perMessageTtl || undefined, headers }; await this.publish(exchangeGroup.retryExchange.name, messageOptions); message.ack(); } } }; } } const handlerThunk = _fooFooMq.default.handle(type, wrappedHandler, finalQueueName); let mq = _fooFooMq.default.getQueue(finalQueueName); if (!mq) { // Let's give it a try. mq = _fooFooMq.default.getQueue(`${finalQueueName}.q`); } (0, _assert.default)(mq, `Specified queue (${finalQueueName}) does not exist`); mq.subscribe(false); this.subs.push([handlerThunk, mq]); } // eslint-disable-next-line class-methods-use-this metadata() { return { activeMessages: Array.from(_WrappedMessage.WrappedMessage.activeMessages()) }; } static async gracefulQueueShutdown(q) { var _q$lastQueue, _q$lastQueue$messages, _q$lastQueue$messages2; if (q === null || q === void 0 ? void 0 : (_q$lastQueue = q.lastQueue) === null || _q$lastQueue === void 0 ? void 0 : (_q$lastQueue$messages = _q$lastQueue.messages) === null || _q$lastQueue$messages === void 0 ? void 0 : (_q$lastQueue$messages2 = _q$lastQueue$messages.messages) === null || _q$lastQueue$messages2 === void 0 ? void 0 : _q$lastQueue$messages2.length) { return new Promise(accept => { q.lastQueue.messages.on('empty', accept).once(); }); } // Mostly for tests which restart right away, but Rabbit is finicky return delay(1000); } static get activeMessages() { return _WrappedMessage.WrappedMessage.activeMessages(); } } exports.default = FooFooClient; class MockRabbitClient { constructor() { this.subscriptions = {}; this.publishMocks = {}; } async internalPublish(exchange, keyOrOptions, maybeBody) { let key = keyOrOptions; let body = maybeBody; if (typeof key !== 'string') { key = keyOrOptions.type; ({ body } = keyOrOptions); } const mock = (this.publishMocks[exchange] || {})[key]; if (mock) { await mock(exchange, key, body); } else { this.context.logger.info(`Unmocked messaged published. Exchange: ${exchange} Key: ${key}`); } } async publish(...args) { return this.internalPublish(...args); } async request(...args) { return this.internalPublish(...args); } async bulkPublish(bulk) { if (Array.isArray(bulk)) { return Promise.all(bulk.map(({ type, body, exchange }) => this.internalPublish(exchange, type, body))); } return Promise.all(Object.entries(bulk).map(([exchange, { type, body }]) => this.internalPublish(exchange, type, body))); } async start(context) { this.context = context; return this; } async subscribe(queueName, key, handler) { this.subscriptions[queueName] = this.subscriptions[queueName] || {}; this.subscriptions[queueName][key] = handler; } resetPublishMocks() { this.publishMocks = {}; } async mockPublish(exchange, key, handler) { this.publishMocks[exchange] = this.publishMocks[exchange] || {}; this.publishMocks[exchange][key] = handler; } async testMessage(context, queueName, key, body, { ack = () => {}, nack = () => {}, reject = () => {} } = {}) { const handler = (this.subscriptions[queueName] || {})[key]; if (!handler) { throw new Error(`No handler for key ${key} on queue ${queueName}`); } else { const message = { body, ack, nack, reject }; if (handler.length === 2) { await handler(context, message); } else { await handler(message); } } } } exports.MockRabbitClient = MockRabbitClient;