UNPKG

node-hdb-pool

Version:

SAP HANA connection pool

649 lines (526 loc) 21 kB
"use strict"; var hdb = require('hdb'), poolModule = require('generic-pool'), uuid = require('node-uuid'), crypto = require('crypto'), async = require('async'), _ = require('underscore'), transformLobs = require('./transformLobs.js') ; var hdbTypes = require('hdb/lib/protocol/common/TypeCode'), hdbTypeCodes = _.invert(hdbTypes); //log('info', hdbTypeCodes); var lobTypes = [hdbTypes.CLOB, hdbTypes.NCLOB, hdbTypes.BLOB, hdbTypes.TEXT]; // types need special handling while streaming var DSV_DELIMITER = '\n'; var DSV_SEPARATOR = ';'; var ESCAPE_REGEXP = new RegExp('"', 'g'); var QUERYMODE = { EXEC: 1, META: 2, STREAM: 3 }; var DSV_QUOTE = { CHAR: true, VARCHAR: true, VARCHAR1: true, VARCHAR2: true, STRING: true, NSTRING: true, NCHAR: true, NVARCHAR: true, SHORTTEXT: true, TEXT: true }; // optional post-processors for specific column types var iso8601 = function(d) { return (d === '\\N') ? '\\N' : moment(d).format('YYYY-MM-DDTHH:mm:ss'); }; // ISO 8601 less tz var DSV_FORMAT = { DAYDATE: iso8601, DATE: iso8601, TIMESTAMP: iso8601, TIMESTAMP_TZ: iso8601 }; var logger = null; function log(level, msg, arg) { if (logger && logger[level]) { if (arg) { logger[level](msg, arg); } else { logger[level](msg); } } } function HDBPool(options, req) { var user = options.user || 'Unknown-user', env = options.env || options.host || 'Unknown-env' ; // extract user from the assertion if (options.assertion && typeof options.assertion != 'function' && !options.user) { var m = options.assertion.match(/<([a-zA-Z0-9]+:)?NameID[^>]*>([^>]+)<\/([a-zA-Z0-9]+:)?NameID>/); if (m && m[2]) { user = m[2]; } else { log('error', 'Assertion does not contain NameID tag'); return; } } var assertionFactory; if (typeof options.assertion === 'function') { assertionFactory = options.assertion; } else if (typeof options.assertion === 'string') { assertionFactory = function(cb) { cb(null, options.assertion); }; } // initialise request queue and pool for the given environment this.reqQueue = {}; // track requests closed by the client/requestor this.env = env; this.user = user; this.options = options; var maxPoolSize = options.maxPoolSize || 1; // setup special behaviour in case of min > 0 (min > 0 means that some connection needs to be always kept alive) if (options.minPoolSize > 0) { // * if minPoolSize > 0, by default generic-pool module will try to create a db-connection automatically when poolModule.Pool({...}) (constructor) is called // * if acquired() is called really soon after the constructor call, the automatically created db-connection will not be available yet. // * but if maxPoolSize > 1, this acquire() call will try to create a new db-connection instead of wating for the automatically created one // the side-effects of the above behaviour: // * for the first acquire() call, two db-connections would be created instead of the necessary one db-connection // * if the pool is configured to connect with SAML-assertion, the same assertion would be used two times, and two HANA sessionCookies would be created for the same user // solution: // * max-pool size is limited to 1 during the first connection attempt, and it will be set to options.maxPoolSize automatically, once the first connection is successfully created maxPoolSize = 1; } var pool = this.pool = poolModule.Pool({ name: 'hdb-' + env + '-' + user, create: function(callback) { var poolFactory = this; var clientOpts = { host: options.host, port: options.port }; var hdbclient = hdb.createClient(clientOpts); hdbclient.on('error', function(err) { log('error', user + ' CLIENT ERROR on ' + env, err); return callback(err, hdbclient); }); var connOpts; if (options.user && options.password) { log('debug', env + ':' + user + ' connecting with username and password'); connOpts = {user: user, password: options.password, method: 'password'}; } else if (options.user && options.sessionCookie) { log('debug', env + ':' + user + ' hdb connecting with sessionCookie'); connOpts = { user: options.user, // workaround to make node-hdb send SCRAMSHA256 response in addition to SAML. // If password is missing, invalid session cookie will result in "1033 error while parsing protocol" password: crypto.randomBytes(1), sessionCookie: new Buffer(options.sessionCookie, 'binary'), method: 'sessionCookie' }; } else if (options.assertion) { log('debug', env + ':' + user + ' hdb connecting with SAML assertion'); } else { log('error', user + ' no authentication method for ' + env); return callback('no authentication method', hdbclient); } if (!connOpts && options.assertion) { assertionFactory(function(err, assertion) { if (err) { return callback('SAML delegation error:' + err, hdbclient); } connOpts = { assertion: assertion, // workaround to make node-hdb send SCRAMSHA256 response in addition to SAML. // If missing, invalid assertion will result in "1033 error while parsing protocol" user: user, password: crypto.randomBytes(1), method: 'assertion' }; conn(); }) } else { conn(); } function conn() { hdbclient.connect(connOpts, function(err) { if (err) { log('info', env + ':' + user + ': error while creating hdb connection', err.message); if (connOpts.assertion) err.assertion = connOpts.assertion; // pass the assertion upstream for inspection return callback(err, hdbclient); } log('info', env + ':' + hdbclient.get('user') + ' connected using ' + connOpts.method); // store user and sessionCookie inside the pool for future connections user = options.user = hdbclient.get('user'); var hdbCookie = hdbclient.get('sessionCookie'); if (connOpts.method == 'assertion' && hdbCookie) { // HDB does not return a cookie for username/password scheme options.sessionCookie = hdbclient.get('sessionCookie').toString('binary'); var pts = options.sessionCookie.split('@'); // split host //options.sessionCookie = pts[0]; log('trace', 'got hdb cookie ->', options.sessionCookie, pts[1] ? pts[1] : ''); // store sessionCookie into express session to support cube-server restart storeIntoSession(req, env, options.user, options.sessionCookie); } // after the first successful connection, maxPoolSize can be updated if (poolFactory.max != options.maxPoolSize) poolFactory.max = options.maxPoolSize; // enable max pool size setDefaultSchema(options, hdbclient, user, env, callback); }); } }, validate: function(client) { if (client && client.readyState === 'connected') { // client is ready to use return true; } else { log('info', env + ':' + user + ' hdb client is not valid'); // it will be automatically removed from the pool return false; } }, destroy: function(client) { log('info', env + ':' + user + ' hdb client destroyed'); client.end(); }, max: maxPoolSize, min: options.minPoolSize || 0, idleTimeoutMillis: options.idleTimeoutMillis || 30000, // specifies how long a resource can stay idle in pool before being removed log: options.genericPoolLog || false, // if true, logs via console.log - can also be a function refreshIdle: (_.isNull(options.refreshIdle) || _.isUndefined(options.refreshIdle)) ? true : options.refreshIdle }); } // simple query function q(sql, args, cb) { var prep = _.isArray(args) && args.length > 0; cb = cb || _.isFunction(args) ? args : function(){}; } // streaming HDBPool.prototype.stream = function stream(sql, args, req, streams, cb, type) { query.call(this, QUERYMODE.STREAM, sql, args, req, streams, cb, type); }; // normal exec HDBPool.prototype.exec = function exec(sql, args, req, streams, cb, type) { if (_.isFunction(req)) // called without HTTP request/response query.call(this, QUERYMODE.EXEC, sql, args, null, null, req); else // called with HTTP request/response query.call(this, QUERYMODE.EXEC, sql, args, req, streams, cb); }; // metadata HDBPool.prototype.meta = function execute(sql, args, req, streams, cb, type) { if (_.isFunction(req)) // called without HTTP request/response query.call(this, QUERYMODE.META, sql, args, null, null, req); else // called with HTTP request/response query.call(this, QUERYMODE.META, sql, args, req, streams, cb); }; // query function query(querymode, sql, args, req, streams, cb, type) { var resp = (streams) ? ((_.isArray(streams)) ? _.last(streams) : streams) : null; var user = this.user, env = this.env, reqQueue = this.reqQueue, pool = this.pool, options = this.options, me = this; initRequest(this, req, resp, function(err, id) { if (err) return cb(err); var doPrep = _.isArray(args) && args.length; var start, // when query was ready to start executing, but it might wait for free connection resource execStart // real execution started ; cb = cb ? cb : (_.isFunction(args) ? args : function(){ log('warn', 'no callback:', sql) }); if (querymode === QUERYMODE.STREAM) { if (!streams || !_.isArray(streams) || streams.length < 1) return cb('streams[] is missing or empty'); type = type || 'a'; // arraystream by default type = _.contains(['a', 'array'], type) ? 'createArrayStream' : 'createObjectStream'; } function ex(o, sqlOrParams, exCb) { if (querymode === QUERYMODE.STREAM || querymode === QUERYMODE.META) executeQuery.call(me, querymode, o, sqlOrParams, id, req, resp, streams, type, exCb); else execQuery.call(me, o, sqlOrParams, id, req, resp, exCb); } start = +new Date(); pool.acquire(function(err, client) { if (err) { // error from factory.create log('info', user + ' ' + id + ' ' + env + ' - error while creating hdb connection', err.message || err); return cb(err); } // handle the case when client drops a request which is in the waiting queue if (!reqQueue[id]) { log('info', user + ' ' + id + ' ' + env + ' - request is already closed, no resource acquired') pool.release(client); log('trace', '# of free resources of ' + user + ' on ' + env + ': ' + pool.availableObjectsCount()); return cb(null); // error should not be sent back to the client since connection is already dropped } var closeCb; // handle the case when client drops a request which is already acquired resource if (req) { closeCb = function() { log('trace', user + ' ' + id + ' ' + env + ' - request closed by the client, resource released') pool.release(client); log('trace', '# of free resources of ' + user + ' on ' + env + ': ' + pool.availableObjectsCount()); return cb(null); // error should not be sent back to the client since connection is already dropped } req.on('close', closeCb); } log('trace', 'resource acquired for ' + user + ' ' + id + ' ' + env); log('trace', '# of waiting requests of ' + user + ' on ' + env + ': ' + pool.waitingClientsCount()); log('trace', '# of free resources of ' + user + ' on ' + env + ': ' + pool.availableObjectsCount()); execStart = +new Date(); // save sessionCookie into express-session storeIntoSession(req, env, options.user, options.sessionCookie); //console.log(sql); //console.log(args); //console.log('---------'); var exCb = function(err, rows) { if (err) log('error', user + ' ' + id + ' ' + env + ' - Error while execution', err); else log('trace', user + ' ' + id + ' ' + env + ' - request served'); pool.release(client); log('trace', '# of free resources of ' + user + ' on ' + env + ': ' + pool.availableObjectsCount()); // resource is already released, so we can de-register the close-listener. This is particularly crucial in case of socket.io connection. if (closeCb) req.removeListener('close', closeCb); return cb(err, rows); }; //setTimeout(function() { if (doPrep) { client.prepare(sql, function(err, s) { if (err) { pool.release(client); log('error', user + ' ' + id + ' ' + env + ' - Error while preparing statement', err); log('trace', '# of free resources of ' + user + ' on ' + env + ': ' + pool.availableObjectsCount()); return cb(err); } ex(s, args, exCb); }); } else { ex(client, sql, exCb); } //}, 20000); }); }); } function execQuery(statement, sqlOrParams, id, req, resp, exCb) { var user = this.user, env = this.env, options = this.options, start = this.start, execStart = this.execStart; log('trace', user + ' ' + id + ' ' + env + '- start exec...'); statement.exec(sqlOrParams, function(err, rows, dummyRows, tableRows) { if (err) { if (err) return exCb(err); } setupRespHeader(options, start, execStart, resp); var exception = null; if (dummyRows && dummyRows[0].errmsg) { try { exception = JSON.parse(dummyRows[0].errmsg); } catch (e) { exception = dummyRows; } if (exception.status === 'success') { rows = exception; exception = null; } } if (!exception && resp) { if (rows) resp.send(JSON.stringify(rows)); else resp.send(''); } return exCb(exception, rows); }); } function executeQuery(querymode, statement, sqlOrParams, id, req, resp, streams, type, exCb) { var user = this.user, env = this.env, options = this.options, start = this.start, execStart = this.execStart; log('trace', user + ' ' + id + ' ' + env + ' - start executing...'); statement.execute(sqlOrParams, function(err, rs) { if (err) return exCb(err); setupRespHeader(options, start, execStart, resp); if (querymode == QUERYMODE.META) { var metadata = rs.metadata; rs.close(); if (resp) { resp.send(JSON.stringify(metadata)) } return exCb(null, metadata); } //log('trace', user + ' ' + id + ' ' + env + ' - start streaming...'); var stream = rs[type](); function onend() { //log('trace', user + ' ' + id + ' ' + env + ' - on end'); if (!rs.closed) rs.close(); } stream.on('error', exCb); stream.on('end', onend); stream.on('finish', function() { //log('trace', user + ' ' + id + ' ' + env + ' - on FINISH'); }); var hasLobColumn = _.find(rs.metadata, function(column) { return _.contains(lobTypes, column.dataType); }); if (hasLobColumn) { // insert a transformLobs stream before any other streams streams.unshift(transformLobs()); // make sure that resultSet is not closed to early, so close it only when transformLobs has finished // otherwise if the content is more than 201 KB, resultSet is closed to early stream.removeListener('end', onend); streams[0].on('end', onend); } var s = stream; // handle the case when client drops the connection - make sure to close the resultset if (req) req.on('close', onend); _.each(streams, function(str, i) { if (_.isFunction(str)) { str = str.call(null, rs.metadata); } s = s.pipe(str); s.on('error', exCb); if (i === streams.length-1) { // call only on the last stream (supposed to be the response stream) s.on('finish', exCb); } }); }); } function setupRespHeader(options, start, execStart, resp) { if (resp) { var headerPrefix = options.resphPrefix || ''; resp.setHeader(headerPrefix + 'query-time', +new Date()-execStart); resp.setHeader(headerPrefix + 'waiting-time', execStart-start); } } function createCsvStringifier(metadata) { return createDsvStringifier(metadata, ','); } function createSsvStringifier(metadata) { return createDsvStringifier(metadata, ';'); } function createDsvStringifier(metadata, sep) { if (!sep) sep = DSV_SEPARATOR; //metadata.forEach(logger.warn); var header = metadata.map(function getName(column) { return column.columnDisplayName; }).join(sep) + DSV_DELIMITER; var functionBody = metadata.reduce(function addLine(body, column) { column.dataTypeName = hdbTypeCodes[column.dataType+'']; var val = 'ifnull(row[\''+ ((column.columnDisplayName === "''") ? 'UnknownColumn' : column.columnDisplayName )+'\'])'; if (DSV_QUOTE[column.dataTypeName]) { val = 'dsvquote('+val+', \''+sep+'\')'; } if (DSV_FORMAT[column.dataTypeName]) { val = 'DSV_FORMAT.'+column.dataTypeName+'('+val+')'; } body += 'line.push(' + val + ')'; if (column.dataType === 13) { body += '.toString(\'hex\')'; } body += '\n'; return body; }, 'functionBody = function(row) { var line = [];\n') + 'return line.join("' + sep + '"); }'; //log('debug', functionBody); functionBody = eval(functionBody); return new hdb.Stringifier({ header: header, footer: '', seperator: DSV_DELIMITER, stringify: functionBody }); } function createJSONStringifier() { return new hdb.Stringifier({ header: '[', footer: ']', seperator: ',', stringify: JSON.stringify }); } function transformLobs() { return new transformLobs(); } // generate request id, process environment, and setup event handlers on request and setTimeout on response function initRequest(pool, req, res, cb) { log('trace', 'init request'); var env = pool.env, reqQueue = pool.reqQueue, user = pool.user, options = pool.options; // generate uuid for the request if it does not have any yet var id = (req && req.hdbPoolRequestId) ? req.hdbPoolRequestId : uuid.v1(); // reuse ID if hdbPool is called several times within one HTTP request log('trace', user + ' ' + id + ' ' + env + ' - new hdbPool call'); // overwrite the default 120 sec response timeout // see: http://www.fingersdancing.net/2012/12/nodejs-connection-times-out-after-2.html if (res) res.setTimeout(0); // save sessionCookie into express-session // if pool is already initialized, options.sessionCookie is already defined so it can be stored // otherwise options.sessionCookie will be available only in the acquire() method-callback where storeIntoSession() is called again // the purpose of this call is to store the HANA sessionCookie as early as possible in order to ensure the smooth restart of the cube-server storeIntoSession(req, env, options.user, options.sessionCookie); reqQueue[id] = {open: true}; if (req && !req.hdbPoolRequestId) { // in case of new HTTP or new socket.io request req.hdbPoolRequestId = id; // store the id in the request so we can distinguish new and reused requests // these listeners are registered only once per request req.on('close', function() { log('warn', user + ' ' + id + ' ' + env + ' - request closed by the client') delete reqQueue[id]; }); req.on('error', function(err) { log('error', user + ' ' + id + ' ' + env + ' - request error', err) delete reqQueue[id]; }); req.on('end', function() { delete reqQueue[id]; log('trace', user + ' ' + id + ' ' + env + ' - request end') }); } return cb(null, id); } // store username and HANA session cookie in the express session function storeIntoSession(req, env, user, sessionCookie) { if (req && env && user && sessionCookie && req && req.session && req.session.passport && req.session.passport.user) { var passportUserEnv = req.session.passport.user[env] = {}; passportUserEnv.HANAUser = user; passportUserEnv.HANAsessionCookie = sessionCookie; if (req.session) { req.session.save(); // persist changes log('trace', 'updated session'); //log('debug', req.session); } } } function setDefaultSchema(options, hdbclient, user, env, callback) { // setup default schema if any if (!options.defaultSchema) return callback(null, hdbclient); hdbclient.exec('SET SCHEMA ' + options.defaultSchema, function (err) { if (err) { return callback(err, hdbclient); } log('trace', env + ':' + user + ' default schema: ' + options.defaultSchema); return callback(null, hdbclient); }); } function dsvquote(v, sep) { // RFC 4180 pharagraph 6: Fields containing line breaks (CRLF), double quotes, and commas should be enclosed in double-quotes. if (v && (v.indexOf(sep) >= 0 || v.indexOf('"') >= 0 || v.match(/\r?\n|\r/))) { // RFC 4180 pharagraph 7: If double-quotes are used to enclose fields, then a double-quote appearing inside a field must be escaped by preceding it with another double quote. return ('"' + v.replace(ESCAPE_REGEXP, '""') + '"'); } else return v; } function ifnull(v) { return v === null ? '\\N' : v; } exports.createPool = function createPool(options, req) { if (options.logger) { logger = options.logger; } // sanity check if (!options.host) { log('error', 'host needs to be specified'); return; } if (!options.port) { log('error', 'port needs to be specified'); return; } return new HDBPool(options, req); }; exports.createCsvStringifier = createCsvStringifier; exports.createSsvStringifier = createSsvStringifier; exports.createJSONStringifier = createJSONStringifier; exports.log = log; exports.setPid = function(pid) { require('hdb/lib/util').pid = pid; };