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.

791 lines
26 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 events = require('events');
const util = require('util');
const tls = require('tls');
const net = require('net');
const Encoder = require('./encoder.js');
const { WriteQueue } = require('./writers');
const requests = require('./requests');
const streams = require('./streams');
const utils = require('./utils');
const types = require('./types');
const errors = require('./errors');
const StreamIdStack = require('./stream-id-stack');
const OperationState = require('./operation-state');
const promiseUtils = require('./promise-utils');
const { ExecutionOptions } = require('./execution-options');
/**
* Represents a connection to a Cassandra node
*/
class Connection extends events.EventEmitter {
/**
* Creates a new instance of Connection.
* @param {String} endpoint An string containing ip address and port of the host
* @param {Number|null} protocolVersion
* @param {ClientOptions} options
*/
constructor(endpoint, protocolVersion, options) {
super();
this.setMaxListeners(0);
if (!options) {
throw new Error('options is not defined');
}
/**
* Gets the ip and port of the server endpoint.
* @type {String}
*/
this.endpoint = endpoint;
/**
* Gets the friendly name of the host, used to identify the connection in log messages.
* With direct connect, this is the address and port.
* With SNI, this will be the address and port of the proxy, plus the server name.
* @type {String}
*/
this.endpointFriendlyName = this.endpoint;
if (options.sni) {
this._serverName = endpoint;
this.endpoint = `${options.sni.addressResolver.getIp()}:${options.sni.port}`;
this.endpointFriendlyName = `${this.endpoint} (${this._serverName})`;
}
if (!this.endpoint || this.endpoint.indexOf(':') < 0) {
throw new Error('EndPoint must contain the ip address and port separated by : symbol');
}
const portSeparatorIndex = this.endpoint.lastIndexOf(':');
this.address = this.endpoint.substr(0, portSeparatorIndex);
this.port = this.endpoint.substr(portSeparatorIndex + 1);
Object.defineProperty(this, "options", { value: options, enumerable: false, writable: false});
if (protocolVersion === null) {
// Set initial protocol version
protocolVersion = types.protocolVersion.maxSupported;
if (options.protocolOptions.maxVersion) {
// User provided the protocol version
protocolVersion = options.protocolOptions.maxVersion;
}
// Allow to check version using this connection instance
this._checkingVersion = true;
}
this.log = utils.log;
this.protocolVersion = protocolVersion;
this._operations = new Map();
this._pendingWrites = [];
this._preparing = new Map();
/**
* The timeout state for the idle request (heartbeat)
*/
this._idleTimeout = null;
this.timedOutOperations = 0;
this._streamIds = new StreamIdStack(this.protocolVersion);
this._metrics = options.metrics;
this.encoder = new Encoder(protocolVersion, options);
this.keyspace = null;
this.emitDrain = false;
/**
* Determines if the socket is open and startup succeeded, whether the connection can be used to send requests /
* receive events
*/
this.connected = false;
/**
* Determines if the socket can be considered as open
*/
this.isSocketOpen = false;
this.send = util.promisify(this.sendStream);
this.closeAsync = util.promisify(this.close);
this.openAsync = util.promisify(this.open);
this.prepareOnceAsync = util.promisify(this.prepareOnce);
}
/**
* Binds the necessary event listeners for the socket
*/
bindSocketListeners() {
//Remove listeners that were used for connecting
this.netClient.removeAllListeners('connect');
this.netClient.removeAllListeners('timeout');
// The socket is expected to be open at this point
this.isSocketOpen = true;
this.netClient.on('close', () => {
this.log('info', `Connection to ${this.endpointFriendlyName} closed`);
this.isSocketOpen = false;
const wasConnected = this.connected;
this.close();
if (wasConnected) {
// Emit only when it was closed unexpectedly
this.emit('socketClose');
}
});
this.protocol = new streams.Protocol({ objectMode: true });
this.parser = new streams.Parser({ objectMode: true }, this.encoder);
const resultEmitter = new streams.ResultEmitter({objectMode: true});
resultEmitter.on('result', this.handleResult.bind(this));
resultEmitter.on('row', this.handleRow.bind(this));
resultEmitter.on('frameEnded', this.freeStreamId.bind(this));
resultEmitter.on('nodeEvent', this.handleNodeEvent.bind(this));
this.netClient
.pipe(this.protocol)
.pipe(this.parser)
.pipe(resultEmitter);
this.writeQueue = new WriteQueue(this.netClient, this.encoder, this.options);
}
/**
* Connects a socket and sends the startup protocol messages.
* Note that when open() callbacks in error, the caller should immediately call {@link Connection#close}.
*/
open(callback) {
const self = this;
this.log('info', `Connecting to ${this.endpointFriendlyName}`);
if (!this.options.sslOptions) {
this.netClient = new net.Socket({ highWaterMark: this.options.socketOptions.coalescingThreshold });
this.netClient.connect(this.port, this.address, function connectCallback() {
self.log('verbose', `Socket connected to ${self.endpointFriendlyName}`);
self.bindSocketListeners();
self.startup(callback);
});
}
else {
// Use TLS
const sslOptions = utils.extend({ rejectUnauthorized: false }, this.options.sslOptions);
if (this.options.sni) {
sslOptions.servername = this._serverName;
}
this.netClient = tls.connect(this.port, this.address, sslOptions, function tlsConnectCallback() {
self.log('verbose', `Secure socket connected to ${self.endpointFriendlyName} with protocol ${self.netClient.getProtocol()}`);
self.bindSocketListeners();
self.startup(callback);
});
// TLSSocket will validate for values from 512 to 16K (depending on the SSL protocol version)
this.netClient.setMaxSendFragment(this.options.socketOptions.coalescingThreshold);
}
this.netClient.once('error', function socketError(err) {
self.errorConnecting(err, false, callback);
});
this.netClient.once('timeout', function connectTimedOut() {
const err = new types.DriverError('Connection timeout');
self.errorConnecting(err, true, callback);
});
this.netClient.setTimeout(this.options.socketOptions.connectTimeout);
// Improve failure detection with TCP keep-alives
if (this.options.socketOptions.keepAlive) {
this.netClient.setKeepAlive(true, this.options.socketOptions.keepAliveDelay);
}
this.netClient.setNoDelay(!!this.options.socketOptions.tcpNoDelay);
}
/**
* Determines the protocol version to use and sends the STARTUP request
* @param {Function} callback
*/
startup(callback) {
if (this._checkingVersion) {
this.log('info', 'Trying to use protocol version 0x' + this.protocolVersion.toString(16));
}
const self = this;
const request = new requests.StartupRequest({
noCompact: this.options.protocolOptions.noCompact,
clientId: this.options.id,
applicationName: this.options.applicationName,
applicationVersion: this.options.applicationVersion
});
this.sendStream(request, null, function responseCallback(err, response) {
if (err && self._checkingVersion) {
let invalidProtocol = (err instanceof errors.ResponseError &&
err.code === types.responseErrorCodes.protocolError &&
err.message.indexOf('Invalid or unsupported protocol version') >= 0);
if (!invalidProtocol && types.protocolVersion.canStartupResponseErrorBeWrapped(self.protocolVersion)) {
//For some versions of Cassandra, the error is wrapped into a server error
//See CASSANDRA-9451
invalidProtocol = (err instanceof errors.ResponseError &&
err.code === types.responseErrorCodes.serverError &&
err.message.indexOf('ProtocolException: Invalid or unsupported protocol version') > 0);
}
if (invalidProtocol) {
// The server can respond with a message using the lower protocol version supported
// or using the same version as the one provided
let lowerVersion = self.protocol.version;
if (lowerVersion === self.protocolVersion) {
lowerVersion = types.protocolVersion.getLowerSupported(self.protocolVersion);
} else if (!types.protocolVersion.isSupported(self.protocol.version)) {
// If we have an unsupported protocol version or a beta version we need to switch
// to something we can support. Note that dseV1 and dseV2 are excluded from this
// logic as they are supported. Also note that any v5 and greater beta protocols
// are included here since the beta flag was introduced in v5.
self.log('info',`Protocol version ${self.protocol.version} not supported by this driver, downgrading`);
lowerVersion = types.protocolVersion.getLowerSupported(self.protocol.version);
}
if (!lowerVersion) {
return startupCallback(
new Error('Connection was unable to STARTUP using protocol version ' + self.protocolVersion));
}
self.log('info', 'Protocol 0x' + self.protocolVersion.toString(16) + ' not supported, using 0x' + lowerVersion.toString(16));
self.decreaseVersion(lowerVersion);
// The host closed the connection, close the socket and start the connection flow again
setImmediate(function decreasingVersionClosing() {
self.close(function decreasingVersionOpening() {
// Attempt to open with the correct protocol version
self.open(callback);
});
});
return;
}
}
if (response && response.mustAuthenticate) {
return self.startAuthenticating(response.authenticatorName, startupCallback);
}
startupCallback(err);
});
function startupCallback(err) {
if (err) {
return self.errorConnecting(err, false, callback);
}
//The socket is connected and the connection is authenticated
return self.connectionReady(callback);
}
}
errorConnecting(err, destroy, callback) {
this.log('warning', `There was an error when trying to connect to the host ${this.endpointFriendlyName}`, err);
if (destroy) {
//there is a TCP connection that should be killed.
this.netClient.destroy();
}
this._metrics.onConnectionError(err);
callback(err);
}
/**
* Sets the connection to ready/connected status
*/
connectionReady(callback) {
this.emit('connected');
this.connected = true;
// Remove existing error handlers as the connection is now ready.
this.netClient.removeAllListeners('error');
this.netClient.on('error', this.handleSocketError.bind(this));
callback();
}
/** @param {Number} lowerVersion */
decreaseVersion(lowerVersion) {
// The response already has the max protocol version supported by the Cassandra host.
this.protocolVersion = lowerVersion;
this.encoder.setProtocolVersion(lowerVersion);
this._streamIds.setVersion(lowerVersion);
}
/**
* Handle socket errors, if the socket is not readable invoke all pending callbacks
*/
handleSocketError(err) {
this._metrics.onConnectionError(err);
this.clearAndInvokePending(err);
}
/**
* Cleans all internal state and invokes all pending callbacks of sent streams
*/
clearAndInvokePending(innerError) {
if (this._idleTimeout) {
//Remove the idle request
clearTimeout(this._idleTimeout);
this._idleTimeout = null;
}
this._streamIds.clear();
if (this.emitDrain) {
this.emit('drain');
}
const err = new types.DriverError('Socket was closed');
err.isSocketError = true;
if (innerError) {
err.innerError = innerError;
}
// Get all handlers
const operations = Array.from(this._operations.values());
// Clear pending operation map
this._operations = new Map();
if (operations.length > 0) {
this.log('info', 'Invoking ' + operations.length + ' pending callbacks');
}
// Invoke all handlers
utils.each(operations, function (operation, next) {
operation.setResult(err);
next();
});
const pendingWritesCopy = this._pendingWrites;
this._pendingWrites = [];
utils.each(pendingWritesCopy, function (operation, next) {
operation.setResult(err);
next();
});
}
/**
* Starts the SASL flow
* @param {String} authenticatorName
* @param {Function} callback
*/
startAuthenticating(authenticatorName, callback) {
if (!this.options.authProvider) {
return callback(new errors.AuthenticationError('Authentication provider not set'));
}
const authenticator = this.options.authProvider.newAuthenticator(this.endpoint, authenticatorName);
const self = this;
authenticator.initialResponse(function initialResponseCallback(err, token) {
// Start the flow with the initial token
if (err) {
return self.onAuthenticationError(callback, err);
}
self.authenticate(authenticator, token, callback);
});
}
/**
* Handles authentication requests and responses.
* @param {Authenticator} authenticator
* @param {Buffer} token
* @param {Function} callback
*/
authenticate(authenticator, token, callback) {
const self = this;
let request = new requests.AuthResponseRequest(token);
if (this.protocolVersion === 1) {
//No Sasl support, use CREDENTIALS
if (!authenticator.username) {
return self.onAuthenticationError(
callback, new errors.AuthenticationError('Only plain text authenticator providers allowed under protocol v1'));
}
request = new requests.CredentialsRequest(authenticator.username, authenticator.password);
}
this.sendStream(request, null, function authResponseCallback(err, result) {
if (err) {
if (err instanceof errors.ResponseError && err.code === types.responseErrorCodes.badCredentials) {
const authError = new errors.AuthenticationError(err.message);
authError.additionalInfo = err;
err = authError;
}
return self.onAuthenticationError(callback, err);
}
if (result.ready) {
authenticator.onAuthenticationSuccess();
return callback();
}
if (result.authChallenge) {
return authenticator.evaluateChallenge(result.token, function evaluateCallback(err, t) {
if (err) {
return self.onAuthenticationError(callback, err);
}
//here we go again
self.authenticate(authenticator, t, callback);
});
}
callback(new errors.DriverInternalError('Unexpected response from Cassandra: ' + util.inspect(result)));
});
}
onAuthenticationError(callback, err) {
this._metrics.onAuthenticationError(err);
callback(err);
}
/**
* Executes a 'USE ' query, if keyspace is provided and it is different from the current keyspace
* @param {?String} keyspace
*/
async changeKeyspace(keyspace) {
if (!keyspace || this.keyspace === keyspace) {
return;
}
if (this.toBeKeyspace === keyspace) {
// It will be invoked once the keyspace is changed
return promiseUtils.fromEvent(this, 'keyspaceChanged');
}
this.toBeKeyspace = keyspace;
const query = `USE "${keyspace}"`;
try {
await this.send(new requests.QueryRequest(query, null, null), null);
this.keyspace = keyspace;
this.emit('keyspaceChanged', null, keyspace);
} catch (err) {
this.log('error', `Connection to ${this.endpointFriendlyName} could not switch active keyspace: ${err}`, err);
this.emit('keyspaceChanged', err);
throw err;
} finally {
this.toBeKeyspace = null;
}
}
/**
* Prepares a query on a given connection. If its already being prepared, it queues the callback.
* @param {String} query
* @param {String} keyspace
* @param {function} callback
*/
prepareOnce(query, keyspace, callback) {
const name = ( keyspace || '' ) + query;
let info = this._preparing.get(name);
if (info) {
// Its being already prepared
return info.once('prepared', callback);
}
info = new events.EventEmitter();
info.setMaxListeners(0);
info.once('prepared', callback);
this._preparing.set(name, info);
this.sendStream(new requests.PrepareRequest(query, keyspace), null, (err, response) => {
info.emit('prepared', err, response);
this._preparing.delete(name);
});
}
/**
* Queues the operation to be written to the wire and invokes the callback once the response was obtained or with an
* error (socket error or OperationTimedOutError or serialization-related error).
* @param {Request} request
* @param {ExecutionOptions|null} execOptions
* @param {function} callback Function to be called once the response has been received
* @return {OperationState}
*/
sendStream(request, execOptions, callback) {
execOptions = execOptions || ExecutionOptions.empty();
// Create a new operation that will contain the request, callback and timeouts
const operation = new OperationState(request, execOptions.getRowCallback(), (err, response, length) => {
if (!err || !err.isSocketError) {
// Emit that a response was obtained when there is a valid response
// or when the error is not a socket error
this.emit('responseDequeued');
}
callback(err, response, length);
});
const streamId = this._getStreamId();
// Start the request timeout without waiting for the request to be written
operation.setRequestTimeout(execOptions, this.options.socketOptions.readTimeout, this.endpoint,
() => this.timedOutOperations++,
() => this.timedOutOperations--);
if (streamId === null) {
this.log('info',
'Enqueuing ' +
this._pendingWrites.length +
', if this message is recurrent consider configuring more connections per host or lowering the pressure');
this._pendingWrites.push(operation);
return operation;
}
this._write(operation, streamId);
return operation;
}
/**
* Pushes the item into the queue.
* @param {OperationState} operation
* @param {Number} streamId
* @private
*/
_write(operation, streamId) {
operation.streamId = streamId;
const self = this;
this.writeQueue.push(operation, function writeCallback (err) {
if (err) {
// The request was not written.
// There was a serialization error or the operation has already timed out or was cancelled
self._streamIds.push(streamId);
return operation.setResult(err);
}
self.log('verbose', 'Sent stream #' + streamId + ' to ' + self.endpointFriendlyName);
if (operation.isByRow()) {
self.parser.setOptions(streamId, { byRow: true });
}
self._setIdleTimeout();
self._operations.set(streamId, operation);
});
}
_setIdleTimeout() {
if (!this.options.pooling.heartBeatInterval) {
return;
}
const self = this;
// Scheduling the new timeout before de-scheduling the previous performs significantly better
// than de-scheduling first, see nodejs implementation: https://github.com/nodejs/node/blob/master/lib/timers.js
const previousTimeout = this._idleTimeout;
self._idleTimeout = setTimeout(() => self._idleTimeoutHandler(), self.options.pooling.heartBeatInterval);
if (previousTimeout) {
//remove the previous timeout for the idle request
clearTimeout(previousTimeout);
}
}
/**
* Function that gets executed once the idle timeout has passed to issue a request to keep the connection alive
*/
_idleTimeoutHandler() {
if (this.sendingIdleQuery) {
//don't issue another
//schedule for next time
this._idleTimeout = setTimeout(() => this._idleTimeoutHandler(), this.options.pooling.heartBeatInterval);
return;
}
this.log('verbose', `Connection to ${this.endpointFriendlyName} idling, issuing a request to prevent disconnects`);
this.sendingIdleQuery = true;
this.sendStream(requests.options, null, (err) => {
this.sendingIdleQuery = false;
if (!err) {
//The sending succeeded
//There is a valid response but we don't care about the response
return;
}
this.log('warning', 'Received heartbeat request error', err);
this.emit('idleRequestError', err, this);
});
}
/**
* Returns an available streamId or null if there isn't any available
* @returns {Number}
*/
_getStreamId() {
return this._streamIds.pop();
}
freeStreamId(header) {
const streamId = header.streamId;
if (streamId < 0) {
// Event ids don't have a matching request operation
return;
}
this._operations.delete(streamId);
this._streamIds.push(streamId);
if (this.emitDrain && this._streamIds.inUse === 0 && this._pendingWrites.length === 0) {
this.emit('drain');
}
this._writeNext();
}
_writeNext() {
if (this._pendingWrites.length === 0) {
return;
}
const streamId = this._getStreamId();
if (streamId === null) {
// No streamId available
return;
}
const self = this;
let operation;
while ((operation = this._pendingWrites.shift()) && !operation.canBeWritten()) {
// Trying to obtain an pending operation that can be written
}
if (!operation) {
// There isn't a pending operation that can be written
this._streamIds.push(streamId);
return;
}
// Schedule after current I/O callbacks have been executed
setImmediate(function writeNextPending() {
self._write(operation, streamId);
});
}
/**
* Returns the number of requests waiting for response
* @returns {Number}
*/
getInFlight() {
return this._streamIds.inUse;
}
/**
* Handles a result and error response
*/
handleResult(header, err, result) {
const streamId = header.streamId;
if(streamId < 0) {
return this.log('verbose', 'event received', header);
}
const operation = this._operations.get(streamId);
if (!operation) {
return this.log('error', 'The server replied with a wrong streamId #' + streamId);
}
this.log('verbose', 'Received frame #' + streamId + ' from ' + this.endpointFriendlyName);
operation.setResult(err, result, header.bodyLength);
}
handleNodeEvent(header, event) {
switch (event.eventType) {
case types.protocolEvents.schemaChange:
this.emit('nodeSchemaChange', event);
break;
case types.protocolEvents.topologyChange:
this.emit('nodeTopologyChange', event);
break;
case types.protocolEvents.statusChange:
this.emit('nodeStatusChange', event);
break;
}
}
/**
* Handles a row response
*/
handleRow(header, row, meta, rowLength, flags) {
const streamId = header.streamId;
if(streamId < 0) {
return this.log('verbose', 'Event received', header);
}
const operation = this._operations.get(streamId);
if (!operation) {
return this.log('error', 'The server replied with a wrong streamId #' + streamId);
}
operation.setResultRow(row, meta, rowLength, flags, header);
}
/**
* Closes the socket (if not already closed) and cancels all in-flight requests.
* Multiple calls to this method have no additional side-effects.
* @param {Function} [callback]
*/
close(callback) {
callback = callback || utils.noop;
if (!this.connected && !this.isSocketOpen) {
return callback();
}
this.connected = false;
// Drain is never going to be emitted, once it is set to closed
this.removeAllListeners('drain');
this.clearAndInvokePending();
if (!this.isSocketOpen) {
return callback();
}
// Set the socket as closed now (before socket.end() is called) to avoid being invoked more than once
this.isSocketOpen = false;
this.log('verbose', `Closing connection to ${this.endpointFriendlyName}`);
const self = this;
// If server doesn't acknowledge the half-close within connection timeout, destroy the socket.
const endTimeout = setTimeout(() => {
this.log('info', `${this.endpointFriendlyName} did not respond to connection close within ` +
`${this.options.socketOptions.connectTimeout}ms, destroying connection`);
this.netClient.destroy();
}, this.options.socketOptions.connectTimeout);
this.netClient.once('close', function (hadError) {
clearTimeout(endTimeout);
if (hadError) {
self.log('info', 'The socket closed with a transmission error');
}
setImmediate(callback);
});
// At this point, the error event can be triggered because:
// - It's connected and writes haven't completed yet
// - The server abruptly closed its end of the connection (ECONNRESET) as a result of protocol error / auth error
// We need to remove any listeners and make sure we callback are pending writes
this.netClient.removeAllListeners('error');
this.netClient.on('error', err => this.clearAndInvokePending(err));
// Half-close the socket, it will result in 'close' event being fired
this.netClient.end();
}
/**
* Gets the local IP address to which this connection socket is bound to.
* @returns {String|undefined}
*/
getLocalAddress() {
if (!this.netClient) {
return undefined;
}
return this.netClient.localAddress;
}
}
module.exports = Connection;