UNPKG

@google-cloud/spanner

Version:
782 lines 26.5 kB
"use strict"; /*! * Copyright 2016 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.SessionPool = exports.SessionPoolExhaustedError = exports.SessionLeakError = exports.ReleaseError = void 0; exports.isSessionNotFoundError = isSessionNotFoundError; const events_1 = require("events"); const is = require("is"); const p_queue_1 = require("p-queue"); const google_gax_1 = require("google-gax"); const trace = require("stack-trace"); const instrument_1 = require("./instrument"); const helper_1 = require("./helper"); const DEFAULTS = { acquireTimeout: Infinity, concurrency: Infinity, fail: false, idlesAfter: 10, keepAlive: 30, labels: {}, max: 100, maxIdle: 1, min: 25, incStep: 25, databaseRole: null, }; /** * Error to be thrown when attempting to release unknown resources. * * @private */ class ReleaseError extends google_gax_1.GoogleError { resource; constructor(resource) { super('Unable to release unknown resource.'); this.resource = resource; } } exports.ReleaseError = ReleaseError; /** * Error to be thrown when session leaks are detected. * * @private */ class SessionLeakError extends google_gax_1.GoogleError { messages; constructor(leaks) { super(`${leaks.length} session leak(s) detected.`); // Restore error name that was overwritten by the super constructor call. this.name = SessionLeakError.name; this.messages = leaks; } } exports.SessionLeakError = SessionLeakError; /** * Error to be thrown when the session pool is exhausted. */ class SessionPoolExhaustedError extends google_gax_1.GoogleError { messages; constructor(leaks) { super("No resources available." /* errors.Exhausted */); // Restore error name that was overwritten by the super constructor call. this.name = SessionPoolExhaustedError.name; this.messages = leaks; } } exports.SessionPoolExhaustedError = SessionPoolExhaustedError; /** * Checks whether the given error is a 'Session not found' error. * @param error the error to check * @return true if the error is a 'Session not found' error, and otherwise false. */ function isSessionNotFoundError(error) { return (error !== undefined && error.code === google_gax_1.grpc.status.NOT_FOUND && error.message.includes('Session not found')); } /** * Class used to manage connections to Spanner. * * **You don't need to use this class directly, connections will be handled for * you.** * * @class * @extends {EventEmitter} */ class SessionPool extends events_1.EventEmitter { database; isOpen; options; _acquires; _evictHandle; _inventory; _onClose; _pending = 0; _waiters = 0; _pingHandle; _requests; _traces; _observabilityOptions; /** * Formats stack trace objects into Node-like stack trace. * * @param {object[]} trace The trace object. * @return {string} */ static formatTrace(frames) { const stack = frames.map(frame => { const name = frame.getFunctionName() || frame.getMethodName(); const file = frame.getFileName(); const lineno = frame.getLineNumber(); const columnno = frame.getColumnNumber(); return ` at ${name} (${file}:${lineno}:${columnno})`; }); return `Session leak detected!\n${stack.join('\n')}`; } /** * Total number of available sessions. * @type {number} */ get available() { return this._inventory.sessions.length; } /** @deprecated Starting from v6.5.0 the same session can be reused for * different types of transactions. */ get currentWriteFraction() { return 0; } /** * Total number of borrowed sessions. * @type {number} */ get borrowed() { return this._inventory.borrowed.size + this._pending; } /** * Flag to determine if Pool is full. * @type {boolean} */ get isFull() { return this.size >= this.options.max; } /** @deprecated Use `size()` instead. */ get reads() { return this.size; } /** * Total size of pool. * @type {number} */ get size() { return this.available + this.borrowed; } /** @deprecated Use `size()` instead. */ get writes() { return this.size; } /** @deprecated Starting v6.5.0 the pending prepare state is obsolete. */ get pendingPrepare() { return 0; } /** * Number of sessions being created or prepared for a read/write transaction. * @type {number} */ get totalPending() { return this._pending; } /** @deprecated Use totalWaiters instead. */ get numReadWaiters() { return this.totalWaiters; } /** @deprecated Use totalWaiters instead. */ get numWriteWaiters() { return this.totalWaiters; } /** * Sum of read and write waiters. * @type {number} */ get totalWaiters() { return this._waiters; } /** * @constructor * @param {Database} database The DB instance. * @param {SessionPoolOptions} [options] Configuration options. */ constructor(database, options) { super(); if (options && options.min && options.max && options.min > options.max) { throw new TypeError('Min sessions may not be greater than max sessions.'); } this.isOpen = false; this.database = database; this.options = Object.assign({}, DEFAULTS, options); this.options.min = Math.min(this.options.min, this.options.max); this.options.databaseRole = this.options.databaseRole ? this.options.databaseRole : database.databaseRole; this._inventory = { sessions: [], borrowed: new Set(), }; this._waiters = 0; this._requests = new p_queue_1.default({ concurrency: this.options.concurrency, }); this._acquires = new p_queue_1.default({ concurrency: 1, }); this._traces = new Map(); this._observabilityOptions = database._observabilityOptions; } /** * Closes and the pool. * * @emits SessionPool#close * @param {SessionPoolCloseCallback} callback The callback function. */ close(callback) { const sessions = [ ...this._inventory.sessions, ...this._inventory.borrowed, ]; this.isOpen = false; this._stopHouseKeeping(); this.emit('close'); sessions.forEach(session => this._destroy(session)); this._requests .onIdle() .then(() => { const leaks = this._getLeaks(); let error; this._inventory.sessions = []; this._inventory.borrowed.clear(); if (leaks.length) { error = new SessionLeakError(leaks); } callback(error); }) .catch(err => callback(err)); } /** * Retrieve a read session. * * @deprecated Use getSession instead. * @param {GetReadSessionCallback} callback The callback function. */ getReadSession(callback) { this.getSession((error, session) => callback(error, session)); } /** * Retrieve a read/write session. * * @deprecated use getSession instead. * @param {GetWriteSessionCallback} callback The callback function. */ getWriteSession(callback) { this.getSession(callback); } /** * Retrieve a session. * * @param {GetSessionCallback} callback The callback function. */ getSession(callback) { this._acquire().then(session => callback(null, session, session.txn), callback); } /** * Opens the pool, filling it to the configured number of read and write * sessions. * * @emits SessionPool#open * @return {Promise} */ open() { this._onClose = new Promise(resolve => this.once('close', resolve)); this._startHouseKeeping(); this.isOpen = true; this.emit('open'); this._fill().catch(err => { // Ignore `Database not found` error. This allows a user to call instance.database('db-name') // for a database that does not yet exist with SessionPoolOptions.min > 0. if ((0, helper_1.isDatabaseNotFoundError)(err) || (0, helper_1.isInstanceNotFoundError)(err) || (0, helper_1.isCreateSessionPermissionError)(err) || (0, helper_1.isDefaultCredentialsNotSetError)(err) || (0, helper_1.isProjectIdNotSetInEnvironmentError)(err)) { return; } this.emit('error', err); }); } /** * Releases session back into the pool. * * @throws {Error} For unknown sessions. * @emits SessionPool#available * @emits SessionPool#error * @fires SessionPool#session-available * @fires @deprecated SessionPool#readonly-available * @fires @deprecated SessionPool#readwrite-available * @param {Session} session The session to release. */ release(session) { if (!this._inventory.borrowed.has(session)) { throw new ReleaseError(session); } delete session.txn; session.lastUsed = Date.now(); if (isSessionNotFoundError(session.lastError)) { // Remove the session from the pool. It is not necessary to call _destroy, // as the session is already removed from the backend. this._inventory.borrowed.delete(session); this._traces.delete(session.id); return; } session.lastError = undefined; // Delete the trace associated with this session to mark the session as checked // back into the pool. This will prevent the session to be marked as leaked if // the pool is closed while the session is being prepared. this._traces.delete(session.id); // Release it into the pool as a session if there are more waiters than // there are sessions available. Releasing it will unblock a waiter as soon // as possible. this._release(session); } /** * Attempts to borrow a session from the pool. * * @private * * @returns {Promise<Session>} */ async _acquire() { const span = (0, instrument_1.getActiveOrNoopSpan)(); if (!this.isOpen) { span.addEvent('SessionPool is closed'); throw new google_gax_1.GoogleError("Database is closed." /* errors.Closed */); } // Get the stacktrace of the caller before we call any async methods, as calling an async method will break the stacktrace. const frames = trace.get(); const startTime = Date.now(); const timeout = this.options.acquireTimeout; // wrapping this logic in a function to call recursively if the session // we end up with is already dead const getSession = async () => { span.addEvent('Acquiring session'); const elapsed = Date.now() - startTime; if (elapsed >= timeout) { span.addEvent('Could not acquire session due to an exceeded timeout'); throw new google_gax_1.GoogleError("Timeout occurred while acquiring session." /* errors.Timeout */); } const session = await this._getSession(startTime); if (this._isValidSession(session)) { span.addEvent('Acquired session', { 'time.elapsed': Date.now() - startTime, 'session.id': session.id.toString(), }); return session; } span.addEvent('Could not acquire session because it was invalid. Retrying', { 'session.id': session.id.toString(), }); this._inventory.borrowed.delete(session); return getSession(); }; const session = await this._acquires.add(getSession); this._prepareTransaction(session); this._traces.set(session.id, frames); return session; } /** * Moves a session into the borrowed group. * * @private * * @param {Session} session The session object. */ _borrow(session) { const index = this._inventory.sessions.indexOf(session); this._inventory.borrowed.add(session); this._inventory.sessions.splice(index, 1); } /** * Borrows the first session from the inventory. * * @private * * @return {Session} */ _borrowFrom() { const session = this._inventory.sessions.pop(); this._inventory.borrowed.add(session); return session; } /** * Grabs the next available session. * * @private * * @returns {Promise<Session>} */ _borrowNextAvailableSession() { return this._borrowFrom(); } /** * Attempts to create a single session. * * @private * * @returns {Promise} */ _createSession() { return this._createSessions(1); } /** * Batch creates sessions. * * @private * * @param {number} [amount] Config specifying how many sessions to create. * @returns {Promise} * @emits SessionPool#createError */ async _createSessions(amount) { const labels = this.options.labels; const databaseRole = this.options.databaseRole; if (amount <= 0) { return; } this._pending += amount; let nReturned = 0; const nRequested = amount; // TODO: Inlining this code for now and later on shall go // extract _traceConfig to the constructor when we have plenty of time. const traceConfig = { opts: this._observabilityOptions, dbName: this.database.formattedName_, }; return (0, instrument_1.startTrace)('SessionPool.createSessions', traceConfig, async (span) => { span.addEvent(`Requesting ${amount} sessions`); // while we can request as many sessions be created as we want, the backend // will return at most 100 at a time, hence the need for a while loop. while (amount > 0) { let sessions = null; span.addEvent(`Creating ${amount} sessions`); try { [sessions] = await this.database.batchCreateSessions({ count: amount, labels: labels, databaseRole: databaseRole, }); amount -= sessions.length; nReturned += sessions.length; } catch (e) { this._pending -= amount; this.emit('createError', e); span.addEvent(`Requested for ${nRequested} sessions returned ${nReturned}`); (0, instrument_1.setSpanErrorAndException)(span, e); span.end(); throw e; } sessions.forEach((session) => { setImmediate(() => { this._inventory.borrowed.add(session); this._pending -= 1; this.release(session); }); }); } span.addEvent(`Requested for ${nRequested} sessions returned ${nReturned}`); span.end(); }); } /** * Attempts to delete a session, optionally creating a new one of the same * type if the pool is still open and we're under the configured min value. * * @private * * @fires SessionPool#error * @param {Session} session The session to delete. * @returns {Promise} */ async _destroy(session) { try { await this._requests.add(() => session.delete()); } catch (e) { this.emit('error', e); } } /** * Deletes idle sessions that exceed the maxIdle configuration. * * @private */ _evictIdleSessions() { const { maxIdle, min } = this.options; const size = this.size; const idle = this._getIdleSessions(); let count = idle.length; let evicted = 0; while (count-- > maxIdle && size - evicted++ > min) { const session = idle.pop(); if (!session) { continue; } const index = this._inventory.sessions.indexOf(session); this._inventory.sessions.splice(index, 1); void this._destroy(session); } } /** * Fills the pool with the minimum number of sessions. * * @return {Promise} */ async _fill() { const needed = this.options.min - this.size; if (needed <= 0) { return; } await this._createSessions(needed); } /** * Retrieves a list of all the idle sessions. * * @private * * @returns {Session[]} */ _getIdleSessions() { const idlesAfter = this.options.idlesAfter * 60000; const sessions = this._inventory.sessions; return sessions.filter(session => { return Date.now() - session.lastUsed >= idlesAfter; }); } /** * Returns stack traces for sessions that have not been released. * * @return {string[]} */ _getLeaks() { return [...this._traces.values()].map(SessionPool.formatTrace); } /** * Returns true if the pool has a usable session. * @private */ _hasSessionUsableFor() { return this._inventory.sessions.length > 0; } /** * Attempts to get a session. * * @private * * @param {number} startTime Timestamp to use when determining timeouts. * @returns {Promise<Session>} */ async _getSession(startTime) { const span = (0, instrument_1.getActiveOrNoopSpan)(); if (this._hasSessionUsableFor()) { span.addEvent('Cache hit: has usable session'); return this._borrowNextAvailableSession(); } if (this.isFull && this.options.fail) { span.addEvent('Session pool is full and failFast=true'); throw new SessionPoolExhaustedError(this._getLeaks()); } let removeOnceCloseListener; let removeListener; // Wait for a session to become available. span.addEvent('Waiting for a session to become available'); const availableEvent = 'session-available'; const promises = [ new Promise((_, reject) => { const onceCloseListener = () => reject(new google_gax_1.GoogleError("Database is closed." /* errors.Closed */)); this.once('close', onceCloseListener); removeOnceCloseListener = this.removeListener.bind(this, 'close', onceCloseListener); }), new Promise(resolve => { this.once(availableEvent, resolve); removeListener = this.removeListener.bind(this, availableEvent, resolve); }), ]; const timeout = this.options.acquireTimeout; let removeTimeoutListener = () => { }; if (!is.infinite(timeout)) { const elapsed = Date.now() - startTime; const remaining = timeout - elapsed; promises.push(new Promise((_, reject) => { const error = new Error("Timeout occurred while acquiring session." /* errors.Timeout */); const timeoutFunction = setTimeout(reject.bind(null, error), remaining); removeTimeoutListener = () => clearTimeout(timeoutFunction); })); } // Only create a new session if there are more waiters than sessions already // being created. The current requester will be waiter number _numWaiters+1. if (!this.isFull && this.totalPending <= this.totalWaiters) { let amount = this.options.incStep ? this.options.incStep : DEFAULTS.incStep; // Create additional sessions if the configured minimum has not been reached. const min = this.options.min ? this.options.min : 0; if (this.size + this.totalPending + amount < min) { amount = min - this.size - this.totalPending; } // Make sure we don't create more sessions than the pool should have. if (amount + this.size > this.options.max) { amount = this.options.max - this.size; } if (amount > 0) { this._pending += amount; promises.push(new Promise((_, reject) => { this._pending -= amount; this._createSessions(amount).catch(reject); })); } } let removeErrorListener; promises.push(new Promise((_, reject) => { this.once('createError', reject); removeErrorListener = this.removeListener.bind(this, 'createError', reject); })); try { this._waiters++; await Promise.race(promises); } finally { this._waiters--; removeOnceCloseListener(); removeListener(); removeErrorListener(); removeTimeoutListener(); } return this._borrowNextAvailableSession(); } /** * Checks to see whether or not session is expired. * * @param {Session} session The session to check. * @returns {boolean} */ _isValidSession(session) { // unpinged sessions only stay good for 1 hour const MAX_DURATION = 60000 * 60; return Date.now() - session.lastUsed < MAX_DURATION; } /** * Pings an individual session. * * @private * * @param {Session} session The session to ping. * @returns {Promise} */ async _ping(session) { // NOTE: Please do not trace Ping as it gets quite spammy // with many root spans polluting the main span. // Please see https://github.com/googleapis/google-cloud-go/issues/1691 this._borrow(session); if (!this._isValidSession(session)) { this._inventory.borrowed.delete(session); return; } try { await session.keepAlive(); this.release(session); } catch (e) { this._inventory.borrowed.delete(session); await this._destroy(session); } } /** * Makes a keep alive request to all the idle sessions. * * @private * * @returns {Promise} */ async _pingIdleSessions() { const sessions = this._getIdleSessions(); const pings = sessions.map(session => this._ping(session)); await Promise.all(pings); try { await this._fill(); } catch (error) { // Ignore `Database not found` error. This allows a user to call instance.database('db-name') // for a database that does not yet exist with SessionPoolOptions.min > 0. const err = error; if ((0, helper_1.isDatabaseNotFoundError)(err) || (0, helper_1.isInstanceNotFoundError)(err) || (0, helper_1.isCreateSessionPermissionError)(err) || (0, helper_1.isDefaultCredentialsNotSetError)(err) || (0, helper_1.isProjectIdNotSetInEnvironmentError)(err)) { return; } this.emit('error', err); } return; } /** * Creates a transaction for a session. * * @private * * @param {Session} session The session object. * @param {object} options The transaction options. */ _prepareTransaction(session) { const transaction = session.transaction(session.parent.queryOptions_); session.txn = transaction; } /** * Releases a session back into the pool. * * @private * * @fires SessionPool#available * @fires SessionPool#session-available * @fires @deprecated SessionPool#readonly-available * @fires @deprecated SessionPool#readwrite-available * @param {Session} session The session object. */ _release(session) { this._inventory.sessions.push(session); this._inventory.borrowed.delete(session); this._traces.delete(session.id); this.emit('available'); this.emit('session-available'); this.emit('readonly-available'); this.emit('readwrite-available'); } /** * Starts housekeeping (pinging/evicting) of idle sessions. * * @private */ _startHouseKeeping() { const evictRate = this.options.idlesAfter * 60000; this._evictHandle = setInterval(() => this._evictIdleSessions(), evictRate); this._evictHandle.unref(); const pingRate = this.options.keepAlive * 60000; this._pingHandle = setInterval(() => this._pingIdleSessions(), pingRate); this._pingHandle.unref(); } /** * Stops housekeeping. * * @private */ _stopHouseKeeping() { // eslint-disable-next-line @typescript-eslint/no-explicit-any clearInterval(this._pingHandle); // eslint-disable-next-line @typescript-eslint/no-explicit-any clearInterval(this._evictHandle); } } exports.SessionPool = SessionPool; //# sourceMappingURL=session-pool.js.map