UNPKG

@imqueue/pg-pubsub

Version:

Reliable PostgreSQL LISTEN/NOTIFY with inter-process lock support

470 lines 16.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.PgPubSub = void 0; /*! * I'm Queue Software Project * Copyright (C) 2025 imqueue.com <support@imqueue.com> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * * If you want to use this code in a closed source (commercial) project, you can * purchase a proprietary commercial license. Please contact us at * <support@imqueue.com> to get commercial licensing options. */ const events_1 = require("events"); const pg_1 = require("pg"); const pg_format_1 = require("pg-format"); const uuid_1 = require("uuid"); const _1 = require("."); const PgChannelEmitter_1 = require("./PgChannelEmitter"); /** * Implements LISTEN/NOTIFY client for PostgreSQL connections. * * It is a basic public interface of this library, so the end-user is going * to work with this class directly to solve his/her tasks. * * Importing: * ~~~typescript * import { AnyJson, PgPubSub } from '@imqueue/pg-pubsub'; * ~~~ * * Instantiation: * ~~~typescript * const pubSub = new PgPubSub(options) * ~~~ * @see PgPubSubOptions * * Connecting and listening: * ~~~typescript * pubSub.on('connect', async () => { * await pubSub.listen('ChannelOne'); * await pubSub.listen('ChannelTwo'); * }); * // or, even better: * pubSub.on('connect', async () => { * await Promise.all( * ['ChannelOne', 'ChannelTwo'].map(channel => channel.listen()), * ); * }); * // or. less reliable: * await pubSub.connect(); * await Promise.all( * ['ChannelOne', 'ChannelTwo'].map(channel => channel.listen()), * ); * ~~~ * * Handle messages: * ~~~typescript * pubSub.on('message', (channel: string, payload: AnyJson) => * console.log(channel, payload); * ); * // or, using channels * pubSub.channels.on('ChannelOne', (payload: AnyJson) => * console.log(1, payload), * ); * pubSub.channels.on('ChannelTwo', (payload: AnyJson) => * console.log(2, payload), * ); * ~~~ * * Destroying: * ~~~typescript * await pubSub.destroy(); * ~~~ * * Closing and re-using connection: * ~~~typescript * await pubSub.close(); * await pubSub.connect(); * ~~~ * * This close/connect technique may be used when doing some heavy message * handling, so while you close, another running copy may handle next * messages... */ class PgPubSub extends events_1.EventEmitter { logger; pgClient; options; channels = new PgChannelEmitter_1.PgChannelEmitter(); locks = {}; retry = 0; processId; /** * @constructor * @param {PgPubSubOptions} options - options * @param {AnyLogger} logger - logger */ constructor(options, logger = console) { super(); this.logger = logger; this.options = { ..._1.DefaultOptions, ...options }; this.pgClient = (this.options.pgClient || new pg_1.Client(this.options)); this.pgClient.on('end', () => this.emit('end')); this.pgClient.on('error', () => this.emit('error')); this.onNotification = this.options.executionLock ? this.onNotificationLockExec.bind(this) : this.onNotification.bind(this); this.reconnect = this.reconnect.bind(this); this.onReconnect = this.onReconnect.bind(this); this.pgClient.on('notification', this.onNotification); } /** * Establishes re-connectable database connection * * @return {Promise<void>} */ async connect() { return new Promise((resolve, reject) => { const onConnect = async () => { await this.setAppName(); await this.setProcessId(); this.emit('connect'); resolve(); cleanup(); }; const onError = (err) => { reject(err); cleanup(); }; const cleanup = () => { this.pgClient.off('connect', onConnect); this.off('error', onError); }; this.setOnceHandler(['end', 'error'], this.reconnect); this.pgClient.once('connect', onConnect); this.once('error', onError); // eslint-disable-next-line @typescript-eslint/no-floating-promises this.pgClient.connect(); }); } /** * Safely closes this database connection * * @return {Promise<void>} */ async close() { this.pgClient.off('end', this.reconnect); this.pgClient.off('error', this.reconnect); await this.pgClient.end(); this.pgClient.removeAllListeners(); this.emit('close'); } /** * Starts listening given channel. If singleListener option is set to * true, it guarantees that only one process would be able to listen * this channel at a time. * * @param {string} channel - channel name to listen * @return {Promise<void>} */ async listen(channel) { // istanbul ignore if if (this.options.executionLock) { await this.pgClient.query(`LISTEN ${(0, pg_format_1.ident)(channel)}`); this.emit('listen', channel); return; } const lock = await this.lock(channel); const acquired = await lock.acquire(); // istanbul ignore else if (acquired) { await this.pgClient.query(`LISTEN ${(0, pg_format_1.ident)(channel)}`); this.emit('listen', channel); } } /** * Stops listening of the given channel, and, if singleListener option is * set to true - will release an acquired lock (if it was settled). * * @param {string} channel - channel name to unlisten * @return {Promise<void>} */ async unlisten(channel) { await this.pgClient.query(`UNLISTEN ${(0, pg_format_1.ident)(channel)}`); if (this.locks[channel]) { await this.locks[channel].destroy(); delete this.locks[channel]; } this.emit('unlisten', [channel]); } /** * Stops listening all connected channels, and, if singleListener option * is set to true - will release all acquired locks (if any was settled). * * @return {Promise<void>} */ async unlistenAll() { await this.pgClient.query('UNLISTEN *'); await this.release(); this.emit('unlisten', Object.keys(this.locks)); } /** * Performs NOTIFY to a given channel with a given payload to all * listening subscribers * * @param {string} channel - channel to publish to * @param {AnyJson} payload - payload to publish for subscribers * @return {Promise<void>} */ async notify(channel, payload) { await this.pgClient.query(`NOTIFY ${(0, pg_format_1.ident)(channel)}, ${(0, pg_format_1.literal)((0, _1.pack)(payload, this.logger))}`); this.emit('notify', channel, payload); } /** * Returns list of all active subscribed channels * * @return {string[]} */ activeChannels() { return Object.keys(this.locks).filter(channel => this.locks[channel].isAcquired()); } /** * Returns list of all inactive channels (those which are known, but * not actively listening at a time) * * @return {string[]} */ inactiveChannels() { return Object.keys(this.locks).filter(channel => !this.locks[channel].isAcquired()); } /** * Returns list of all known channels, despite the fact they are listening * (active) or not (inactive). * * @return {string[]} */ allChannels() { return Object.keys(this.locks); } /** * If channel argument passed will return true if channel is in active * state (listening by this pub/sub), false - otherwise. If channel is * not specified - will return true if there is at least one active channel * listened by this pub/sub, false - otherwise. * * @param {string} channel * @return {boolean} */ isActive(channel) { if (!channel) { return this.activeChannels().length > 0; } return !!~this.activeChannels().indexOf(channel); } /** * Destroys this object properly, destroying all locks, * closing all connections and removing all event listeners to avoid * memory leaking. So whenever you need to destroy an object * programmatically - use this method. * Note, that after destroy it is broken and should be removed from memory. * * @return {Promise<void>} */ async destroy() { await Promise.all([this.close(), _1.PgIpLock.destroy()]); this.channels.removeAllListeners(); this.removeAllListeners(); } /** * Safely sets given handler for given pg client events, making sure * we won't flood events with non-fired same stack of handlers * * @access private * @param {string[]} events - list of events to set handler for * @param {(...args: any[]) => any} handler - handler reference * @return {PgPubSub} */ setOnceHandler(events, handler) { for (const event of events) { // make sure we won't flood events with given handler, // so do a cleanup first this.clearListeners(event, handler); // now set event handler this.pgClient.once(event, handler); } return this; } /** * Clears all similar handlers under given event * * @param {string} event - event name * @param {(...args: any) => any} handler - handler reference */ clearListeners(event, handler) { this.pgClient.listeners(event).forEach(listener => listener === handler && this.pgClient.off(event, handler)); } /** * Database notification event handler * * @access private * @param {Notification} notification - database message data * @return {Promise<void>} */ async onNotification(notification) { const lock = await this.lock(notification.channel); const skip = _1.RX_LOCK_CHANNEL.test(notification.channel) || (this.options.filtered && this.processId === notification.processId); if (skip) { // as we use the same connection with locks mechanism // we should avoid pub/sub client to parse lock channels data // and also filter same-notify-channel messages if filtered option // is set to true return; } if (this.options.singleListener && !lock.isAcquired()) { return; // we are not really a listener } const payload = (0, _1.unpack)(notification.payload); this.emit('message', notification.channel, payload); this.channels.emit(notification.channel, payload); } /** * Database notification event handler for execution lock * * @access private * @param {Notification} notification - database message data * @return {Promise<void>} */ async onNotificationLockExec(notification) { const skip = _1.RX_LOCK_CHANNEL.test(notification.channel) || (this.options.filtered && this.processId === notification.processId); if (skip) { // as we use the same connection with locks mechanism // we should avoid pub/sub client to parse lock channels data // and also filter same-notify-channel messages if filtered option // is set to true return; } const lock = await this.createLock(notification.channel, (0, _1.signature)(notification.processId, notification.channel, notification.payload)); await lock.acquire(); // istanbul ignore if if (this.options.singleListener && !lock.isAcquired()) { return; // we are not really a listener } const payload = (0, _1.unpack)(notification.payload); this.emit('message', notification.channel, payload); this.channels.emit(notification.channel, payload); await lock.release(); } /** * On reconnect event emitter * * @access private * @return {Promise<void>} */ async onReconnect() { await Promise.all(Object.keys(this.locks).map(channel => this.listen(channel))); this.emit('reconnect', this.retry); this.retry = 0; } /** * Reconnect routine, used for implementation of auto-reconnecting db * connection * * @access private * @return {number} */ reconnect() { return setTimeout(async () => { if (this.options.retryLimit <= ++this.retry) { this.emit('error', new Error(`Connect failed after ${this.retry} retries...`)); return this.close(); } this.setOnceHandler(['connect'], this.onReconnect); try { await this.connect(); } catch (err) { /* ignore */ } }, this.options.retryDelay); } /** * Instantiates and returns process lock for a given channel or returns * existing one * * @access private * @param {string} channel * @return {Promise<PgIpLock>} */ async lock(channel) { if (!this.locks[channel]) { this.locks[channel] = await this.createLock(channel); } return this.locks[channel]; } /** * Instantiates new lock, properly initializes it and returns * * @param {string} channel * @param {string} [uniqueKey] * @return {Promise<AnyLock>} */ async createLock(channel, uniqueKey) { if (this.options.singleListener) { const lock = new _1.PgIpLock(channel, { pgClient: this.pgClient, logger: this.logger, acquireInterval: this.options.acquireInterval, }, uniqueKey); await lock.init(); !uniqueKey && lock.onRelease(chan => this.listen(chan)); return lock; } return new _1.NoLock(); } /** * Releases all acquired locks in current session * * @access private * @return {Promise<void>} */ async release() { await Promise.all(Object.keys(this.locks).map(async (channel) => { const lock = await this.lock(channel); if (lock.isAcquired()) { await lock.release(); } delete this.locks[channel]; })); } /** * Sets application_name for this connection as unique identifier * * @access private * @return {Promise<void>} */ async setAppName() { try { this.pgClient.appName = (0, uuid_1.v4)(); await this.pgClient.query(`SET APPLICATION_NAME TO '${this.pgClient.appName}'`); } catch (err) { /* ignore */ } } /** * Retrieves process identifier from the database connection and sets it to * `this.processId`. * * @return {Promise<void>} */ async setProcessId() { try { const { rows: [{ pid }] } = await this.pgClient.query(` SELECT pid FROM pg_stat_activity WHERE application_name = ${(0, pg_format_1.literal)(this.pgClient.appName)} `); this.processId = +pid; } catch (err) { /* ignore */ } } } exports.PgPubSub = PgPubSub; //# sourceMappingURL=PgPubSub.js.map