646 lines
18 KiB
JavaScript
646 lines
18 KiB
JavaScript
// SPDX-License-Identifier: LGPL-2.1-or-later
|
|
// Copyright (c) 2015-2024 MariaDB Corporation Ab
|
|
|
|
'use strict';
|
|
|
|
const { EventEmitter } = require('events');
|
|
|
|
const Queue = require('denque');
|
|
const Errors = require('./misc/errors');
|
|
const Utils = require('./misc/utils');
|
|
const Connection = require('./connection');
|
|
const CommandParameter = require('./command-parameter');
|
|
|
|
class Pool extends EventEmitter {
|
|
opts;
|
|
#closed = false;
|
|
#connectionInCreation = false;
|
|
#errorCreatingConnection = null;
|
|
#idleConnections = new Queue();
|
|
#activeConnections = {};
|
|
#requests = new Queue();
|
|
#unusedConnectionRemoverId;
|
|
#requestTimeoutId;
|
|
#connErrorNumber = 0;
|
|
#initialized = false;
|
|
_sizeHandlerTimeout;
|
|
|
|
constructor(options) {
|
|
super();
|
|
this.opts = options;
|
|
|
|
this.on('_idle', this._requestsHandler);
|
|
this.on('validateSize', this._sizeHandler);
|
|
this._sizeHandler();
|
|
}
|
|
|
|
//*****************************************************************
|
|
// pool automatic handlers
|
|
//*****************************************************************
|
|
|
|
_doCreateConnection(resolve, reject, timeoutEnd) {
|
|
this._createConnection()
|
|
.then((conn) => {
|
|
if (this.#closed) {
|
|
conn.forceEnd(
|
|
null,
|
|
() => {},
|
|
() => {}
|
|
);
|
|
reject(
|
|
new Errors.createFatalError(
|
|
'Cannot create new connection to pool, pool closed',
|
|
Errors.ER_ADD_CONNECTION_CLOSED_POOL
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
conn.lastUse = Date.now();
|
|
const nativeDestroy = conn.destroy.bind(conn);
|
|
const pool = this;
|
|
|
|
conn.destroy = function () {
|
|
pool._endLeak(conn);
|
|
delete pool.#activeConnections[conn.threadId];
|
|
nativeDestroy();
|
|
pool.emit('validateSize');
|
|
};
|
|
|
|
conn.once('error', function () {
|
|
let idx = 0;
|
|
let currConn;
|
|
pool._endLeak(conn);
|
|
delete pool.#activeConnections[conn.threadId];
|
|
while ((currConn = pool.#idleConnections.peekAt(idx))) {
|
|
if (currConn === conn) {
|
|
pool.#idleConnections.removeOne(idx);
|
|
continue;
|
|
}
|
|
//since connection did have an error, other waiting connection might too
|
|
//force validation when borrowed next time, even if "minDelayValidation" is not reached.
|
|
currConn.lastUse = Math.min(currConn.lastUse, Date.now() - pool.opts.minDelayValidation);
|
|
idx++;
|
|
}
|
|
setTimeout(() => {
|
|
if (!pool.#requests.isEmpty()) {
|
|
pool._sizeHandler();
|
|
}
|
|
}, 0);
|
|
});
|
|
|
|
this.#idleConnections.push(conn);
|
|
this.#connectionInCreation = false;
|
|
this.emit('_idle');
|
|
this.emit('connection', conn);
|
|
resolve(conn);
|
|
})
|
|
.catch((err) => {
|
|
//if timeout is reached or authentication fail return error
|
|
if (err instanceof AggregateError) {
|
|
err = err.errors[0];
|
|
}
|
|
if (
|
|
this.#closed ||
|
|
(err.errno && (err.errno === 1524 || err.errno === 1045 || err.errno === 1698)) ||
|
|
timeoutEnd < Date.now()
|
|
) {
|
|
err.message = err.message + this._errorMsgAddon();
|
|
reject(err);
|
|
return;
|
|
}
|
|
setTimeout(this._doCreateConnection.bind(this, resolve, reject, timeoutEnd), 500);
|
|
});
|
|
}
|
|
|
|
_destroy(conn) {
|
|
this._endLeak(conn);
|
|
delete this.#activeConnections[conn.threadId];
|
|
conn.lastUse = Date.now();
|
|
conn.forceEnd(
|
|
null,
|
|
() => {},
|
|
() => {}
|
|
);
|
|
|
|
if (this.totalConnections() === 0) {
|
|
this._stopReaping();
|
|
}
|
|
|
|
this.emit('validateSize');
|
|
}
|
|
|
|
release(conn) {
|
|
// ensure releasing only once
|
|
if (this.#activeConnections[conn.threadId]) {
|
|
this._endLeak(conn);
|
|
this.#activeConnections[conn.threadId] = null;
|
|
conn.lastUse = Date.now();
|
|
|
|
if (this.#closed) {
|
|
conn.forceEnd(
|
|
null,
|
|
() => {},
|
|
() => {}
|
|
);
|
|
} else if (conn.isValid()) {
|
|
this.emit('release', conn);
|
|
this.#idleConnections.push(conn);
|
|
process.nextTick(this.emit.bind(this, '_idle'));
|
|
} else {
|
|
this.emit('validateSize');
|
|
}
|
|
}
|
|
}
|
|
|
|
_checkLeak(conn) {
|
|
conn.lastUse = Date.now();
|
|
conn.leaked = false;
|
|
conn.leakProcess = setTimeout(
|
|
(conn) => {
|
|
conn.leaked = true;
|
|
conn.opts.logger.warning(
|
|
`A possible connection leak on the thread ${
|
|
conn.info.threadId
|
|
} (the connection not returned to the pool since ${
|
|
Date.now() - conn.lastUse
|
|
} ms). Has the connection.release() been called ?` + this._errorMsgAddon()
|
|
);
|
|
},
|
|
this.opts.leakDetectionTimeout,
|
|
conn
|
|
);
|
|
}
|
|
|
|
_endLeak(conn) {
|
|
if (conn.leakProcess) {
|
|
clearTimeout(conn.leakProcess);
|
|
conn.leakProcess = null;
|
|
if (conn.leaked) {
|
|
conn.opts.logger.warning(
|
|
`Previous possible leak connection with thread ${conn.info.threadId} was returned to pool`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Permit to remove idle connection if unused for some time.
|
|
*/
|
|
_startReaping() {
|
|
if (!this.#unusedConnectionRemoverId && this.opts.idleTimeout > 0) {
|
|
this.#unusedConnectionRemoverId = setInterval(this._reaper.bind(this), 500);
|
|
}
|
|
}
|
|
|
|
_stopReaping() {
|
|
if (this.#unusedConnectionRemoverId && this.totalConnections() === 0) {
|
|
clearInterval(this.#unusedConnectionRemoverId);
|
|
}
|
|
}
|
|
|
|
_reaper() {
|
|
const idleTimeRemoval = Date.now() - this.opts.idleTimeout * 1000;
|
|
let maxRemoval = Math.max(0, this.#idleConnections.length - this.opts.minimumIdle);
|
|
while (maxRemoval > 0) {
|
|
const conn = this.#idleConnections.peek();
|
|
maxRemoval--;
|
|
if (conn && conn.lastUse < idleTimeRemoval) {
|
|
this.#idleConnections.shift();
|
|
conn.forceEnd(
|
|
null,
|
|
() => {},
|
|
() => {}
|
|
);
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (this.totalConnections() === 0) {
|
|
this._stopReaping();
|
|
}
|
|
this.emit('validateSize');
|
|
}
|
|
|
|
_shouldCreateMoreConnections() {
|
|
return (
|
|
!this.#connectionInCreation &&
|
|
this.#idleConnections.length < this.opts.minimumIdle &&
|
|
this.totalConnections() < this.opts.connectionLimit &&
|
|
!this.#closed
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Grow pool connections until reaching connection limit.
|
|
*/
|
|
_sizeHandler() {
|
|
if (this._shouldCreateMoreConnections() && !this._sizeHandlerTimeout) {
|
|
this.#connectionInCreation = true;
|
|
setImmediate(
|
|
function () {
|
|
const timeoutEnd = Date.now() + this.opts.initializationTimeout;
|
|
new Promise((resolve, reject) => {
|
|
this._doCreateConnection(resolve, reject, timeoutEnd);
|
|
})
|
|
.then(() => {
|
|
this.#initialized = true;
|
|
this.#errorCreatingConnection = null;
|
|
this.#connErrorNumber = 0;
|
|
if (this._shouldCreateMoreConnections()) {
|
|
this.emit('validateSize');
|
|
}
|
|
this._startReaping();
|
|
})
|
|
.catch((err) => {
|
|
this.#connectionInCreation = false;
|
|
if (!this.#closed) {
|
|
if (!this.#initialized) {
|
|
err.message = 'Error during pool initialization: ' + err.message;
|
|
} else {
|
|
err.message = 'Pool fails to create connection: ' + err.message;
|
|
}
|
|
this.#errorCreatingConnection = err;
|
|
this.emit('error', err);
|
|
|
|
//delay next try
|
|
this._sizeHandlerTimeout = setTimeout(
|
|
function () {
|
|
this._sizeHandlerTimeout = null;
|
|
if (!this.#requests.isEmpty()) {
|
|
this._sizeHandler();
|
|
}
|
|
}.bind(this),
|
|
Math.min(++this.#connErrorNumber * 500, 10000)
|
|
);
|
|
}
|
|
});
|
|
}.bind(this)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Launch next waiting task request if available connections.
|
|
*/
|
|
_requestsHandler() {
|
|
clearTimeout(this.#requestTimeoutId);
|
|
this.#requestTimeoutId = null;
|
|
const request = this.#requests.shift();
|
|
if (request) {
|
|
const conn = this.#idleConnections.shift();
|
|
if (conn) {
|
|
if (this.opts.leakDetectionTimeout > 0) this._checkLeak(conn);
|
|
this.emit('acquire', conn);
|
|
this.#activeConnections[conn.threadId] = conn;
|
|
request.resolver(conn);
|
|
} else {
|
|
this.#requests.unshift(request);
|
|
}
|
|
this._requestTimeoutHandler();
|
|
}
|
|
}
|
|
|
|
_hasIdleConnection() {
|
|
return !this.#idleConnections.isEmpty();
|
|
}
|
|
|
|
/**
|
|
* Return an idle Connection.
|
|
* If connection has not been used for some time ( minDelayValidation), validate connection status.
|
|
*
|
|
* @returns {Promise<Connection>} connection of null of no valid idle connection.
|
|
*/
|
|
async _doAcquire() {
|
|
if (!this._hasIdleConnection() || this.#closed) return Promise.reject();
|
|
let conn;
|
|
let mustRecheckSize = false;
|
|
while ((conn = this.#idleConnections.shift()) != null) {
|
|
//just check connection state first
|
|
if (conn.isValid()) {
|
|
this.#activeConnections[conn.threadId] = conn;
|
|
//if not used for some time, validate connection with a COM_PING
|
|
if (this.opts.minDelayValidation <= 0 || Date.now() - conn.lastUse > this.opts.minDelayValidation) {
|
|
try {
|
|
const cmdParam = new CommandParameter(null, null, { timeout: this.opts.pingTimeout });
|
|
await new Promise(conn.ping.bind(conn, cmdParam));
|
|
} catch (e) {
|
|
delete this.#activeConnections[conn.threadId];
|
|
continue;
|
|
}
|
|
}
|
|
if (this.opts.leakDetectionTimeout > 0) this._checkLeak(conn);
|
|
if (mustRecheckSize) setImmediate(this.emit.bind(this, 'validateSize'));
|
|
return Promise.resolve(conn);
|
|
}
|
|
mustRecheckSize = true;
|
|
}
|
|
setImmediate(this.emit.bind(this, 'validateSize'));
|
|
return Promise.reject();
|
|
}
|
|
|
|
_requestTimeoutHandler() {
|
|
//handle next Timer
|
|
this.#requestTimeoutId = null;
|
|
const currTime = Date.now();
|
|
let request;
|
|
while ((request = this.#requests.peekFront())) {
|
|
if (request.timeout <= currTime) {
|
|
this.#requests.shift();
|
|
|
|
let cause = this.activeConnections() === 0 ? this.#errorCreatingConnection : null;
|
|
let err = Errors.createError(
|
|
`retrieve connection from pool timeout after ${Math.abs(
|
|
Date.now() - (request.timeout - this.opts.acquireTimeout)
|
|
)}ms${this._errorMsgAddon()}`,
|
|
Errors.ER_GET_CONNECTION_TIMEOUT,
|
|
null,
|
|
'HY000',
|
|
null,
|
|
false,
|
|
request.stack,
|
|
null,
|
|
cause
|
|
);
|
|
|
|
request.reject(err);
|
|
} else {
|
|
this.#requestTimeoutId = setTimeout(this._requestTimeoutHandler.bind(this), request.timeout - currTime);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search info object of an existing connection. to know server type and version.
|
|
* @returns information object if connection available.
|
|
*/
|
|
_searchInfo() {
|
|
let info = null;
|
|
let conn = this.#idleConnections.get(0);
|
|
|
|
if (!conn) {
|
|
for (const threadId in Object.keys(this.#activeConnections)) {
|
|
conn = this.#activeConnections[threadId];
|
|
if (!conn) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (conn) {
|
|
info = conn.info;
|
|
}
|
|
return info;
|
|
}
|
|
|
|
async _createConnection() {
|
|
const conn = new Connection(this.opts.connOptions);
|
|
await conn.connect();
|
|
const pool = this;
|
|
conn.forceEnd = conn.end;
|
|
conn.release = function (resolve) {
|
|
if (pool.#closed || !conn.isValid()) {
|
|
pool._destroy(conn);
|
|
resolve();
|
|
return;
|
|
}
|
|
if (pool.opts.noControlAfterUse) {
|
|
pool.release(conn);
|
|
resolve();
|
|
return;
|
|
}
|
|
//if server permit it, reset the connection, or rollback only if not
|
|
// COM_RESET_CONNECTION exist since mysql 5.7.3 and mariadb 10.2.4
|
|
// but not possible to use it with mysql waiting for https://bugs.mysql.com/bug.php?id=97633 correction.
|
|
// and mariadb only since https://jira.mariadb.org/browse/MDEV-18281
|
|
let revertFunction;
|
|
if (
|
|
pool.opts.resetAfterUse &&
|
|
conn.info.isMariaDB() &&
|
|
((conn.info.serverVersion.minor === 2 && conn.info.hasMinVersion(10, 2, 22)) ||
|
|
conn.info.hasMinVersion(10, 3, 13))
|
|
) {
|
|
revertFunction = conn.reset.bind(conn, new CommandParameter());
|
|
} else revertFunction = conn.changeTransaction.bind(conn, new CommandParameter('ROLLBACK'));
|
|
|
|
new Promise(revertFunction).then(pool.release.bind(pool, conn), pool._destroy.bind(pool, conn)).finally(resolve);
|
|
};
|
|
conn.end = conn.release;
|
|
return conn;
|
|
}
|
|
|
|
_leakedConnections() {
|
|
let counter = 0;
|
|
for (const connection of Object.values(this.#activeConnections)) {
|
|
if (connection && connection.leaked) counter++;
|
|
}
|
|
return counter;
|
|
}
|
|
|
|
_errorMsgAddon() {
|
|
if (this.opts.leakDetectionTimeout > 0) {
|
|
return `\n (pool connections: active=${this.activeConnections()} idle=${this.idleConnections()} leak=${this._leakedConnections()} limit=${
|
|
this.opts.connectionLimit
|
|
})`;
|
|
}
|
|
return `\n (pool connections: active=${this.activeConnections()} idle=${this.idleConnections()} limit=${
|
|
this.opts.connectionLimit
|
|
})`;
|
|
}
|
|
|
|
//*****************************************************************
|
|
// public methods
|
|
//*****************************************************************
|
|
|
|
get closed() {
|
|
return this.#closed;
|
|
}
|
|
|
|
/**
|
|
* Get current total connection number.
|
|
* @return {number}
|
|
*/
|
|
totalConnections() {
|
|
return this.activeConnections() + this.idleConnections();
|
|
}
|
|
|
|
/**
|
|
* Get current active connections.
|
|
* @return {number}
|
|
*/
|
|
activeConnections() {
|
|
let counter = 0;
|
|
for (const connection of Object.values(this.#activeConnections)) {
|
|
if (connection) counter++;
|
|
}
|
|
return counter;
|
|
}
|
|
|
|
/**
|
|
* Get current idle connection number.
|
|
* @return {number}
|
|
*/
|
|
idleConnections() {
|
|
return this.#idleConnections.length;
|
|
}
|
|
|
|
/**
|
|
* Get current stacked connection request.
|
|
* @return {number}
|
|
*/
|
|
taskQueueSize() {
|
|
return this.#requests.length;
|
|
}
|
|
|
|
escape(value) {
|
|
return Utils.escape(this.opts.connOptions, this._searchInfo(), value);
|
|
}
|
|
|
|
escapeId(value) {
|
|
return Utils.escapeId(this.opts.connOptions, this._searchInfo(), value);
|
|
}
|
|
|
|
//*****************************************************************
|
|
// promise methods
|
|
//*****************************************************************
|
|
|
|
/**
|
|
* Retrieve a connection from pool.
|
|
* Create a new one, if limit is not reached.
|
|
* wait until acquireTimeout.
|
|
* @param cmdParam for stackTrace error
|
|
* @return {Promise}
|
|
*/
|
|
getConnection(cmdParam) {
|
|
if (this.#closed) {
|
|
return Promise.reject(
|
|
Errors.createError(
|
|
'pool is closed',
|
|
Errors.ER_POOL_ALREADY_CLOSED,
|
|
null,
|
|
'HY000',
|
|
cmdParam === null ? null : cmdParam.sql,
|
|
false,
|
|
cmdParam.stack
|
|
)
|
|
);
|
|
}
|
|
return this._doAcquire().then(
|
|
(conn) => {
|
|
// connection is available. process task
|
|
this.emit('acquire', conn);
|
|
return conn;
|
|
},
|
|
() => {
|
|
if (this.#closed) {
|
|
throw Errors.createError(
|
|
'Cannot add request to pool, pool is closed',
|
|
Errors.ER_POOL_ALREADY_CLOSED,
|
|
null,
|
|
'HY000',
|
|
cmdParam === null ? null : cmdParam.sql,
|
|
false,
|
|
cmdParam.stack
|
|
);
|
|
}
|
|
// no idle connection available
|
|
// create a new connection if limit is not reached
|
|
setImmediate(this.emit.bind(this, 'validateSize'));
|
|
return new Promise(
|
|
function (resolver, rejecter) {
|
|
// stack request
|
|
setImmediate(this.emit.bind(this, 'enqueue'));
|
|
const request = new Request(Date.now() + this.opts.acquireTimeout, cmdParam.stack, resolver, rejecter);
|
|
this.#requests.push(request);
|
|
if (!this.#requestTimeoutId) {
|
|
this.#requestTimeoutId = setTimeout(this._requestTimeoutHandler.bind(this), this.opts.acquireTimeout);
|
|
}
|
|
}.bind(this)
|
|
);
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Close all connection in pool
|
|
* Ends in multiple step :
|
|
* - close idle connections
|
|
* - ensure that no new request is possible
|
|
* (active connection release are automatically closed on release)
|
|
* - if remaining, after 10 seconds, close remaining active connections
|
|
*
|
|
* @return Promise
|
|
*/
|
|
end() {
|
|
if (this.#closed) {
|
|
return Promise.reject(Errors.createError('pool is already closed', Errors.ER_POOL_ALREADY_CLOSED));
|
|
}
|
|
this.#closed = true;
|
|
clearInterval(this.#unusedConnectionRemoverId);
|
|
clearInterval(this._sizeHandlerTimeout);
|
|
const cmdParam = new CommandParameter();
|
|
if (this.opts.trace) Error.captureStackTrace(cmdParam);
|
|
//close unused connections
|
|
const idleConnectionsEndings = [];
|
|
let conn;
|
|
while ((conn = this.#idleConnections.shift())) {
|
|
idleConnectionsEndings.push(new Promise(conn.forceEnd.bind(conn, cmdParam)));
|
|
}
|
|
|
|
clearTimeout(this.#requestTimeoutId);
|
|
this.#requestTimeoutId = null;
|
|
|
|
//reject all waiting task
|
|
if (!this.#requests.isEmpty()) {
|
|
const err = Errors.createError(
|
|
'pool is ending, connection request aborted',
|
|
Errors.ER_CLOSING_POOL,
|
|
null,
|
|
'HY000',
|
|
null,
|
|
false,
|
|
cmdParam.stack
|
|
);
|
|
let task;
|
|
while ((task = this.#requests.shift())) {
|
|
task.reject(err);
|
|
}
|
|
}
|
|
const pool = this;
|
|
return Promise.all(idleConnectionsEndings).then(async () => {
|
|
if (pool.activeConnections() > 0) {
|
|
// wait up to 10 seconds, that active connection are released
|
|
let remaining = 100;
|
|
while (remaining-- > 0) {
|
|
if (pool.activeConnections() > 0) {
|
|
await new Promise((res) => setTimeout(() => res(), 100));
|
|
}
|
|
}
|
|
|
|
// force close any remaining active connections
|
|
for (const connection of Object.values(pool.#activeConnections)) {
|
|
if (connection) connection.destroy();
|
|
}
|
|
}
|
|
return Promise.resolve();
|
|
});
|
|
}
|
|
}
|
|
|
|
class Request {
|
|
constructor(timeout, stack, resolver, rejecter) {
|
|
this.timeout = timeout;
|
|
this.stack = stack;
|
|
this.resolver = resolver;
|
|
this.rejecter = rejecter;
|
|
}
|
|
|
|
reject(err) {
|
|
process.nextTick(this.rejecter, err);
|
|
}
|
|
}
|
|
|
|
module.exports = Pool;
|