@google-cloud/spanner
Version:
Cloud Spanner Client Library for Node.js
782 lines • 26.5 kB
JavaScript
"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