rascal
Version:
An advanced RabbitMQ / AMQP client built on amqplib
518 lines (453 loc) • 17.3 kB
JavaScript
const debug = require('debug')('rascal:Vhost');
const format = require('util').format;
const inherits = require('util').inherits;
const EventEmitter = require('events').EventEmitter;
const async = require('async');
const genericPool = require('generic-pool');
const uuid = require('uuid').v4;
const _ = require('lodash');
const tasks = require('./tasks');
const backoff = require('../backoff');
const setTimeoutUnref = require('../utils/setTimeoutUnref');
module.exports = {
create(config, components, next) {
new Vhost(config, components).init(next);
},
};
inherits(Vhost, EventEmitter);
function Vhost(vhostConfig, components) {
const self = this;
let connection;
let connectionConfig;
let regularChannelPool;
let confirmChannelPool;
const channelCreator = async.queue(createChannel, 1);
const init = async.compose(tasks.closeChannels, tasks.applyBindings, tasks.purgeQueues, tasks.checkQueues, tasks.assertQueues, tasks.checkExchanges, tasks.assertExchanges, tasks.createChannels, tasks.createConnection, tasks.checkVhost, tasks.assertVhost);
const connect = async.compose(tasks.createConnection);
const purge = async.compose(tasks.closeConnection, tasks.closeChannels, tasks.purgeQueues, tasks.createChannels, tasks.createConnection);
const nuke = async.compose(tasks.closeConnection, tasks.closeChannels, tasks.deleteQueues, tasks.deleteExchanges, tasks.createChannels, tasks.createConnection);
let timer = backoff({});
let paused = true;
let shuttingDown = false;
let reconnectTimeout;
this.name = vhostConfig.name;
this.connectionIndex = 0;
pauseChannelAllocation();
this.init = function (next) {
if (shuttingDown) {
debug('Aborting initialisation. Vhost %s is shutting down.', self.name);
return next();
}
debug('Initialising vhost: %s', self.name);
pauseChannelAllocation();
init(vhostConfig, { connectionIndex: self.connectionIndex, components }, (err, config, ctx) => {
if (err) return next(err);
connection = ctx.connection;
self.connectionIndex = ctx.connectionIndex;
connectionConfig = ctx.connectionConfig;
timer = backoff(ctx.connectionConfig.retry);
attachDisconnectionHandlers(config);
forwardRabbitMQConnectionEvents();
ensureChannelPools();
resumeChannelAllocation();
debug('vhost: %s was initialised with connection: %s', self.name, connection._rascal_id);
self.emit('connect');
self.emit('vhost_initialised', self.getConnectionDetails());
return next(null, self);
});
return self;
};
this.forewarn = function (next) {
debug('Forewarning vhost: %s about impending shutdown', self.name);
pauseChannelAllocation();
shuttingDown = true;
channelCreator.resume();
next();
};
this.shutdown = function (next) {
debug('Shutting down vhost: %s', self.name);
clearTimeout(reconnectTimeout);
pauseChannelAllocation();
drainChannelPools((err) => {
if (err) return next(err);
self.disconnect(next);
});
};
this.nuke = function (next) {
debug('Nuking vhost: %s', self.name);
pauseChannelAllocation();
drainChannelPools((err) => {
if (err) return next(err);
nuke(vhostConfig, { connectionIndex: self.connectionIndex }, (err) => {
if (err) return next(err);
debug('Finished nuking vhost: %s', self.name);
setImmediate(next);
});
});
};
this.purge = function (next) {
debug('Purging vhost: %s', self.name);
purge(vhostConfig, { purge: true, connectionIndex: self.connectionIndex }, (err) => {
if (err) return next(err);
debug('Finished purging vhost: %s', self.name);
setImmediate(next);
});
};
this.bounce = function (next) {
async.series([self.disconnect, self.init], (err) => {
if (err) return next(err);
debug('Finished bouncing vhost: %s', self.name);
setImmediate(next);
});
};
this.connect = function (next) {
debug('Connecting to vhost: %s', self.name);
connect(vhostConfig, { connectionIndex: self.connectionIndex }, (err, config, ctx) => {
return next(err, ctx.connection);
});
};
this.disconnect = function (next) {
debug('Disconnecting from vhost: %s', self.name);
if (!connection) return next();
connection.removeAllListeners();
connection.on('error', (err) => {
debug('Error disconnecting from %s. Original error was: %s', connectionConfig.loggableUrl, err.message);
});
connection.close((err) => {
connection = undefined;
next(err);
});
};
this.getChannel = function (next) {
channelCreator.push({ confirm: false }, next);
debug('Requested channel. Outstanding channel requests: %d', channelCreator.length());
};
this.getConfirmChannel = function (next) {
channelCreator.push({ confirm: true }, next);
debug('Requested confirm channel. Outstanding channel requests: %d', channelCreator.length());
};
this.borrowChannel = function (next) {
if (!regularChannelPool) return next(new Error(format('Vhost: %s must be initialised before you can borrow a channel', self.name)));
regularChannelPool.borrow(next);
};
this.returnChannel = function (channel) {
if (!regularChannelPool) return;
regularChannelPool.release(channel);
};
this.destroyChannel = function (channel) {
if (!regularChannelPool) return;
regularChannelPool.destroy(channel);
};
this.borrowConfirmChannel = function (next) {
if (!confirmChannelPool) return next(new Error(format('Vhost: %s must be initialised before you can borrow a confirm channel', self.name)));
confirmChannelPool.borrow(next);
};
this.returnConfirmChannel = function (channel) {
if (!confirmChannelPool) return;
confirmChannelPool.release(channel);
};
this.destroyConfirmChannel = function (channel) {
if (!confirmChannelPool) return;
confirmChannelPool.destroy(channel);
};
this.isPaused = function () {
return paused;
};
this.getConnectionDetails = function () {
return { vhost: self.name, connectionUrl: connectionConfig.loggableUrl };
};
function createChannelPool(options) {
const mode = getChannelMode(options.confirm);
let pool;
let poolQueue;
let busy = false;
const factory = {
create() {
return new Promise((resolve, reject) => {
debug('Creating pooled %s channel for vhost: %s', mode, vhostConfig.name);
createChannelWhenInitialised(options.confirm, (err, channel) => {
if (err) return deferRejection(reject, err);
if (!channel) return deferRejection(reject, new Error('Vhost is shutting down'));
const destroyChannel = _.once(() => {
debug('Destroying %s channel: %s for vhost: %s due to error or close event', mode, channel._rascal_id, vhostConfig.name);
channel._rascal_closed = true;
if (pool.isBorrowedResource(channel)) {
pool.destroy(channel).catch((err) => {
debug('Error destroying %s channel: %s for vhost: %s. %s', mode, channel._rascal_id, vhostConfig.name, err.message);
});
}
});
channel.on('error', destroyChannel);
channel.on('close', destroyChannel);
resolve(channel);
});
});
},
destroy(channel) {
return new Promise((resolve, reject) => {
debug('Destroying %s channel: %s for vhost: %s', mode, channel._rascal_id, vhostConfig.name);
if (channel._rascal_closed) {
resolve();
return;
}
channel.removeAllListeners();
channel.on('error', reject);
const closeChannelCb = (err) => {
if (err) return reject(err);
resolve();
};
// When a connection drops it may take a while for the heartbeat protocol or TCP keep alives to notice
// Consequently a publication using confirm channels may timeout and attempt to close the channel
// before Rascal notices that the connection died. In this circumstance the channel close command
// will never receive a response from the broker, and the callback will never yield.
const once = _.once(closeChannelCb);
setTimeoutUnref(() => {
once(new Error(format('Timeout after %dms closing %s channel: %s for vhost: %s', options.pool.destroyTimeoutMillis, mode, channel._rascal_id, vhostConfig.name)));
}, 1000);
channel.close(once);
});
},
validate(channel) {
return new Promise((resolve) => {
resolve(!channel._rascal_closed && connection && connection.connection === channel.connection);
});
},
};
function deferRejection(reject, err) {
// generic-pool does not handle rejection well - it results in a tight loop which will
// eat CPU and consume memory. Using setTimeout to spread the loop out a bit
// https://github.com/coopernurse/node-pool/issues/197#issuecomment-477862861
setTimeoutUnref(() => {
reject(err);
}, options.pool.rejectionDelayMillis);
}
function stats() {
return {
vhost: self.name,
mode,
queue: poolQueue.length(),
size: pool.size,
available: pool.available,
borrowed: pool.borrowed,
min: pool.min,
max: pool.max,
};
}
function borrow(next) {
debug('Requested %s channel. %o', mode, stats());
if (poolQueue.length() >= options.pool.max) {
busy = true;
self.emit('busy', stats());
}
poolQueue.push(null, (err, channel) => {
if (err) return next(err);
debug('Borrowed %s channel: %s. %o', mode, channel._rascal_id, stats());
next(null, channel);
});
}
function release(channel) {
debug('Releasing %s channel: %s. %o', mode, channel._rascal_id, stats());
pool
.release(channel)
.catch((err) => {
debug('Error releasing %s channel: %s. %s', mode, channel._rascal_id, err.message);
})
.then(() => checkReady());
}
function destroy(channel) {
debug('Destroying %s channel: %s. %o', mode, channel._rascal_id, stats());
pool
.destroy(channel)
.catch((err) => {
debug('Error destroying %s channel: %s. %s', mode, channel._rascal_id, err.message);
})
.then(() => checkReady());
}
function checkReady() {
if (poolQueue.length() > 0 || !busy) return;
busy = false;
self.emit('ready', stats());
}
function drain(next) {
debug('Draining %s channel pool. %o', mode, stats());
pool
.drain()
.then(() => {
return pool.clear().then(() => {
debug('Drained %s channel pool. %o', mode, stats());
setImmediate(next);
});
})
.catch((err) => {
debug('Error draining %s channel pool. %s', mode, err.message);
setImmediate(next);
});
}
debug('Creating %s channel pool %o', mode, options.pool);
pool = genericPool.createPool(factory, options.pool);
pool.on('factoryCreateError', (err) => {
debug('Create error emitted by %s channel pool: %s', mode, err.message);
});
pool.on('factoryDestroyError', (err) => {
debug('Destroy error emitted by %s channel pool: %s', mode, err.message);
});
poolQueue = async.queue((__, next) => {
pool
.acquire()
.then((channel) => {
setImmediate(() => {
next(null, channel);
});
})
.catch(next);
}, 1);
return {
stats,
borrow,
release,
destroy,
drain,
pause: poolQueue.pause.bind(poolQueue),
resume: poolQueue.resume.bind(poolQueue),
};
}
function createChannelWhenInitialised(confirm, next) {
if (connection) return createChannel(confirm, next);
debug('Vhost: %s is not initialised. Deferring channel creation', self.name);
function onVhostInitialised() {
debug('Vhost: %s was initialised. Resuming channel creation', self.name);
createChannel(confirm, next);
}
self.once('vhost_initialised', () => {
onVhostInitialised();
});
}
function createChannel(confirm, next) {
if (shuttingDown) {
debug('Ignoring create channel request. Vhost: %s is shutting down.', self.name);
return next();
}
if (!connection) return next(new Error(format('Vhost: %s must be initialised before you can create a channel', self.name)));
// Same problem as https://github.com/onebeyond/rascal/issues/17
const once = _.once(next);
let invocations = 0;
const channelId = uuid();
connection.once('close', closeHandler);
connection.once('error', errorHandler);
confirm ? connection.createConfirmChannel(callback) : connection.createChannel(callback);
function closeHandler() {
once(new Error('Connection closed'));
}
function errorHandler(err) {
once(err);
}
function callback(err, channel) {
invocations++;
connection && connection.removeListener('close', closeHandler);
connection && connection.removeListener('error', errorHandler);
if (err) {
debug('Error creating channel: %s from %s: %s', channelId, connectionConfig.loggableUrl, err.message);
return once(err);
}
channel._rascal_id = channelId;
channel.connection._rascal_id = connection._rascal_id;
channel.connection.setMaxListeners(0);
debug('Created %s channel: %s from connection: %s', getChannelMode(confirm), channel._rascal_id, connection._rascal_id);
// See https://github.com/squaremo/amqp.node/issues/388
if (invocations > 1) {
debug('Closing superfluous channel: %s previously reported as errored', channel._rascal_id);
return channel.close();
}
once(null, channel);
}
}
function getChannelMode(confirm) {
return confirm ? 'confirm' : 'regular';
}
function pauseChannelAllocation() {
channelCreator.pause();
regularChannelPool && regularChannelPool.pause();
confirmChannelPool && confirmChannelPool.pause();
paused = true;
self.emit('paused', { vhost: self.name });
}
function resumeChannelAllocation() {
channelCreator.resume();
regularChannelPool && regularChannelPool.resume();
confirmChannelPool && confirmChannelPool.resume();
paused = false;
self.emit('resumed', { vhost: self.name });
}
function forwardRabbitMQConnectionEvents() {
connection.on('blocked', (reason) => {
self.emit('blocked', reason, self.getConnectionDetails());
});
connection.on('unblocked', () => {
self.emit('unblocked', self.getConnectionDetails());
});
}
function ensureChannelPools() {
regularChannelPool = regularChannelPool
|| createChannelPool({
confirm: false,
pool: vhostConfig.publicationChannelPools.regularPool,
});
confirmChannelPool = confirmChannelPool
|| createChannelPool({
confirm: true,
pool: vhostConfig.publicationChannelPools.confirmPool,
});
}
function drainChannelPools(next) {
async.series(
[
function (cb) {
regularChannelPool ? regularChannelPool.drain(cb) : cb();
},
function (cb) {
confirmChannelPool ? confirmChannelPool.drain(cb) : cb();
},
],
next,
);
}
function attachDisconnectionHandlers(config) {
connection.removeAllListeners('error');
const disconectionHandler = makeDisconnectionHandler(config);
connection.on('error', disconectionHandler);
connection.on('close', disconectionHandler);
}
function makeDisconnectionHandler(config) {
return _.once((err) => {
// Use setImmediate to avoid amqplib accept loop swallowing errors
setImmediate(() => (err
// Treat close events with errors as error events
? handleConnectionError(connection, config, err)
: handleConnectionClose(connection, config)));
});
}
function handleConnectionError(borked, config, err) {
debug('Handling connection error: %s initially from connection: %s, %s', err.message, borked._rascal_id, connectionConfig.loggableUrl);
pauseChannelAllocation();
connection = undefined;
self.emit('disconnect');
self.emit('error', err, self.getConnectionDetails());
retryConnection(borked, config);
}
function handleConnectionClose(borked, config) {
debug('Handling connection close initially from connection: %s, %s', borked._rascal_id, connectionConfig.loggableUrl);
pauseChannelAllocation();
connection = undefined;
self.emit('disconnect');
self.emit('close', self.getConnectionDetails());
retryConnection(borked, config);
}
function retryConnection(borked, config) {
connectionConfig.retry && self.init((err) => {
if (!err) return;
const delay = timer.next();
debug('Will attempt reconnection in in %dms', delay);
reconnectTimeout = setTimeoutUnref(handleConnectionError.bind(null, borked, config, err), delay);
});
}
}