@nestjs/microservices
Version:
Nest - modern, fast, powerful node.js web framework (@microservices)
337 lines (336 loc) • 13.9 kB
JavaScript
import { REDIS_DEFAULT_HOST, REDIS_DEFAULT_PORT } from '../constants.js';
import { ClientProxy } from './client-proxy.js';
import { Logger } from '@nestjs/common';
import { loadPackage } from '@nestjs/common/internal';
/**
* @publicApi
*/
export class ClientRedis extends ClientProxy {
options;
logger = new Logger(ClientProxy.name);
subscriptionsCount = new Map();
pubClient;
subClient;
connectionPromise = null;
isManuallyClosed = false;
wasInitialConnectionSuccessful = false;
pendingEventListeners = [];
constructor(options) {
super();
this.options = options;
this.initializeSerializer(options);
this.initializeDeserializer(options);
}
getRequestPattern(pattern) {
return pattern;
}
getReplyPattern(pattern) {
return `${pattern}.reply`;
}
async close() {
this.isManuallyClosed = true;
this.handleClose();
this.pubClient && (await this.quitClient(this.pubClient));
this.subClient && (await this.quitClient(this.subClient));
this.pubClient = this.subClient = null;
this.connectionPromise = null;
this.isManuallyClosed = false;
this.wasInitialConnectionSuccessful = false;
this.pendingEventListeners = [];
}
// `quit()` rejects when the command cannot be sent (e.g., while
// reconnecting with the offline queue disabled), which would leave the
// other client open and `close()` half done. Close the connection right
// away instead.
async quitClient(client) {
try {
await client.quit();
}
catch {
client.disconnect();
}
}
connect() {
if (this.connectionPromise) {
return this.connectionPromise;
}
const connectionPromise = this.handleConnection().catch(err => {
// Once the clients are connecting, the "reconnecting", "ready" and "end"
// listeners take over `connectionPromise`: ioredis keeps retrying in
// the background, and "end" resets the state once it gives up. If none
// of them did, nothing else is going to reset it, so do it here to let
// the next `connect()` call try again.
if (this.connectionPromise === connectionPromise) {
this.discardClients();
}
throw err;
});
this.connectionPromise = connectionPromise;
return connectionPromise;
}
/**
* Drops the current pub/sub pair, so the next `connect()` call starts over
* with new clients. Events the dropped clients emit from now on are ignored.
*/
discardClients() {
const clients = [this.pubClient, this.subClient];
this.pubClient = this.subClient = null;
this.connectionPromise = null;
this.wasInitialConnectionSuccessful = false;
for (const client of clients) {
// An ended client has no connection or pending retry left to stop
if (client && client.status !== 'end') {
client.disconnect();
}
}
}
/**
* Whether `client` belongs to the current pub/sub pair, as opposed to a
* client that has been dropped and is still shutting down.
*/
isCurrentClient(client) {
return client === this.pubClient || client === this.subClient;
}
async handleConnection() {
this.pubClient = await this.createClient();
this.subClient = await this.createClient();
[this.pubClient, this.subClient].forEach((client, index) => {
const type = index === 0 ? 'pub' : 'sub';
this.registerErrorListener(client);
this.registerReconnectListener(client);
this.registerReadyListener(client);
this.registerEndListener(client);
this.pendingEventListeners.forEach(({ event, callback }) => client.on(event, (...args) => callback(type, ...args)));
});
await Promise.all([this.subClient.connect(), this.pubClient.connect()]);
}
async createClient() {
const clientInfoTag = this.getOptionsProp(this.options, 'clientInfoTag');
const redisPackage = await loadPackage('ioredis', ClientRedis.name, () => import('ioredis'));
const RedisClient = redisPackage.default || redisPackage;
return new RedisClient({
host: REDIS_DEFAULT_HOST,
port: REDIS_DEFAULT_PORT,
...this.getClientOptions(),
...(clientInfoTag && { clientInfoTag }),
lazyConnect: true,
});
}
registerErrorListener(client) {
client.addListener("error" /* RedisEventsMap.ERROR */, (err) => this.logger.error(err));
}
registerReconnectListener(client) {
client.on("reconnecting" /* RedisEventsMap.RECONNECTING */, () => {
if (this.isManuallyClosed || !this.isCurrentClient(client)) {
return;
}
this.connectionPromise = Promise.reject('Error: Connection lost. Trying to reconnect...');
// Prevent unhandled rejections
this.connectionPromise.catch(() => { });
this._status$.next("reconnecting" /* RedisStatus.RECONNECTING */);
if (this.wasInitialConnectionSuccessful) {
this.logger.log('Reconnecting to Redis...');
}
});
}
registerReadyListener(client) {
client.on("ready" /* RedisEventsMap.READY */, () => {
if (!this.isCurrentClient(client) ||
this.pubClient?.status !== 'ready' ||
this.subClient?.status !== 'ready') {
return;
}
this.connectionPromise = Promise.resolve();
this._status$.next("connected" /* RedisStatus.CONNECTED */);
this.logger.log('Connected to Redis. Subscribing to channels...');
if (!this.wasInitialConnectionSuccessful) {
this.wasInitialConnectionSuccessful = true;
this.subClient.on(this.options.returnBuffers ? 'messageBuffer' : 'message', this.createResponseCallback());
}
});
}
registerEndListener(client) {
client.on('end', () => {
if (this.isManuallyClosed || !this.isCurrentClient(client)) {
return;
}
this._status$.next("disconnected" /* RedisStatus.DISCONNECTED */);
this.handleClose();
this.logger.error('Disconnected from Redis.');
// ioredis gave up on this client (retry attempts not specified or
// exhausted), so drop the pair and let the next `connect()` call
// create a new one
this.discardClients();
});
}
handleClose() {
if (this.routingMap.size > 0) {
const err = new Error('Connection closed');
const callbacks = [...this.routingMap.values()];
this.routingMap.clear();
for (const callback of callbacks) {
try {
callback({ err });
}
catch (callbackErr) {
// A failing callback must not keep the remaining requests pending
// nor prevent the connection from being closed.
this.logger.error(callbackErr);
}
}
}
this.subscriptionsCount.clear();
}
getClientOptions() {
const retryStrategy = (times) => this.createRetryStrategy(times);
return {
...(this.options || {}),
retryStrategy,
};
}
on(event, callback) {
// Kept until `close()`, so the pairs created later get it as well
this.pendingEventListeners.push({ event, callback });
if (this.subClient && this.pubClient) {
this.subClient.on(event, (...args) => callback('sub', ...args));
this.pubClient.on(event, (...args) => callback('pub', ...args));
}
}
unwrap() {
if (!this.pubClient || !this.subClient) {
throw new Error('Not initialized. Please call the "connect" method first.');
}
return [this.pubClient, this.subClient];
}
createRetryStrategy(times) {
if (this.isManuallyClosed) {
return undefined;
}
if (!this.getOptionsProp(this.options, 'retryAttempts')) {
this.logger.error('Redis connection closed and retry attempts not specified');
return;
}
if (times > this.getOptionsProp(this.options, 'retryAttempts', 0)) {
this.logger.error('Retry time exhausted');
return;
}
return this.getOptionsProp(this.options, 'retryDelay', 5000);
}
createResponseCallback() {
return async (channel, buffer) => {
let packet;
try {
packet = JSON.parse(buffer);
}
catch (err) {
this.logger.debug('Redis response packet is not in json format, bypassing...');
packet = buffer;
}
let incomingResponse;
try {
incomingResponse = await this.deserializer.deserialize(packet);
}
catch (error) {
// Only the deserializer knows the id, so no request can be failed here.
// Nobody awaits this listener, so a rejection would crash the process.
this.logger.error(`Dropped a response that the deserializer failed on: ${error}`, error?.stack);
return;
}
const { err, response, isDisposed, id } = incomingResponse;
const callback = this.routingMap.get(id);
if (!callback) {
if (Buffer.isBuffer(buffer))
this.logger.debug('You have to parse your buffer on your own to get id from it, because it is not in json format');
this.logger.debug('No matching callback found for Redis response packet with id: ' + id);
return;
}
if (isDisposed || err) {
return callback({
err,
response,
isDisposed: true,
});
}
callback({
err,
response,
});
};
}
publish(partialPacket, callback) {
try {
const packet = this.assignPacketId(partialPacket);
const pattern = this.normalizePattern(partialPacket.pattern);
const serializedPacket = this.serializer.serialize(packet);
const responseChannel = this.getReplyPattern(pattern);
let subscriptionsCount = this.subscriptionsCount.get(responseChannel) || 0;
let isPublished = false;
let isTornDown = false;
const undoBookkeeping = () => {
isTornDown = true;
isPublished = false;
this.subscriptionsCount.set(responseChannel, (this.subscriptionsCount.get(responseChannel) || 1) - 1);
this.routingMap.delete(packet.id);
};
const publishPacket = () => {
if (isTornDown) {
return;
}
subscriptionsCount = this.subscriptionsCount.get(responseChannel) || 0;
this.subscriptionsCount.set(responseChannel, subscriptionsCount + 1);
this.routingMap.set(packet.id, callback);
isPublished = true;
try {
this.pubClient.publish(this.getRequestPattern(pattern), JSON.stringify(serializedPacket), err => {
if (err && !isTornDown) {
undoBookkeeping();
callback({ err });
}
});
}
catch (err) {
// The broker can acknowledge the subscription later, so this runs
// outside the outer catch and has to undo its own work. Only the
// bookkeeping though: a concurrent request on this pattern may still
// be waiting for its own subscribe reply, so the broker subscription
// is left to self-heal, as in #17671.
undoBookkeeping();
callback({ err });
}
};
const cleanup = () => {
isTornDown = true;
if (!isPublished) {
return;
}
isPublished = false;
this.unsubscribeFromChannel(responseChannel);
this.routingMap.delete(packet.id);
};
if (subscriptionsCount <= 0) {
this.subClient.subscribe(responseChannel, (err) => err ? callback({ err }) : publishPacket());
}
else {
publishPacket();
}
return cleanup;
}
catch (err) {
callback({ err });
return () => { };
}
}
dispatchEvent(packet) {
const pattern = this.normalizePattern(packet.pattern);
const serializedPacket = this.serializer.serialize(packet);
return new Promise((resolve, reject) => this.pubClient.publish(pattern, JSON.stringify(serializedPacket), err => err ? reject(err) : resolve()));
}
unsubscribeFromChannel(channel) {
const subscriptionCount = this.subscriptionsCount.get(channel);
this.subscriptionsCount.set(channel, subscriptionCount - 1);
if (subscriptionCount - 1 <= 0) {
// ioredis rejects when the connection is gone (e.g. in "handleClose")
this.subClient.unsubscribe(channel, () => { });
}
}
}