UNPKG

volatile-redis-cluster

Version:
189 lines (176 loc) 5.78 kB
// Copyright 2016 Zipscene, LLC // Licensed under the Apache License, Version 2.0 // http://www.apache.org/licenses/LICENSE-2.0 var seedRandom = require('seed-random'); var crc32 = require('buffer-crc32'); function ConsistentHash(options) { // Map from node names to node states: { up: true, stateChangeTime: Date, name: name } this.nodes = {}; // Sorted list of buckets: { value: ..., name: nodename } this.buckets = []; if(!options) options = {}; this.bucketsPerNode = options.bucketsPerNode || 16; this.downNodeExpiry = (options.downNodeExpiry || 0) * 1000; this.pruneInterval = (options.pruneInterval || 30) * 1000; this.lastPruneTime = new Date().getTime(); this.numUpNodes = 0; this.nodeNumMap = []; // map from node numbers to node names } ConsistentHash.prototype.clear = function() { this.nodes = {}; this.buckets = []; this.nodeNumMap = []; this.lastPruneTime = new Date().getTime(); this.numUpNodes = 0; }; ConsistentHash.prototype._updateNodeNumMap = function() { var self = this; this.nodeNumMap = Object.keys(self.nodes).sort().filter(function (key) { return !!self.nodes[key].up; }); }; ConsistentHash.prototype.addNode = function(name, weight, stateChangeTime, data) { var curTime = new Date().getTime(); if(!stateChangeTime) stateChangeTime = curTime; if(!weight) weight = 1.0; if(this.nodes[name]) { if(this.nodes[name].weight != weight) throw new Error('Tried to add node ' + name + ' with different weight!'); this.nodes[name].data = data; this.nodes[name].stateChangeTime = stateChangeTime; if(this.nodes[name].up) return; this.nodes[name].up = true; this.numUpNodes++; this._updateNodeNumMap(); return; } else { this.nodes[name] = { up: true, stateChangeTime: stateChangeTime, name: name, weight: weight, data: data }; this.numUpNodes++; } var random = seedRandom(name); var numBuckets = Math.round(this.bucketsPerNode * weight); if(numBuckets < 1) numBuckets = 1; for(var i = 0; i < numBuckets; i++) { this.buckets.push({ value: Math.floor(random() * 0xffffffff), name: name }); } this.buckets.sort(function(a, b) { return a.value - b.value; }); this._checkPrune(); this._updateNodeNumMap(); }; ConsistentHash.prototype.removeNode = function(name, stateChangeTime) { if(!this.nodes[name] || !this.nodes[name].up) return; this.numUpNodes--; this.nodes[name].up = false; var curTime = new Date().getTime(); this.nodes[name].stateChangeTime = stateChangeTime || curTime; this._checkPrune(); this._updateNodeNumMap(); }; ConsistentHash.prototype.addDownNode = function(name, weight, stateChangeTime, data) { this.addNode(name, weight, data); this.removeNode(name, stateChangeTime); }; // Options: // - downNodeExpiry - If a down node is down for less than this amount of time (seconds), keys are still hashed to that node. Default is 0. // Returns object: { up: true|false, name: nodename, stateChangeTime: timeinmillis, timeSinceLastChange: timeinmillis } ConsistentHash.prototype.hashToNode = function(key, options) { this._checkPrune(); var curTime = new Date().getTime(); if(!options) options = {}; if (!this.numUpNodes) return null; // If key is a number, hash to to that specific shard number if (typeof key === 'number') { key = key % this.nodeNumMap.length; if (key < 0) key = -key; return this.nodes[this.nodeNumMap[key]]; } var downNodeExpiry = (options.downNodeExpiry || 0) * 1000; var hash = crc32.unsigned(key); var startBucket = this._findStartingBucket(hash); if(startBucket === null) return null; // Find first valid bucket var firstIteration = true; for(var curBucket = startBucket; ; curBucket++) { if(curBucket >= this.buckets.length) curBucket = 0; if(curBucket == startBucket && !firstIteration) return null; firstIteration = false; var bucket = this.buckets[curBucket]; // is bucket valid? var node = this.nodes[bucket.name]; var timeSinceLastChange = curTime - node.stateChangeTime; node.timeSinceLastChange = timeSinceLastChange; if(node.up) return node; if(timeSinceLastChange < downNodeExpiry) { //console.log('Returning downed node because time since last change ' + timeSinceLastChange + ' < ' + downNodeExpiry); return node; } } }; ConsistentHash.prototype._findStartingBucket = function(hash) { // Bisect search in sorted buckets array if(!this.buckets.length) return null; if(this.buckets.length == 1) return this.buckets[0]; var rangeStart = 0; var rangeEnd = this.buckets.length - 1; for(;;) { var mid = Math.floor((rangeStart + rangeEnd) / 2); var value = this.buckets[mid].value; if(value == hash) { return mid; } else if(value > hash) { rangeEnd = mid; } else if(value < hash) { rangeStart = mid + 1; } if(rangeStart >= this.buckets.length) { return 0; // wrap around } if(rangeStart == rangeEnd) { return rangeStart; } if(rangeEnd - rangeStart == 1) { if(this.buckets[rangeStart].value >= hash) { return rangeStart; } else { return rangeEnd; } } } }; ConsistentHash.prototype._prune = function() { var curTime = new Date().getTime(); var self = this; var removedNodeSet = {}; Object.keys(self.nodes).forEach(function(name) { var node = self.nodes[name]; if(!node.up) { if(curTime - node.stateChangeTime >= self.downNodeExpiry) { removedNodeSet[name] = true; delete self.nodes[name]; } } }); if(Object.keys(removedNodeSet).length) { self.buckets = self.buckets.filter(function(bucket) { return !removedNodeSet[bucket.name]; }); } self.lastPruneTime = curTime; this._updateNodeNumMap(); }; ConsistentHash.prototype._checkPrune = function() { if(new Date().getTime() - this.lastPruneTime >= this.pruneInterval) { this._prune(); } }; module.exports = ConsistentHash;