UNPKG

@citrineos/util

Version:

The OCPP util module which supplies helpful utilities like cache and queue connectors, etc.

294 lines 13.6 kB
"use strict"; // Copyright (c) 2023 S44, LLC // Copyright Contributors to the CitrineOS Project // // SPDX-License-Identifier: Apache 2.0 var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.RabbitMqReceiver = void 0; const amqplib = __importStar(require("amqplib")); const __1 = require("../.."); const base_1 = require("@citrineos/base"); const class_transformer_1 = require("class-transformer"); /** * Implementation of a {@link IMessageHandler} using RabbitMQ as the underlying transport. */ class RabbitMqReceiver extends base_1.AbstractMessageHandler { constructor(config, logger, module, cache) { super(config, logger, module); this._reconnecting = false; this._cache = cache || new __1.MemoryCache(); } initConnection() { return __awaiter(this, void 0, void 0, function* () { this._abortReconnectController = new AbortController(); this._channel = yield this._connectWithRetry(this._abortReconnectController.signal); }); } /** * Methods */ /** * Binds queue to an exchange given identifier and optional actions and filter. * Note: Due to the nature of AMQP 0-9-1 model, if you need to filter for the identifier, you **MUST** provide it in the filter object. * * @param {string} identifier - The identifier of the channel to subscribe to. * @param {CallAction[]} actions - Optional. An array of actions to filter the messages. * @param {{ [k: string]: string; }} filter - Optional. An object representing the filter to apply on the messages. * @return {Promise<boolean>} A promise that resolves to true if the subscription is successful, false otherwise. */ subscribe(identifier, actions, filter) { return __awaiter(this, void 0, void 0, function* () { var _a; // If actions are a defined but empty list, it is likely a module // with no available actions and should not have a queue. // // If actions are undefined, it is likely a charger, // which is "allowed" not to have actions. if (actions && actions.length === 0) { this._logger.debug(`Skipping queue binding for module ${identifier} as there are no available actions.`); return true; } const exchange = (_a = this._config.util.messageBroker.amqp) === null || _a === void 0 ? void 0 : _a.exchange; const queueName = `${RabbitMqReceiver.QUEUE_PREFIX}${identifier}_${Date.now()}`; // Ensure that filter includes the x-match header set to all filter = filter ? Object.assign({ 'x-match': 'all' }, filter) : { 'x-match': 'all' }; if (!this._channel) { throw new Error('RabbitMQ is down: cannot subscribe.'); } const channel = this._channel; // Assert exchange and queue yield channel.assertExchange(exchange, 'headers', { durable: false }); yield channel.assertQueue(queueName, { durable: false, autoDelete: true, exclusive: false, }); // Bind queue based on provided actions and filters if (actions && actions.length > 0) { for (const action of actions) { this._logger.debug(`Bind ${queueName} on ${exchange} for ${action} with filter ${JSON.stringify(filter)}.`); yield channel.bindQueue(queueName, exchange, '', Object.assign({ action }, filter)); } } else { this._logger.debug(`Bind ${queueName} on ${exchange} with filter ${JSON.stringify(filter)}.`); yield channel.bindQueue(queueName, exchange, '', filter); } // Start consuming messages yield channel.consume(queueName, (msg) => this._onMessage(msg, channel)); // Define cache key const cacheKey = `${RabbitMqReceiver.CACHE_PREFIX}${identifier}`; // Retrieve cached queue names const cachedQueues = yield this._cache .get(cacheKey, base_1.CacheNamespace.Other, () => Array) .then((value) => { if (value) { value.push(queueName); return value; } return new Array(queueName); }); // Add queue name to cache yield this._cache.set(cacheKey, JSON.stringify(cachedQueues), base_1.CacheNamespace.Other); return true; }); } unsubscribe(identifier) { return this._cache .get(`${RabbitMqReceiver.CACHE_PREFIX}${identifier}`, base_1.CacheNamespace.Other, () => Array) .then((queues) => __awaiter(this, void 0, void 0, function* () { var _a, _b; if (queues) { if (!this._channel) { throw new Error('RabbitMQ is down: cannot unsubscribe.'); } const channel = this._channel; this._channel = channel; for (const queue of queues) { yield channel.unbindQueue(queue, ((_a = this._config.util.messageBroker.amqp) === null || _a === void 0 ? void 0 : _a.exchange) || '', ''); const messageCount = yield ((_b = this._channel) === null || _b === void 0 ? void 0 : _b.deleteQueue(queue)); this._logger.info(`Queue ${identifier} deleted with ${messageCount === null || messageCount === void 0 ? void 0 : messageCount.messageCount} messages remaining.`); } return true; } else { this._logger.warn(`Failed to delete queue for ${identifier}, queue name not found in cache.`); return false; } })); } shutdown() { var _a; (_a = this._abortReconnectController) === null || _a === void 0 ? void 0 : _a.abort(); return Promise.resolve(); } /** * Protected Methods */ /** * Connect to RabbitMQ with retry logic. * This method will keep trying to connect until successful, unless aborted. * * @param {AbortSignal} [abortSignal] - Optional abort signal to stop retrying. * @return {Promise<amqplib.Channel>} A promise that resolves to the AMQP channel. */ _connectWithRetry(abortSignal) { return __awaiter(this, void 0, void 0, function* () { var _a; let reconnectAttempts = 0; const url = (_a = this._config.util.messageBroker.amqp) === null || _a === void 0 ? void 0 : _a.url; if (!url) { throw new Error('RabbitMQ URL is not configured'); } while (true) { if (abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.aborted) { this._logger.warn('RabbitMQ reconnect aborted by signal.'); throw new Error('RabbitMQ reconnect aborted'); } try { const connection = yield amqplib.connect(url); this._connection = connection; const channel = yield connection.createChannel(); channel.on('error', (err) => { this._logger.error('AMQP channel error', err); // TODO: add recovery logic }); this._setupConnectionListeners(); return channel; } catch (err) { reconnectAttempts++; this._logger.error(`RabbitMQ reconnect attempt ${reconnectAttempts} failed (context: _connectWithRetry)`, err); yield new Promise((res) => setTimeout(res, RabbitMqReceiver.RECONNECT_DELAY)); } } }); } /** * Setup listeners for connection and channel events. * This will handle disconnections and errors. * Ensures listeners are not attached multiple times to the same connection. */ _setupConnectionListeners() { if (this._connection) { // Only attach listeners if not already attached to this connection if (this._connection._listenersAttached) return; this._connection.removeAllListeners('close'); this._connection.removeAllListeners('error'); this._connection.on('close', () => this._handleDisconnect()); this._connection.on('error', () => this._handleDisconnect()); this._connection._listenersAttached = true; } } /** * Handle RabbitMQ disconnection. * This method will attempt to reconnect to RabbitMQ when the connection is lost. * Debounces concurrent reconnects. */ _handleDisconnect() { return __awaiter(this, void 0, void 0, function* () { var _a; if (this._reconnecting) { this._logger.warn('RabbitMQ reconnect already in progress, skipping duplicate reconnect.'); return; } this._reconnecting = true; (_a = this._abortReconnectController) === null || _a === void 0 ? void 0 : _a.abort(); this._abortReconnectController = new AbortController(); this._logger.warn('RabbitMQ connection lost. Attempting to reconnect...'); this._channel = undefined; this._connection = undefined; try { this._channel = yield this._connectWithRetry(this._abortReconnectController.signal); this._logger.info('RabbitMQ reconnected successfully.'); } catch (err) { this._logger.error('Failed to reconnect to RabbitMQ (context: _handleDisconnect)', err); } finally { this._reconnecting = false; } }); } /** * Underlying RabbitMQ message handler. * * @param message The AMQPMessage to process * @param channel */ _onMessage(message, channel) { return __awaiter(this, void 0, void 0, function* () { if (message) { try { this._logger.debug('_onMessage:Received message:', message.properties, message.content.toString()); const parsed = (0, class_transformer_1.plainToInstance)((base_1.Message), JSON.parse(message.content.toString())); yield this.handle(parsed, message.properties); } catch (error) { if (error instanceof base_1.RetryMessageError) { this._logger.warn('Retrying message: ', error.message); // Retryable error, usually ongoing call with station when trying to send new call channel.nack(message); return; } else { this._logger.error('Error while processing message:', error, message); } } channel.ack(message); } }); } } exports.RabbitMqReceiver = RabbitMqReceiver; /** * Constants */ RabbitMqReceiver.QUEUE_PREFIX = 'rabbit_queue_'; RabbitMqReceiver.CACHE_PREFIX = 'rabbit_subscription_'; RabbitMqReceiver.RECONNECT_DELAY = 5000; //# sourceMappingURL=receiver.js.map