You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
497 lines
17 KiB
JavaScript
497 lines
17 KiB
JavaScript
/*
|
|
* Copyright DataStax, Inc.
|
|
*
|
|
* 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.
|
|
*/
|
|
'use strict';
|
|
|
|
const errors = require('./errors');
|
|
const requests = require('./requests');
|
|
const retry = require('./policies/retry');
|
|
const types = require('./types');
|
|
const utils = require('./utils');
|
|
const promiseUtils = require('./promise-utils');
|
|
|
|
const retryOnCurrentHost = Object.freeze({
|
|
decision: retry.RetryPolicy.retryDecision.retry,
|
|
useCurrentHost: true,
|
|
consistency: undefined
|
|
});
|
|
|
|
const rethrowDecision = Object.freeze({ decision: retry.RetryPolicy.retryDecision.rethrow });
|
|
|
|
/**
|
|
* An internal representation of an error that occurred during the execution of a request.
|
|
*/
|
|
const errorCodes = {
|
|
none: 0,
|
|
// Socket error
|
|
socketError: 1,
|
|
// Socket error before the request was written to the wire
|
|
socketErrorBeforeRequestWritten: 2,
|
|
// OperationTimedOutError
|
|
clientTimeout: 3,
|
|
// Response error "unprepared"
|
|
serverErrorUnprepared: 4,
|
|
// Response error "overloaded", "is_bootstrapping" and "truncateError":
|
|
serverErrorOverloaded: 5,
|
|
serverErrorReadTimeout: 6,
|
|
serverErrorUnavailable: 7,
|
|
serverErrorWriteTimeout: 8,
|
|
// Any other server error (different from the ones detailed above)
|
|
serverErrorOther: 9
|
|
};
|
|
|
|
const metricsHandlers = new Map([
|
|
[ errorCodes.none, (metrics, err, latency) => metrics.onSuccessfulResponse(latency) ],
|
|
[ errorCodes.socketError, (metrics, err) => metrics.onConnectionError(err) ],
|
|
[ errorCodes.clientTimeout, (metrics, err) => metrics.onClientTimeoutError(err) ],
|
|
[ errorCodes.serverErrorOverloaded, (metrics, err) => metrics.onOtherError(err) ],
|
|
[ errorCodes.serverErrorReadTimeout, (metrics, err) => metrics.onReadTimeoutError(err) ],
|
|
[ errorCodes.serverErrorUnavailable, (metrics, err) => metrics.onUnavailableError(err) ],
|
|
[ errorCodes.serverErrorWriteTimeout, (metrics, err) => metrics.onWriteTimeoutError(err) ],
|
|
[ errorCodes.serverErrorOther, (metrics, err) => metrics.onOtherError(err) ]
|
|
]);
|
|
|
|
const metricsRetryHandlers = new Map([
|
|
[ errorCodes.socketError, (metrics, err) => metrics.onOtherErrorRetry(err) ],
|
|
[ errorCodes.clientTimeout, (metrics, err) => metrics.onClientTimeoutRetry(err) ],
|
|
[ errorCodes.serverErrorOverloaded, (metrics, err) => metrics.onOtherErrorRetry(err) ],
|
|
[ errorCodes.serverErrorReadTimeout, (metrics, err) => metrics.onReadTimeoutRetry(err) ],
|
|
[ errorCodes.serverErrorUnavailable, (metrics, err) => metrics.onUnavailableRetry(err) ],
|
|
[ errorCodes.serverErrorWriteTimeout, (metrics, err) => metrics.onWriteTimeoutRetry(err) ],
|
|
[ errorCodes.serverErrorOther, (metrics, err) => metrics.onOtherErrorRetry(err) ]
|
|
]);
|
|
|
|
class RequestExecution {
|
|
/**
|
|
* Encapsulates a single flow of execution against a coordinator, handling individual retries and failover.
|
|
* @param {RequestHandler!} parent
|
|
* @param {Host!} host
|
|
* @param {Connection!} connection
|
|
*/
|
|
constructor(parent, host, connection) {
|
|
this._parent = parent;
|
|
/** @type {OperationState} */
|
|
this._operation = null;
|
|
this._host = host;
|
|
this._connection = connection;
|
|
this._cancelled = false;
|
|
this._startTime = null;
|
|
this._retryCount = 0;
|
|
// The streamId information is not included in the request.
|
|
// A pointer to the parent request can be used, except when changing the consistency level from the retry policy
|
|
this._request = this._parent.request;
|
|
|
|
// Mark that it launched a new execution
|
|
parent.speculativeExecutions++;
|
|
}
|
|
|
|
/**
|
|
* Sends the request using the active connection.
|
|
*/
|
|
start() {
|
|
this._sendOnConnection();
|
|
}
|
|
|
|
/**
|
|
* Borrows the next connection available using the query plan and sends the request.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async restart() {
|
|
try {
|
|
const { host, connection } = this._parent.getNextConnection();
|
|
|
|
this._connection = connection;
|
|
this._host = host;
|
|
} catch (err) {
|
|
return this._parent.handleNoHostAvailable(err, this);
|
|
}
|
|
|
|
// It could be a new connection from the pool, we should make sure it's in the correct keyspace.
|
|
const keyspace = this._parent.client.keyspace;
|
|
if (keyspace && keyspace !== this._connection.keyspace) {
|
|
try {
|
|
await this._connection.changeKeyspace(keyspace);
|
|
} catch (err) {
|
|
// When its a socket error, attempt to retry.
|
|
// Otherwise, rethrow the error to the user.
|
|
return this._handleError(err, RequestExecution._getErrorCode(err));
|
|
}
|
|
}
|
|
|
|
if (this._cancelled) {
|
|
// No need to send the request or invoke any callback
|
|
return;
|
|
}
|
|
|
|
this._sendOnConnection();
|
|
}
|
|
|
|
/**
|
|
* Sends the request using the active connection.
|
|
* @private
|
|
*/
|
|
_sendOnConnection() {
|
|
this._startTime = process.hrtime();
|
|
|
|
this._operation =
|
|
this._connection.sendStream(this._request, this._parent.executionOptions, (err, response, length) => {
|
|
const errorCode = RequestExecution._getErrorCode(err);
|
|
|
|
this._trackResponse(process.hrtime(this._startTime), errorCode, err, length);
|
|
|
|
if (this._cancelled) {
|
|
// Avoid handling the response / err
|
|
return;
|
|
}
|
|
|
|
if (errorCode !== errorCodes.none) {
|
|
return this._handleError(errorCode, err);
|
|
}
|
|
|
|
if (response.schemaChange) {
|
|
return promiseUtils.toBackground(
|
|
this._parent.client
|
|
.handleSchemaAgreementAndRefresh(this._connection, response.schemaChange)
|
|
.then(agreement => {
|
|
if (this._cancelled) {
|
|
// After the schema agreement method was started, this execution was cancelled
|
|
return;
|
|
}
|
|
|
|
this._parent.setCompleted(null, this._getResultSet(response, agreement));
|
|
})
|
|
);
|
|
}
|
|
|
|
if (response.keyspaceSet) {
|
|
this._parent.client.keyspace = response.keyspaceSet;
|
|
}
|
|
|
|
if (response.meta && response.meta.newResultId && this._request.queryId) {
|
|
// Update the resultId on the existing prepared statement.
|
|
// Eventually would want to update the result metadata as well (NODEJS-433)
|
|
const info = this._parent.client.metadata.getPreparedById(this._request.queryId);
|
|
info.meta.resultId = response.meta.newResultId;
|
|
}
|
|
|
|
this._parent.setCompleted(null, this._getResultSet(response));
|
|
});
|
|
}
|
|
|
|
_trackResponse(latency, errorCode, err, length) {
|
|
// Record metrics
|
|
RequestExecution._invokeMetricsHandler(errorCode, this._parent.client.metrics, err, latency);
|
|
|
|
// Request tracker
|
|
const tracker = this._parent.client.options.requestTracker;
|
|
|
|
if (tracker === null) {
|
|
return;
|
|
}
|
|
|
|
// Avoid using instanceof as property check is faster
|
|
const query = this._request.query || this._request.queries;
|
|
const parameters = this._request.params;
|
|
const requestLength = this._request.length;
|
|
|
|
if (err) {
|
|
tracker.onError(this._host, query, parameters, this._parent.executionOptions, requestLength, err, latency);
|
|
} else {
|
|
tracker.onSuccess(this._host, query, parameters, this._parent.executionOptions, requestLength, length, latency);
|
|
}
|
|
}
|
|
|
|
_getResultSet(response, agreement) {
|
|
const rs = new types.ResultSet(response, this._host.address, this._parent.triedHosts, this._parent.speculativeExecutions,
|
|
this._request.consistency, agreement === undefined || agreement);
|
|
|
|
if (rs.rawPageState) {
|
|
rs.nextPageAsync = this._parent.getNextPageHandler();
|
|
}
|
|
|
|
return rs;
|
|
}
|
|
|
|
/**
|
|
* Gets the method of the {ClientMetrics} instance depending on the error code and invokes it.
|
|
* @param {Number} errorCode
|
|
* @param {ClientMetrics} metrics
|
|
* @param {Error} err
|
|
* @param {Array} latency
|
|
* @private
|
|
*/
|
|
static _invokeMetricsHandler(errorCode, metrics, err, latency) {
|
|
const handler = metricsHandlers.get(errorCode);
|
|
if (handler !== undefined) {
|
|
handler(metrics, err, latency);
|
|
}
|
|
|
|
if (!err || err instanceof errors.ResponseError) {
|
|
metrics.onResponse(latency);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the method of the {ClientMetrics} instance related to retry depending on the error code and invokes it.
|
|
* @param {Number} errorCode
|
|
* @param {ClientMetrics} metrics
|
|
* @param {Error} err
|
|
* @private
|
|
*/
|
|
static _invokeMetricsHandlerForRetry(errorCode, metrics, err) {
|
|
const handler = metricsRetryHandlers.get(errorCode);
|
|
|
|
if (handler !== undefined) {
|
|
handler(metrics, err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Allows the handler to cancel the current request.
|
|
* When the request has been already written, we can unset the callback and forget about it.
|
|
*/
|
|
cancel() {
|
|
this._cancelled = true;
|
|
|
|
if (this._operation === null) {
|
|
return;
|
|
}
|
|
|
|
this._operation.cancel();
|
|
}
|
|
|
|
/**
|
|
* Determines if the current execution was cancelled.
|
|
*/
|
|
wasCancelled() {
|
|
return this._cancelled;
|
|
}
|
|
|
|
_handleError(errorCode, err) {
|
|
this._parent.triedHosts[this._host.address] = err;
|
|
err['coordinator'] = this._host.address;
|
|
|
|
if (errorCode === errorCodes.serverErrorUnprepared) {
|
|
return this._prepareAndRetry(err.queryId);
|
|
}
|
|
|
|
if (errorCode === errorCodes.socketError || errorCode === errorCodes.socketErrorBeforeRequestWritten) {
|
|
this._host.removeFromPool(this._connection);
|
|
} else if (errorCode === errorCodes.clientTimeout) {
|
|
this._parent.log('warning', err.message);
|
|
this._host.checkHealth(this._connection);
|
|
}
|
|
|
|
const decisionInfo = this._getDecision(errorCode, err);
|
|
|
|
if (!decisionInfo || decisionInfo.decision === retry.RetryPolicy.retryDecision.rethrow) {
|
|
if (this._request instanceof requests.QueryRequest || this._request instanceof requests.ExecuteRequest) {
|
|
err['query'] = this._request.query;
|
|
}
|
|
return this._parent.setCompleted(err);
|
|
}
|
|
|
|
const metrics = this._parent.client.metrics;
|
|
|
|
if (decisionInfo.decision === retry.RetryPolicy.retryDecision.ignore) {
|
|
metrics.onIgnoreError(err);
|
|
|
|
// Return an empty ResultSet
|
|
return this._parent.setCompleted(null, this._getResultSet(utils.emptyObject));
|
|
}
|
|
|
|
RequestExecution._invokeMetricsHandlerForRetry(errorCode, metrics, err);
|
|
|
|
return this._retry(decisionInfo.consistency, decisionInfo.useCurrentHost);
|
|
}
|
|
|
|
/**
|
|
* Gets a decision whether or not to retry based on the error information.
|
|
* @param {Number} errorCode
|
|
* @param {Error} err
|
|
* @returns {{decision, useCurrentHost, consistency}}
|
|
*/
|
|
_getDecision(errorCode, err) {
|
|
const operationInfo = {
|
|
query: this._request && this._request.query,
|
|
executionOptions: this._parent.executionOptions,
|
|
nbRetry: this._retryCount
|
|
};
|
|
|
|
const retryPolicy = operationInfo.executionOptions.getRetryPolicy();
|
|
|
|
switch (errorCode) {
|
|
case errorCodes.socketErrorBeforeRequestWritten:
|
|
// The request was definitely not applied, it's safe to retry.
|
|
// Retry on the current host as there might be other connections open, in case it fails to obtain a connection
|
|
// on the current host, the driver will immediately retry on the next host.
|
|
return retryOnCurrentHost;
|
|
case errorCodes.socketError:
|
|
case errorCodes.clientTimeout:
|
|
case errorCodes.serverErrorOverloaded:
|
|
if (operationInfo.executionOptions.isIdempotent()) {
|
|
return retryPolicy.onRequestError(operationInfo, this._request.consistency, err);
|
|
}
|
|
return rethrowDecision;
|
|
case errorCodes.serverErrorUnavailable:
|
|
return retryPolicy.onUnavailable(operationInfo, err.consistencies, err.required, err.alive);
|
|
case errorCodes.serverErrorReadTimeout:
|
|
return retryPolicy.onReadTimeout(
|
|
operationInfo, err.consistencies, err.received, err.blockFor, err.isDataPresent);
|
|
case errorCodes.serverErrorWriteTimeout:
|
|
if (operationInfo.executionOptions.isIdempotent()) {
|
|
return retryPolicy.onWriteTimeout(
|
|
operationInfo, err.consistencies, err.received, err.blockFor, err.writeType);
|
|
}
|
|
return rethrowDecision;
|
|
default:
|
|
return rethrowDecision;
|
|
}
|
|
}
|
|
|
|
static _getErrorCode(err) {
|
|
if (!err) {
|
|
return errorCodes.none;
|
|
}
|
|
|
|
if (err.isSocketError) {
|
|
if (err.requestNotWritten) {
|
|
return errorCodes.socketErrorBeforeRequestWritten;
|
|
}
|
|
return errorCodes.socketError;
|
|
}
|
|
|
|
if (err instanceof errors.OperationTimedOutError) {
|
|
return errorCodes.clientTimeout;
|
|
}
|
|
|
|
if (err instanceof errors.ResponseError) {
|
|
switch (err.code) {
|
|
case types.responseErrorCodes.overloaded:
|
|
case types.responseErrorCodes.isBootstrapping:
|
|
case types.responseErrorCodes.truncateError:
|
|
return errorCodes.serverErrorOverloaded;
|
|
case types.responseErrorCodes.unavailableException:
|
|
return errorCodes.serverErrorUnavailable;
|
|
case types.responseErrorCodes.readTimeout:
|
|
return errorCodes.serverErrorReadTimeout;
|
|
case types.responseErrorCodes.writeTimeout:
|
|
return errorCodes.serverErrorWriteTimeout;
|
|
case types.responseErrorCodes.unprepared:
|
|
return errorCodes.serverErrorUnprepared;
|
|
}
|
|
}
|
|
|
|
return errorCodes.serverErrorOther;
|
|
}
|
|
|
|
/**
|
|
* @param {Number|undefined} consistency
|
|
* @param {Boolean} useCurrentHost
|
|
* @param {Object} [meta]
|
|
* @private
|
|
*/
|
|
_retry(consistency, useCurrentHost, meta) {
|
|
if (this._cancelled) {
|
|
// No point in retrying
|
|
return;
|
|
}
|
|
|
|
this._parent.log('info', 'Retrying request');
|
|
this._retryCount++;
|
|
|
|
if (meta || (typeof consistency === 'number' && this._request.consistency !== consistency)) {
|
|
this._request = this._request.clone();
|
|
if (typeof consistency === 'number') {
|
|
this._request.consistency = consistency;
|
|
}
|
|
// possible that we are retrying because we had to reprepare. In this case it is also possible
|
|
// that our known metadata had changed, therefore we update it on the request.
|
|
if (meta) {
|
|
this._request.meta = meta;
|
|
}
|
|
}
|
|
|
|
if (useCurrentHost !== false) {
|
|
// Reusing the existing connection is suitable for the most common scenarios, like server read timeouts that
|
|
// will be fixed with a new request.
|
|
// To cover all scenarios (e.g., where a different connection to the same host might mean something different),
|
|
// we obtain a new connection from the host pool.
|
|
// When there was a socket error, the connection provided was already removed from the pool earlier.
|
|
try {
|
|
this._connection = this._host.borrowConnection(this._connection);
|
|
} catch (err) {
|
|
// All connections are busy (`BusyConnectionError`) or there isn't a ready connection in the pool (`Error`)
|
|
// The retry policy declared the intention to retry on the current host but its not available anymore.
|
|
// Use the next host
|
|
return promiseUtils.toBackground(this.restart());
|
|
}
|
|
|
|
return this._sendOnConnection();
|
|
}
|
|
|
|
// Use the next host in the query plan to send the request in the background
|
|
promiseUtils.toBackground(this.restart());
|
|
}
|
|
|
|
/**
|
|
* Issues a PREPARE request on the current connection.
|
|
* If there's a socket or timeout issue, it moves to next host and executes the original request.
|
|
* @param {Buffer} queryId
|
|
* @private
|
|
*/
|
|
_prepareAndRetry(queryId) {
|
|
const connection = this._connection;
|
|
|
|
this._parent.log('info',
|
|
`Query 0x${queryId.toString('hex')} not prepared on` +
|
|
` host ${connection.endpointFriendlyName}, preparing and retrying`);
|
|
|
|
const info = this._parent.client.metadata.getPreparedById(queryId);
|
|
|
|
if (!info) {
|
|
return this._parent.setCompleted(new errors.DriverInternalError(
|
|
`Unprepared response invalid, id: 0x${queryId.toString('hex')}`));
|
|
}
|
|
|
|
const version = this._connection.protocolVersion;
|
|
|
|
if (!types.protocolVersion.supportsKeyspaceInRequest(version) && info.keyspace && info.keyspace !== connection.keyspace) {
|
|
return this._parent.setCompleted(
|
|
new Error(`Query was prepared on keyspace ${info.keyspace}, can't execute it on ${connection.keyspace} (${info.query})`));
|
|
}
|
|
|
|
const self = this;
|
|
this._connection.prepareOnce(info.query, info.keyspace, function (err, result) {
|
|
if (err) {
|
|
if (!err.isSocketError && err instanceof errors.OperationTimedOutError) {
|
|
self._parent.log('warning',
|
|
`Unexpected timeout error when re-preparing query on host ${connection.endpointFriendlyName}`);
|
|
}
|
|
|
|
// There was a failure re-preparing on this connection.
|
|
// Execute the original request on the next connection and forget about the PREPARE-UNPREPARE flow.
|
|
return self._retry(undefined, false);
|
|
}
|
|
|
|
// It's possible that when re-preparing we got new metadata (i.e. if schema changed), update cache.
|
|
info.meta = result.meta;
|
|
// pass the metadata so it can be used in retry.
|
|
self._retry(undefined, true, result.meta);
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = RequestExecution; |