UNPKG

@gasbuddy/configured-rabbitmq-client

Version:

A configuration driven RabbitMQ client

429 lines (376 loc) 14.4 kB
import { EventEmitter } from 'events'; import assert from 'assert'; import foofoo from 'foo-foo-mq'; import _ from 'lodash'; import { normalizeExchangeGroups, fooFooConfigFromExchangeGroups, } from './exchangeGroups'; import { WrappedMessage } from './WrappedMessage'; import boleLogger from './boleLogger'; 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 = normalizeExchangeGroups(exchangeGroups); const exchangeGroupConfig = 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 = _.map(dependencies, d => Object.assign({ name: d }, dependencyAttribs)); finalConfig.exchanges = finalConfig.exchanges.concat(dependencies); if (opts.logging) { foofoo.log(boleLogger(context.logger, Array.isArray(opts.logging) ? opts.logging : undefined)); } finalConfig.exchangeGroups = exchangeGroups; return finalConfig; } export default class FooFooClient extends EventEmitter { constructor(context, opts) { super(); assert(opts, 'configured-rabbitmq-client must be passed arguments'); assert(opts.username, 'configured-rabbitmq-client missing username setting'); assert(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 = foofoo.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 = foofoo; 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) { assert(!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 foofoo.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]; _.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 foofoo.shutdown(); foofoo.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; } } } foofoo.nackUnhandled(); foofoo.nackOnError(); if (typeof this.subscriptions === 'object') { for (const [, sub] of Object.entries(this.subscriptions)) { this.subscribe(sub.queue, sub.type, sub.handler); } } this.closeSubscription = foofoo.on('closed', () => { if (!this.shuttingDown) { context.logger.error('RabbitMQ connection was closed.'); } }); this.unreachSubscription = foofoo.on('unreachable', () => { // TODO shutdown the process? context.logger.error('RabbitMQ connection has failed permanently.'); this.emit('unreachable'); }); this.failSubscription = foofoo.onReturned('failed', (e) => { context.logger.error('RabbitMQ connection has failed.', { error: e.message, stack: e.stack, }); }); return this; } async stop(context) { assert(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 foofoo.shutdown(); foofoo.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(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(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 = foofoo.handle(type, wrappedHandler, finalQueueName); let mq = foofoo.getQueue(finalQueueName); if (!mq) { // Let's give it a try. mq = foofoo.getQueue(`${finalQueueName}.q`); } assert(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.activeMessages()), }; } static async gracefulQueueShutdown(q) { if (q?.lastQueue?.messages?.messages?.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.activeMessages(); } } export 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); } } } }