mariadb
Version:
fast mariadb or mysql connector.
952 lines (826 loc) • 26.5 kB
JavaScript
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (c) 2015-2025 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');
class Pool extends EventEmitter {
opts;
#closed = false;
#connectionInCreation = false;
#errorCreatingConnection = null;
#idleConnections;
#activeConnections = {};
#requests = new Queue();
#unusedConnectionRemoverId;
#requestTimeoutId;
#connErrorNumber = 0;
#initialized = false;
_managePoolSizeTask;
_connectionCreationTask;
constructor(options) {
super();
this.opts = options;
this.#idleConnections = new Queue(null, { capacity: this.opts.connectionLimit });
this.on('_idle', this._processNextPendingRequest);
this.on('validateSize', this._managePoolSize);
this._managePoolSize();
}
//*****************************************************************
// pool automatic handlers
//*****************************************************************
/**
* Manages pool size by creating new connections when needed
*/
_managePoolSize() {
// Only create new connections if conditions are met and no creation is in progress
if (!this._shouldCreateMoreConnections() || this._managePoolSizeTask) {
return;
}
this.#connectionInCreation = true;
const timeoutEnd = Date.now() + this.opts.initializationTimeout;
this._initiateConnectionCreation(timeoutEnd);
}
/**
* Initiates connection creation with proper error handling
* @param {number} timeoutEnd - When the connection attempt should time out
*/
_initiateConnectionCreation(timeoutEnd) {
this._createPoolConnection(
// Success callback
() => this._onConnectionCreationSuccess(),
// Error callback
(err) => this._onConnectionCreationError(err, timeoutEnd),
timeoutEnd
);
}
/**
* Handles successful connection creation
*/
_onConnectionCreationSuccess() {
this.#initialized = true;
this.#errorCreatingConnection = null;
this.#connErrorNumber = 0;
this._connectionCreationTask = null;
// Check if we need more connections
if (this._shouldCreateMoreConnections()) {
this.emit('validateSize');
}
this._startConnectionReaping();
}
/**
* Handles errors during connection creation
* @param {Error} err - The error that occurred
* @param {number} timeoutEnd - When the connection attempt should time out
*/
_onConnectionCreationError(err, timeoutEnd) {
this.#connectionInCreation = false;
if (this.#closed) {
return;
}
if (this.#errorCreatingConnection) err = this.#errorCreatingConnection;
// Format error message based on pool state
let error;
if (!this.#initialized) {
error = Errors.createError(
`Error during pool initialization`,
Errors.ER_POOL_NOT_INITIALIZED,
null,
null,
null,
false,
null,
null,
err
);
} else {
error = Errors.createError(
`Pool fails to create connection`,
Errors.ER_POOL_NO_CONNECTION,
null,
null,
null,
false,
null,
null,
err
);
}
// Schedule next attempt with exponential backoff
const backoffTime = Math.min(++this.#connErrorNumber * 200, 10000);
this._scheduleRetryWithBackoff(backoffTime);
this.emit('error', error);
}
/**
* Schedules the next connection creation attempt with backoff
* @param {number} delay - Time to wait before next attempt
*/
_scheduleRetryWithBackoff(delay) {
if (this.#closed) {
return;
}
this._managePoolSizeTask = setTimeout(() => {
this._managePoolSizeTask = null;
if (!this.#requests.isEmpty()) {
this._managePoolSize();
}
}, delay);
}
/**
* Creates a new connection for the pool with proper error handling
* @param {Function} onSuccess - Success callback
* @param {Function} onError - Error callback
* @param {number} timeoutEnd - Timestamp when connection attempt should time out
*/
_createPoolConnection(onSuccess, onError, timeoutEnd) {
const minTimeout = timeoutEnd - Date.now();
const connectionOpts = Object.assign({}, this.opts.connOptions, {
connectTimeout: Math.max(1, Math.min(minTimeout, this.opts.connOptions.connectTimeout || Number.MAX_SAFE_INTEGER))
});
const conn = new Connection(connectionOpts);
this._connectionCreationTask = null;
// Use direct callback approach instead of Promise
conn
.connect()
.then((conn) => this._prepareNewConnection(conn, onSuccess, onError))
.catch((err) => this._handleConnectionCreationError(err, onSuccess, onError, timeoutEnd));
}
/**
* Sets up a newly created connection for use in the pool
* @param {Connection} conn - The new connection
* @param {Function} onSuccess - Success callback
* @param {Function} onError - Error callback
*/
_prepareNewConnection(conn, onSuccess, onError) {
// Handle pool closed during connection creation
if (this.#closed) {
this._cleanupConnection(conn, 'pool_closed');
onError(
new Errors.createFatalError(
'Cannot create new connection to pool, pool closed',
Errors.ER_ADD_CONNECTION_CLOSED_POOL
)
);
return;
}
// Initialize connection for pool use
conn.lastUse = Date.now();
// Setup connection for pool use
conn.forceEnd = conn.end;
conn.release = (callback) => this._handleRelease(conn, callback);
conn.end = conn.release;
// Override destroy method to handle pool cleanup
this._overrideConnectionMethods(conn);
// Setup error handler for connection failures
this._setupConnectionErrorHandler(conn);
// Add to idle connections and mark creation as complete
this.#idleConnections.push(conn);
this.#connectionInCreation = false;
// Emit events and call success callback
this.emit('_idle');
this.emit('connection', conn);
onSuccess(conn);
}
/**
* Overrides connection methods for pool integration
* @param {Connection} conn - The connection to modify
*/
_overrideConnectionMethods(conn) {
const nativeDestroy = conn.destroy.bind(conn);
const pool = this;
conn.destroy = function () {
pool._endLeak(conn);
delete pool.#activeConnections[conn.threadId];
nativeDestroy();
pool.emit('validateSize');
};
}
/**
* Sets up error handler for a connection
* @param {Connection} conn - The connection to set up
*/
_setupConnectionErrorHandler(conn) {
const pool = this;
conn.once('error', () => {
// Clean up this connection
pool._endLeak(conn);
delete pool.#activeConnections[conn.threadId];
// Process idle connections
pool._processIdleConnectionsOnError(conn);
// Check if we need to create more connections
setImmediate(() => {
if (!pool.#requests.isEmpty()) {
pool._managePoolSize();
}
});
});
}
/**
* Processes idle connections when an error occurs
* @param {Connection} errorConn - The connection that had an error
*/
_processIdleConnectionsOnError(errorConn) {
let idx = 0;
while (idx < this.#idleConnections.length) {
const currConn = this.#idleConnections.peekAt(idx);
if (currConn === errorConn) {
this.#idleConnections.removeOne(idx);
continue;
}
// Force validation on other connections
currConn.lastUse = Math.min(currConn.lastUse, Date.now() - this.opts.minDelayValidation);
idx++;
}
}
/**
* Handles errors during connection creation
* @param {Error} err - The error that occurred
* @param {Function} onSuccess - Success callback
* @param {Function} onError - Error callback
* @param {number} timeoutEnd - Timestamp when connection attempt should time out
*/
_handleConnectionCreationError(err, onSuccess, onError, timeoutEnd) {
// Handle connection creation errors
if (err instanceof AggregateError) {
err = err.errors[0];
}
if (!this.#errorCreatingConnection) this.#errorCreatingConnection = err;
// Determine if we should retry or fail
const isFatalError =
this.#closed || (err.errno && [1524, 1045, 1698].includes(err.errno)) || timeoutEnd < Date.now();
if (isFatalError) {
// Fatal error - call error callback with additional pool info
err.message = err.message + this._errorMsgAddon();
this._connectionCreationTask = null;
onError(err);
return;
}
// Retry connection after delay
this._connectionCreationTask = setTimeout(
() => this._createPoolConnection(onSuccess, onError, timeoutEnd),
Math.min(500, timeoutEnd - Date.now())
);
}
/**
* Checks for timed-out requests and rejects them
*/
_checkRequestTimeouts() {
this.#requestTimeoutId = null;
const currentTime = Date.now();
while (this.#requests.length > 0) {
const request = this.#requests.peekFront();
if (this._hasRequestTimedOut(request, currentTime)) {
this._rejectTimedOutRequest(request, currentTime);
continue;
}
this._scheduleNextTimeoutCheck(request, currentTime);
return;
}
}
/**
* Checks if a request has timed out
* @param {Request} request - The request to check
* @param {number} currentTime - Current timestamp
* @returns {boolean} - True if request has timed out
*/
_hasRequestTimedOut(request, currentTime) {
return request.timeout <= currentTime;
}
/**
* Rejects a timed out request
* @param {Request} request - The request to reject
* @param {number} currentTime - Current timestamp
*/
_rejectTimedOutRequest(request, currentTime) {
this.#requests.shift();
// Determine the cause of the timeout
const timeoutCause = this.activeConnections() === 0 ? this.#errorCreatingConnection : null;
const waitTime = Math.abs(currentTime - (request.timeout - this.opts.acquireTimeout));
// Create appropriate error message with pool state information
const timeoutError = Errors.createError(
`pool timeout: failed to retrieve a connection from pool after ${waitTime}ms${this._errorMsgAddon()}`,
Errors.ER_GET_CONNECTION_TIMEOUT,
null,
'HY000',
null,
false,
request.stack,
null,
timeoutCause
);
request.reject(timeoutError);
}
/**
* Schedules the next timeout check
* @param {Request} request - The next request in queue
* @param {number} currentTime - Current timestamp
*/
_scheduleNextTimeoutCheck(request, currentTime) {
const timeUntilNextTimeout = request.timeout - currentTime;
this.#requestTimeoutId = setTimeout(() => this._checkRequestTimeouts(), timeUntilNextTimeout);
}
_destroy(conn) {
this._endLeak(conn);
delete this.#activeConnections[conn.threadId];
conn.lastUse = Date.now();
conn.forceEnd(
null,
() => {},
() => {}
);
if (this.totalConnections() === 0) {
this._stopConnectionReaping();
}
this.emit('validateSize');
}
release(conn) {
if (!this.#activeConnections[conn.threadId]) {
return; // Already released
}
this._endLeak(conn);
this.#activeConnections[conn.threadId] = null;
conn.lastUse = Date.now();
if (this.#closed) {
this._cleanupConnection(conn, 'pool_closed');
return;
}
// Only basic validation here - full validation happens when acquiring
if (conn.isValid()) {
this.emit('release', conn);
this.#idleConnections.push(conn);
process.nextTick(this.emit.bind(this, '_idle'));
} else {
this._cleanupConnection(conn, 'validation_failed');
}
}
_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.
*/
_startConnectionReaping() {
if (!this.#unusedConnectionRemoverId && this.opts.idleTimeout > 0) {
this.#unusedConnectionRemoverId = setInterval(this._removeIdleConnections.bind(this), 500);
}
}
_stopConnectionReaping() {
if (this.#unusedConnectionRemoverId && this.totalConnections() === 0) {
clearInterval(this.#unusedConnectionRemoverId);
}
}
/**
* Removes idle connections that have been unused for too long
*/
_removeIdleConnections() {
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._stopConnectionReaping();
}
this.emit('validateSize');
}
_shouldCreateMoreConnections() {
return (
!this.#connectionInCreation &&
this.#idleConnections.length < this.opts.minimumIdle &&
this.totalConnections() < this.opts.connectionLimit &&
!this.#closed
);
}
/**
* Processes the next request in the queue if connections are available
*/
_processNextPendingRequest() {
clearTimeout(this.#requestTimeoutId);
this.#requestTimeoutId = null;
const request = this.#requests.shift();
if (!request) return;
const conn = this.#idleConnections.shift();
if (conn) {
if (this.opts.leakDetectionTimeout > 0) {
this._startLeakDetection(conn);
}
this.#activeConnections[conn.threadId] = conn;
this.emit('acquire', conn);
request.resolver(conn);
} else {
this.#requests.unshift(request);
}
this._checkRequestTimeouts();
}
_hasIdleConnection() {
return !this.#idleConnections.isEmpty();
}
/**
* Acquires an idle connection from the pool
* @param {Function} callback - Callback function(err, conn)
*/
_acquireIdleConnection(callback) {
// Quick check if acquisition is possible
if (!this._hasIdleConnection() || this.#closed) {
callback(new Error('No idle connections available'));
return;
}
this._findValidIdleConnection(callback, false);
}
/**
* 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;
}
/**
* Recursively searches for a valid idle connection
* @param {Function} callback - Callback function(err, conn)
* @param {boolean} needPoolSizeCheck - Whether to check pool size after
*/
_findValidIdleConnection(callback, needPoolSizeCheck) {
if (this.#idleConnections.isEmpty()) {
// No more connections to check
if (needPoolSizeCheck) {
setImmediate(() => this.emit('validateSize'));
}
callback(new Error('No valid connections found'));
return;
}
const conn = this.#idleConnections.shift();
this.#activeConnections[conn.threadId] = conn;
this._validateConnectionHealth(conn, (isValid) => {
if (isValid) {
if (this.opts.leakDetectionTimeout > 0) {
this._startLeakDetection(conn);
}
if (needPoolSizeCheck) {
setImmediate(() => this.emit('validateSize'));
}
callback(null, conn);
return;
} else {
delete this.#activeConnections[conn.threadId];
}
// Connection failed validation, try next one
this._findValidIdleConnection(callback, true);
});
}
/**
* Validates if a connection is healthy and can be used
* @param {Connection} conn - The connection to validate
* @param {Function} callback - Callback function(isValid)
*/
_validateConnectionHealth(conn, callback) {
if (!conn) {
callback(false);
return;
}
// Skip validation if connection is already invalid or was recently used
const recentlyUsed = this.opts.minDelayValidation > 0 && Date.now() - conn.lastUse <= this.opts.minDelayValidation;
if (!conn.isValid() || recentlyUsed) {
callback(conn.isValid());
return;
}
// Perform ping to verify connection is responsive
const pingOptions = { opts: { timeout: this.opts.pingTimeout } };
conn.ping(
pingOptions,
() => callback(true),
() => callback(false)
);
}
_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
})`;
}
toString() {
return `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 the pool.
* Create a new one if limit is not reached.
* wait until acquireTimeout.
* @param cmdParam for stackTrace error
* @param {Function} callback - Callback function(err, conn)
*/
getConnection(cmdParam, callback) {
if (typeof cmdParam === 'function') {
callback = cmdParam;
cmdParam = {};
}
if (this.#closed) {
const err = Errors.createError(
'pool is closed',
Errors.ER_POOL_ALREADY_CLOSED,
null,
'HY000',
cmdParam === null ? null : cmdParam.sql,
false,
cmdParam.stack
);
callback(err);
return;
}
this._acquireIdleConnection((err, conn) => {
if (!err && conn) {
// connection is available
this.emit('acquire', conn);
callback(null, conn);
return;
}
if (this.#closed) {
callback(
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
)
);
return;
}
// no idle connection available
// creates a new connection if the limit is not reached
setImmediate(this.emit.bind(this, 'validateSize'));
// stack request
setImmediate(this.emit.bind(this, 'enqueue'));
const request = new Request(
Date.now() + this.opts.acquireTimeout,
cmdParam.stack,
(conn) => callback(null, conn),
(err) => callback(err)
);
this.#requests.push(request);
if (!this.#requestTimeoutId) {
this.#requestTimeoutId = setTimeout(this._checkRequestTimeouts.bind(this), this.opts.acquireTimeout);
}
});
}
/**
* 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._managePoolSizeTask);
clearTimeout(this._connectionCreationTask);
clearTimeout(this.#requestTimeoutId);
const cmdParam = {};
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();
});
}
_cleanupConnection(conn, reason = '') {
if (!conn) return;
this._endLeak(conn);
delete this.#activeConnections[conn.threadId];
try {
// using end in case pool ends while connection succeed without still having function wrappers
const endingFct = conn.forceEnd ? conn.forceEnd : conn.end;
endingFct.call(
conn,
null,
() => this.emit('connectionClosed', { threadId: conn.threadId, reason }),
() => {}
);
} catch (err) {
this.emit('error', new Error(`Failed to cleanup connection: ${err.message}`));
}
if (this.totalConnections() === 0) {
this._stopConnectionReaping();
}
this.emit('validateSize');
}
/**
* Handles the release of a connection back to the pool
* @param {Connection} conn - The connection to release
* @param {Function} callback - Callback function when complete
*/
_handleRelease(conn, callback) {
callback = callback || function () {};
// Handle special cases first
if (this.#closed || !conn.isValid()) {
this._destroy(conn);
callback();
return;
}
// Skip transaction state reset if configured
if (this.opts.noControlAfterUse) {
this.release(conn);
callback();
return;
}
// Reset connection state before returning to pool
const resetFunction = this._getRevertFunction(conn);
resetFunction((err) => {
if (err) {
this._destroy(conn);
} else {
this.release(conn);
}
callback();
});
}
/**
* Get the appropriate function to reset connection state
* @returns {Function} Function that takes a callback
*/
_getRevertFunction(conn) {
const canUseReset =
this.opts.resetAfterUse &&
conn.info.isMariaDB() &&
((conn.info.serverVersion.minor === 2 && conn.info.hasMinVersion(10, 2, 22)) ||
conn.info.hasMinVersion(10, 3, 13));
return canUseReset
? (callback) => conn.reset({}, callback)
: (callback) =>
conn.changeTransaction(
{ sql: 'ROLLBACK' },
() => callback(null),
(err) => callback(err)
);
}
/**
* Sets up leak detection for a connection
* @param {Connection} conn - The connection to monitor
*/
_startLeakDetection(conn) {
conn.lastUse = Date.now();
conn.leaked = false;
// Set timeout to detect potential leaks
conn.leakProcess = setTimeout(
() => {
conn.leaked = true;
const unusedTime = Date.now() - conn.lastUse;
// Log warning about potential leak
conn.opts.logger.warning(
`A possible connection leak on thread ${conn.info.threadId} ` +
`(connection not returned to pool for ${unusedTime}ms). ` +
`Has connection.release() been called?${this._errorMsgAddon()}`
);
},
this.opts.leakDetectionTimeout,
conn
);
}
}
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;