@imqueue/pg-pubsub
Version:
Reliable PostgreSQL LISTEN/NOTIFY with inter-process lock support
470 lines • 16.1 kB
JavaScript
"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