@moleculer/channels
Version:
Reliable messages for Moleculer services
620 lines (543 loc) • 17.6 kB
JavaScript
/*
* @moleculer/channels
* Copyright (c) 2021 MoleculerJS (https://github.com/moleculerjs/channels)
* MIT Licensed
*/
;
const BaseAdapter = require("./base");
const _ = require("lodash");
const { MoleculerError, MoleculerRetryableError } = require("moleculer").Errors;
const C = require("../constants");
let Amqplib;
/**
* @typedef {import('amqplib').Connection} AMQPLibConnection AMQP connection
* @typedef {import('amqplib').Channel} AMQPLibChannel AMQP Channel. More info: http://www.squaremobius.net/amqp.node/channel_api.html#channel
* @typedef {import("moleculer").ServiceBroker} ServiceBroker Moleculer Service Broker instance
* @typedef {import("moleculer").LoggerInstance} Logger Logger instance
* @typedef {import("../index").Channel} Channel Base channel definition
* @typedef {import("./base").BaseDefaultOptions} BaseDefaultOptions Base adapter options
*/
/**
* @typedef {Object} AmqpDefaultOptions AMQP Adapter configuration
* @property {Number} maxInFlight Max-in-flight messages
* @property {Object} amqp AMQP lib configuration
* @property {String|String[]} amqp.url Connection URI
* @property {Object} amqp.socketOptions AMQP lib socket configuration
* @property {Object} amqp.queueOptions AMQP lib queue configuration
* @property {Object} amqp.exchangeOptions AMQP lib exchange configuration
* @property {Object} amqp.messageOptions AMQP lib message configuration
* @property {Object} amqp.consumerOptions AMQP lib consume configuration
* @property {publishAssertExchange} amqp.publishAssertExchange AMQP lib exchange configuration for one-time calling assertExchange() before publishing in new exchange by sendToChannel
*/
/**
* @typedef {Object} publishAssertExchange
* @property {Boolean} enabled Enable/disable one-time calling channel.assertExchange() before publishing in new exchange by sendToChannel
* @property {Object} exchangeOptions AMQP lib exchange configuration https://amqp-node.github.io/amqplib/channel_api.html#channel_assertExchange
*/
/**
* @typedef {Object} SubscriptionEntry
* @property {Channel & AmqpDefaultOptions} chan AMQP Channel
* @property {String} consumerTag AMQP consumer tag. More info: https://www.rabbitmq.com/consumers.html#consumer-tags
*/
/**
* AMQP adapter for RabbitMQ
*
* TODO: rewrite to using RabbitMQ Streams
* https://www.rabbitmq.com/streams.html
*
* @class AmqpAdapter
* @extends {BaseAdapter}
*/
class AmqpAdapter extends BaseAdapter {
/**
* Constructor of adapter
* @param {Object?} opts
*/
constructor(opts) {
if (_.isString(opts)) {
opts = {
amqp: {
url: opts
}
};
}
super(opts);
/** @type {AmqpDefaultOptions & BaseDefaultOptions} */
this.opts = _.defaultsDeep(this.opts, {
maxInFlight: 1,
amqp: {
socketOptions: {},
queueOptions: {},
exchangeOptions: {},
messageOptions: {},
consumerOptions: {}
}
});
if (typeof this.opts.amqp.url == "string") {
this.opts.amqp.url = this.opts.amqp.url
.split(";")
.filter(s => !!s)
.map(s => s.trim());
}
/** @type {AMQPLibConnection} */
this.connection = null;
/** @type {AMQPLibChannel} */
this.channel = null;
this.clients = new Map();
/**
* @type {Map<string,SubscriptionEntry>}
*/
this.subscriptions = new Map();
this.stopping = false;
this.connectAttempt = 0;
this.connectionCount = 0; // To detect reconnections
/**
* @type {Set<string>}
*/
this.assertedExchanges = new Set(); // For a collecting exchange names on which assertExchange() was called
}
/**
* Initialize the adapter.
*
* @param {ServiceBroker} broker
* @param {Logger} logger
*/
init(broker, logger) {
super.init(broker, logger);
try {
Amqplib = require("amqplib");
} catch (err) {
/* istanbul ignore next */
this.broker.fatal(
"The 'amqplib' package is missing! Please install it with 'npm install amqplib --save' command.",
err,
true
);
}
this.checkClientLibVersion("amqplib", "^0.8.0 || ^0.9.0");
}
/**
* Connect to the adapter with reconnecting logic
*/
connect() {
return new Promise(resolve => {
const doConnect = () => {
this.tryConnect()
.then(resolve)
.catch(err => {
this.logger.error("Unable to connect AMQP server.", err);
setTimeout(() => {
this.logger.info("Reconnecting...");
doConnect();
}, 2000);
});
};
doConnect();
});
}
/**
* Trying connect to the adapter.
*/
async tryConnect() {
let uri = this.opts.amqp.url;
if (Array.isArray(uri)) {
this.connectAttempt = (this.connectAttempt || 0) + 1;
const urlIndex = (this.connectAttempt - 1) % uri.length;
uri = uri[urlIndex];
}
this.logger.debug("Connecting to AMQP server...", uri);
this.connection = await Amqplib.connect(uri, this.opts.amqp.socketOptions);
this.connected = true;
this.connection
.on("error", err => {
// No need to reject here since close event will be fired after
// if not connected at all connection promise will be rejected
this.logger.error("AMQP connection error.", err);
})
.on("close", err => {
this.connected = false;
if (!this.stopping) {
this.logger.error("AMQP connection is closed.", err);
setTimeout(() => {
this.logger.info("Reconnecting...");
this.connect();
}, 2000);
} else {
this.logger.info("AMQP connection is closed gracefully.");
}
})
.on("blocked", reason => {
this.logger.warn("AMQP connection is blocked.", reason);
})
.on("unblocked", () => {
this.logger.info("AMQP connection is unblocked.");
});
this.logger.info("AMQP is connected.");
this.logger.debug(`Creating AMQP channel...`);
this.channel = await this.connection.createChannel();
this.channel
.on("close", () => {
if (!this.stopping) {
this.logger.error("AMQP channel closed.");
}
})
.on("error", err => {
this.logger.error("AMQP channel error", err);
})
.on("drain", () => {
this.logger.info("AMQP channel is drained.");
})
.on("return", msg => {
this.logger.warn("AMQP channel returned a message.", msg);
});
if (this.opts.maxInFlight != null) {
this.channel.prefetch(this.opts.maxInFlight);
}
this.logger.info("AMQP channel created.");
this.connectionCount++;
const isReconnect = this.connectionCount > 1;
if (isReconnect) {
await this.resubscribeAllChannels();
}
}
/**
* Disconnect from adapter
*/
async disconnect() {
if (this.stopping) return;
this.stopping = true;
try {
if (this.connection) {
await new Promise((resolve, reject) => {
const checkPendingMessages = () => {
if (this.getNumberOfTrackedChannels() === 0) {
// Stop the connection
this.logger.info("Closing AMQP connection...");
return this.connection
.close()
.then(() => {
this.connection = null;
this.channel = null;
this.connected = false;
resolve();
})
.catch(reject);
} else {
this.logger.warn(
`Processing ${this.getNumberOfTrackedChannels()} active connections(s)...`
);
setTimeout(checkPendingMessages, 1000);
}
};
setImmediate(checkPendingMessages);
});
}
} catch (err) {
this.logger.error("Error while closing AMQP connection.", err);
}
this.assertedExchanges.clear();
this.stopping = false;
}
/**
* Subscribe to a channel.
*
* @param {Channel & AmqpDefaultOptions} chan
*/
async subscribe(chan) {
this.logger.debug(
`Subscribing to '${chan.name}' chan with '${chan.group}' group...'`,
chan.id
);
try {
if (chan.maxRetries == null) chan.maxRetries = this.opts.maxRetries;
chan.deadLettering = _.defaultsDeep({}, chan.deadLettering, this.opts.deadLettering);
if (chan.deadLettering.enabled) {
chan.deadLettering.queueName = this.addPrefixTopic(chan.deadLettering.queueName);
chan.deadLettering.exchangeName = this.addPrefixTopic(
chan.deadLettering.exchangeName
? chan.deadLettering.exchangeName
: "DEAD_LETTER"
);
this.logger.debug(`Asserting exchange ${chan.deadLettering.exchangeName}`);
this.assertedExchanges.add(chan.deadLettering.exchangeName);
await this.channel.assertExchange(
chan.deadLettering.exchangeName,
"fanout",
_.defaultsDeep(
{},
chan.deadLettering.exchangeOptions,
this.opts.amqp.exchangeOptions
)
);
// assert dead letter queue
this.logger.debug(`Asserting queue '${chan.deadLettering.queueName}'`);
await this.channel.assertQueue(
chan.deadLettering.queueName,
_.defaultsDeep({}, chan.deadLettering.queueOptions, this.opts.amqp.queueOptions)
);
// bind queue to exchange
this.logger.debug(
`Binding '${chan.deadLettering.exchangeName}' -> '${chan.deadLettering.queueName}'...`
);
this.channel.bindQueue(
chan.deadLettering.queueName,
chan.deadLettering.exchangeName,
""
);
}
// --- CREATE EXCHANGE ---
// More info: http://www.squaremobius.net/amqp.node/channel_api.html#channel_assertExchange
const exchangeOptions = _.defaultsDeep(
{},
chan.amqp ? chan.amqp.exchangeOptions : {},
this.opts.amqp.exchangeOptions
);
this.logger.debug(`Asserting '${chan.name}' fanout exchange...`, exchangeOptions);
this.channel.assertExchange(chan.name, "fanout", exchangeOptions);
// --- CREATE QUEUE ---
const queueName = `${chan.group}.${chan.name}`;
// More info: http://www.squaremobius.net/amqp.node/channel_api.html#channel_assertQueue
const queueOptions = _.defaultsDeep(
{},
chan.amqp ? chan.amqp.queueOptions : {},
this.opts.amqp.queueOptions
);
this.logger.debug(`Asserting '${queueName}' queue...`, queueOptions);
await this.channel.assertQueue(queueName, queueOptions);
// --- BIND QUEUE TO EXCHANGE ---
this.logger.debug(`Binding '${chan.name}' -> '${queueName}'...`);
this.channel.bindQueue(queueName, chan.name, "");
// More info http://www.squaremobius.net/amqp.node/channel_api.html#channel_consume
const consumerOptions = _.defaultsDeep(
{},
chan.amqp ? chan.amqp.consumerOptions : {},
this.opts.amqp.consumerOptions
);
this.initChannelActiveMessages(chan.id, false);
this.logger.debug(`Consuming '${queueName}' queue...`, consumerOptions);
const res = await this.channel.consume(
queueName,
this.createConsumerHandler(chan),
consumerOptions
);
this.subscriptions.set(chan.id, { chan, consumerTag: res.consumerTag });
} catch (err) {
this.logger.error(
`Error while subscribing to '${chan.name}' chan with '${chan.group}' group`,
err
);
throw err;
}
}
/**
* Create a handler for the consumer.
*
* @param {Channel & AmqpDefaultOptions} chan
* @returns {Function}
*/
createConsumerHandler(chan) {
return async msg => {
// Service is stopping. Skip processing...
if (chan.unsubscribing) return;
this.logger.debug(`AMQP message received in '${chan.name}' queue. Processing...`);
const id =
msg.properties.correlationId ||
`${msg.fields.consumerTag}:${msg.fields.deliveryTag}`;
try {
this.addChannelActiveMessages(chan.id, [id]);
const content = this.serializer.deserialize(msg.content);
//this.logger.debug("Content:", content);
await chan.handler(content, msg);
this.channel.ack(msg);
this.removeChannelActiveMessages(chan.id, [id]);
} catch (err) {
this.removeChannelActiveMessages(chan.id, [id]);
this.metricsIncrement(C.METRIC_CHANNELS_MESSAGES_ERRORS_TOTAL, chan);
this.logger.warn(`AMQP message processing error in '${chan.name}' queue.`, err);
if (!chan.maxRetries) {
if (chan.deadLettering.enabled) {
// Reached max retries and has dead-letter topic, move message
this.logger.debug(
`No retries, moving message to '${chan.deadLettering.queueName}' queue...`
);
await this.moveToDeadLetter(chan, msg);
} else {
// No retries, drop message
this.logger.error(`No retries, drop message...`);
this.channel.nack(msg, false, false);
}
return;
}
let redeliveryCount = msg.properties.headers[C.HEADER_REDELIVERED_COUNT] || 0;
redeliveryCount++;
if (chan.maxRetries > 0 && redeliveryCount >= chan.maxRetries) {
if (chan.deadLettering.enabled) {
// Reached max retries and has dead-letter topic, move message
this.logger.debug(
`Message redelivered too many times (${redeliveryCount}). Moving message to '${chan.deadLettering.queueName}' queue...`
);
await this.moveToDeadLetter(chan, msg);
} else {
// Reached max retries and no dead-letter topic, drop message
this.logger.error(
`Message redelivered too many times (${redeliveryCount}). Drop message...`
);
this.channel.nack(msg, false, false);
}
} else {
// Redeliver the message directly to the queue instead of exchange
const queueName = `${chan.group}.${chan.name}`;
this.logger.warn(
`Redeliver message into '${queueName}' queue.`,
redeliveryCount
);
this.metricsIncrement(C.METRIC_CHANNELS_MESSAGES_RETRIES_TOTAL, chan);
const res = this.channel.publish("", queueName, msg.content, {
headers: Object.assign({}, msg.properties.headers, {
[C.HEADER_REDELIVERED_COUNT]: redeliveryCount
})
});
if (res === false)
throw new MoleculerError("AMQP publish error. Write buffer is full.");
this.channel.ack(msg);
}
}
};
}
/**
* Moves message into dead letter
*
* @param {Channel & AmqpDefaultOptions} chan
* @param {Object} msg
*/
async moveToDeadLetter(chan, msg) {
const res = this.channel.publish(
chan.deadLettering.exchangeName || "",
chan.deadLettering.queueName,
msg.content,
{
headers: {
[C.HEADER_ORIGINAL_CHANNEL]: chan.name,
[C.HEADER_ORIGINAL_GROUP]: chan.group
}
}
);
if (res === false) {
this.broker.logger.error(
"AMQP publish error. Write buffer is full. Throwing away message"
);
} else {
this.metricsIncrement(C.METRIC_CHANNELS_MESSAGES_DEAD_LETTERING_TOTAL, chan);
}
this.channel.ack(msg);
}
/**
* Unsubscribe from a channel.
*
* @param {Channel & AmqpDefaultOptions} chan
*/
async unsubscribe(chan) {
if (chan.unsubscribing) return;
chan.unsubscribing = true;
this.logger.debug(`Unsubscribing from '${chan.name}' chan with '${chan.group}' group...'`);
const sub = this.subscriptions.get(chan.id);
if (!sub) return;
await this.channel.cancel(sub.consumerTag);
await new Promise((resolve, reject) => {
const checkPendingMessages = () => {
try {
if (this.getNumberOfChannelActiveMessages(chan.id) === 0) {
this.logger.debug(
`Unsubscribing from '${chan.name}' chan with '${chan.group}' group...'`
);
// Stop tracking channel's active messages
this.stopChannelActiveMessages(chan.id);
resolve();
} else {
this.logger.warn(
`Processing ${this.getNumberOfChannelActiveMessages(
chan.id
)} message(s) of '${chan.id}'...`
);
setTimeout(() => checkPendingMessages(), 1000);
}
} catch (err) {
reject(err);
}
};
checkPendingMessages();
});
}
/**
* Resubscribe to all channels.
* @returns {Promise<void>}
*/
async resubscribeAllChannels() {
this.logger.info("Resubscribing to all channels...");
for (const { chan } of Array.from(this.subscriptions.values())) {
await this.subscribe(chan);
}
}
/**
* Publish a payload to a channel.
*
* @param {String} channelName
* @param {any} payload
* @param {Object?} opts
*/
async publish(channelName, payload, opts = {}) {
// Adapter is stopping. Publishing no longer is allowed
if (this.stopping) return;
if (!this.connected) {
throw new MoleculerRetryableError("Adapter not yet connected. Skipping publishing.");
}
// Available options: http://www.squaremobius.net/amqp.node/channel_api.html#channel_publish
const messageOptions = _.defaultsDeep(
{},
{
persistent: opts.persistent,
expiration: opts.ttl,
priority: opts.priority,
correlationId: opts.correlationId,
headers: opts.headers
// ? mandatory: opts.mandatory
},
this.opts.amqp.messageOptions
);
this.logger.debug(
`Publish a message to '${channelName || opts.routingKey}' channel...`,
payload,
opts
);
const data = opts.raw ? payload : this.serializer.serialize(payload);
const publishAssertExchange = _.defaultsDeep(
opts.publishAssertExchange,
this.opts.amqp.publishAssertExchange,
{
enabled: false,
exchangeOptions: {}
}
);
if (publishAssertExchange.enabled && !this.assertedExchanges.has(channelName)) {
this.logger.debug(`Asserting exchange ${channelName}`);
this.assertedExchanges.add(channelName);
await this.channel.assertExchange(
channelName,
"fanout",
publishAssertExchange.exchangeOptions
);
}
const res = this.channel.publish(channelName, opts.routingKey || "", data, messageOptions);
if (res === false) throw new MoleculerError("AMQP publish error. Write buffer is full.");
this.logger.debug(`Message was published at '${channelName}'`);
}
/**
* Parse the headers from incoming message to a POJO.
* @param {any} raw
* @returns {object}
*/
parseMessageHeaders(raw) {
return raw && raw.properties ? raw.properties.headers : null;
}
}
module.exports = AmqpAdapter;