mariadb
Version:
fast mariadb or mysql connector.
447 lines (394 loc) • 13.1 kB
JavaScript
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (c) 2015-2025 MariaDB Corporation Ab
'use strict';
const ClusterOptions = require('./config/cluster-options');
const PoolOptions = require('./config/pool-options');
const PoolCallback = require('./pool-callback');
const PoolPromise = require('./pool-promise');
const FilteredCluster = require('./filtered-cluster');
const FilteredClusterCallback = require('./filtered-cluster-callback');
const EventEmitter = require('events');
/**
* Create a new Cluster.
* Cluster handle pools with patterns and handle failover / distributed load
* according to selectors (round-robin / random / ordered )
*
* @param args cluster arguments. see pool-cluster-options.
* @constructor
*/
class Cluster extends EventEmitter {
#opts;
#nodes = {};
#cachedPatterns = {};
#nodeCounter = 0;
constructor(args) {
super();
this.#opts = new ClusterOptions(args);
}
/**
* Add a new pool node to the cluster.
*
* @param id identifier
* @param config pool configuration
*/
add(id, config) {
let identifier;
if (typeof id === 'string' || id instanceof String) {
identifier = id;
if (this.#nodes[identifier]) throw new Error(`Node identifier '${identifier}' already exist !`);
} else {
identifier = 'PoolNode-' + this.#nodeCounter++;
config = id;
}
const options = new PoolOptions(config);
this.#nodes[identifier] = this._createPool(options);
}
/**
* End cluster (and underlying pools).
*
* @return {Promise<any[]>}
*/
end() {
const cluster = this;
this.#cachedPatterns = {};
const poolEndPromise = [];
Object.keys(this.#nodes).forEach((pool) => {
const res = cluster.#nodes[pool].end();
if (res) poolEndPromise.push(res);
});
this.#nodes = null;
return Promise.all(poolEndPromise);
}
of(pattern, selector) {
return new FilteredCluster(this, pattern, selector);
}
_ofCallback(pattern, selector) {
return new FilteredClusterCallback(this, pattern, selector);
}
/**
* Remove nodes according to pattern.
*
* @param pattern pattern
*/
remove(pattern) {
if (!pattern) throw new Error('pattern parameter in Cluster.remove(pattern) is mandatory');
const regex = RegExp(pattern);
Object.keys(this.#nodes).forEach(
function (key) {
if (regex.test(key)) {
this.#nodes[key].end();
delete this.#nodes[key];
this.#cachedPatterns = {};
}
}.bind(this)
);
}
/**
* Get connection from an available pools matching pattern, according to selector
*
* @param pattern pattern filter (not mandatory)
* @param selector node selector ('RR','RANDOM' or 'ORDER')
* @return {Promise}
*/
getConnection(pattern, selector) {
return this._getConnection(pattern, selector, undefined, undefined, undefined);
}
/**
* Force using callback methods.
*/
_setCallback() {
this.getConnection = this._getConnectionCallback;
this._createPool = this._createPoolCallback;
this.of = this._ofCallback;
}
/**
* Get connection from an available pools matching pattern, according to selector
* with additional parameter to avoid reusing failing node
*
* @param pattern pattern filter (not mandatory)
* @param selector node selector ('RR','RANDOM' or 'ORDER')
* @param avoidNodeKey failing node
* @param lastError last error
* @param remainingRetry remaining possible retry
* @return {Promise}
* @private
*/
_getConnection(pattern, selector, remainingRetry, avoidNodeKey, lastError) {
const matchingNodeList = this._matchingNodes(pattern || /^/);
if (matchingNodeList.length === 0) {
if (Object.keys(this.#nodes).length === 0 && !lastError) {
return Promise.reject(
new Error('No node have been added to cluster or nodes have been removed due to too much connection error')
);
}
if (avoidNodeKey === undefined) return Promise.reject(new Error(`No node found for pattern '${pattern}'`));
const errMsg = `No Connection available for '${pattern}'${
lastError ? '. Last connection error was: ' + lastError.message : ''
}`;
return Promise.reject(new Error(errMsg));
}
if (remainingRetry === undefined) remainingRetry = matchingNodeList.length;
const retry = --remainingRetry >= 0 ? this._getConnection.bind(this, pattern, selector, remainingRetry) : null;
try {
const nodeKey = this._selectPool(matchingNodeList, selector, avoidNodeKey);
return this._handleConnectionError(matchingNodeList, nodeKey, retry);
} catch (e) {
return Promise.reject(e);
}
}
_createPool(options) {
const pool = new PoolPromise(options);
pool.on('error', (err) => {});
return pool;
}
_createPoolCallback(options) {
const pool = new PoolCallback(options);
pool.on('error', (err) => {});
return pool;
}
/**
* Get connection from an available pools matching pattern, according to selector
* with additional parameter to avoid reusing failing node
*
* @param pattern pattern filter (not mandatory)
* @param selector node selector ('RR','RANDOM' or 'ORDER')
* @param callback callback function
* @param remainingRetry remaining retry
* @param avoidNodeKey failing node
* @param lastError last error
* @private
*/
_getConnectionCallback(pattern, selector, callback, remainingRetry, avoidNodeKey, lastError) {
const matchingNodeList = this._matchingNodes(pattern || /^/);
if (matchingNodeList.length === 0) {
if (Object.keys(this.#nodes).length === 0 && !lastError) {
callback(
new Error('No node have been added to cluster or nodes have been removed due to too much connection error')
);
return;
}
if (avoidNodeKey === undefined) callback(new Error(`No node found for pattern '${pattern}'`));
const errMsg = `No Connection available for '${pattern}'${
lastError ? '. Last connection error was: ' + lastError.message : ''
}`;
callback(new Error(errMsg));
return;
}
if (remainingRetry === undefined) remainingRetry = matchingNodeList.length;
const retry =
--remainingRetry >= 0
? this._getConnectionCallback.bind(this, pattern, selector, callback, remainingRetry)
: null;
try {
const nodeKey = this._selectPool(matchingNodeList, selector, avoidNodeKey);
this._handleConnectionCallbackError(matchingNodeList, nodeKey, retry, callback);
} catch (e) {
callback(e);
}
}
/**
* Selecting nodes according to pattern.
*
* @param pattern pattern
* @return {*}
* @private
*/
_matchingNodes(pattern) {
if (this.#cachedPatterns[pattern]) return this.#cachedPatterns[pattern];
const regex = RegExp(pattern);
const matchingNodeList = [];
Object.keys(this.#nodes).forEach((key) => {
if (regex.test(key)) {
matchingNodeList.push(key);
}
});
this.#cachedPatterns[pattern] = matchingNodeList;
return matchingNodeList;
}
/**
* Select the next node to be chosen in the nodeList according to selector and failed nodes.
*
* @param nodeList current node list
* @param selectorParam selector
* @param avoidNodeKey last failing node to avoid selecting this one.
* @return {Promise}
* @private
*/
_selectPool(nodeList, selectorParam, avoidNodeKey) {
const selector = selectorParam || this.#opts.defaultSelector;
let selectorFct;
switch (selector) {
case 'RR':
selectorFct = roundRobinSelector;
break;
case 'RANDOM':
selectorFct = randomSelector;
break;
case 'ORDER':
selectorFct = orderedSelector;
break;
default:
throw new Error(`Wrong selector value '${selector}'. Possible values are 'RR','RANDOM' or 'ORDER'`);
}
let nodeIdx = 0;
let nodeKey = selectorFct(nodeList, nodeIdx);
// first loop : search for node not blacklisted AND not the avoided key
while (
(avoidNodeKey === nodeKey ||
(this.#nodes[nodeKey].blacklistedUntil && this.#nodes[nodeKey].blacklistedUntil > Date.now())) &&
nodeIdx < nodeList.length - 1
) {
nodeIdx++;
nodeKey = selectorFct(nodeList, nodeIdx);
}
if (avoidNodeKey === nodeKey) {
// second loop, search even in blacklisted node in order to choose a different node than to be avoided
nodeIdx = 0;
while (avoidNodeKey === nodeKey && nodeIdx < nodeList.length - 1) {
nodeIdx++;
nodeKey = selectorFct(nodeList, nodeIdx);
}
}
return nodeKey;
}
/**
* Handle node blacklisting and potential removal after a connection error
*
* @param {string} nodeKey - The key of the node that failed
* @param {Array<string>} nodeList - List of available nodes
* @returns {void}
* @private
*/
_handleNodeFailure(nodeKey, nodeList) {
const node = this.#nodes[nodeKey];
if (!node) return;
const cluster = this;
// Increment error count and blacklist node temporarily
node.errorCount = node.errorCount ? node.errorCount + 1 : 1;
node.blacklistedUntil = Date.now() + cluster.#opts.restoreNodeTimeout;
// Check if node should be removed due to excessive errors
if (
cluster.#opts.removeNodeErrorCount &&
node.errorCount >= cluster.#opts.removeNodeErrorCount &&
cluster.#nodes[nodeKey]
) {
delete cluster.#nodes[nodeKey];
cluster.#cachedPatterns = {};
delete nodeList.lastRrIdx;
setImmediate(cluster.emit.bind(cluster, 'remove', nodeKey));
if (node instanceof PoolCallback) {
node.end(() => {
// Intentionally ignore error during cleanup
});
} else {
node.end().catch((err) => {
// Intentionally ignore error during cleanup
});
}
}
}
/**
* Connect, or if fail handle retry / set timeout error
*
* @param nodeList current node list
* @param nodeKey node name to connect
* @param retryFct retry function
* @return {Promise}
* @private
*/
_handleConnectionError(nodeList, nodeKey, retryFct) {
const cluster = this;
const node = this.#nodes[nodeKey];
return node
.getConnection()
.then((conn) => {
// Connection successful, reset error state
node.blacklistedUntil = null;
node.errorCount = 0;
return conn;
})
.catch((err) => {
this._handleNodeFailure(nodeKey, nodeList);
if (nodeList.length !== 0 && cluster.#opts.canRetry && retryFct) {
return retryFct(nodeKey, err);
}
return Promise.reject(err);
});
}
/**
* Connect, or if fail handle retry / set timeout error
*
* @param nodeList current node list
* @param nodeKey node name to connect
* @param retryFct retry function
* @param callback callback function
* @private
*/
_handleConnectionCallbackError(nodeList, nodeKey, retryFct, callback) {
const cluster = this;
const node = this.#nodes[nodeKey];
node.getConnection((err, conn) => {
if (err) {
this._handleNodeFailure(nodeKey, nodeList);
if (nodeList.length !== 0 && cluster.#opts.canRetry && retryFct) {
return retryFct(nodeKey, err);
}
callback(err);
} else {
// Connection successful, reset error state
node.blacklistedUntil = null;
node.errorCount = 0;
callback(null, conn);
}
});
}
//*****************************************************************
// internal public testing methods
//*****************************************************************
get __tests() {
return new TestMethods(this.#nodes);
}
}
class TestMethods {
#nodes;
constructor(nodes) {
this.#nodes = nodes;
}
getNodes() {
return this.#nodes;
}
}
/**
* Round robin selector: using nodes one after the other.
*
* @param nodeList node list
* @return {String}
*/
const roundRobinSelector = (nodeList) => {
let lastRoundRobin = nodeList.lastRrIdx;
if (lastRoundRobin === undefined) lastRoundRobin = -1;
if (++lastRoundRobin >= nodeList.length) lastRoundRobin = 0;
nodeList.lastRrIdx = lastRoundRobin;
return nodeList[lastRoundRobin];
};
/**
* Random selector: use a random node.
*
* @param {Array<string>} nodeList - List of available nodes
* @return {String} - Selected node key
*/
const randomSelector = (nodeList) => {
const randomIdx = Math.floor(Math.random() * nodeList.length);
return nodeList[randomIdx];
};
/**
* Ordered selector: always use the nodes in sequence, unless failing.
*
* @param nodeList node list
* @param retry sequence number if last node is tagged has failing
* @return {String}
*/
const orderedSelector = (nodeList, retry) => {
return nodeList[retry];
};
module.exports = Cluster;