@cap-js/db-service
Version:
CDS base database service
303 lines (269 loc) • 9.56 kB
JavaScript
const cds = require('@sap/cds')
const LOG = cds.log('db')
const use_new_pool = cds.requires.db?.pool?.builtin || cds.env.features.pool === 'builtin'
const createPool = use_new_pool ? (...args) => new Pool(...args) : require('generic-pool').createPool
function ConnectionPool (factory, tenant) {
let bound_factory = { __proto__: factory, create: factory.create.bind(null, tenant) }
return createPool(bound_factory, factory.options)
}
function TrackedConnectionPool (factory, tenant) {
const pool = new ConnectionPool (factory, tenant)
const { acquire, release } = pool
return Object.assign(pool, {
async acquire() {
const connections = (this._trackedConnections ??= new Set())
try {
let dbc = await acquire.call(this)
connections.add((dbc._beginStack = new Error('begin called from:')))
return dbc
} catch (err) {
// TODO: add acquire timeout error check
err.stack += `\nActive connections:${connections.size}\n${[...connections].map(e => e.stack).join('\n')}`
throw err
}
},
release(dbc) {
this._trackedConnections?.delete(dbc._beginStack)
return release.call(this, dbc)
},
})
}
const DEBUG = /\bpool\b/.test(process.env.DEBUG)
module.exports = DEBUG && !use_new_pool ? TrackedConnectionPool : ConnectionPool
// Drop-in replacement for https://github.com/coopernurse/node-pool
// TODO: fifo: true? relevant for our use case?
const { EventEmitter } = require('events')
const ResourceState = Object.freeze({
ALLOCATED: 'allocated',
IDLE: 'idle',
INVALID: 'invalid',
VALIDATION: 'validation'
})
const RequestState = Object.freeze({
PENDING: 'pending',
RESOLVED: 'resolved',
REJECTED: 'rejected'
})
class Request {
constructor (ttl) {
this.state = RequestState.PENDING
this.promise = new Promise((resolve, reject) => {
this._resolve = value => {
clearTimeout(this._timeout)
this.state = RequestState.RESOLVED
resolve(value)
}
this._reject = reason => {
clearTimeout(this._timeout)
this.state = RequestState.REJECTED
reject(reason)
}
if (typeof ttl === 'number' && ttl >= 0) {
const err = new Error(`Pool resource could not be acquired within ${ttl / 1000}s`)
this._timeout = setTimeout(() => this._reject(err), ttl).unref()
}
})
}
resolve (v) { if (this.state === RequestState.PENDING) this._resolve(v) }
reject (e) { if (this.state === RequestState.PENDING) this._reject(e) }
}
class PooledResource {
constructor(resource) {
this.obj = resource
this.creationTime = Date.now()
this.idle()
}
update(newState) {
this.state = newState
}
idle() {
this.state = ResourceState.IDLE
this.lastIdleTime = Date.now()
}
}
class Pool extends EventEmitter {
constructor (factory, options = {}) {
super()
this.factory = factory
this.options = Object.assign({
testOnBorrow: false,
evictionRunIntervalMillis: 100000,
numTestsPerEvictionRun: 3,
softIdleTimeoutMillis: -1,
idleTimeoutMillis: 30000,
acquireTimeoutMillis: null,
destroyTimeoutMillis: null,
fifo: false,
min: 0,
max: 10
}, options)
/** @type {boolean} */
this._draining = false
/** @type {Set<PooledResource>} */
this._available = new Set()
/** @type {Map<any, { pooledResource: PooledResource }>} */
this._loans = new Map()
/** @type {Set<PooledResource>} */
this._all = new Set()
/** @type {Set<Promise<void>>} */
this._creates = new Set()
/** @type {Request[]} */
this._queue = []
this.#scheduleEviction()
const initial = this.options.min - this.size
for (let i = 0; i < initial; i++) this.#createResource()
}
async acquire() {
if (this._draining) throw new Error('Pool is draining and cannot accept new requests')
const request = new Request(this.options.acquireTimeoutMillis)
this._queue.push(request)
this.#dispense()
return request.promise
}
async release(resource) {
const loan = this._loans.get(resource)
if (!loan) throw new Error('Resource not currently part of this pool')
this._loans.delete(resource)
const pooledResource = loan.pooledResource
pooledResource.idle()
this._available.add(pooledResource)
this.#dispense()
}
async destroy(resource) {
const loan = this._loans.get(resource)
if (!loan) throw new Error('Resource not currently part of this pool')
this._loans.delete(resource)
const pooledResource = loan.pooledResource
await this.#destroy(pooledResource)
this.#dispense()
}
async drain() {
this._draining = true
for (const request of this._queue.splice(0)) {
if (request.state === RequestState.PENDING) request.reject(new Error('Pool is draining and cannot fulfil request'))
}
clearTimeout(this._scheduledEviction)
}
async clear() {
await Promise.allSettled(Array.from(this._creates))
await Promise.allSettled(Array.from(this._all).map(resource => this.#destroy(resource)))
}
async #createResource() {
const createPromise = (async () => {
try {
const resource = new PooledResource(await this.factory.create())
this._all.add(resource)
this._available.add(resource)
} catch (err) {
this._queue.shift()?.reject(err)
}
})()
this._creates.add(createPromise)
createPromise.finally(() => {
this._creates.delete(createPromise)
this.#dispense()
})
return createPromise
}
async #dispense() {
const waiting = this._queue.length
if (waiting === 0) return
const capacity = this._available.size + this._creates.size
const shortfall = waiting - capacity
if (shortfall > 0 && this.size < this.options.max) {
const needed = Math.min(shortfall, this.options.max - this.size)
for (let i = 0; i < needed; i++) this.#createResource()
}
const dispense = async resource => {
const request = this._queue.shift()
if (!request) {
resource.idle()
this._available.add(resource)
return false
}
if (request.state !== RequestState.PENDING) {
this.#dispense()
return false
}
this._loans.set(resource.obj, { pooledResource: resource })
resource.update(ResourceState.ALLOCATED)
request.resolve(resource.obj)
return true
}
const _dispenses = []
for (let i = 0; i < Math.min(this._available.size, waiting); i++) {
const resource = this._available.values().next().value
this._available.delete(resource)
if (this.options.testOnBorrow) {
const validationPromise = (async () => {
resource.update(ResourceState.VALIDATION)
try {
const isValid = await this.factory.validate(resource.obj)
if (isValid) return dispense(resource)
} catch {/* marked as invalid below */}
resource.update(ResourceState.INVALID)
await this.#destroy(resource)
this.#dispense()
return false
})()
_dispenses.push(validationPromise)
} else {
_dispenses.push(dispense(resource))
}
}
await Promise.all(_dispenses)
}
async #destroy(resource) {
resource.update(ResourceState.INVALID)
this._all.delete(resource)
this._available.delete(resource)
this._loans.delete(resource.obj)
try {
const destroyPromise = Promise.resolve(this.factory.destroy(resource.obj))
const { destroyTimeoutMillis } = this.options
if (destroyTimeoutMillis && destroyTimeoutMillis > 0) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Resource destruction timed out after ${destroyTimeoutMillis}ms`)), destroyTimeoutMillis).unref()
)
await Promise.race([destroyPromise, timeout])
} else {
await destroyPromise
}
} catch (e) {
LOG.error(e)
/* FIXME: We have to ignore errors here due to a TypeError in hdb */
/* This was also a problem with the old (generic-pool) implementation */
/* Root cause in hdb needs to be fixed */
} finally {
if (!this._draining && this.size < this.options.min) this.#createResource()
}
}
#scheduleEviction() {
const { evictionRunIntervalMillis, numTestsPerEvictionRun, softIdleTimeoutMillis, min, idleTimeoutMillis } = this.options
if (evictionRunIntervalMillis <= 0) return
this._scheduledEviction = setTimeout(async () => {
try {
const evictionCandidates = Array.from(this._available).slice(0, numTestsPerEvictionRun)
const destructionPromises = []
for (const resource of evictionCandidates) {
const idleTime = Date.now() - resource.lastIdleTime
const softEvict = softIdleTimeoutMillis > 0 && softIdleTimeoutMillis < idleTime && this._all.size > min
const hardEvict = idleTimeoutMillis < idleTime
if (softEvict || hardEvict) {
if (this._available.delete(resource)) {
destructionPromises.push(this.#destroy(resource))
}
}
}
await Promise.all(destructionPromises)
} finally {
this.#scheduleEviction()
}
}, evictionRunIntervalMillis).unref()
}
get size() { return this._all.size + this._creates.size }
get available() { return this._available.size }
get borrowed() { return this._loans.size }
get pending() { return this._queue.length }
get tenant() { return this.options.tenant }
}