UNPKG

@imqueue/pg-pubsub

Version:

Reliable PostgreSQL LISTEN/NOTIFY with inter-process lock support

446 lines 15.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.RX_LOCK_CHANNEL = exports.PgIpLock = void 0; const pg_format_1 = require("pg-format"); const timers_1 = require("timers"); const constants_1 = require("./constants"); /** * Implements manageable inter-process locking mechanism over * existing PostgreSQL connection for a given `LISTEN` channel. * * It uses periodic locks acquire retries and implements graceful shutdown * using `SIGINT`, `SIGTERM` and `SIGABRT` OS signals, by which safely releases * an acquired lock, which causes an event to other similar running instances * on another processes (or on another hosts) to capture free lock. * * By running inside Docker containers this would work flawlessly on * implementation auto-scaling services, as docker destroys containers * gracefully. * * Currently, the only known issue could happen only if, for example, database * or software (or hardware) in the middle will cause a silent disconnect. For * some period of time, despite the fact that there are other live potential * listeners some messages can go into void. This time period can be tuned by * bypassing wanted `acquireInterval` argument. By the way, take into account * that too short period and number of running services may cause huge flood of * lock acquire requests to a database, so selecting the proper number should be * a thoughtful trade-off between overall system load and reliability level. * * Usually you do not need to instantiate this class directly - it will be done * by a PgPubSub instances on their needs. Therefore, you may re-use this piece * of code in some other implementations, so it is exported as is. */ class PgIpLock { channel; options; uniqueKey; /** * DB lock schema name getter * * @return {string} */ get schemaName() { const suffix = this.uniqueKey ? '_unique' : ''; return (0, pg_format_1.ident)(constants_1.SCHEMA_NAME + suffix); } /** * Calls destroy() on all created instances at a time * * @return {Promise<void>} */ static async destroy() { await Promise.all(PgIpLock.instances.slice().map(lock => lock.destroy())); } /** * Returns true if at least one instance was created, false - otherwise * * @return {boolean} */ static hasInstances() { return PgIpLock.instances.length > 0; } static instances = []; acquired = false; notifyHandler; acquireTimer; /** * @constructor * @param {string} channel - source channel name to manage locking on * @param {PgIpLockOptions} options - lock instantiate options * @param {string} [uniqueKey] - unique key for specific message */ constructor(channel, options, uniqueKey) { this.channel = channel; this.options = options; this.uniqueKey = uniqueKey; this.channel = `__${PgIpLock.name}__:${channel.replace(exports.RX_LOCK_CHANNEL, '')}`; PgIpLock.instances.push(this); } /** * Initializes inter-process locks storage in database and starts * listening of lock release events, as well as initializes lock * acquire retry timer. * * @return {Promise<void>} */ async init() { if (!await this.schemaExists()) { try { await this.createSchema(); await Promise.all([this.createLock(), this.createDeadlockCheck()]); } catch (e) { /*ignore*/ } } if (this.notifyHandler && !this.uniqueKey) { this.options.pgClient.on('notification', this.notifyHandler); } if (!~PgIpLock.instances.indexOf(this)) { PgIpLock.instances.push(this); } if (!this.uniqueKey) { await this.listen(); // noinspection TypeScriptValidateTypes !this.acquireTimer && (this.acquireTimer = setInterval(() => !this.acquired && this.acquire(), this.options.acquireInterval)); } } /** * This would provide release handler which will be called once the * lock is released and the channel name would be bypassed to a given * handler * * @param {(channel: string) => void} handler */ onRelease(handler) { if (!!this.notifyHandler) { throw new TypeError('Release handler for IPC lock has been already set up!'); } this.notifyHandler = (message) => { // istanbul ignore else // we should skip messages from pub/sub channels and listen // only to those which are ours if (message.channel === this.channel) { handler(this.channel.replace(exports.RX_LOCK_CHANNEL, '')); } }; this.options.pgClient.on('notification', this.notifyHandler); } /** * Acquires a lock on the current channel. Returns true on success, * false - otherwise * * @return {Promise<boolean>} */ async acquire() { try { this.uniqueKey ? await this.acquireUniqueLock() : await this.acquireChannelLock(); this.acquired = true; } catch (err) { // will throw, because insert duplicates existing lock this.acquired = false; // istanbul ignore next if (!(err.code === 'P0001' && err.detail === 'LOCKED')) { this.options.logger.error(err); } } return this.acquired; } /** * Returns true if lock schema exists, false - otherwise * * @return {Promise<boolean>} */ async schemaExists() { const { rows } = await this.options.pgClient.query(` SELECT schema_name FROM information_schema.schemata WHERE schema_name = '${this.schemaName}' `); return (rows.length > 0); } /** * Acquires a lock with ID * * @return {Promise<void>} */ async acquireUniqueLock() { // noinspection SqlResolve await this.options.pgClient.query(` INSERT INTO ${this.schemaName}.lock (id, channel, app) VALUES ( ${(0, pg_format_1.literal)(this.uniqueKey)}, ${(0, pg_format_1.literal)(this.channel)}, ${(0, pg_format_1.literal)(this.options.pgClient.appName)} ) ON CONFLICT (id) DO UPDATE SET app = ${this.schemaName}.deadlock_check( ${this.schemaName}.lock.app, ${(0, pg_format_1.literal)(this.options.pgClient.appName)} ) `); } /** * Acquires a lock by unique channel * * @return {Promise<void>} */ async acquireChannelLock() { // noinspection SqlResolve await this.options.pgClient.query(` INSERT INTO ${this.schemaName}.lock (channel, app) VALUES ( ${(0, pg_format_1.literal)(this.channel)}, ${(0, pg_format_1.literal)(this.options.pgClient.appName)} ) ON CONFLICT (channel) DO UPDATE SET app = ${this.schemaName}.deadlock_check( ${this.schemaName}.lock.app, ${(0, pg_format_1.literal)(this.options.pgClient.appName)} ) `); } /** * Releases acquired lock on this channel. After lock is released, another * running process or host would be able to acquire the lock. * * @return {Promise<void>} */ async release() { if (this.uniqueKey) { // noinspection SqlResolve await this.options.pgClient.query(` DELETE FROM ${this.schemaName}.lock WHERE id=${(0, pg_format_1.literal)(this.uniqueKey)} `); } else { if (!this.acquired) { return; // nothing to release, this lock has not been acquired } // noinspection SqlResolve await this.options.pgClient.query(` DELETE FROM ${this.schemaName}.lock WHERE channel=${(0, pg_format_1.literal)(this.channel)} `); } this.acquired = false; } /** * Returns current lock state, true if acquired, false - otherwise. * * @return {boolean} */ isAcquired() { return this.acquired; } /** * Destroys this lock properly. * * @return {Promise<void>} */ async destroy() { try { if (this.notifyHandler) { this.options.pgClient.off('notification', this.notifyHandler); } if (this.acquireTimer) { // noinspection TypeScriptValidateTypes (0, timers_1.clearInterval)(this.acquireTimer); delete this.acquireTimer; } await Promise.all([this.unlisten(), this.release()]); PgIpLock.instances.splice(PgIpLock.instances.findIndex(lock => lock === this), 1); } catch (err) { // do not crash - just log this.options.logger && this.options.logger.error && this.options.logger.error(err); } } /** * Starts listening lock release channel * * @return {Promise<void>} */ async listen() { await this.options.pgClient.query(`LISTEN ${(0, pg_format_1.ident)(this.channel)}`); } /** * Stops listening lock release channel * * @return {Promise<void>} */ async unlisten() { await this.options.pgClient.query(`UNLISTEN ${(0, pg_format_1.ident)(this.channel)}`); } /** * Creates lock db schema * * @return {Promise<void>} */ async createSchema() { await this.options.pgClient.query(` CREATE SCHEMA IF NOT EXISTS ${this.schemaName} `); } /** * Creates lock table with delete trigger, which notifies on record removal * * @return {Promise<void>} */ async createLock() { // istanbul ignore if if (this.uniqueKey) { await this.createUniqueLock(); return; } await this.createChannelLock(); } /** * Creates unique locks by IDs in the database * * @return {Promise<void>} */ async createUniqueLock() { await this.options.pgClient.query(` DO $$ BEGIN IF NOT EXISTS ( SELECT * FROM information_schema.columns WHERE table_schema = '${this.schemaName}' AND table_name = 'lock' AND column_name = 'id' ) THEN DROP TABLE IF EXISTS ${this.schemaName}.lock; END IF; END $$ `); await this.options.pgClient.query(` CREATE TABLE IF NOT EXISTS ${this.schemaName}."lock" ( "id" CHARACTER VARYING NOT NULL PRIMARY KEY, "channel" CHARACTER VARYING NOT NULL, "app" CHARACTER VARYING NOT NULL ) `); await this.options.pgClient.query(` DROP TRIGGER IF EXISTS notify_release_lock_trigger ON ${this.schemaName}.lock `); } /** * Creates locks by channel names in the database * * @return {Promise<void>} */ async createChannelLock() { await this.options.pgClient.query(` DO $$ BEGIN IF EXISTS ( SELECT * FROM information_schema.columns WHERE table_schema = '${this.schemaName}' AND table_name = 'lock' AND column_name = 'id' ) THEN DROP TABLE IF EXISTS ${this.schemaName}.lock; END IF; END $$ `); await this.options.pgClient.query(` CREATE TABLE IF NOT EXISTS ${this.schemaName}."lock" ( "channel" CHARACTER VARYING NOT NULL PRIMARY KEY, "app" CHARACTER VARYING NOT NULL ) `); // noinspection SqlResolve await this.options.pgClient.query(` CREATE OR REPLACE FUNCTION ${this.schemaName}.notify_lock() RETURNS TRIGGER LANGUAGE PLPGSQL AS $$ BEGIN PERFORM PG_NOTIFY(OLD.channel, '1'); RETURN OLD; END; $$ `); await this.options.pgClient.query(` DROP TRIGGER IF EXISTS notify_release_lock_trigger ON ${this.schemaName}.lock `); try { await this.options.pgClient.query(` CREATE CONSTRAINT TRIGGER notify_release_lock_trigger AFTER DELETE ON ${this.schemaName}.lock DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE PROCEDURE ${this.schemaName}.notify_lock() `); } catch (e) { /*ignore*/ } } /** * Creates deadlocks check routine used on lock acquaintance * * @return {Promise<void>} */ async createDeadlockCheck() { await this.options.pgClient.query(` CREATE OR REPLACE FUNCTION ${this.schemaName}.deadlock_check( old_app TEXT, new_app TEXT ) RETURNS TEXT LANGUAGE PLPGSQL AS $$ DECLARE num_apps INTEGER; BEGIN SELECT count(query) INTO num_apps FROM pg_stat_activity WHERE application_name = old_app; IF num_apps > 0 THEN RAISE EXCEPTION 'Duplicate channel for app %', new_app USING DETAIL = 'LOCKED'; END IF; RETURN new_app; END; $$ `); } } exports.PgIpLock = PgIpLock; exports.RX_LOCK_CHANNEL = new RegExp(`^(__${PgIpLock.name}__:)+`); let timer; /** * Performs graceful shutdown of running process releasing all instantiated * locks and properly destroy all their instances. */ async function terminate() { let code = 0; timer && clearTimeout(timer); timer = setTimeout(() => process.exit(code), constants_1.SHUTDOWN_TIMEOUT); code = await destroyLock(); } /** * Destroys all instanced locks and returns exit code */ async function destroyLock() { // istanbul ignore if if (!PgIpLock.hasInstances()) { return 0; } try { await PgIpLock.destroy(); return 0; } catch (err) { // istanbul ignore next (PgIpLock.hasInstances() ? PgIpLock.instances[0].options.logger : console)?.error(err); return 1; } } process.on('SIGTERM', terminate); process.on('SIGINT', terminate); process.on('SIGABRT', terminate); //# sourceMappingURL=PgIpLock.js.map