260 lines
10 KiB
Python
260 lines
10 KiB
Python
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
|
# All Rights Reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
# not use this file except in compliance with the License. You may obtain
|
|
# a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
# License for the specific language governing permissions and limitations
|
|
# under the License.
|
|
|
|
import hashlib
|
|
from unittest import mock
|
|
|
|
from testtools import matchers
|
|
from testtools import testcase
|
|
|
|
from tooz import hashring
|
|
|
|
|
|
class HashRingTestCase(testcase.TestCase):
|
|
|
|
# NOTE(deva): the mapping used in these tests is as follows:
|
|
# if nodes = [foo, bar]:
|
|
# fake -> foo, bar
|
|
# if nodes = [foo, bar, baz]:
|
|
# fake -> foo, bar, baz
|
|
# fake-again -> bar, baz, foo
|
|
|
|
try:
|
|
_ = hashlib.md5(usedforsecurity=False)
|
|
_fips_enabled = True
|
|
except TypeError:
|
|
_fips_enabled = False
|
|
|
|
@mock.patch.object(hashlib, 'md5', autospec=True)
|
|
def test_hash2int_returns_int(self, mock_md5):
|
|
r1 = 32 * 'a'
|
|
r2 = 32 * 'b'
|
|
# 2**PARTITION_EXPONENT calls to md5.update per node
|
|
# PARTITION_EXPONENT is currently always 5, so 32 calls each here
|
|
mock_md5.return_value.hexdigest.side_effect = [r1] * 32 + [r2] * 32
|
|
|
|
nodes = ['foo', 'bar']
|
|
ring = hashring.HashRing(nodes)
|
|
|
|
self.assertIn(int(r1, 16), ring._ring)
|
|
self.assertIn(int(r2, 16), ring._ring)
|
|
if self._fips_enabled:
|
|
mock_md5.assert_called_with(mock.ANY, usedforsecurity=False)
|
|
else:
|
|
mock_md5.assert_called_with(mock.ANY)
|
|
|
|
def test_create_ring(self):
|
|
nodes = {'foo', 'bar'}
|
|
ring = hashring.HashRing(nodes)
|
|
self.assertEqual(nodes, set(ring.nodes.keys()))
|
|
self.assertEqual(2 ** 5 * 2, len(ring))
|
|
|
|
def test_wrong_hash_function(self):
|
|
nodes = {'foo', 'bar'}
|
|
self.assertRaisesRegex(ValueError, 'is not supported',
|
|
hashring.HashRing, nodes,
|
|
hash_function='fold twice and leave to dry')
|
|
|
|
def test_add_node(self):
|
|
nodes = {'foo', 'bar'}
|
|
ring = hashring.HashRing(nodes)
|
|
self.assertEqual(nodes, set(ring.nodes.keys()))
|
|
self.assertEqual(2 ** 5 * len(nodes), len(ring))
|
|
nodes.add('baz')
|
|
ring.add_node('baz')
|
|
self.assertEqual(nodes, set(ring.nodes.keys()))
|
|
self.assertEqual(2 ** 5 * len(nodes), len(ring))
|
|
|
|
def test_add_node_bytes(self):
|
|
nodes = {'foo', 'bar'}
|
|
ring = hashring.HashRing(nodes)
|
|
self.assertEqual(nodes, set(ring.nodes.keys()))
|
|
self.assertEqual(2 ** 5 * len(nodes), len(ring))
|
|
nodes.add(b'Z\xe2\xfa\x90\x17EC\xac\xae\x88\xa7[\xa1}:E')
|
|
ring.add_node(b'Z\xe2\xfa\x90\x17EC\xac\xae\x88\xa7[\xa1}:E')
|
|
self.assertEqual(nodes, set(ring.nodes.keys()))
|
|
self.assertEqual(2 ** 5 * len(nodes), len(ring))
|
|
|
|
def test_add_node_unicode(self):
|
|
nodes = {'foo', 'bar'}
|
|
ring = hashring.HashRing(nodes)
|
|
self.assertEqual(nodes, set(ring.nodes.keys()))
|
|
self.assertEqual(2 ** 5 * len(nodes), len(ring))
|
|
nodes.add(u'\u0634\u0628\u06a9\u0647')
|
|
ring.add_node(u'\u0634\u0628\u06a9\u0647')
|
|
self.assertEqual(nodes, set(ring.nodes.keys()))
|
|
self.assertEqual(2 ** 5 * len(nodes), len(ring))
|
|
|
|
def test_add_node_weight(self):
|
|
nodes = {'foo', 'bar'}
|
|
ring = hashring.HashRing(nodes)
|
|
self.assertEqual(nodes, set(ring.nodes.keys()))
|
|
self.assertEqual(2 ** 5 * len(nodes), len(ring))
|
|
nodes.add('baz')
|
|
ring.add_node('baz', weight=10)
|
|
self.assertEqual(nodes, set(ring.nodes.keys()))
|
|
self.assertEqual(2 ** 5 * 12, len(ring))
|
|
|
|
def test_add_nodes_weight(self):
|
|
nodes = {'foo', 'bar'}
|
|
ring = hashring.HashRing(nodes)
|
|
self.assertEqual(nodes, set(ring.nodes.keys()))
|
|
self.assertEqual(2 ** 5 * len(nodes), len(ring))
|
|
nodes.add('baz')
|
|
nodes.add('baz2')
|
|
ring.add_nodes(set(['baz', 'baz2']), weight=10)
|
|
self.assertEqual(nodes, set(ring.nodes.keys()))
|
|
self.assertEqual(2 ** 5 * 22, len(ring))
|
|
|
|
def test_remove_node(self):
|
|
nodes = {'foo', 'bar'}
|
|
ring = hashring.HashRing(nodes)
|
|
self.assertEqual(nodes, set(ring.nodes.keys()))
|
|
self.assertEqual(2 ** 5 * len(nodes), len(ring))
|
|
nodes.discard('bar')
|
|
ring.remove_node('bar')
|
|
self.assertEqual(nodes, set(ring.nodes.keys()))
|
|
self.assertEqual(2 ** 5 * len(nodes), len(ring))
|
|
|
|
def test_remove_node_bytes(self):
|
|
nodes = {'foo', b'Z\xe2\xfa\x90\x17EC\xac\xae\x88\xa7[\xa1}:E'}
|
|
ring = hashring.HashRing(nodes)
|
|
self.assertEqual(nodes, set(ring.nodes.keys()))
|
|
self.assertEqual(2 ** 5 * len(nodes), len(ring))
|
|
nodes.discard(b'Z\xe2\xfa\x90\x17EC\xac\xae\x88\xa7[\xa1}:E')
|
|
ring.remove_node(b'Z\xe2\xfa\x90\x17EC\xac\xae\x88\xa7[\xa1}:E')
|
|
self.assertEqual(nodes, set(ring.nodes.keys()))
|
|
self.assertEqual(2 ** 5 * len(nodes), len(ring))
|
|
|
|
def test_remove_node_unknown(self):
|
|
nodes = ['foo', 'bar']
|
|
ring = hashring.HashRing(nodes)
|
|
self.assertRaises(
|
|
hashring.UnknownNode,
|
|
ring.remove_node, 'biz')
|
|
|
|
def test_add_then_removenode(self):
|
|
nodes = {'foo', 'bar'}
|
|
ring = hashring.HashRing(nodes)
|
|
self.assertEqual(nodes, set(ring.nodes.keys()))
|
|
self.assertEqual(2 ** 5 * len(nodes), len(ring))
|
|
nodes.add('baz')
|
|
ring.add_node('baz')
|
|
self.assertEqual(nodes, set(ring.nodes.keys()))
|
|
self.assertEqual(2 ** 5 * len(nodes), len(ring))
|
|
nodes.discard('bar')
|
|
ring.remove_node('bar')
|
|
self.assertEqual(nodes, set(ring.nodes.keys()))
|
|
self.assertEqual(2 ** 5 * len(nodes), len(ring))
|
|
|
|
def test_distribution_one_replica(self):
|
|
nodes = ['foo', 'bar', 'baz']
|
|
ring = hashring.HashRing(nodes)
|
|
fake_1_nodes = ring.get_nodes(b'fake')
|
|
fake_2_nodes = ring.get_nodes(b'fake-again')
|
|
# We should have one nodes for each thing
|
|
self.assertEqual(1, len(fake_1_nodes))
|
|
self.assertEqual(1, len(fake_2_nodes))
|
|
# And they must not be the same answers even on this simple data.
|
|
self.assertNotEqual(fake_1_nodes, fake_2_nodes)
|
|
|
|
def test_distribution_more_replica(self):
|
|
nodes = ['foo', 'bar', 'baz']
|
|
ring = hashring.HashRing(nodes)
|
|
fake_1_nodes = ring.get_nodes(b'fake', replicas=2)
|
|
fake_2_nodes = ring.get_nodes(b'fake-again', replicas=2)
|
|
# We should have one nodes for each thing
|
|
self.assertEqual(2, len(fake_1_nodes))
|
|
self.assertEqual(2, len(fake_2_nodes))
|
|
fake_1_nodes = ring.get_nodes(b'fake', replicas=3)
|
|
fake_2_nodes = ring.get_nodes(b'fake-again', replicas=3)
|
|
# We should have one nodes for each thing
|
|
self.assertEqual(3, len(fake_1_nodes))
|
|
self.assertEqual(3, len(fake_2_nodes))
|
|
self.assertEqual(fake_1_nodes, fake_2_nodes)
|
|
|
|
def test_ignore_nodes(self):
|
|
nodes = ['foo', 'bar', 'baz']
|
|
ring = hashring.HashRing(nodes)
|
|
equals_bar_or_baz = matchers.MatchesAny(
|
|
matchers.Equals({'bar'}),
|
|
matchers.Equals({'baz'}))
|
|
self.assertThat(
|
|
ring.get_nodes(b'fake', ignore_nodes=['foo']),
|
|
equals_bar_or_baz)
|
|
self.assertThat(
|
|
ring.get_nodes(b'fake', ignore_nodes=['foo', 'bar']),
|
|
equals_bar_or_baz)
|
|
self.assertEqual(set(), ring.get_nodes(b'fake', ignore_nodes=nodes))
|
|
|
|
@staticmethod
|
|
def _compare_rings(nodes, conductors, ring, new_conductors, new_ring):
|
|
delta = {}
|
|
mapping = {
|
|
'node': list(ring.get_nodes(node.encode('ascii')))[0]
|
|
for node in nodes
|
|
}
|
|
new_mapping = {
|
|
'node': list(new_ring.get_nodes(node.encode('ascii')))[0]
|
|
for node in nodes
|
|
}
|
|
|
|
for key, old in mapping.items():
|
|
new = new_mapping.get(key, None)
|
|
if new != old:
|
|
delta[key] = (old, new)
|
|
return delta
|
|
|
|
def test_rebalance_stability_join(self):
|
|
num_services = 10
|
|
num_nodes = 10000
|
|
# Adding 1 service to a set of N should move 1/(N+1) of all nodes
|
|
# Eg, for a cluster of 10 nodes, adding one should move 1/11, or 9%
|
|
# We allow for 1/N to allow for rounding in tests.
|
|
redistribution_factor = 1.0 / num_services
|
|
|
|
nodes = [str(x) for x in range(num_nodes)]
|
|
services = [str(x) for x in range(num_services)]
|
|
new_services = services + ['new']
|
|
delta = self._compare_rings(
|
|
nodes, services, hashring.HashRing(services),
|
|
new_services, hashring.HashRing(new_services))
|
|
|
|
self.assertLess(len(delta), num_nodes * redistribution_factor)
|
|
|
|
def test_rebalance_stability_leave(self):
|
|
num_services = 10
|
|
num_nodes = 10000
|
|
# Removing 1 service from a set of N should move 1/(N) of all nodes
|
|
# Eg, for a cluster of 10 nodes, removing one should move 1/10, or 10%
|
|
# We allow for 1/(N-1) to allow for rounding in tests.
|
|
redistribution_factor = 1.0 / (num_services - 1)
|
|
|
|
nodes = [str(x) for x in range(num_nodes)]
|
|
services = [str(x) for x in range(num_services)]
|
|
new_services = services[:]
|
|
new_services.pop()
|
|
delta = self._compare_rings(
|
|
nodes, services, hashring.HashRing(services),
|
|
new_services, hashring.HashRing(new_services))
|
|
|
|
self.assertLess(len(delta), num_nodes * redistribution_factor)
|
|
|
|
def test_ignore_non_existent_node(self):
|
|
nodes = ['foo', 'bar']
|
|
ring = hashring.HashRing(nodes)
|
|
self.assertEqual({'foo'}, ring.get_nodes(b'fake',
|
|
ignore_nodes=['baz']))
|