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