From 0cf6655f612ade9d70c5513f2fbd8d890e5e7d8c Mon Sep 17 00:00:00 2001 From: Wieger Steggerda Date: Fri, 31 May 2019 22:09:31 +0200 Subject: [PATCH 1/4] add rendezvous hashing as hashing strategy --- config.js | 8 +- index.js | 12 +- .../consistent-hashing}/index.js | 4 +- .../consistent-hashing}/rbtree.js | 2 +- lib/{ring => hasher}/events.js | 2 +- lib/hasher/hashing-strategies.js | 26 ++ lib/hasher/rendezvous-hashing/index.js | 314 ++++++++++++++++ test/integration/ring-test.js | 2 +- test/unit/hashring_test.js | 12 +- test/unit/rbiterator_test.js | 6 +- test/unit/rbtree_test.js | 6 +- test/unit/rendezvous_hash_test.js | 345 ++++++++++++++++++ test/unit/ring-test.js | 4 +- test/unit/ringnode_test.js | 4 +- 14 files changed, 722 insertions(+), 25 deletions(-) rename lib/{ring => hasher/consistent-hashing}/index.js (98%) rename lib/{ring => hasher/consistent-hashing}/rbtree.js (99%) rename lib/{ring => hasher}/events.js (97%) create mode 100644 lib/hasher/hashing-strategies.js create mode 100644 lib/hasher/rendezvous-hashing/index.js create mode 100644 test/unit/rendezvous_hash_test.js diff --git a/config.js b/config.js index df620dda..8336049f 100644 --- a/config.js +++ b/config.js @@ -1,4 +1,4 @@ -// Copyright (c) 2017 Uber Technologies, Inc. +// Copyright (c) 2019 Uber Technologies, Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -23,6 +23,7 @@ var _ = require('underscore'); var EventEmitter = require('events').EventEmitter; var LoggingLevels = require('./lib/logging/levels.js'); +var HashingStrategies = require('./lib/hasher/hashing-strategies.js'); var util = require('util'); // This Config class is meant to be a central store @@ -151,6 +152,11 @@ Config.prototype._seed = function _seed(seed) { // Ping a maximum ratio of the pingable members on self eviction seedOrDefault('selfEvictionMaxPingRatio', 0.4); + // Hashing strategy + // Use consistentHashing for backwards compatibility. + // Use rendezvousHashing for better ring distribution and light-weight bootstrap. + seedOrDefault('hashingStrategy', HashingStrategies.consistentHashing); + function seedOrDefault(name, defaultVal, validator, reason) { var seedVal = seed[name]; if (typeof seedVal === 'undefined') { diff --git a/index.js b/index.js index 51ea4900..79d4f4d0 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,4 @@ -// Copyright (c) 2017 Uber Technologies, Inc. +// Copyright (c) 2019 Uber Technologies, Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -50,7 +50,9 @@ var Dissemination = require('./lib/gossip/dissemination.js'); var discoverProviderFactory = require('./discover-providers.js'); var errors = require('./lib/errors.js'); var getTChannelVersion = require('./lib/util.js').getTChannelVersion; -var HashRing = require('./lib/ring'); +var HashingStrategies = require('./lib/hasher/hashing-strategies'); +var HashRing = require('./lib/hasher/consistent-hashing'); +var RendezvousHasher = require('./lib/hasher/rendezvous-hashing'); var initMembership = require('./lib/membership/index.js'); var LoggerFactory = require('./lib/logging/logger_factory.js'); var LagSampler = require('./lib/lag_sampler.js'); @@ -109,7 +111,6 @@ function RingPop(options) { } this.timers = options.timers || timers; this.setTimeout = options.setTimeout || timers.setTimeout; - this.Ring = options.Ring || HashRing; this.isReady = false; @@ -154,6 +155,11 @@ function RingPop(options) { enforceKeyConsistency: options.enforceKeyConsistency }); + this.Ring = options.Ring || HashRing; + if (this.config.get('hashingStrategy') === HashingStrategies.rendezvousHashing) { + this.Ring = RendezvousHasher; + } + this.ring = new this.Ring({ hashFunc: this.hashFunc }); diff --git a/lib/ring/index.js b/lib/hasher/consistent-hashing/index.js similarity index 98% rename from lib/ring/index.js rename to lib/hasher/consistent-hashing/index.js index dd1ef2f9..c9bf8d8d 100644 --- a/lib/ring/index.js +++ b/lib/hasher/consistent-hashing/index.js @@ -1,4 +1,4 @@ -// Copyright (c) 2017 Uber Technologies, Inc. +// Copyright (c) 2019 Uber Technologies, Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,7 @@ var EventEmitter = require('events').EventEmitter; var farmhash = require('farmhash'); var util = require('util'); var RBTree = require('./rbtree').RBTree; -var RingEvents = require('./events.js'); +var RingEvents = require('../events.js'); function HashRing(options) { this.options = options || {}; diff --git a/lib/ring/rbtree.js b/lib/hasher/consistent-hashing/rbtree.js similarity index 99% rename from lib/ring/rbtree.js rename to lib/hasher/consistent-hashing/rbtree.js index ea3d8fc8..83f78aa8 100644 --- a/lib/ring/rbtree.js +++ b/lib/hasher/consistent-hashing/rbtree.js @@ -1,4 +1,4 @@ -// Copyright (c) 2017 Uber Technologies, Inc. +// Copyright (c) 2019 Uber Technologies, Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/lib/ring/events.js b/lib/hasher/events.js similarity index 97% rename from lib/ring/events.js rename to lib/hasher/events.js index 75c6d96e..1b86c234 100644 --- a/lib/ring/events.js +++ b/lib/hasher/events.js @@ -1,4 +1,4 @@ -// Copyright (c) 2017 Uber Technologies, Inc. +// Copyright (c) 2019 Uber Technologies, Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/lib/hasher/hashing-strategies.js b/lib/hasher/hashing-strategies.js new file mode 100644 index 00000000..ad1f03b2 --- /dev/null +++ b/lib/hasher/hashing-strategies.js @@ -0,0 +1,26 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +'use strict'; + +module.exports = { + consistentHashing: 'consistent-hashing', + rendezvousHashing: 'rendezvous-hashing' +}; diff --git a/lib/hasher/rendezvous-hashing/index.js b/lib/hasher/rendezvous-hashing/index.js new file mode 100644 index 00000000..d5731730 --- /dev/null +++ b/lib/hasher/rendezvous-hashing/index.js @@ -0,0 +1,314 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +var EventEmitter = require('events').EventEmitter; +var farmhash = require('farmhash'); +var RingEvents = require('../events.js'); +var util = require('util'); + +function RendezvousHasher(options) { + this.options = options || {}; + + this.hashFunc = this.options.hashFunc || farmhash.hash32v1; + + this.servers = {}; + this.serverHashes = []; + this.serverNames = [] + this.checksum = null; +} + +RendezvousHasher.comparator = function comparator(a, b) { + var result = a.hash - b.hash; + + if (result !== 0) { + return result; + } + + var addressA = a.address || ''; + var addressB = b.address || ''; + + if (addressA === addressB) { + return 0; + } + + if (addressA > addressB) { + return 1; + } + + return -1; +}; + +util.inherits(RendezvousHasher, EventEmitter); + +RendezvousHasher.prototype.addServer = function addServer(server) { + if (this.hasServer(server)) { + return; + } + + this.servers[server] = true; + + var hash = _serverHash(this.hashFunc, server); + this.serverHashes.push(hash); + this.serverNames.push(server) + + this.computeChecksum(); + + this.emit('added', server); +}; + +RendezvousHasher.prototype.removeServer = function removeServer(server) { + if (!this.hasServer(server)) { + return; + } + + delete this.servers[server]; + + var i = this.serverNames.indexOf(server); + if (i!==-1) { + hash = this.serverHashes.pop(); + name = this.serverNames.pop(); + + if (this.serverHashes.length > 0) { + this.serverHashes[i] = hash; + this.serverNames[i] = name; + } + } + + this.computeChecksum(); + + this.emit('removed', server); +}; + + +RendezvousHasher.prototype.addRemoveServers = function addRemoveServers(serversToAdd, serversToRemove) { + serversToAdd = serversToAdd || []; + serversToRemove = serversToRemove || []; + + var addedServers = false; + var removedServers = false; + + var server; + + for (var i = 0; i < serversToAdd.length; i++) { + server = serversToAdd[i]; + + if (!this.hasServer(server)) { + this.addServer(server); + addedServers = true; + } + } + + for (var j = 0; j < serversToRemove.length; j++) { + server = serversToRemove[j]; + + if (this.hasServer(server)) { + this.removeServer(server); + removedServers = true; + } + } + + var ringChanged = addedServers || removedServers; + + if (ringChanged) { + this.computeChecksum(); + this.emit('ringChanged', new RingEvents.RingChangedEvent( + serversToAdd, serversToRemove)); + } + + return ringChanged; +}; + +RendezvousHasher.prototype.computeChecksum = function computeChecksum() { + // If servers is empty, a checksum will still be computed + // for the empty string. + var serverservers = Object.keys(this.servers); + var serverserverStr = serverservers.sort().join(';'); + + var oldChecksum = this.checksum; + this.checksum = this.hashFunc(serverserverStr); + + this.emit('checksumComputed', new RingEvents.ChecksumComputedEvent( + this.checksum, oldChecksum)); +}; + +RendezvousHasher.prototype.getServerCount = function getServerCount() { + return Object.keys(this.servers).length; +}; + +RendezvousHasher.prototype.getStats = function getStats() { + return { + checksum: this.checksum, + servers: Object.keys(this.servers) + }; +}; + +RendezvousHasher.prototype.hasServer = function hasServer(server) { + return !!this.servers[server]; +}; + + + +RendezvousHasher.prototype.lookup = function lookup(str) { + // Numbers in js are floats with 53 bits for precission. If we need more than + // 53 bits to store the result of a multiplication, we lose information. To + // avoid this we multiply as follows: + // ((x & 0xffff0000) * y + (x & 0x0000ffff) * y) & 0xffffffff + // + // Another trick we use is x >>> 0, which creates a uint32. + // + // https://stackoverflow.com/questions/6232939/is-there-a-way-to-correctly-multiply-two-32-bit-integers-in-javascript + // + + var hash = this.hashFunc(str) >>> 0; + var hlo = hash & 0x0000ffff; + var hhi = hash - hlo; + + var max = 0; + var maxServer = ""; + for (var i=0; i < this.serverHashes.length; i++) { + sh = this.serverHashes[i]; + + // 32 bit multiplication of hash * sh (with implicit mod 2^32) + var weight = ((hhi*sh>>>0) + (hlo*sh))>>>0; + if (weight >= max) { // optimization to first check >= instead of having multiple ifs. + + if (weight == max) { + if (this.serverNames[i] < maxServer) { // prefer first in lexigraphic order + maxServer = this.serverNames[i]; + } + } else { + max = weight; + maxServer = this.serverNames[i]; + } + } + } + + return maxServer; +}; + +// find (up to) N unique successor nodes (aka the 'preference list') for the given key +RendezvousHasher.prototype.lookupN = function lookupN(str, n) { + // can't return more than the number of servers + var serverCount = this.getServerCount(); + if (n > serverCount) { + n = serverCount; + } + + var hash = this.hashFunc(str) >>> 0; + var hlo = hash & 0x0000ffff; + var hhi = hash - hlo; + + // init heap with capacity n + let serversByWeight = {}; + let heap = []; + for (var i=0; i>> 0) + (hlo*sh)) >>> 0; + if (heap[0] >= weight) { + continue + } + + // add weight to heap + if (!serversByWeight.hasOwnProperty(weight)) { + serversByWeight[weight] = []; + } + serversByWeight[weight].push(server); + _pushpop(heap, weight); + } + + // pop all weights from the heap + orderedWeights = []; + while (heap.length > 0) { + orderedWeights.push(heap[0]); + + // We perform a heap pop by removing an element from the end of the + // heap array and using a pushpop operation to push it back on and + // remove the smallest element. Not that heap.pop() shirnks the array + // and is not the same as a heap pop operation. + item = heap.pop(); + _pushpop(heap, item); + } + + // get the servers associated with the weights + var resultArray = []; + for (var i = orderedWeights.length - 1; i >= 0; i--) { + var w = orderedWeights[i]; + if (serversByWeight[w].length > 1) { + // hash collissions are extremely rare so it doesn't matter this + // doesn't look efficient. + serversByWeight[w].sort(); + serversByWeight[w].reverse(); + } + server = serversByWeight[w].pop(); + resultArray.push(server); + } + + return resultArray; +}; + +function _serverHash(hashFunc, v) { + // We calculate max hash of server-hash * key-hash. Odd numbers spread out + // perfectly over the uint32 range, odd numbers, however, don't. Take 0 for + // example which doesn't distribute at all because 0 * key-hash is always 0. + // The | operation converts the uint32 to a signed integer. We use >>> to + // convert back to a uint32. + return (hashFunc(v)|1) >>> 0; +} + +function _pushpop(heap, item){ + if (heap.length===0 || item 0) { + p = (i - 1) >> 1; + if (item > heap[p]) { + break + } + heap[i] = heap[p]; + i = p; + } + heap[i] = item; +} + +module.exports = RendezvousHasher; diff --git a/test/integration/ring-test.js b/test/integration/ring-test.js index 6546059b..9607ff6e 100644 --- a/test/integration/ring-test.js +++ b/test/integration/ring-test.js @@ -1,4 +1,4 @@ -// Copyright (c) 2017 Uber Technologies, Inc. +// Copyright (c) 2019 Uber Technologies, Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/test/unit/hashring_test.js b/test/unit/hashring_test.js index e7c2672d..c92e1c39 100644 --- a/test/unit/hashring_test.js +++ b/test/unit/hashring_test.js @@ -1,4 +1,4 @@ -// Copyright (c) 2017 Uber Technologies, Inc. +// Copyright (c) 2019 Uber Technologies, Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -18,8 +18,8 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -var HashRing = require('../../lib/ring'); -var RBTree = require('../../lib/ring/rbtree').RBTree; +var HashRing = require('../../lib/hasher/consistent-hashing'); +var RBTree = require('../../lib/hasher/consistent-hashing/rbtree').RBTree; var test = require('tape'); @@ -166,7 +166,7 @@ test('servers removed out of order result in same checksum', function t(assert) } }); -test('hashring consistent lookups on collision - synthetic collision', function t(assert) { +test('HashRing consistent lookups on collision - synthetic collision', function t(assert) { var ring1 = new HashRing(); var ring2 = new HashRing(); @@ -190,7 +190,7 @@ test('hashring consistent lookups on collision - synthetic collision', function assert.end(); }); -test('hashring consistent lookups on collision - real collision', function t(assert) { +test('HashRing consistent lookups on collision - real collision', function t(assert) { var ring1 = new HashRing(); var ring2 = new HashRing(); @@ -217,7 +217,7 @@ test('hashring consistent lookups on collision - real collision', function t(ass assert.end(); }); -test('hashring replica point comparator', function t(assert) { +test('HashRing replica point comparator', function t(assert) { var comparator = HashRing.comparator; assert.true(comparator({hash: 1}, {hash: 2}) < 0, '1 < 2'); diff --git a/test/unit/rbiterator_test.js b/test/unit/rbiterator_test.js index 8200a380..3fb67834 100644 --- a/test/unit/rbiterator_test.js +++ b/test/unit/rbiterator_test.js @@ -1,4 +1,4 @@ -// Copyright (c) 2017 Uber Technologies, Inc. +// Copyright (c) 2019 Uber Technologies, Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -18,8 +18,8 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -var RBTree = require('../../lib/ring/rbtree').RBTree; -var RBIterator = require('../../lib/ring/rbtree').RBIterator; +var RBTree = require('../../lib/hasher/consistent-hashing/rbtree').RBTree; +var RBIterator = require('../../lib/hasher/consistent-hashing/rbtree').RBIterator; var comparator = require('../lib/int-comparator'); var test = require('tape'); diff --git a/test/unit/rbtree_test.js b/test/unit/rbtree_test.js index 797f577e..8ddd5f68 100644 --- a/test/unit/rbtree_test.js +++ b/test/unit/rbtree_test.js @@ -1,4 +1,4 @@ -// Copyright (c) 2017 Uber Technologies, Inc. +// Copyright (c) 2019 Uber Technologies, Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -18,8 +18,8 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -var RBTree = require('../../lib/ring/rbtree').RBTree; -var RingNode = require('../../lib/ring/rbtree').RingNode; +var RBTree = require('../../lib/hasher/consistent-hashing/rbtree').RBTree; +var RingNode = require('../../lib/hasher/consistent-hashing/rbtree').RingNode; var comparator = require('../lib/int-comparator'); var test = require('tape'); diff --git a/test/unit/rendezvous_hash_test.js b/test/unit/rendezvous_hash_test.js new file mode 100644 index 00000000..219dd35f --- /dev/null +++ b/test/unit/rendezvous_hash_test.js @@ -0,0 +1,345 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFhasherEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +var RendezvousHasher = require('../../lib/hasher/rendezvous-hashing'); + +var test = require('tape'); + +test('construct a new RendezvousHasher with defaults', function t(assert) { + var hasher = new RendezvousHasher(); + + assert.strictEquals(typeof hasher.options, 'object', 'hasher.options is an Object'); + assert.strictEquals(Object.keys(hasher.options).length, 0, 'hasher.options is empty by default'); + assert.strictEquals(typeof hasher.servers, 'object', 'hasher.servers is an Object'); + assert.strictEquals(Object.keys(hasher.servers).length, 0, 'hasher.servers is empty by default'); + assert.strictEquals(typeof hasher.serverHashes, 'object', 'hasher.servers is an Object'); + assert.strictEquals(Object.keys(hasher.serverHashes).length, 0, 'hasher.servers is empty by default'); + assert.strictEquals(hasher.checksum, null); + + assert.end(); +}); + +test('construct a new RendezvousHasher with options', function t(assert) { + fakeHash = function(a) {return a;} + var hasher = new RendezvousHasher({ hashFunc: fakeHash }); + + assert.strictEquals(typeof hasher.options, 'object', 'hasher.options is an Object'); + assert.strictEquals(Object.keys(hasher.options).length, 1, 'hasher.options is 1'); + assert.strictEquals(hasher.hashFunc, fakeHash, 'hashFunc is fakeHash'); + assert.strictEquals(typeof hasher.servers, 'object', 'hasher.servers is an Object'); + assert.strictEquals(Object.keys(hasher.servers).length, 0, 'hasher.servers is empty by default'); + assert.strictEquals(typeof hasher.serverHashes, 'object', 'hasher.servers is an Object'); + assert.strictEquals(Object.keys(hasher.serverHashes).length, 0, 'hasher.servers is empty by default'); + assert.strictEquals(hasher.checksum, null); + + assert.end(); +}); + +test('RendezvousHasher.addServer', function t(assert) { + var name = 'test 1'; + var hasher = new RendezvousHasher(); + var emitted = false; + hasher.on('added', function () { emitted = true; }); + hasher.addServer(name); + + assert.strictEquals(hasher.servers[name], true, 'hasher.servers has new server'); + assert.strictEquals(Object.keys(hasher.servers).length, 1, 'hasher.servers is 1'); + assert.strictEquals(emitted, true, 'hasher emits added event'); + assert.strictEquals(hasher.getServerCount(), 1, 'hasher.getServerCount() returns 1'); + + assert.end(); +}); + +test('RendezvousHasher.removeServer', function t(assert) { + var name1 = 'test 1'; + var name2 = 'test 2'; + var hasher = new RendezvousHasher(); + var added = 0; + var removed = 0; + hasher.on('added', function () { added++; }); + hasher.on('removed', function () { removed++; }); + hasher.addServer(name1); + hasher.addServer(name2); + + assert.strictEquals(hasher.servers[name1], true, 'hasher.servers has name1'); + assert.strictEquals(hasher.servers[name2], true, 'hasher.servers has name2'); + assert.strictEquals(Object.keys(hasher.servers).length, 2, 'hasher.servers is 2'); + assert.strictEquals(Object.keys(hasher.serverHashes).length, 2, 'hasher.serverHashes is 2'); + assert.strictEquals(Object.keys(hasher.serverNames).length, 2, 'hasher.serverNames is 2'); + assert.strictEquals(added, 2, 'hasher emits added events'); + + hasher.removeServer(name1); + + assert.strictEquals(hasher.servers[name1], undefined, 'hasher.servers does not have name1'); + assert.strictEquals(hasher.servers[name2], true, 'hasher.servers has name2'); + assert.strictEquals(Object.keys(hasher.servers).length, 1, 'hasher.servers is 1'); + assert.strictEquals(Object.keys(hasher.serverHashes).length, 1, 'hasher.serverHashes is 1'); + assert.strictEquals(Object.keys(hasher.serverNames).length, 1, 'hasher.serverNames is 1'); + assert.strictEquals(removed, 1, 'hasher emits removed events'); + + assert.end(); +}); + +test('checksum is null upon instantiation', function t(assert) { + var hasher = new RendezvousHasher(); + assert.equals(hasher.checksum, null, 'checksum is null'); + assert.end(); +}); + +test('checksum is not null when server added', function t(assert) { + var hasher = new RendezvousHasher(); + hasher.addServer('127.0.0.1:3000'); + assert.doesNotEqual(hasher.checksum, null, 'checksum is not null'); + assert.end(); +}); + +test('checksum is still null when non-existent server removed', function t(assert) { + var hasher = new RendezvousHasher(); + hasher.removeServer('127.0.0.1:3000'); + assert.equals(hasher.checksum, null, 'checksum is null'); + assert.end(); +}); + +test('checksum recomputed after server added, then removed', function t(assert) { + var hasher = new RendezvousHasher(); + + hasher.addServer('127.0.0.1:3000'); + hasher.addServer('127.0.0.1:3001'); + var firstChecksum = hasher.checksum; + + hasher.removeServer('127.0.0.1:3000'); + var secondChecksum = hasher.checksum; + + hasher.addServer('127.0.0.1:3000'); + var thirdChecksum = hasher.checksum; + + assert.doesNotEqual(firstChecksum, null, 'first checksum is not null'); + assert.doesNotEqual(secondChecksum, null, 'second checksum is not null'); + assert.doesNotEqual(firstChecksum, secondChecksum, 'checksums are different'); + assert.equals(firstChecksum, thirdChecksum, 'checksums are the same'); + assert.end(); +}); + +test('servers added out of order result in same checksum', function t(assert) { + var hasher1 = new RendezvousHasher(); + hasher1.addServer('127.0.0.1:3000'); + hasher1.addServer('127.0.0.1:3001'); + + var hasher2 = new RendezvousHasher(); + hasher2.addServer('127.0.0.1:3001'); + hasher2.addServer('127.0.0.1:3000'); + + assert.doesNotEqual(hasher1.checksum, null, 'hasher1 checksum is not null'); + assert.doesNotEqual(hasher2.checksum, null, 'hasher2 checksum is not null'); + assert.equals(hasher1.checksum, hasher2.checksum, 'checksums are same'); + assert.end(); +}); + +test('servers removed out of order result in same checksum', function t(assert) { + var hasher1 = new RendezvousHasher(); + addServers(hasher1); + hasher1.removeServer('127.0.0.1:3001'); + hasher1.removeServer('127.0.0.1:3002'); + + var hasher2 = new RendezvousHasher(); + addServers(hasher2); + hasher2.removeServer('127.0.0.1:3002'); + hasher2.removeServer('127.0.0.1:3001'); + + assert.doesNotEqual(hasher1.checksum, null, 'hasher1 checksum is not null'); + assert.doesNotEqual(hasher2.checksum, null, 'hasher2 checksum is not null'); + assert.equals(hasher1.checksum, hasher2.checksum, 'checksums are same'); + assert.end(); + + function addServers(hasher) { + for (var i = 0; i < 4; i++) { + hasher.addServer('127.0.0.1:300' + i); + } + } +}); + +test('consistent lookups', function t(assert) { + var hasher1 = new RendezvousHasher(); + var hasher2 = new RendezvousHasher(); + + var serverA = '10.0.0.1:50'; + var serverB = '10.0.0.1:501'; + var key = '10.0.0.1:5011'; + + hasher1.addServer(serverA); + hasher1.addServer(serverB); + + // Add servers in different order + hasher2.addServer(serverB); + hasher2.addServer(serverA); + + assert.equal(hasher1.lookup(key), serverB); + assert.equal(hasher2.lookup(key), serverB); + + assert.end(); +}); + +test('consistent lookups on collision - real collision', function t(assert) { + var hasher1 = new RendezvousHasher(); + var hasher2 = new RendezvousHasher(); + + // These ip addresses and lookup key look 'magic' but are actually + // the first hash collision (1477543671) we found "in the wild". + servers = ['10.66.3.137:3153839', '10.66.135.9:3184872'].sort() + var serverA = servers[0]; + var serverB = servers[1]; + var key = servers[0]; + + assert.equals(hasher1.hashFunc(serverA), 1477543671); + assert.equals(hasher1.hashFunc(serverB), 1477543671); + + hasher1.addServer(serverA); + hasher1.addServer(serverB); + + // Add servers in different order + hasher2.addServer(serverB); + hasher2.addServer(serverA); + + assert.equal(hasher1.lookup(key), serverA); + assert.equal(hasher1.lookup(key), serverA); + assert.equal(hasher1.lookupN(key,1)[0], serverA); + assert.equal(hasher1.lookupN(key,1)[0], serverA); + assert.equal(hasher1.lookupN(key,2)[0], serverA); + assert.equal(hasher2.lookupN(key,2)[0], serverA); + assert.equal(hasher1.lookupN(key,2)[1], serverB); + assert.equal(hasher2.lookupN(key,2)[1], serverB); + assert.equal(hasher1.lookupN(key,3).length, 2); + assert.equal(hasher2.lookupN(key,3).length, 2); + assert.equal(hasher1.lookupN(key,3)[0], serverA); + assert.equal(hasher2.lookupN(key,3)[0], serverA); + assert.equal(hasher1.lookupN(key,3)[1], serverB); + assert.equal(hasher2.lookupN(key,3)[1], serverB); + + assert.end(); +}); + +test('hashes distributed evenly', function t(assert) { + var hasher = new RendezvousHasher(); + + servers = []; + for (var i=0; i<13; i++) { + servers.push("worker"+i); + } + hasher.addRemoveServers(servers, []); + + distr = {} + avgLoad = 1000; + for (var key=0; key < servers.length * avgLoad; key++) { + server = hasher.lookup("key"+key); + distr[server] = (distr[server] || 0) + 1; + } + + assert.equals(Object.keys(distr).length, 13, 'all servers received load') + var max = 0; + var min = avgLoad * servers.length; + for (var i=0; i<13; i++) { + server = "worker"+i; + if (distr[server] > max) { + max = distr[server]; + } + if (distr[server] < min) { + min = distr[server]; + } + } + + assert.ok(min / avgLoad > 0.95, 'least loaded server is >0.95 of avg'); + assert.ok(max / avgLoad < 1.05, 'most loaded server is <1.05 of avg'); + assert.end(); +}); + +test('lookupN hashes are distributed evenly', function t(assert) { + var hasher = new RendezvousHasher(); + + servers = []; + for (var i=0; i<13; i++) { + servers.push("worker"+i); + } + hasher.addRemoveServers(servers, []); + + for(var x=0; x<5; x++) { + distr = {} + avgLoad = 1000; + for (var key=0; key < servers.length * avgLoad; key++) { + server = hasher.lookupN("key"+key,5)[x]; + distr[server] = (distr[server] || 0) + 1; + } + + assert.equals(Object.keys(distr).length, 13, 'all servers received load') + var max = 0; + var min = avgLoad * servers.length; + for (var i=0; i<13; i++) { + server = "worker"+i; + if (distr[server] > max) { + max = distr[server]; + } + if (distr[server] < min) { + min = distr[server]; + } + } + assert.ok(min / avgLoad > 0.92, 'least loaded server is >0.95 of avg'); + assert.ok(max / avgLoad < 1.08, 'most loaded server is <1.05 of avg'); + } + assert.end(); +}); + +test('server hashes are odd uint32 numbers', function t(assert) { + var hasher = new RendezvousHasher(); + + servers = []; + for (var i=0; i<13; i++) { + servers.push("worker"+i); + } + hasher.addRemoveServers(servers, []); + + odd=0; + negative=0; + above32bits=0; + above31bits=0; + below31bits=0; + + for (var i=0; i < hasher.serverHashes.length; i++) { + hash = hasher.serverHashes[i]; + if (hash&1 === 1) { + odd++; + } + if (hash < 0) { + negative++; + } + if (hash >= 2**32) { + above32bits++; + } + if (hash >= 2**31) { + above31bits++; + } else { + below31bits++ + } + } + + assert.equals(odd, 13, 'all server hashes are odd'); + assert.equals(negative, 0, 'no server hashes are negative'); + assert.equals(above32bits, 0, 'no server hashes are bigger than 32 bits'); + assert.ok(above31bits > 3 && below31bits > 3, 'full uint32 range is utilized'); + assert.end(); +}); \ No newline at end of file diff --git a/test/unit/ring-test.js b/test/unit/ring-test.js index e40da2c5..2ddb48de 100644 --- a/test/unit/ring-test.js +++ b/test/unit/ring-test.js @@ -1,4 +1,4 @@ -// Copyright (c) 2017 Uber Technologies, Inc. +// Copyright (c) 2019 Uber Technologies, Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -21,7 +21,7 @@ 'use strict'; var _ = require('underscore'); -var HashRing = require('../../lib/ring'); +var HashRing = require('../../lib/hasher/consistent-hashing'); var test = require('tape'); function createServers(size) { diff --git a/test/unit/ringnode_test.js b/test/unit/ringnode_test.js index 4803d5c3..ce064407 100644 --- a/test/unit/ringnode_test.js +++ b/test/unit/ringnode_test.js @@ -1,4 +1,4 @@ -// Copyright (c) 2017 Uber Technologies, Inc. +// Copyright (c) 2019 Uber Technologies, Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -18,7 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -var RingNode = require('../../lib/ring/rbtree').RingNode; +var RingNode = require('../../lib/hasher/consistent-hashing/rbtree').RingNode; var test = require('tape'); test('construct a new red node with supplied values', function t(assert) { From d7f7d42609e7df267dc21cf4211a606236a1873e Mon Sep 17 00:00:00 2001 From: Wieger Steggerda Date: Sat, 1 Jun 2019 02:14:41 +0200 Subject: [PATCH 2/4] enable rendezvous hashing by default for testing --- build/config.gypi | 53 +++++++++++++++++++++++++++++++++++++++++++++++ index.js | 2 ++ 2 files changed, 55 insertions(+) create mode 100644 build/config.gypi diff --git a/build/config.gypi b/build/config.gypi new file mode 100644 index 00000000..776d7171 --- /dev/null +++ b/build/config.gypi @@ -0,0 +1,53 @@ +# Do not edit. File was generated by node-gyp's "configure" step +{ + "target_defaults": { + "cflags": [], + "default_configuration": "Release", + "defines": [], + "include_dirs": [], + "libraries": [] + }, + "variables": { + "asan": 0, + "host_arch": "x64", + "icu_data_file": "icudt56l.dat", + "icu_data_in": "../../deps/icu/source/data/in/icudt56l.dat", + "icu_endianness": "l", + "icu_gyp_path": "tools/icu/icu-generic.gyp", + "icu_locales": "en,root", + "icu_path": "./deps/icu", + "icu_small": "true", + "icu_ver_major": "56", + "llvm_version": 0, + "node_byteorder": "little", + "node_install_npm": "true", + "node_prefix": "/", + "node_release_urlbase": "https://nodejs.org/download/release/", + "node_shared_http_parser": "false", + "node_shared_libuv": "false", + "node_shared_openssl": "false", + "node_shared_zlib": "false", + "node_tag": "", + "node_use_dtrace": "true", + "node_use_etw": "false", + "node_use_lttng": "false", + "node_use_openssl": "true", + "node_use_perfctr": "false", + "openssl_fips": "", + "openssl_no_asm": 0, + "target_arch": "x64", + "uv_parent_path": "/deps/uv/", + "uv_use_dtrace": "true", + "v8_enable_gdbjit": 0, + "v8_enable_i18n_support": 1, + "v8_no_strict_aliasing": 1, + "v8_optimized_debug": 0, + "v8_random_seed": 0, + "v8_use_snapshot": "true", + "want_separate_host_toolset": 0, + "xcode_version": "7.0", + "nodedir": "/Users/wieger/.node-gyp/4.4.7", + "copy_dev_lib": "true", + "standalone_static_library": 1 + } +} diff --git a/index.js b/index.js index 79d4f4d0..f6289d6a 100644 --- a/index.js +++ b/index.js @@ -159,6 +159,8 @@ function RingPop(options) { if (this.config.get('hashingStrategy') === HashingStrategies.rendezvousHashing) { this.Ring = RendezvousHasher; } + // HACK (always use rendezvousHasher while testing) + this.Ring = RendezvousHasher; this.ring = new this.Ring({ hashFunc: this.hashFunc From 93cd96279c54c89859f708dcb4a2f3af77e248ee Mon Sep 17 00:00:00 2001 From: Wieger Steggerda Date: Sat, 1 Jun 2019 14:00:04 +0200 Subject: [PATCH 3/4] rm config.gypi --- build/config.gypi | 53 ----------------------------------------------- 1 file changed, 53 deletions(-) delete mode 100644 build/config.gypi diff --git a/build/config.gypi b/build/config.gypi deleted file mode 100644 index 776d7171..00000000 --- a/build/config.gypi +++ /dev/null @@ -1,53 +0,0 @@ -# Do not edit. File was generated by node-gyp's "configure" step -{ - "target_defaults": { - "cflags": [], - "default_configuration": "Release", - "defines": [], - "include_dirs": [], - "libraries": [] - }, - "variables": { - "asan": 0, - "host_arch": "x64", - "icu_data_file": "icudt56l.dat", - "icu_data_in": "../../deps/icu/source/data/in/icudt56l.dat", - "icu_endianness": "l", - "icu_gyp_path": "tools/icu/icu-generic.gyp", - "icu_locales": "en,root", - "icu_path": "./deps/icu", - "icu_small": "true", - "icu_ver_major": "56", - "llvm_version": 0, - "node_byteorder": "little", - "node_install_npm": "true", - "node_prefix": "/", - "node_release_urlbase": "https://nodejs.org/download/release/", - "node_shared_http_parser": "false", - "node_shared_libuv": "false", - "node_shared_openssl": "false", - "node_shared_zlib": "false", - "node_tag": "", - "node_use_dtrace": "true", - "node_use_etw": "false", - "node_use_lttng": "false", - "node_use_openssl": "true", - "node_use_perfctr": "false", - "openssl_fips": "", - "openssl_no_asm": 0, - "target_arch": "x64", - "uv_parent_path": "/deps/uv/", - "uv_use_dtrace": "true", - "v8_enable_gdbjit": 0, - "v8_enable_i18n_support": 1, - "v8_no_strict_aliasing": 1, - "v8_optimized_debug": 0, - "v8_random_seed": 0, - "v8_use_snapshot": "true", - "want_separate_host_toolset": 0, - "xcode_version": "7.0", - "nodedir": "/Users/wieger/.node-gyp/4.4.7", - "copy_dev_lib": "true", - "standalone_static_library": 1 - } -} From 3ccd745874e140b697a805ad180d20dc863c5cc8 Mon Sep 17 00:00:00 2001 From: Wieger Steggerda Date: Sat, 1 Jun 2019 14:09:05 +0200 Subject: [PATCH 4/4] rm comparator --- lib/hasher/rendezvous-hashing/index.js | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/lib/hasher/rendezvous-hashing/index.js b/lib/hasher/rendezvous-hashing/index.js index d5731730..209c7ef1 100644 --- a/lib/hasher/rendezvous-hashing/index.js +++ b/lib/hasher/rendezvous-hashing/index.js @@ -34,27 +34,6 @@ function RendezvousHasher(options) { this.checksum = null; } -RendezvousHasher.comparator = function comparator(a, b) { - var result = a.hash - b.hash; - - if (result !== 0) { - return result; - } - - var addressA = a.address || ''; - var addressB = b.address || ''; - - if (addressA === addressB) { - return 0; - } - - if (addressA > addressB) { - return 1; - } - - return -1; -}; - util.inherits(RendezvousHasher, EventEmitter); RendezvousHasher.prototype.addServer = function addServer(server) {