You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

735 lines
22 KiB

/**
* Copyright (c) 2013 Yahoo! Inc. All rights reserved.
*
* Copyrights licensed under the MIT License. See the accompanying LICENSE file
* for terms.
*/
var net = require('net');
var utils = require('util');
var events = require('events');
var jute = require('./jute');
var ConnectionStringParser = require('./ConnectionStringParser.js');
var WatcherManager = require('./WatcherManager.js');
var PacketQueue = require('./PacketQueue.js');
var Exception = require('./Exception.js');
/**
* This class manages the connection between the client and the ensemble.
*
* @module node-zookeeper-client
*/
// Constants.
var STATES = { // Connection States.
DISCONNECTED : 0,
CONNECTING : 1,
CONNECTED : 2,
CONNECTED_READ_ONLY : 3,
CLOSING : -1,
CLOSED : -2,
SESSION_EXPIRED : -3,
AUTHENTICATION_FAILED : -4
};
/**
* Construct a new ConnectionManager instance.
*
* @class ConnectionStringParser
* @constructor
* @param connectionString {String} ZooKeeper server ensemble string.
* @param options {Object} Client options.
* @param stateListener {Object} Listener for state changes.
*/
function ConnectionManager(connectionString, options, stateListener) {
events.EventEmitter.call(this);
this.watcherManager = new WatcherManager();
this.connectionStringParser = new ConnectionStringParser(connectionString);
this.servers = this.connectionStringParser.getServers();
this.chrootPath = this.connectionStringParser.getChrootPath();
this.nextServerIndex = 0;
this.serverAttempts = 0;
this.state = STATES.DISCONNECTED;
this.options = options;
this.spinDelay = options.spinDelay;
this.updateTimeout(options.sessionTimeout);
this.connectTimeoutHandler = null;
this.xid = 0;
this.sessionId = new Buffer(8);
if (Buffer.isBuffer(options.sessionId)) {
options.sessionId.copy(this.sessionId);
} else {
this.sessionId.fill(0);
}
this.sessionPassword = new Buffer(16);
if (Buffer.isBuffer(options.sessionPassword)) {
options.sessionPassword.copy(this.sessionPassword);
} else {
this.sessionPassword.fill(0);
}
// scheme:auth pairs
this.credentials = [];
// Last seen zxid.
this.zxid = new Buffer(8);
this.zxid.fill(0);
this.pendingBuffer = null;
this.packetQueue = new PacketQueue();
this.packetQueue.on('readable', this.onPacketQueueReadable.bind(this));
this.pendingQueue = [];
this.on('state', stateListener);
}
utils.inherits(ConnectionManager, events.EventEmitter);
/**
* Update the session timeout and related timeout variables.
*
* @method updateTimeout
* @private
* @param sessionTimeout {Number} Milliseconds of the timeout value.
*/
ConnectionManager.prototype.updateTimeout = function (sessionTimeout) {
this.sessionTimeout = sessionTimeout;
// Designed to have time to try all the servers.
this.connectTimeout = Math.floor(sessionTimeout / this.servers.length);
// We at least send out one ping one third of the session timeout, so
// the read timeout is two third of the session timeout.
this.pingTimeout = Math.floor(this.sessionTimeout / 3);
// this.readTimeout = Math.floor(sessionTimeout * 2 / 3);
};
/**
* Find the next available server to connect. If all server has been tried,
* it will wait for a random time between 0 to spin delay before call back
* with the next server.
*
* callback prototype:
* callback(server);
*
* @method findNextServer
* @param callback {Function} callback function.
*
*/
ConnectionManager.prototype.findNextServer = function (callback) {
var self = this;
self.nextServerIndex %= self.servers.length;
if (self.serverAttempts === self.servers.length) {
setTimeout(function () {
callback(self.servers[self.nextServerIndex]);
self.nextServerIndex += 1;
// reset attempts since we already waited for enough time.
self.serverAttempts = 0;
}, Math.random() * self.spinDelay);
} else {
self.serverAttempts += 1;
process.nextTick(function () {
callback(self.servers[self.nextServerIndex]);
self.nextServerIndex += 1;
});
}
};
/**
* Change the current state to the given state if the given state is different
* from current state. Emit the state change event with the changed state.
*
* @method setState
* @param state {Number} The state to be set.
*/
ConnectionManager.prototype.setState = function (state) {
if (typeof state !== 'number') {
throw new Error('state must be a valid number.');
}
if (this.state !== state) {
this.state = state;
this.emit('state', this.state);
}
};
ConnectionManager.prototype.registerDataWatcher = function (path, watcher) {
this.watcherManager.registerDataWatcher(path, watcher);
};
ConnectionManager.prototype.registerChildWatcher = function (path, watcher) {
this.watcherManager.registerChildWatcher(path, watcher);
};
ConnectionManager.prototype.registerExistenceWatcher = function (path, watcher) {
this.watcherManager.registerExistenceWatcher(path, watcher);
};
ConnectionManager.prototype.cleanupPendingQueue = function (errorCode) {
var pendingPacket = this.pendingQueue.shift();
while (pendingPacket) {
if (pendingPacket.callback) {
pendingPacket.callback(Exception.create(errorCode));
}
pendingPacket = this.pendingQueue.shift();
}
};
ConnectionManager.prototype.getSessionId = function () {
var result = new Buffer(8);
this.sessionId.copy(result);
return result;
};
ConnectionManager.prototype.getSessionPassword = function () {
var result = new Buffer(16);
this.sessionPassword.copy(result);
return result;
};
ConnectionManager.prototype.getSessionTimeout = function () {
return this.sessionTimeout;
};
ConnectionManager.prototype.connect = function () {
var self = this;
self.setState(STATES.CONNECTING);
self.findNextServer(function (server) {
self.socket = net.connect(server);
self.connectTimeoutHandler = setTimeout(
self.onSocketConnectTimeout.bind(self),
self.connectTimeout
);
// Disable the Nagle algorithm.
self.socket.setNoDelay();
self.socket.on('connect', self.onSocketConnected.bind(self));
self.socket.on('data', self.onSocketData.bind(self));
self.socket.on('drain', self.onSocketDrain.bind(self));
self.socket.on('close', self.onSocketClosed.bind(self));
self.socket.on('error', self.onSocketError.bind(self));
});
};
ConnectionManager.prototype.close = function () {
var self = this,
header = new jute.protocol.RequestHeader(),
request;
self.setState(STATES.CLOSING);
header.type = jute.OP_CODES.CLOSE_SESSION;
request = new jute.Request(header, null);
self.queue(request);
};
ConnectionManager.prototype.onSocketClosed = function (hasError) {
var retry = false,
errorCode,
pendingPacket;
switch (this.state) {
case STATES.CLOSING:
errorCode = Exception.CONNECTION_LOSS;
retry = false;
break;
case STATES.SESSION_EXPIRED:
errorCode = Exception.SESSION_EXPIRED;
retry = false;
break;
case STATES.AUTHENTICATION_FAILED:
errorCode = Exception.AUTH_FAILED;
retry = false;
break;
default:
errorCode = Exception.CONNECTION_LOSS;
retry = true;
}
this.cleanupPendingQueue(errorCode);
this.setState(STATES.DISCONNECTED);
if (retry) {
this.connect();
} else {
this.setState(STATES.CLOSED);
}
};
ConnectionManager.prototype.onSocketError = function (error) {
if (this.connectTimeoutHandler) {
clearTimeout(this.connectTimeoutHandler);
}
// After socket error, the socket closed event will be triggered,
// we will retry connect in that listener function.
};
ConnectionManager.prototype.onSocketConnectTimeout = function () {
// Destroy the current socket so the socket closed event
// will be trigger.
this.socket.destroy();
};
ConnectionManager.prototype.onSocketConnected = function () {
var connectRequest,
authRequest,
setWatchesRequest,
header,
payload;
if (this.connectTimeoutHandler) {
clearTimeout(this.connectTimeoutHandler);
}
connectRequest = new jute.Request(null, new jute.protocol.ConnectRequest(
jute.PROTOCOL_VERSION,
this.zxid,
this.sessionTimeout,
this.sessionId,
this.sessionPassword
));
// XXX No read only support yet.
this.socket.write(connectRequest.toBuffer());
// Set auth info
if (this.credentials.length > 0) {
this.credentials.forEach(function (credential) {
header = new jute.protocol.RequestHeader();
payload = new jute.protocol.AuthPacket();
header.xid = jute.XID_AUTHENTICATION;
header.type = jute.OP_CODES.AUTH;
payload.type = 0;
payload.scheme = credential.scheme;
payload.auth = credential.auth;
authRequest = new jute.Request(header, payload);
this.queue(authRequest);
}, this);
}
// Reset the watchers if we have any.
if (!this.watcherManager.isEmpty()) {
header = new jute.protocol.RequestHeader();
payload = new jute.protocol.SetWatches();
header.type = jute.OP_CODES.SET_WATCHES;
header.xid = jute.XID_SET_WATCHES;
payload.setChrootPath(this.chrootPath);
payload.relativeZxid = this.zxid;
payload.dataWatches = this.watcherManager.getDataWatcherPaths();
payload.existWatches = this.watcherManager.getExistenceWatcherPaths();
payload.childWatches = this.watcherManager.getChildWatcherPaths();
setWatchesRequest = new jute.Request(header, payload);
this.queue(setWatchesRequest);
}
};
ConnectionManager.prototype.onSocketTimeout = function () {
var header,
request;
if (this.socket &&
(this.state === STATES.CONNECTED ||
this.state === STATES.CONNECTED_READ_ONLY)) {
header = new jute.protocol.RequestHeader(
jute.XID_PING,
jute.OP_CODES.PING
);
request = new jute.Request(header, null);
this.queue(request);
// Re-register the timeout handler since it only fired once.
this.socket.setTimeout(
this.pingTimeout,
this.onSocketTimeout.bind(this)
);
}
};
/* eslint-disable complexity,max-depth */
ConnectionManager.prototype.onSocketData = function (buffer) {
var self = this,
offset = 0,
size = 0,
connectResponse,
pendingPacket,
responseHeader,
responsePayload,
response,
event;
// Combine the pending buffer with the new buffer.
if (self.pendingBuffer) {
buffer = Buffer.concat(
[self.pendingBuffer, buffer],
self.pendingBuffer.length + buffer.length
);
}
// We need at least 4 bytes
if (buffer.length < 4) {
self.pendingBuffer = buffer;
return;
}
size = buffer.readInt32BE(offset);
offset += 4;
if (buffer.length < size + 4) {
// More data are coming.
self.pendingBuffer = buffer;
return;
}
if (buffer.length === size + 4) {
// The size is perfect.
self.pendingBuffer = null;
} else {
// We have extra bytes, splice them out as pending buffer.
self.pendingBuffer = buffer.slice(size + 4);
buffer = buffer.slice(0, size + 4);
}
if (self.state === STATES.CONNECTING) {
// Handle connect response.
connectResponse = new jute.protocol.ConnectResponse();
offset += connectResponse.deserialize(buffer, offset);
if (connectResponse.timeOut <= 0) {
self.setState(STATES.SESSION_EXPIRED);
} else {
// Reset the server connection attempts since we connected now.
self.serverAttempts = 0;
self.sessionId = connectResponse.sessionId;
self.sessionPassword = connectResponse.passwd;
self.updateTimeout(connectResponse.timeOut);
self.setState(STATES.CONNECTED);
// Check if we have anything to send out just in case.
self.onPacketQueueReadable();
self.socket.setTimeout(
self.pingTimeout,
self.onSocketTimeout.bind(self)
);
}
} else {
// Handle all other repsonses.
responseHeader = new jute.protocol.ReplyHeader();
offset += responseHeader.deserialize(buffer, offset);
// TODO BETTTER LOGGING
switch (responseHeader.xid) {
case jute.XID_PING:
break;
case jute.XID_AUTHENTICATION:
if (responseHeader.err === Exception.AUTH_FAILED) {
self.setState(STATES.AUTHENTICATION_FAILED);
}
break;
case jute.XID_NOTIFICATION:
event = new jute.protocol.WatcherEvent();
if (self.chrootPath) {
event.setChrootPath(self.chrootPath);
}
offset += event.deserialize(buffer, offset);
self.watcherManager.emit(event);
break;
default:
pendingPacket = self.pendingQueue.shift();
if (!pendingPacket) {
// TODO, better error handling and logging need to be done.
// Need to clean up and do a reconnect.
// throw new Error(
// 'Nothing in pending queue but got data from server.'
// );
self.socket.destroy(); // this will trigger reconnect
return;
}
if (pendingPacket.request.header.xid !== responseHeader.xid) {
// TODO, better error handling/logging need to bee done here.
// Need to clean up and do a reconnect.
// throw new Error(
// 'Xid out of order. Got xid: ' +
// responseHeader.xid + ' with error code: ' +
// responseHeader.err + ', expected xid: ' +
// pendingPacket.request.header.xid + '.'
// );
self.socket.destroy(); // this will trigger reconnect
return;
}
if (responseHeader.zxid) {
// TODO, In Java implementation, the condition is to
// check whether the long zxid is greater than 0, here
// use buffer so we simplify.
// Need to figure out side effect.
self.zxid = responseHeader.zxid;
}
if (responseHeader.err === 0) {
switch (pendingPacket.request.header.type) {
case jute.OP_CODES.CREATE:
responsePayload = new jute.protocol.CreateResponse();
break;
case jute.OP_CODES.DELETE:
responsePayload = null;
break;
case jute.OP_CODES.GET_CHILDREN2:
responsePayload = new jute.protocol.GetChildren2Response();
break;
case jute.OP_CODES.EXISTS:
responsePayload = new jute.protocol.ExistsResponse();
break;
case jute.OP_CODES.SET_DATA:
responsePayload = new jute.protocol.SetDataResponse();
break;
case jute.OP_CODES.GET_DATA:
responsePayload = new jute.protocol.GetDataResponse();
break;
case jute.OP_CODES.SET_ACL:
responsePayload = new jute.protocol.SetACLResponse();
break;
case jute.OP_CODES.GET_ACL:
responsePayload = new jute.protocol.GetACLResponse();
break;
case jute.OP_CODES.SET_WATCHES:
responsePayload = null;
break;
case jute.OP_CODES.CLOSE_SESSION:
responsePayload = null;
break;
case jute.OP_CODES.MULTI:
responsePayload = new jute.TransactionResponse();
break;
default:
// throw new Error('Unknown request OP_CODE: ' +
// pendingPacket.request.header.type);
self.socket.destroy(); // this will trigger reconnect
return;
}
if (responsePayload) {
if (self.chrootPath) {
responsePayload.setChrootPath(self.chrootPath);
}
offset += responsePayload.deserialize(buffer, offset);
}
if (pendingPacket.callback) {
pendingPacket.callback(
null,
new jute.Response(responseHeader, responsePayload)
);
}
} else if (pendingPacket.callback) {
pendingPacket.callback(
Exception.create(responseHeader.err),
new jute.Response(responseHeader, null)
);
}
}
}
// We have more data to process, need to recursively process it.
if (self.pendingBuffer) {
self.onSocketData(new Buffer(0));
}
};
/* eslint-enable complexity,max-depth */
ConnectionManager.prototype.onSocketDrain = function () {
// Trigger write on socket.
this.onPacketQueueReadable();
};
ConnectionManager.prototype.onPacketQueueReadable = function () {
var packet,
header;
switch (this.state) {
case STATES.CONNECTED:
case STATES.CONNECTED_READ_ONLY:
case STATES.CLOSING:
// Continue
break;
case STATES.DISCONNECTED:
case STATES.CONNECTING:
case STATES.CLOSED:
case STATES.SESSION_EXPIRED:
case STATES.AUTHENTICATION_FAILED:
// Skip since we can not send traffic out
return;
default:
throw new Error('Unknown state: ' + this.state);
}
while ((packet = this.packetQueue.shift()) !== undefined) {
header = packet.request.header;
if (header !== null &&
header.type !== jute.OP_CODES.PING &&
header.type !== jute.OP_CODES.AUTH) {
header.xid = this.xid;
this.xid += 1;
// Only put requests that are not connect, ping and auth into
// the pending queue.
this.pendingQueue.push(packet);
}
if (!this.socket.write(packet.request.toBuffer())) {
// Back pressure is handled here, when the socket emit
// drain event, this method will be invoked again.
break;
}
if (header.type === jute.OP_CODES.CLOSE_SESSION) {
// The close session should be the final packet sent to the
// server.
break;
}
}
};
ConnectionManager.prototype.addAuthInfo = function (scheme, auth) {
if (!scheme || typeof scheme !== 'string') {
throw new Error('scheme must be a non-empty string.');
}
if (!Buffer.isBuffer(auth)) {
throw new Error('auth must be a valid instance of Buffer');
}
var header,
payload,
request;
this.credentials.push({
scheme : scheme,
auth : auth
});
switch (this.state) {
case STATES.CONNECTED:
case STATES.CONNECTED_READ_ONLY:
// Only queue the auth request when connected.
header = new jute.protocol.RequestHeader();
payload = new jute.protocol.AuthPacket();
header.xid = jute.XID_AUTHENTICATION;
header.type = jute.OP_CODES.AUTH;
payload.type = 0;
payload.scheme = scheme;
payload.auth = auth;
this.queue(new jute.Request(header, payload));
break;
case STATES.DISCONNECTED:
case STATES.CONNECTING:
case STATES.CLOSING:
case STATES.CLOSED:
case STATES.SESSION_EXPIRED:
case STATES.AUTHENTICATION_FAILED:
// Skip when we are not in a live state.
return;
default:
throw new Error('Unknown state: ' + this.state);
}
};
ConnectionManager.prototype.queue = function (request, callback) {
if (typeof request !== 'object') {
throw new Error('request must be a valid instance of jute.Request.');
}
if (this.chrootPath && request.payload) {
request.payload.setChrootPath(this.chrootPath);
}
callback = callback || function () {};
switch (this.state) {
case STATES.DISCONNECTED:
case STATES.CONNECTING:
case STATES.CONNECTED:
case STATES.CONNECTED_READ_ONLY:
// queue the packet
this.packetQueue.push({
request : request,
callback : callback
});
break;
case STATES.CLOSING:
if (request.header &&
request.header.type === jute.OP_CODES.CLOSE_SESSION) {
this.packetQueue.push({
request : request,
callback : callback
});
} else {
callback(Exception.create(Exception.CONNECTION_LOSS));
}
break;
case STATES.CLOSED:
callback(Exception.create(Exception.CONNECTION_LOSS));
return;
case STATES.SESSION_EXPIRED:
callback(Exception.create(Exception.SESSION_EXPIRED));
return;
case STATES.AUTHENTICATION_FAILED:
callback(Exception.create(Exception.AUTH_FAILED));
return;
default:
throw new Error('Unknown state: ' + this.state);
}
};
module.exports = ConnectionManager;
module.exports.STATES = STATES;