serverless-mysql
Version:
A module for managing MySQL connections at serverless scale.
545 lines (441 loc) • 18.5 kB
JavaScript
const NodeURL = require('url')
/**
* This module manages MySQL connections in serverless applications.
* More detail regarding the MySQL module can be found here:
* https://github.com/mysqljs/mysql
* @author Jeremy Daly <jeremy@jeremydaly.com>
* @license MIT
*/
module.exports = (params) => {
// Mutable values
let client = null // Init null client object
let counter = 0 // Total reuses counter
let errors = 0 // Error count
let retries = 0 // Retry count
let _cfg = {} // MySQL config globals
let _maxConns = { updated: 0 } // Cache max connections
let _usedConns = { updated: 0 } // Cache used connections
// Common Too Many Connections Errors
const tooManyConnsErrors = [
'ER_TOO_MANY_USER_CONNECTIONS',
'ER_CON_COUNT_ERROR',
'ER_USER_LIMIT_REACHED',
'ER_OUT_OF_RESOURCES',
'PROTOCOL_CONNECTION_LOST', // if the connection is lost
'PROTOCOL_SEQUENCE_TIMEOUT', // if the connection times out
'ETIMEDOUT' // if the connection times out
]
// Common Transient Query Errors that can be retried
const retryableQueryErrors = [
'ER_LOCK_DEADLOCK', // Deadlock found when trying to get lock
'ER_LOCK_WAIT_TIMEOUT', // Lock wait timeout exceeded
'ER_QUERY_INTERRUPTED', // Query execution was interrupted
'ER_QUERY_TIMEOUT', // Query execution time exceeded
'ER_CONNECTION_KILLED', // Connection was killed
'ER_LOCKING_SERVICE_TIMEOUT', // Locking service timeout
'ER_LOCKING_SERVICE_DEADLOCK', // Locking service deadlock
'ER_ABORTING_CONNECTION', // Aborted connection
'PROTOCOL_CONNECTION_LOST', // Connection lost
'PROTOCOL_SEQUENCE_TIMEOUT', // Connection timeout
'ETIMEDOUT', // Connection timeout
'ECONNRESET' // Connection reset
]
// Init setting values
let MYSQL, manageConns, cap, base, maxRetries, connUtilization, backoff,
zombieMinTimeout, zombieMaxTimeout, maxConnsFreq, usedConnsFreq,
onConnect, onConnectError, onRetry, onClose, onError, onKill, onKillError, PromiseLibrary, returnFinalSqlQuery,
maxQueryRetries, onQueryRetry, queryRetryBackoff
/********************************************************************/
/** HELPER/CONVENIENCE FUNCTIONS **/
/********************************************************************/
const getCounter = () => counter
const incCounter = () => counter++
const resetCounter = () => counter = 0
const getClient = () => client
const resetClient = () => client = null
const resetRetries = () => retries = 0
const getErrorCount = () => errors
const getConfig = () => _cfg
const config = (args) => {
if (typeof args === 'string') {
return Object.assign(_cfg, uriToConnectionConfig(args))
}
return Object.assign(_cfg, args)
}
const delay = ms => new PromiseLibrary(res => setTimeout(res, ms))
const randRange = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min
const fullJitter = () => randRange(0, Math.min(cap, base * 2 ** retries))
const decorrelatedJitter = (sleep = 0) => Math.min(cap, randRange(base, sleep * 3))
const uriToConnectionConfig = (connectionString) => {
let uri = undefined
try {
uri = new NodeURL.URL(connectionString)
} catch (error) {
throw new Error('Invalid data source URL provided')
}
const extraFields = {}
for (const [name, value] of uri.searchParams) {
extraFields[name] = value
}
const database = uri.pathname && uri.pathname.startsWith('/') ? uri.pathname.slice(1) : undefined
const connectionFields = {
host: uri.hostname ? uri.hostname : undefined,
user: uri.username ? uri.username : undefined,
port: uri.port ? Number(uri.port) : undefined,
password: uri.password ? uri.password : undefined,
database
}
return Object.assign(connectionFields, extraFields)
}
/********************************************************************/
/** CONNECTION MANAGEMENT FUNCTIONS **/
/********************************************************************/
// Public connect method, handles backoff and catches
// TOO MANY CONNECTIONS errors
const connect = async (wait) => {
try {
await _connect()
} catch (e) {
if (tooManyConnsErrors.includes(e.code) && retries < maxRetries) {
retries++
wait = Number.isInteger(wait) ? wait : 0
let sleep = backoff === 'decorrelated' ? decorrelatedJitter(wait) :
typeof backoff === 'function' ? backoff(wait, retries) :
fullJitter()
onRetry(e, retries, sleep, typeof backoff === 'function' ? 'custom' : backoff) // fire onRetry event
await delay(sleep).then(() => connect(sleep))
} else {
onConnectError(e) // Fire onConnectError event
throw new Error(e)
}
}
} // end connect
// Internal connect method
const _connect = () => {
if (client === null) { // if no client connection exists
resetCounter() // Reset the total use counter
// Return a new promise
return new PromiseLibrary((resolve, reject) => {
// Connect to the MySQL database
client = MYSQL.createConnection(_cfg)
// Wait until MySQL is connected and ready before moving on
client.connect(function (err) {
if (err) {
resetClient()
reject(err)
} else {
resetRetries()
onConnect(client)
return resolve(true)
}
})
// Add error listener (reset client on failures)
client.on('error', async err => {
errors++
resetClient() // reset client
resetCounter() // reset counter
onError(err) // fire onError event (PROTOCOL_CONNECTION_LOST)
})
}) // end promise
// Else the client already exists
} else {
return PromiseLibrary.resolve()
} // end if-else
} // end _connect
// Function called at the end that attempts to clean up zombies
// and maintain proper connection limits
const end = async () => {
if (client !== null && manageConns) {
incCounter() // increment the reuse counter
// Check the number of max connections
let maxConns = await getMaxConnections()
// Check the number of used connections
let usedConns = await getTotalConnections()
// If over utilization threshold, try and clean up zombies
if (usedConns.total / maxConns.total > connUtilization) {
// Calculate the zombie timeout
let timeout = Math.min(Math.max(usedConns.maxAge, zombieMinTimeout), zombieMaxTimeout)
// Kill zombies if they are within the timeout
let killedZombies = timeout <= usedConns.maxAge ? await killZombieConnections(timeout) : 0
// If no zombies were cleaned up, close this connection
if (killedZombies === 0) {
quit()
}
// If zombies exist that are more than the max timeout, kill them
} else if (usedConns.maxAge > zombieMaxTimeout) {
await killZombieConnections(zombieMaxTimeout)
}
} // end if client
} // end end() method
// Function that explicitly closes the MySQL connection.
const quit = () => {
if (client !== null) {
client.end() // Quit the connection.
resetClient() // reset the client to null
resetCounter() // reset the reuse counter
onClose() // fire onClose event
}
}
/********************************************************************/
/** QUERY FUNCTIONS **/
/********************************************************************/
// Main query function
const query = async function (...args) {
// Establish connection
await connect()
// Track query retries
let queryRetries = 0
// Function to execute the query with retry logic
const executeQuery = async () => {
return new PromiseLibrary((resolve, reject) => {
if (client !== null) {
// If no args are passed in a transaction, ignore query
if (this && this.rollback && args.length === 0) { return resolve([]) }
const queryObj = client.query(...args, async (err, results) => {
if (returnFinalSqlQuery && queryObj.sql && err) {
err.sql = queryObj.sql
}
if (err && err.code === 'PROTOCOL_SEQUENCE_TIMEOUT') {
client.destroy() // destroy connection on timeout
resetClient() // reset the client
reject(err) // reject the promise with the error
} else if (
err && (/^PROTOCOL_ENQUEUE_AFTER_/.test(err.code)
|| err.code === 'PROTOCOL_CONNECTION_LOST'
|| err.code === 'EPIPE'
|| err.code === 'ECONNRESET')
) {
resetClient() // reset the client
return resolve(query(...args)) // attempt the query again
} else if (err && retryableQueryErrors.includes(err.code) && queryRetries < maxQueryRetries) {
// Increment retry counter
queryRetries++
// Calculate backoff time
let wait = 0
let sleep = queryRetryBackoff === 'decorrelated' ? decorrelatedJitter(wait) :
typeof queryRetryBackoff === 'function' ? queryRetryBackoff(wait, queryRetries) :
fullJitter()
// Fire onQueryRetry event
onQueryRetry(err, queryRetries, sleep, typeof queryRetryBackoff === 'function' ? 'custom' : queryRetryBackoff)
// Wait and retry
await delay(sleep)
return resolve(executeQuery())
} else if (err) {
if (this && this.rollback) {
await query('ROLLBACK')
this.rollback(err)
}
reject(err)
}
if (returnFinalSqlQuery && queryObj.sql) {
if (Array.isArray(results)) {
Object.defineProperty(results, 'sql', {
enumerable: false,
value: queryObj.sql
})
} else if (results && typeof results === 'object') {
results.sql = queryObj.sql
}
}
return resolve(results)
})
}
})
}
// Execute the query with retry logic
return executeQuery()
} // end query
// Change user method
const changeUser = async (options) => {
// Ensure we have a connection
await connect()
// Return a new promise
return new PromiseLibrary((resolve, reject) => {
if (client !== null) {
// Call the underlying changeUser method
client.changeUser(options, (err) => {
if (err) {
// If connection error, reset client and reject
if (err.code === 'PROTOCOL_CONNECTION_LOST' ||
err.code === 'EPIPE' ||
err.code === 'ECONNRESET') {
resetClient() // reset the client
reject(err)
} else {
// For other errors, just reject
reject(err)
}
} else {
// Successfully changed user
resolve(true)
}
})
} else {
// No client connection exists
reject(new Error('No connection available to change user'))
}
})
} // end changeUser
// Get the max connections (either for this user or total)
const getMaxConnections = async () => {
// If cache is expired
if (Date.now() - _maxConns.updated > maxConnsFreq) {
let results = await query(
`SELECT IF(@@max_user_connections > 0,
LEAST(@@max_user_connections,@@max_connections),
@@max_connections) AS total,
IF(@@max_user_connections > 0,true,false) AS userLimit`
)
// Update _maxConns
_maxConns = {
total: results[0].total || 0,
userLimit: results[0].userLimit === 1 ? true : false,
updated: Date.now()
}
} // end if renewing cache
return _maxConns
} // end getMaxConnections
// Get the total connections being used and the longest sleep time
const getTotalConnections = async () => {
// If cache is expired
if (Date.now() - _usedConns.updated > usedConnsFreq) {
let results = await query(
`SELECT COUNT(ID) as total, MAX(time) as max_age
FROM information_schema.processlist
WHERE (user = ? AND @@max_user_connections > 0) OR true`, [_cfg.user])
_usedConns = {
total: results[0].total || 0,
maxAge: results[0].max_age || 0,
updated: Date.now()
}
} // end if refreshing cache
return _usedConns
} // end getTotalConnections
// Kill all zombie connections that are older than the threshold
const killZombieConnections = async (timeout) => {
let killedZombies = 0
// Hunt for zombies (just the sleeping ones that this user owns)
let zombies = await query(
`SELECT ID,time FROM information_schema.processlist
WHERE command = 'Sleep' AND time >= ? AND user = ?
ORDER BY time DESC`,
[!isNaN(timeout) ? timeout : 60 * 15, _cfg.user])
// Kill zombies
for (let i = 0; i < zombies.length; i++) {
try {
await query('KILL ?', zombies[i].ID)
onKill(zombies[i]) // fire onKill event
killedZombies++
} catch (e) {
// if (e.code !== 'ER_NO_SUCH_THREAD') console.log(e)
onKillError(e) // fire onKillError event
}
} // end for
return killedZombies
} // end killZombieConnections
/********************************************************************/
/** TRANSACTION MANAGEMENT **/
/********************************************************************/
// Init a transaction object and return methods
const transaction = () => {
let queries = [] // keep track of queries
let rollback = () => { } // default rollback event
return {
query: function (...args) {
if (typeof args[0] === 'function') {
queries.push(args[0])
} else {
queries.push(() => [...args])
}
return this
},
rollback: function (fn) {
if (typeof fn === 'function') { rollback = fn }
return this
},
commit: async function () { return await commit(queries, rollback) }
}
}
// Commit transaction by running queries
const commit = async (queries, rollback) => {
let results = [] // keep track of results
// Start a transaction
await query('START TRANSACTION')
// Loop through queries
for (let i = 0; i < queries.length; i++) {
// Execute the queries, pass the rollback as context
let result = await query.apply({ rollback }, queries[i](results[results.length - 1], results))
// Add the result to the main results accumulator
results.push(result)
}
// Commit our transaction
await query('COMMIT')
// Return the results
return results
}
/********************************************************************/
/** INITIALIZATION **/
/********************************************************************/
const cfg = typeof params === 'object' && !Array.isArray(params) ? params : {}
MYSQL = cfg.library || require('mysql2')
PromiseLibrary = cfg.promise || Promise
// Set defaults for connection management
manageConns = cfg.manageConns === false ? false : true // default to true
cap = Number.isInteger(cfg.cap) ? cfg.cap : 100 // default to 100 ms
base = Number.isInteger(cfg.base) ? cfg.base : 2 // default to 2 ms
maxRetries = Number.isInteger(cfg.maxRetries) ? cfg.maxRetries : 50 // default to 50 attempts
backoff = typeof cfg.backoff === 'function' ? cfg.backoff :
cfg.backoff && ['full', 'decorrelated'].includes(cfg.backoff.toLowerCase()) ?
cfg.backoff.toLowerCase() : 'full' // default to full Jitter
connUtilization = !isNaN(cfg.connUtilization) ? cfg.connUtilization : 0.8 // default to 0.7
zombieMinTimeout = Number.isInteger(cfg.zombieMinTimeout) ? cfg.zombieMinTimeout : 3 // default to 3 seconds
zombieMaxTimeout = Number.isInteger(cfg.zombieMaxTimeout) ? cfg.zombieMaxTimeout : 60 * 15 // default to 15 minutes
maxConnsFreq = Number.isInteger(cfg.maxConnsFreq) ? cfg.maxConnsFreq : 15 * 1000 // default to 15 seconds
usedConnsFreq = Number.isInteger(cfg.usedConnsFreq) ? cfg.usedConnsFreq : 0 // default to 0 ms
returnFinalSqlQuery = cfg.returnFinalSqlQuery === true // default to false
// Query retry settings
maxQueryRetries = Number.isInteger(cfg.maxQueryRetries) ? cfg.maxQueryRetries : 0 // default to 0 attempts (disabled for backward compatibility)
queryRetryBackoff = typeof cfg.queryRetryBackoff === 'function' ? cfg.queryRetryBackoff :
cfg.queryRetryBackoff && ['full', 'decorrelated'].includes(cfg.queryRetryBackoff.toLowerCase()) ?
cfg.queryRetryBackoff.toLowerCase() : 'full' // default to full Jitter
// Event handlers
onConnect = typeof cfg.onConnect === 'function' ? cfg.onConnect : () => { }
onConnectError = typeof cfg.onConnectError === 'function' ? cfg.onConnectError : () => { }
onRetry = typeof cfg.onRetry === 'function' ? cfg.onRetry : () => { }
onClose = typeof cfg.onClose === 'function' ? cfg.onClose : () => { }
onError = typeof cfg.onError === 'function' ? cfg.onError : () => { }
onKill = typeof cfg.onKill === 'function' ? cfg.onKill : () => { }
onKillError = typeof cfg.onKillError === 'function' ? cfg.onKillError : () => { }
onQueryRetry = typeof cfg.onQueryRetry === 'function' ? cfg.onQueryRetry : () => { }
let connCfg = {}
const isConfigAnObject = typeof cfg.config === 'object' && !Array.isArray(cfg.config)
const isConfigAString = typeof cfg.config === 'string'
if (isConfigAnObject || isConfigAString) {
connCfg = cfg.config
} else if (typeof params === 'string') {
connCfg = params
}
let escape = MYSQL.escape
let escapeId = MYSQL.escapeId
let format = MYSQL.format
// Set MySQL configs
config(connCfg)
// Return public methods
return {
connect,
config,
query,
end,
escape,
escapeId,
format,
quit,
transaction,
getCounter,
getClient,
getConfig,
getErrorCount,
changeUser
}
} // end exports