UNPKG

hashring

Version:

A consistent hashring compatible with ketama hashing and python's hash_ring

555 lines (467 loc) 14.2 kB
'use strict'; var SimpleCache = require("simple-lru-cache") , parse = require('connection-parse') , crypto = require('crypto'); /** * Generate the hash of the value. * * @api private */ function hashValueHash(a, b, c, d) { return ((a << 24) | (b << 16) | (c << 8) | d) >>> 0; } /** * Add a virtual node parser to the connection string parser. * * @param {Object} data server data * @param {Mixed} value optional value * @api private */ parse.extension('vnodes', function vnode(data, value) { if (typeof value === 'object' && !Array.isArray(value) && 'vnodes' in value) { data.vnodes = +value.vnodes || 0; } else { data.vnodes = 0; } }); /** * HashRing implements consistent hashing so adding or removing servers of one * slot does not significantly change the mapping of the key to slots. The * consistent hashing algorithm is based on ketama or libketama. * * @constructor * @param {Mixed} server Servers that need to be added to the ring. * @param {Mixed} algorithm Either a Crypto compatible algorithm or custom hasher. * @param {Object} options Optional configuration and options for the ring. * @api public */ function HashRing(servers, algorithm, options) { options = options || {}; // // These properties can be configured // this.vnode = 'vnode count' in options ? options['vnode count'] : 40; this.algorithm = algorithm || 'md5'; // // if the default port is set, and a host uses it, then it is excluded from // the hash. // this.defaultport = options['default port'] || null; // // There's a slight difference between libketama and python's hash_ring // module, libketama creates 160 points per server: // // 40 hashes (vnodes) and 4 replicas per hash = 160 points per server. // // The hash_ring module only uses 120 points per server: // // 40 hashes (vnodes) and 3 replicas per hash = 120 points per server. // // And that's the only difference between the original ketama hash and the // hash_ring package. Small, but important. // this.replicas = options.compatibility ? (options.compatibility === 'hash_ring' ? 3 : 4) : ('replicas' in options ? +options.replicas : 4); // // Replica's cannot be 0 as it means we have nothing to iterate over when // creating the initial hash ring. // if (this.replicas <= 0) this.replicas = 1; // Private properties. var connections = parse(servers); this.ring = []; this.size = 0; this.vnodes = connections.vnodes; this.servers = connections.servers; // Set up a ache as we don't want to preform a hashing operation every single // time we lookup a key. this.cache = new SimpleCache({ maxSize: 'max cache size' in options ? options['max cache size'] : 5000 }); // Override the hashing function if people want to use a hashing algorithm // that is not supported by Node, for example if you want to MurMur hashing or // something else exotic. if ('function' === typeof this.algorithm) { this.hash = this.algorithm; } // Generate the continuum of the HashRing. this.continuum(); } /** * Generates the continuum of server a.k.a. the Hash Ring based on their weights * and virtual nodes assigned. * * @returns {HashRing} * @api private */ HashRing.prototype.continuum = function generate() { var servers = this.servers , self = this , index = 0 , total = 0; // No servers, bailout. if (!servers.length) return this; // Generate the total weight of all the servers. total = servers.reduce(function reduce(total, server) { return total + server.weight; }, 0); servers.forEach(function each(server) { var percentage = server.weight / total , vnodes = self.vnodes[server.string] || self.vnode , length = Math.floor(percentage * vnodes * servers.length) , key , x; // If you supply us with a custom vnode size, we will use that instead of // our computed distribution. if (vnodes !== self.vnode) length = vnodes; for (var i = 0; i < length; i++) { if (self.defaultport && server.port === self.defaultport) { x = self.digest(server.host +'-'+ i); } else { x = self.digest(server.string +'-'+ i); } for (var j = 0; j < self.replicas; j++) { key = hashValueHash(x[3 + j * 4], x[2 + j * 4], x[1 + j * 4], x[j * 4]); self.ring[index] = new Node(key, server.string); index++; } } }); // Sort the keys using the continuum points compare that is used in ketama // hashing. this.ring = this.ring.sort(function sorted(a, b) { if (a.value === b.value) return 0; else if (a.value > b.value) return 1; return -1; }); this.size = this.ring.length; return this; }; /** * Find the correct node for the key which is closest to the point after what * the given key hashes to. * * @param {String} key Key who's server we need to figure out. * @returns {String} Server address. * @api public */ HashRing.prototype.get = function get(key) { var cache = this.cache.get(key); if (cache) return cache; var node = this.ring[this.find(this.hashValue(key))]; if (!node) return undefined; this.cache.set(key, node.server); return node.server; }; /** * Returns the position of the hashValue in the hashring. * * @param {Number} hashValue Find the nearest server close to this hash. * @returns {Number} Position of the server in the hash ring. * @api public */ HashRing.prototype.find = function find(hashValue) { var ring = this.ring , high = this.size , low = 0 , middle , prev , mid; // Preform a search on the array to find the server with the next biggest // point after what the given key hashes to. while (true) { mid = (low + high) >> 1; if (mid === this.size) return 0; middle = ring[mid].value; prev = mid === 0 ? 0 : ring[mid - 1].value; if (hashValue <= middle && hashValue > prev) return mid; if (middle < hashValue) { low = mid + 1; } else { high = mid - 1; } if (low > high) return 0; } }; /** * Generates a hash of the string. * * @param {String} key * @returns {String|Buffer} Hash, depends on node version. * @api private */ HashRing.prototype.hash = function hash(key) { return crypto.createHash(this.algorithm).update(key).digest(); }; /** * Digest hash so we can make a numeric representation from the hash. * * @param {String} key The key that needs to be hashed. * @returns {Array} * @api private */ HashRing.prototype.digest = function digest(key) { var hash = this.hash(key +''); // Support for Node 0.10 which returns buffers so we don't need to charAt // lookups. if ('string' !== typeof hash) return hash; return hash.split('').map(function charCode(char) { return char.charCodeAt(0); }); }; /** * Get the hashed value for the given key. * * @param {String} key * @returns {Number} * @api private */ HashRing.prototype.hashValue = function hasher(key) { var x = this.digest(key); return hashValueHash(x[3], x[2], x[1], x[0]); }; /** * None ketama: * * The following changes are not ported from the ketama algorithm and are hash * ring specific. Add, remove or replace servers with as less disruption as * possible. */ /** * Get a range of different servers. * * @param {String} key * @param {Number} size Amount of servers it should return. * @param {Boolean} unique Return only unique keys. * @return {Array} * @api public */ HashRing.prototype.range = function range(key, size, unique) { if (!this.size) return []; size = size || this.servers.length; unique = unique || 'undefined' === typeof unique; var position = this.find(this.hashValue(key)) , length = this.ring.length , servers = [] , node; // Start searching for servers from the position of the key to the end of // HashRing. for (var i = position; i < length; i++) { node = this.ring[i]; // Do we need to make sure that we retrieve a unique list of servers? if (unique) { if (!~servers.indexOf(node.server)) servers.push(node.server); } else { servers.push(node.server); } if (servers.length === size) return servers; } // Not enough results yet, so iterate from the start of the hash ring to the // position of the hash ring. So we reach full circle again. for (i = 0; i < position; i++) { node = this.ring[i]; // Do we need to make sure that we retrieve a unique list of servers? if (unique) { if (!~servers.indexOf(node.server)) servers.push(node.server); } else { servers.push(node.server); } if (servers.length === size) return servers; } return servers; }; /** * Returns the points per server. * * @param {String} server Optional server to filter down. * @returns {Object} server -> Array(points). * @api public */ HashRing.prototype.points = function points(servers) { servers = Array.isArray(servers) ? servers : Object.keys(this.vnodes); var nodes = Object.create(null) , node; servers.forEach(function servers(server) { nodes[server] = []; }); for (var i = 0; i < this.size; i++) { node = this.ring[i]; if (node.server in nodes) { nodes[node.server].push(node.value); } } return nodes; }; /** * Hotswap identical servers with each other. This doesn't require the cache to * be completely nuked and the hash ring distribution to be re-calculated. * * Please note that removing the server and a new adding server could * potentially create a different distribution. * * @param {String} from The server that needs to be replaced. * @param {String} to The server that replaces the server. * @returns {HashRing} * @api public */ HashRing.prototype.swap = function swap(from, to) { var connection = parse(to).servers.pop() , self = this; this.ring.forEach(function forEach(node) { if (node.server === from) node.server = to; }); this.cache.forEach(function forEach(value, key) { if (value === from) self.cache.set(key, to); }, this); // Update the virtual nodes this.vnodes[to] = this.vnodes[from]; delete this.vnodes[from]; // Update the servers this.servers = this.servers.map(function mapswap(server) { if (server.string === from) { server.string = to; server.host = connection.host; server.port = connection.port; } return server; }); return this; }; /** * Add a new server to ring without having to re-initialize the hashring. It * accepts the same arguments as you can use in the constructor. * * @param {Mixed} servers Servers that need to be added to the ring. * @returns {HashRing} * @api public */ HashRing.prototype.add = function add(servers) { var connections = Object.create(null); // Add the current servers to the set. this.servers.forEach(function forEach(server) { connections[server.string] = server; }); parse(servers).servers.forEach(function forEach(server) { // Don't add duplicate servers if (server.string in connections) return; connections[server.string] = server; }); // Now that we generated a complete set of servers, we can update the re-parse // the set and correctly added all the servers again. connections = parse(connections); this.vnodes = connections.vnodes; this.servers = connections.servers; // Rebuild the hash ring. this.reset(); return this.continuum(); }; /** * Remove a server from the hashring. * * @param {Mixed} server The sever we want to remove. * @returns {HashRing} * @api public */ HashRing.prototype.remove = function remove(server) { var connection = parse(server).servers.pop(); delete this.vnodes[connection.string]; this.servers = this.servers.map(function map(server) { if (server.string === connection.string) return undefined; return server; }).filter(Boolean); // Rebuild the hash ring this.reset(); return this.continuum(); }; /** * Checks if a given server exists in the hash ring. * * @param {String} server Server for whose existence we're checking * @returns {Boolean} Indication if we have that server. * @api public */ HashRing.prototype.has = function add(server) { for (var i = 0; i < this.ring.length; i++) { if (this.ring[i].server === server) return true; } return false; }; /** * Reset the HashRing to clean up all references * * @returns {HashRing} * @api public */ HashRing.prototype.reset = function reset() { this.ring.length = 0; this.size = 0; this.cache.reset(); return this; }; /** * End the hashring and clean up all of it's references. * * @returns {HashRing} * @api public */ HashRing.prototype.end = function end() { this.reset(); this.vnodes = {}; this.servers.length = 0; return this; }; /** * A single Node in our hash ring. * * @constructor * @param {Number} hashvalue * @param {String} server * @api private */ function Node(hashvalue, server) { this.value = hashvalue; this.server = server; } // // Set up the legacy API aliases. These will be deprecated in the next release. // [ { from: 'replaceServer' }, { from: 'replace' }, { from: 'removeServer', to: 'remove' }, { from: 'addServer', to: 'add' }, { from: 'getNode', to: 'get' }, { from: 'getNodePosition', to: 'find' }, { from: 'position', to: 'find' } ].forEach(function depricate(api) { var notified = false; HashRing.prototype[api.from] = function depricating() { if (!notified) { console.warn(); console.warn('[depricated] HashRing#'+ api.from +' is removed.'); // Not every API has a replacement API that should be used if (api.to) { console.warn('[depricated] use HashRing#'+ api.to +' as replacement.'); } else { console.warn('[depricated] the API has no replacement'); } console.warn(); notified = true; } if (api.to) return HashRing.prototype[api.to].apply(this, arguments); }; }); /** * Expose the current version number. * * @type {String} * @public */ HashRing.version = require('./package.json').version; /** * Expose the module. * * @api public */ module.exports = HashRing;