UNPKG

@google-cloud/spanner

Version:
300 lines 10.8 kB
"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