@amazon-dax-sdk/client-dax
Version:
Amazon DAX Client for JavaScript
457 lines (397 loc) • 13.9 kB
JavaScript
/*
* Copyright 2017 Amazon.com, Inc. or its affiliates. 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. A copy of the License
* is located at
*
* http://aws.amazon.com/apache2.0/
*
* or in the "license" file accompanying this file. This file 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 CborEncoder = require('./CborEncoder');
const DaxClientError = require('./DaxClientError');
const DaxErrorCode = require('./DaxErrorCode');
const SessionVersion = require('./SessionVersion');
const SigV4Gen = require('./SigV4Gen');
const StreamBuffer = require('./ByteStreamBuffer');
const ControllablePromise = require('./ControllablePromise');
const {ENCRYPTED_SCHEME} = require('./Util');
const net = require('net');
const tls = require('tls');
const MAGIC_STRING = 'J7yne5G';
const USER_AGENT_STRING = 'UserAgent';
const USER_AGENT = 'DaxJSV3Client-' + require('../package.json').version;
const WINDOW_SCALAR = 0.1;
const DAX_ADDR = 'https://dax.amazonaws.com';
const BUFFER_ZERO = Buffer.from([0]);
const DEFAULT_FLUSH_SIZE = 4096;
const MAX_ALLOC_CONNECTION_STEP = 10; // same number as JAVA client
class TimeoutError extends DaxClientError {
constructor(timeout) {
super('Connection timeout after ' + timeout + 'ms', DaxErrorCode.Connection);
}
}
exports.USER_AGENT = USER_AGENT;
class ClientTube {
constructor(socket, version, credProvider, region) {
this.cbor = new CborEncoder();
if(!ClientTube.ENCODED_INIT_PREFIX) {
// lazily initialize ClientTube cbor encoder/context.
ClientTube.makeCborContext(this.cbor);
}
socket.setKeepAlive(true);
socket.setNoDelay(true);
this.socket = socket;
this._authExp = 0;
this._nextTube = null;
this._authTTLMillis = 5 * 60 * 1000;
this._poolWindow = (this._authTTLMillis / 2);
this._tubeWindow = (this._authTTLMillis * WINDOW_SCALAR);
this._region = region;
this._credProvider = credProvider;
this._sessionVersion = version;
this.requestBuffer = new StreamBuffer();
this.responseBuffer = new StreamBuffer();
this._init(version.session);
this._closed = false;
this.socket.on('close', (had_error) => {
// If the server closes the socket for some reason, mark the tube as closed
this._closed = true;
});
}
static makeCborContext(cbor) {
// construct reusable cbor context.
cbor = new CborEncoder();
cbor._writeString(MAGIC_STRING);
cbor._write(BUFFER_ZERO);
ClientTube.ENCODED_INIT_PREFIX = cbor.read();
cbor._writeMapHeader(1);
cbor._writeString(USER_AGENT_STRING);
cbor._writeString(USER_AGENT);
cbor._write(BUFFER_ZERO);
ClientTube.ENCODED_INIT_SUFFIX = cbor.read();
cbor._writeInt(1);
cbor._writeInt(1489122155);
ClientTube.ENCODED_AUTH_PREFIX = cbor.read();
ClientTube.ENCODED_USER_AGENT = (USER_AGENT ? cbor.encodeString(USER_AGENT) : cbor.encodeNull());
}
_init(session) {
this.write(ClientTube.ENCODED_INIT_PREFIX);
if(!session) {
this.write(this.cbor.encodeNull());
} else {
this.write(this.cbor.encodeBinary(session));
}
this.write(ClientTube.ENCODED_INIT_SUFFIX);
this.flush();
}
close() {
this._closed = true;
this.socket.end();
this._cleanupListeners();
}
_cleanupListeners() {
// don't use removeAllListeners() to remove all listeners since there
// are some default system listeners that deal with socket end/close event.
if(this.socket) {
this.socket.removeAllListeners('timeout');
this.socket.removeAllListeners('data');
this.socket.removeAllListeners('error');
}
}
write(data) {
this.requestBuffer.write(data);
if(this.requestBuffer.length >= DEFAULT_FLUSH_SIZE) {
this.socket.write(this.requestBuffer.read());
}
}
flush(data) {
if(this.requestBuffer.length > 0) {
this.socket.write(this.requestBuffer.read());
}
}
reauth() {
let currTime = Date.now();
if(this._authExp - currTime <= this._tubeWindow
|| currTime - this._lastPoolAuth >= this._poolWindow) {
return this._credProvider.resolvePromise().then((creds) => {
this._checkAndUpdateAccessKeyId(creds.accessKeyId);
this._lastPoolAuth = currTime;
this._authExp = currTime + this._authTTLMillis;
this._authHandler(creds);
// return the containing tube for further use
return this;
});
} else {
return Promise.resolve(this);
}
}
invalidateAuth() {
this._authExp = 0;
}
setTimeout(timeout, callback) {
if(this.socket) {
this.socket.setTimeout(timeout, callback);
}
}
_authHandler(creds) {
// Make sure the same credentials are used to sign and authorize.
let sigHead = SigV4Gen.generateSigAndStringToSign(creds, DAX_ADDR, this._region, '');
this.write(ClientTube.ENCODED_AUTH_PREFIX);
this.write(this.cbor.encodeString(creds.accessKeyId));
this.write(this.cbor.encodeString(sigHead.signature));
this.write(this.cbor.encodeBinary(Buffer.from(sigHead.stringToSign)));
this.write(sigHead.sessionToken === null ? this.cbor.encodeNull() : this.cbor.encodeString(sigHead.sessionToken));
this.write(ClientTube.ENCODED_USER_AGENT);
}
_checkAndUpdateAccessKeyId(other) {
if(!other) {
throw new DaxClientError('AWSCredentialsProvider provided null AWSAccessKeyId', DaxErrorCode.Authentication, false);
}
let equality = (other === this._accessKeyId);
if(!equality) {
this._accessKeyId = other;
}
return equality;
}
}
class Connector {
constructor(isEncrypted, host, port, skipHostnameVerification, endpointHost) {
let checkServerIdentity;
if(isEncrypted) {
checkServerIdentity = skipHostnameVerification ? () => undefined :
(_, cert) => tls.checkServerIdentity(endpointHost, cert);
} else if(skipHostnameVerification) {
console.warn('Skipping hostname verification for unencrypted clusters will have no effect.');
}
this._connectOps = {
host: host,
port: port,
checkServerIdentity: checkServerIdentity,
};
this._protocol = isEncrypted ? tls : net;
}
connect(callback) {
return this._protocol.connect(this._connectOps, callback);
}
}
class SocketTubePool {
constructor(hostname, port, credProvider, region, idleTimeout, connectTimeout, tube, seeds, skipHostnameVerification, maxConcurrentConnections) {
this._hostname = hostname;
this._port = port;
this._headTube = null;
this._region = region;
this._credProvider = credProvider;
this._sessionVersion = SessionVersion.create();
this._allocConnectionStep = 0;
this._pendingJob = [];
this._idleTimeout = idleTimeout || 5000;
this._connectTimeout = connectTimeout || 10000;
this._maxConcurrentConnections = maxConcurrentConnections;
/**
* For client to cluster encryption, the client will only support one encrypted URL.
* The scheme must be the same for all endpoints.
* Endpoint host is needed for hostname verification of encrypted clusters.
*/
const containsSeed = seeds != null && seeds.length > 0; // This exists because many unit tests don't enumerate seeds.
const endpointScheme = containsSeed ? seeds[0].scheme : null;
this._isEncrypted = endpointScheme == ENCRYPTED_SCHEME;
this._endpointHost = containsSeed ? seeds[0].host : null;
this._skipHostnameVerification = skipHostnameVerification;
this._connector = new Connector(this._isEncrypted, this._hostname, this._port, this._skipHostnameVerification, this._endpointHost);
this._connectionCount = 1;
this.recycle(tube);
}
alloc() {
let tube = this._headTube;
if(tube) {
// open tube is available, so use it
this._headTube = tube._nextTube;
tube._nextTube = null;
if(tube.socket) {
// remove the idle handler
tube.socket.removeAllListeners('timeout');
// ref socket before return to caller.
tube.socket.ref();
tube._inPool = false;
}
return Promise.resolve(tube);
} else {
// no open available tubes, so try to create one
let wait = new ControllablePromise(this._connectTimeout, new TimeoutError(this._connectTimeout));
this._pendingJob.push(wait);
this._alloc(wait);
return wait;
}
}
_shouldAlloc() {
// Burst protection: reject if connection number at once exceeds default max alloc connection step
if(this._allocConnectionStep >= MAX_ALLOC_CONNECTION_STEP) {
return false;
}
// check availability of creating new connections
if(this._connectionCount < this._maxConcurrentConnections) {
return true;
}
return false;
}
_alloc(wait) {
if(!this._shouldAlloc(wait)) {
return null;
}
this._allocConnectionStep++;
this._connectionCount++;
let localSessionVersion = this._sessionVersion;
const socket = this._connector.connect(() => this.socketCallback(socket, localSessionVersion)).on('error', (e) => this.socketError(wait, e));
}
socketCallback(socket, localSessionVersion) {
let newTube = new ClientTube(socket, localSessionVersion, this._credProvider, this._region);
this.recycle(newTube);
this._allocConnectionStep--;
}
socketError(wait, error) {
if(wait && !wait.isDone()) {
wait.reject(new DaxClientError(error.message, DaxErrorCode.Connection));
}
this._allocConnectionStep--;
this._connectionCount--;
}
recycle(tube) {
if(!tube || tube._inPool || tube._closed) {
return;
}
if(tube._sessionVersion === this._sessionVersion) {
// remove all socket listeners to avoid leaks
tube._cleanupListeners();
// first check whether we can assign tube to someone still waiting for it.
while(this._pendingJob.length > 0) {
let job = this._pendingJob.shift();
if(job.isDone()) {
continue;
} else {
job.resolve(tube);
return;
}
}
// no valid candidate, recycle to pool.
// unref the socket recycled back to avoid hanging event loop.
tube.socket.unref();
tube._inPool = true;
// set the idle timeout to remove it if unused
tube.setTimeout(this._idleTimeout, () => {
this._removeIdleTube(tube);
});
tube._nextTube = this._headTube;
this._headTube = tube;
} else {
tube.close();
this._connectionCount && this._connectionCount--;
}
}
// when calling reset, it's most likely that all tubes are affected, so we
// preemptively close every tube instead of waiting for each tube to get an
// exception and closed.
reset(tube) {
if(!tube) {
return;
}
tube.close();
if(tube._sessionVersion !== this._sessionVersion) {
return;
}
this._signalAll(false);
this._versionBump();
tube = this._headTube;
this._headTube = null;
this._closeAll(tube);
}
// Signal pending connect jobs. 'reject' value will indicate whether to
// reject directly or allow retry when still within connect timeout.
_signalAll(reject) {
for(let job of this._pendingJob) {
if(!job.isDone()) {
if(reject) {
job.reject(new DaxClientError('pool is reset or closed', DaxErrorCode.Connection, true));
} else {
// We should give it another connect try instead of fail this
// request as long as it's within connect timeout which is
// configurable.
this._alloc();
}
}
}
// Don't need to clean pending job list here since frequent array slice
// is not efficient. If it will be used again, it will be shifted when
// recycle method try to find a candidate. Otherwise, it will be
// garbage collected.
}
close() {
this._signalAll(true);
this._versionBump();
let tube = this._headTube;
this._headTube = null;
this._closeAll(tube);
}
_closeAll(tube) {
let reapCount = 0;
let next;
while(tube) {
reapCount++;
tube.close();
this._connectionCount && this._connectionCount--;
next = tube._nextTube;
tube._nextTube = null;
tube = next;
}
return reapCount;
}
_versionBump() {
this._sessionVersion = SessionVersion.create();
}
_removeIdleTube(tube) {
if(!tube || !tube._inPool) {
return;
}
if(this._headTube === tube) {
if(this._headTube._nextTube) {
// if the head tube is idle and there's another tube available, remove the head tube
this._headTube.close();
this._connectionCount && this._connectionCount--;
this._headTube = this._headTube._nextTube;
} else {
// if there is no other tube, then leave it intact
return;
}
} else {
// find the idle tube in the list
let prevTube = this._headTube;
let curTube = this._headTube._nextTube;
while(curTube) {
if(curTube === tube) {
// remove this tube from the list, let GC take care of it
// prevTube cannot be null
curTube.close();
this._connectionCount && this._connectionCount--;
prevTube._nextTube = curTube._nextTube;
curTube._nextTube = null;
return;
} else {
prevTube = curTube;
curTube = curTube._nextTube;
}
}
}
// if we get here the tube was not found, but ignore it
}
}
module.exports = {
ClientTube: ClientTube,
SocketTubePool: SocketTubePool,
TimeoutError: TimeoutError,
Connector: Connector,
};