UNPKG

memcache-plus

Version:
647 lines (557 loc) 19.1 kB
/** * @file Main file for the Memcache Client */ var debug = require('debug')('memcache-plus:client'); var _ = require('lodash'), HashRing = require('hashring'), misc = require('./misc'), Immutable = require('immutable'), Promise = require('bluebird'), R = require('ramda'); var Connection = require('./connection'); var ConnectionPool = require('./connection-pool'); function validateKey(key, operation) { misc.assert(key, 'Cannot "' + operation + '" without key!'); misc.assert(typeof key === 'string', 'Key needs to be of type "string"'); misc.assert(Buffer.byteLength(key) < 250, 'Key must be less than 250 bytes long'); misc.assert(key.length < 250, 'Key must be less than 250 bytes long'); misc.assert(!/[\x00-\x20]/.test(key), 'Key must not include control characters or whitespace'); } /** * Constructor - Initiate client */ function Client(opts) { if (!(this instanceof Client)) { return new Client(opts); } var options = {}; // If single connection provided, array-ify it if (typeof opts === 'string') { options.hosts = [opts]; opts = options; } else if (typeof opts === 'undefined') { opts = {}; } else if (_.isArray(opts)) { options.hosts = opts; opts = options; } _.defaults(opts, { autodiscover: false, bufferBeforeError: 1000, disabled: false, hosts: null, reconnect: true, onNetError: function onNetError(err) { console.error(err); }, queue: true, netTimeout: 500, backoffLimit: 10000, maxValueSize: 1048576, poolSize: 1, tls: null }); // Iterate over options, assign each to this object R.keys(opts).forEach(function(key) { this[key] = opts[key]; }, this); if (this.queue) { this.buffer = new Immutable.List(); } debug('Connect options', opts); this.connect(); } /** * connect() - Iterate over all hosts, connect to each. * * @api private */ Client.prototype.connect = function() { debug('starting connection'); this.connections = {}; if (this.hosts === null) { this.hosts = ['localhost:11211']; } if (this.autodiscover) { // First connect to the servers provided this.getHostList() .bind(this) .then(function() { debug('got host list, connecting to hosts'); // Connect to these hosts this.connectToHosts(); this.ring = new HashRing(_.keys(this.connections)); }); // Then get the list of servers // Then connect to those } else { this.connectToHosts(); } }; /** * disconnect() - Iterate over all hosts, disconnect from each. * * @api private */ Client.prototype.disconnect = function(opts) { debug('starting disconnection'); var connections; if (typeof opts === 'string') { // If single connection provided, array-ify it connections = [opts]; } else if (typeof opts === 'undefined') { // No connections specified so client wants to disconnect from all connections = R.keys(this.connections); } else if (_.isArray(opts)) { connections = opts; } if (connections.length === R.keys(this.connections).length) { // Fair to assume if client is requesting a full disconnect, they don't // want it to just reconnect this.reconnect = false; } connections.map(function(ckey) { debug('disconnecting from %s', ckey); // Check that host exists before disconnecting from it if (this.connections[ckey] === undefined) { debug('failure trying to disconnect from server [%s] because not connected', ckey); throw new Error('Cannot disconnect from server unless connected'); } this.connections[ckey].disconnect(); // Remove this connection delete this.connections[ckey]; // Remove this host from the list of hosts this.hosts = this.hosts.filter(function(host) { return host !== ckey; }); }.bind(this)); return Promise.resolve(null); }; /** * getHostList() - Given a list of hosts, contact them via Elasticache * autodiscover and retrieve the list of hosts * * @api private */ Client.prototype.getHostList = function() { var client = this; var connections = {}; // Promise.any because we don't care which completes first, as soon as we get // a list of hosts we can stop return Promise.any(this.hosts.map(function(host) { var h = this.splitHost(host); var deferred = misc.defer(); const ConnClass = this.poolSize > 1 ? ConnectionPool : Connection; connections[host] = new ConnClass({ host: h.host, port: h.port, netTimeout: this.netTimeout, reconnect: false, tls: this.tls, poolSize: this.poolSize, onConnect: function() { // Do the autodiscovery, then resolve with hosts return deferred.resolve(this.autodiscovery()); }, onError: function (err) { client.onNetError(err); deferred.reject(err); } }); return deferred.promise; }, this)).bind(this).then( function(hosts) { this.hosts = hosts; this.connectToHosts(); this.flushBuffer(); }, function (err) { var wrappedError = new Error('Autodiscovery failed. Errors were:\n' + err.join('\n---\n')); this.flushBuffer(wrappedError); } ); }; /** * connectToHosts() - Given a list of hosts, actually connect to them * * @api private */ Client.prototype.connectToHosts = function() { debug('connecting to all hosts'); this.hosts.forEach(function(host) { var h = this.splitHost(host); var client = this; // Connect to host const ConnClass = this.poolSize > 1 ? ConnectionPool : Connection; this.connections[host] = new ConnClass({ host: h.host, port: h.port, reconnect: this.reconnect, onConnect: function() { client.flushBuffer(); }, bufferBeforeError: this.bufferBeforeError, netTimeout: this.netTimeout, onError: this.onNetError, maxValueSize: this.maxValueSize, tls: this.tls, poolSize: this.poolSize, }); }, this); this.ring = new HashRing(_.keys(this.connections)); }; /** * flushBuffer() - Flush the current buffer of commands, if any * * @api private */ Client.prototype.flushBuffer = function(err) { this.bufferedError = err; if (this.buffer && this.buffer.size > 0) { debug('flushing client write buffer'); // @todo Watch out for and handle how this behaves with a very long buffer while(this.buffer.size > 0) { var item = this.buffer.first(); this.buffer = this.buffer.shift(); // Something bad happened before things got a chonce to run. We // need to cancel all pending operations. if (err) { item.deferred.reject(err); continue; } // First, retrieve the correct connection out of the hashring var connection = this.connections[this.ring.get(item.key)]; var promise = connection[item.cmd].apply(connection, item.args); promise.then(item.deferred.resolve, item.deferred.reject); } } }; /** * splitHost() - Helper to split a host string into port and host * * @api private */ Client.prototype.splitHost = function(str) { var host = str.split(':'); if (host.length === 1 && host.indexOf(':') === -1) { host.push('11211'); } else if (host[0].length === 0) { host[0] = 'localhost'; } return { host: host[0], port: host[1] }; }; /** * ready() - Predicate function, returns true if Client is ready, false otherwise. * Client is ready when all of its connections are open and ready. If autodiscovery * is enabled, Client is ready once it has contacted Elasticache and then initialized * all of the connections */ Client.prototype.ready = function() { var size = _.size(this.connections); if (size < 1) { return false; } else { return _.reduce(this.connections, function(ready, conn) { ready = ready && conn.ready; return ready; }, true); } }; /** * delete() - Delete an item from the cache * * @param {String} key - The key of the item to delete * @param {Function} [cb] - Callback to call when we have a value * @returns {Promise} */ Client.prototype.delete = function(key, cb) { validateKey(key, 'delete'); return this.run('delete', [key], cb); }; /** * deleteMulti() - Delete multiple items from the cache * * @param {Array} keys - The keys of the items to delete * @param {Function} [cb] - Callback to call when we have a value * @returns {Promise} */ Client.prototype.deleteMulti = function(keys, cb) { var self = this; misc.assert(keys, 'Cannot delete without keys!'); return Promise.props(R.reduce(function(acc, key) { validateKey(key, 'deleteMulti'); acc[key] = self.run('delete', [key], null); return acc; }, {}, keys)).nodeify(cb); }; /** * set() - Set a value for the provided key * * @param {String} key - The key to set * @param {*} value - The value to set for this key. Can be of any type * @param {Number|Object|Function} [ttl = 0] - The time to live for this key or callback * @param {Function} [cb] - Callback to call when we have a value * @returns {Promise} */ Client.prototype.set = function(key, val, ttl, cb) { validateKey(key, 'set'); if (typeof ttl === 'function') { cb = ttl; ttl = 0; } return this.run('set', [key, val, ttl], cb); }; /** * cas() - Set a value for the provided key if the CAS value matches * * @param {String} key - The key to set * @param {*} value - The value to set for this key. Can be of any type * @param {String} cas - A CAS value returned from a 'gets' call * @param {Number|Object|Function} [ttl = 0] - The time to live for this key or callback * @param {Function} [cb] - Callback to call when we have a value * @returns {Promise} with a boolean value indicating if the value was stored (true) or not (false) */ Client.prototype.cas = function(key, val, cas, ttl, cb) { validateKey(key, 'cas'); if (typeof ttl === 'function') { cb = ttl; ttl = 0; } return this.run('cas', [key, val, cas, ttl], cb); }; /** * gets() - Get the value and CAS id for the provided key * * @param {String} key - The key to get * @param {Object} opts - Any options for this request * @param {Function} [cb] - The (optional) callback called on completion * @returns {Promise} which is an array containing the value and CAS id */ Client.prototype.gets = function(key, opts, cb) { validateKey(key, 'gets'); if (typeof opts === 'function' && typeof cb === 'undefined') { cb = opts; opts = {}; } return this.run('gets', [key, opts], cb); }; /** * get() - Get the value for the provided key * * @param {String} key - The key to get * @param {Object} opts - Any options for this request * @param {Function} [cb] - The (optional) callback called on completion * @returns {Promise} */ Client.prototype.get = function(key, opts, cb) { if (typeof opts === 'function' && typeof cb === 'undefined') { cb = opts; opts = {}; } if (_.isArray(key)) { return this.getMulti(key, opts, cb); } else { validateKey(key, 'get'); return this.run('get', [key, opts], cb); } }; /** * getMulti() - Get multiple values for the provided array of keys * * @param {Array} keys - The keys to get * @param {Function} [cb] - The value to set for this key. Can be of any type * @returns {Promise} */ Client.prototype.getMulti = function(keys, opts, cb) { var self = this; misc.assert(keys, 'Cannot get without key!'); if (typeof opts === 'function' && typeof cb === 'undefined') { cb = opts; opts = {}; } return Promise.props(R.reduce(function(acc, key) { validateKey(key, 'getMulti'); acc[key] = self.run('get', [key, opts], null); return acc; }, {}, keys)).nodeify(cb); }; /** * incr() - Increment a value for the provided key * * @param {String} key - The key to incr * @param {Number|Function} [value = 1] - The value to increment this key by. Must be an integer * @param {Function} [cb] - Callback to call when we have a value * @returns {Promise} */ Client.prototype.incr = function(key, val, cb) { validateKey(key, 'incr'); if (typeof val === 'function' || typeof val === 'undefined') { cb = val; val = 1; } misc.assert(typeof val === 'number', 'Cannot incr in memcache with a non number value'); return this.run('incr', [key, val], cb); }; /** * decr() - Decrement a value for the provided key * * @param {String} key - The key to decr * @param {Number|Function} [value = 1] - The value to decrement this key by. Must be an integer * @param {Function} [cb] - Callback to call when we have a value * @returns {Promise} */ Client.prototype.decr = function(key, val, cb) { validateKey(key, 'decr'); if (typeof val === 'function' || typeof val === 'undefined') { cb = val; val = 1; } misc.assert(typeof val === 'number', 'Cannot decr in memcache with a non number value'); return this.run('decr', [key, val], cb); }; /** * flush() - Removes all stored values * @param {Number|Function} [delay = 0] - Delay invalidation by specified seconds * @param {Function} [cb] - The (optional) callback called on completion * @returns {Promise} */ Client.prototype.flush = function (delay, cb) { if (typeof delay === 'function' || typeof delay === 'undefined') { cb = delay; delay = 0; } return this.run('flush_all', [delay], cb); }; /** * items() - Gets items statistics * @param {Function} [cb] - The (optional) callback called on completion * @returns {Promise} */ Client.prototype.items = function(cb) { return this.run('stats items', [], cb); }; /** * add() - Add value for the provided key only if it didn't already exist * * @param {String} key - The key to set * @param {*} value - The value to set for this key. Can be of any type * @param {Number|Object|Function} [ttl = 0] - The time to live for this key or callback * @param {Function} [cb] - Callback to call when we have a value * @returns {Promise} */ Client.prototype.add = function(key, val, ttl, cb) { validateKey(key, 'add'); if (typeof ttl === 'function') { cb = ttl; ttl = 0; } return this.run('add', [key, val, ttl], cb); }; /** * replace() - Replace value for the provided key only if it already exists * * @param {String} key - The key to replace * @param {*} value - The value to replace for this key. Can be of any type * @param {Number|Object|Function} [ttl = 0] - The time to live for this key or callback * @param {Function} [cb] - Callback to call when we have a value * @returns {Promise} */ Client.prototype.replace = function(key, val, ttl, cb) { validateKey(key, 'replace'); if (typeof ttl === 'function') { cb = ttl; ttl = 0; } return this.run('replace', [key, val, ttl], cb); }; /** * append() - Append value for the provided key only if it already exists * * @param {String} key - The key to append * @param {*} value - The value to append for this key. Can be of any type * @param {Number|Object|Function} [ttl = 0] - The time to live for this key or callback * @param {Function} [cb] - Callback to call when we have a value * @returns {Promise} */ Client.prototype.append = function(key, val, ttl, cb) { validateKey(key, 'append'); if (typeof ttl === 'function') { cb = ttl; ttl = 0; } return this.run('append', [key, val, ttl], cb); }; /** * prepend() - Prepend value for the provided key only if it already exists * * @param {String} key - The key to prepend * @param {*} value - The value to prepend for this key. Can be of any type * @param {Number|Object|Function} [ttl = 0] - The time to live for this key or callback * @param {Function} [cb] - Callback to call when we have a value * @returns {Promise} */ Client.prototype.prepend = function(key, val, ttl, cb) { validateKey(key, 'prepend'); if (typeof ttl === 'function') { cb = ttl; ttl = 0; } return this.run('prepend', [key, val, ttl], cb); }; /** * cachedump() - get cache information for a given slabs id * @param {number} slabsId * @param {number} [limit] Limit result to number of entries. Default is 0 (unlimited). * @param {Function} [cb] - The (optional) callback called on completion * @returns {Promise} */ Client.prototype.cachedump = function(slabsId, limit, cb) { misc.assert(slabsId, 'Cannot cachedump without slabId!'); if (typeof limit === 'function' || typeof limit === 'undefined') { cb = limit; limit = 0; } return this.run('stats cachedump', [slabsId, limit], cb); }; /** * version() - Get current Memcached version from the server * @param {Function} [cb] - The (optional) callback called on completion * @returns {Promise} */ Client.prototype.version = function(cb) { return this.run('version', [], cb); }; /** * run() - Run this command on the appropriate connection. Will buffer command * if connection(s) are not ready * * @param {String} command - The command to run * @param {Array} args - The arguments to send with this command * @returns {Promise} */ Client.prototype.run = function(command, args, cb) { if (this.disabled) { return Promise.resolve(null).nodeify(cb); } if (this.ready()) { // First, retrieve the correct connection out of the hashring var connection = this.connections[this.ring.get(args[0])]; // Run this command return connection[command].apply(connection, args).nodeify(cb); } else if (this.bufferBeforeError === 0 || !this.queue) { return Promise.reject(new Error('Connection is not ready, either not connected yet or disconnected')).nodeify(cb); } else if (this.bufferedError) { return Promise.reject(this.bufferedError).nodeify(cb); } else { var deferred = misc.defer(args[0]); this.buffer = this.buffer.push({ cmd: command, args: args, key: args[0], deferred: deferred }); return deferred.promise.nodeify(cb); } }; module.exports = Client;