@google-cloud/spanner
Version:
Cloud Spanner Client Library for Node.js
300 lines • 10.8 kB
JavaScript
"use strict";
/*!
* Copyright 2019 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.AsyncTransactionRunner = exports.TransactionRunner = exports.Runner = exports.DeadlineError = void 0;
exports.isRetryableInternalError = isRetryableInternalError;
const promisify_1 = require("@google-cloud/promisify");
const google_gax_1 = require("google-gax");
const protobufjs_1 = require("protobufjs");
const through = require("through2");
const session_pool_1 = require("./session-pool");
const protos_1 = require("../protos/protos");
var IsolationLevel = protos_1.google.spanner.v1.TransactionOptions.IsolationLevel;
var ReadLockMode = protos_1.google.spanner.v1.TransactionOptions.ReadWrite.ReadLockMode;
// eslint-disable-next-line @typescript-eslint/no-var-requires
const jsonProtos = require('../protos/protos.json');
const RETRY_INFO = 'google.rpc.retryinfo-bin';
const RETRYABLE = [google_gax_1.grpc.status.ABORTED];
// tslint:disable-next-line variable-name
const RetryInfo = protobufjs_1.Root.fromJSON(jsonProtos).lookup('google.rpc.RetryInfo');
/**
* Error class used to signal a Transaction timeout.
*
* @private
* @class
*
* @param {Error} [err] The last known retryable Error.
*/
class DeadlineError extends Error {
code;
details;
metadata;
errors;
constructor(error) {
super('Deadline for Transaction exceeded.');
this.code = google_gax_1.grpc.status.DEADLINE_EXCEEDED;
this.details = error?.details || '';
this.metadata = error?.metadata || new google_gax_1.grpc.Metadata();
this.errors = [];
if (error) {
this.errors.push(error);
}
}
}
exports.DeadlineError = DeadlineError;
/**
* Base class for running/retrying Transactions.
*
* @private
* @class
* @abstract
*
* @param {Database} database The Database to pull Sessions/Transactions from.
* @param {RunTransactionOptions} [options] The runner options.
*/
class Runner {
attempts;
session;
transaction;
options;
multiplexedSessionPreviousTransactionId;
constructor(session, transaction, options) {
this.attempts = 0;
this.session = session;
this.transaction = transaction;
this.transaction.useInRunner();
const defaults = {
timeout: 3600000,
isolationLevel: IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED,
readLockMode: ReadLockMode.READ_LOCK_MODE_UNSPECIFIED,
};
this.options = Object.assign(defaults, options);
}
/**
* Attempts to retrieve the retry delay from the supplied error. If absent it
* will create one based on the number of attempts made thus far.
*
* @private
*
* @param {Error} err The service error.
* @returns {number} Delay in milliseconds.
*/
getNextDelay(err) {
const retryInfo = err.metadata && err.metadata.get(RETRY_INFO);
if (retryInfo && retryInfo.length) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { retryDelay } = RetryInfo.decode(retryInfo[0]);
let { seconds } = retryDelay;
if (typeof seconds !== 'number') {
seconds = seconds.toNumber();
}
const secondsInMs = Math.floor(seconds) * 1000;
const nanosInMs = Math.floor(retryDelay.nanos) / 1e6;
return secondsInMs + nanosInMs;
}
// A 'Session not found' error without any specific retry info should not
// cause any delay between retries.
if ((0, session_pool_1.isSessionNotFoundError)(err)) {
return 0;
}
// Max backoff should be 32 seconds.
return (Math.pow(2, Math.min(this.attempts, 5)) * 1000 +
Math.floor(Math.random() * 1000));
}
/** Returns whether the given error should cause a transaction retry. */
shouldRetry(err) {
return (RETRYABLE.includes(err.code) ||
(0, session_pool_1.isSessionNotFoundError)(err) ||
isRetryableInternalError(err));
}
/**
* Retrieves a transaction to run against.
*
* @private
*
* @returns Promise<Transaction>
*/
async getTransaction() {
if (this.transaction) {
const transaction = this.transaction;
delete this.transaction;
return transaction;
}
const transaction = this.session.transaction(this.session.parent.queryOptions_);
transaction.setReadWriteTransactionOptions(this.options);
transaction.multiplexedSessionPreviousTransactionId =
this.multiplexedSessionPreviousTransactionId;
if (this.attempts > 0) {
await transaction.begin();
}
return transaction;
}
/**
* This function is responsible for getting transactions, running them and
* handling any errors, retrying if necessary.
*
* @private
*
* @returns {Promise}
*/
async run() {
const start = Date.now();
const timeout = this.options.timeout;
let lastError;
// The transaction runner should always execute at least one attempt before
// timing out.
while (this.attempts === 0 || Date.now() - start < timeout) {
const transaction = await this.getTransaction();
try {
return await this._run(transaction);
}
catch (e) {
this.session.lastError = e;
lastError = e;
}
finally {
this.multiplexedSessionPreviousTransactionId = transaction.id;
}
// Note that if the error is a 'Session not found' error, it will be
// thrown here. We do this to bubble this error up to the caller who is
// responsible for retrying the transaction on a different session.
if (!RETRYABLE.includes(lastError.code) &&
!isRetryableInternalError(lastError)) {
throw lastError;
}
this.attempts += 1;
const delay = this.getNextDelay(lastError);
await new Promise(resolve => setTimeout(resolve, delay));
}
throw new DeadlineError(lastError);
}
}
exports.Runner = Runner;
/**
* This class handles transactions expecting to be ran in callback mode.
*
* @private
* @class
*
* @param {Database} database The database to pull sessions/transactions from.
* @param {RunTransactionCallback} runFn The user supplied run function.
* @param {RunTransactionOptions} [options] Runner options.
*/
class TransactionRunner extends Runner {
runFn;
constructor(session, transaction, runFn, options) {
super(session, transaction, options);
this.runFn = runFn;
}
/**
* Because the user has decided to use callback mode, we want to try and
* intercept any ABORTED or UNKNOWN errors and stop the current function
* execution.
*
* @private
*
* @param {Transaction} transaction The transaction to intercept errors for.
* @param {Function} reject Function to call when a retryable error is found.
*/
_interceptErrors(transaction, reject) {
const request = transaction.request;
transaction.request = (0, promisify_1.promisify)((config, callback) => {
request(config, (err, resp) => {
if (!err || !this.shouldRetry(err)) {
callback(err, resp);
return;
}
reject(err);
});
});
const requestStream = transaction.requestStream;
transaction.requestStream = (config) => {
const proxyStream = through.obj();
const stream = requestStream(config);
stream
.on('error', (err) => {
if (!this.shouldRetry(err)) {
proxyStream.destroy(err);
return;
}
stream.unpipe(proxyStream);
reject(err);
})
.pipe(proxyStream);
return proxyStream;
};
}
/**
* Creates a Promise that should resolve when the provided transaction has
* been committed or rolled back. Rejects if a retryable error occurs.
*
* @private
*
* @param {Transaction}
* @returns {Promise}
*/
_run(transaction) {
return new Promise((resolve, reject) => {
transaction.once('end', resolve);
this._interceptErrors(transaction, reject);
this.runFn(null, transaction);
});
}
}
exports.TransactionRunner = TransactionRunner;
/**
* This class handles transactions expecting to be ran in promise mode.
*
* @private
* @class
*
* @param {Database} database The database to pull sessions/transactions from.
* @param {AsyncRunTransactionCallback} runFn The user supplied run function.
* @param {RunTransactionOptions} [options] Runner options.
*/
class AsyncTransactionRunner extends Runner {
runFn;
constructor(session, transaction, runFn, options) {
super(session, transaction, options);
this.runFn = runFn;
}
/**
* Since this is promise mode all we need to do is return the user function.
*
* @private
*
* @param {Transaction} transaction The transaction to be ran against.
* @returns {Promise}
*/
_run(transaction) {
return this.runFn(transaction);
}
}
exports.AsyncTransactionRunner = AsyncTransactionRunner;
/**
* Checks whether the given error is a retryable internal error.
* @param error the error to check
* @return true if the error is a retryable internal error, and otherwise false.
*/
function isRetryableInternalError(err) {
return (err.code === google_gax_1.grpc.status.INTERNAL &&
(err.message.includes('Received unexpected EOS on DATA frame from server') ||
err.message.includes('RST_STREAM') ||
err.message.includes('HTTP/2 error code: INTERNAL_ERROR') ||
err.message.includes('Connection closed with unknown cause')));
}
//# sourceMappingURL=transaction-runner.js.map