@gasbuddy/configured-rabbitmq-client
Version:
A configuration driven RabbitMQ client
477 lines (386 loc) • 15.6 kB
JavaScript
"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;