// 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'); 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(timeoutEnd) .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 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 = { opts: { 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(timeoutEnd) { // ensure setting a connection timeout if no connection timeout is set const minTimeout = Math.max(1, timeoutEnd - 100); const connectionOpts = !this.opts.connOptions.connectTimeout || this.opts.connOptions.connectTimeout > minTimeout ? Object.assign({}, this.opts.connOptions, { connectTimeout: minTimeout }) : this.opts.connOptions; const conn = new Connection(connectionOpts); 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, {}); } else revertFunction = conn.changeTransaction.bind(conn, { sql: '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 })`; } 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 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 = {}; 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;