Files
crypto/hash/consistent/ring_test.go
T

448 lines
11 KiB
Go

// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package consistent
import (
"testing"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"github.com/luxfi/crypto/hash/hashingmock"
)
var (
_ Hashable = (*testKey)(nil)
// nodes
node1 = testKey{key: "node-1", hash: 1}
node2 = testKey{key: "node-2", hash: 2}
node3 = testKey{key: "node-3", hash: 3}
)
// testKey is a simple wrapper around a key and its mocked hash for testing.
type testKey struct {
// key
key string
// mocked hash value of the key
hash uint64
}
func (t testKey) ConsistentHashKey() []byte {
return []byte(t.key)
}
// Tests that a key routes to its closest clockwise node.
// Test cases are described in greater detail below; see diagrams for Ring.
func TestGetMapsToClockwiseNode(t *testing.T) {
tests := []struct {
// name of the test
name string
// nodes that exist in the ring
ringNodes []testKey
// key to try to route
key testKey
// expected key to be routed to
expectedNode testKey
}{
{
// If we're left of a node in the ring, we should route to it.
//
// Ring:
// ... -> foo -> node-1 -> ...
name: "key with right node",
ringNodes: []testKey{
node1,
},
key: testKey{
key: "foo",
hash: 0,
},
expectedNode: node1,
},
{
// If we occupy the same hash as the only ring node, we should route to it.
//
// Ring:
// ... -> foo, node-1 -> ...
name: "key with equal node",
ringNodes: []testKey{
node1,
},
key: testKey{
key: "foo",
hash: 1,
},
expectedNode: node1,
},
{
// If we're clockwise of the only node, we should wrap around and route to that node.
//
// Ring:
// ... -> node-1 -> foo -> ...
name: "key wraps around to left-most node",
ringNodes: []testKey{
node1,
},
key: testKey{
key: "foo",
hash: 2,
},
expectedNode: node1,
},
{
// If we're left of multiple nodes in the ring, we should route to the first clockwise node.
//
// Ring:
// ... -> foo -> node-1 -> node-2 -> ...
name: "key with two right nodes",
ringNodes: []testKey{
node1,
node2,
},
key: testKey{
key: "foo",
hash: 0,
},
expectedNode: node1,
},
{
// If we occupy the same hash as a node, we should route to the node clockwise of it.
//
// Ring:
// ... -> foo, node-1 -> node-2 -> ...
name: "key with one equal node and one right node",
ringNodes: []testKey{
node2,
node1,
},
key: testKey{
key: "foo",
hash: 1,
},
expectedNode: node2,
},
{
// If we're in between two nodes, we should route to the clockwise node.
//
// Ring:
// ... -> node-1 -> foo -> node-3 -> ...
name: "key between two nodes",
ringNodes: []testKey{
node3,
node1,
},
key: testKey{
key: "foo",
hash: 2,
},
expectedNode: node3,
},
{
// If we're clockwise of all ring keys, we should wrap around and route to the left-most node.
//
// Ring:
// ... -> node-1 -> node-2 -> foo -> ...
name: "key with two left nodes and no right neighbors",
ringNodes: []testKey{
node2,
node1,
},
key: testKey{
key: "foo",
hash: 3,
},
expectedNode: node1,
},
{
// If we occupy the same hash as a node, we should wrap around to the clockwise node.
//
// Ring:
// ... -> node-1 -> node-2, foo -> ...
name: "key with equal neighbor and no right node wraps around to left-most node",
ringNodes: []testKey{
node2,
node1,
},
key: testKey{
key: "foo",
hash: 2,
},
expectedNode: node1,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require := require.New(t)
ring, hasher := setupTest(t, 1)
// setup expected calls
calls := make([]any, len(test.ringNodes)+1)
for i, key := range test.ringNodes {
calls[i] = hasher.EXPECT().Hash(getHashKey(key.ConsistentHashKey(), 0)).Return(key.hash).Times(1)
}
calls[len(test.ringNodes)] = hasher.EXPECT().Hash(test.key.ConsistentHashKey()).Return(test.key.hash).Times(1)
gomock.InOrder(calls...)
// execute test
for _, key := range test.ringNodes {
ring.Add(key)
}
node, err := ring.Get(test.key)
require.NoError(err)
require.Equal(test.expectedNode, node)
})
}
}
// Tests that if we have an empty ring, trying to call Get results in an error, as there is no node to route to.
func TestGetOnEmptyRingReturnsError(t *testing.T) {
ring, _ := setupTest(t, 1)
foo := testKey{
key: "foo",
hash: 0,
}
_, err := ring.Get(foo)
require.ErrorIs(t, err, errEmptyRing)
}
// Tests that trying to call Remove on a node that doesn't exist should return false.
func TestRemoveNonExistentKeyReturnsFalse(t *testing.T) {
ring, hasher := setupTest(t, 1)
gomock.InOrder(
hasher.EXPECT().Hash(getHashKey(node1.ConsistentHashKey(), 0)).Return(uint64(1)).Times(1),
)
// try to remove something from an empty ring.
require.False(t, ring.Remove(node1))
}
// Tests that trying to call Remove on a node that doesn't exist should return true.
func TestRemoveExistingKeyReturnsTrue(t *testing.T) {
ring, hasher := setupTest(t, 1)
gomock.InOrder(
hasher.EXPECT().Hash(getHashKey(node1.ConsistentHashKey(), 0)).Return(uint64(1)).Times(1),
)
// Add a node into the ring.
//
// Ring:
// ... -> node-1 -> ...
ring.Add(node1)
gomock.InOrder(
hasher.EXPECT().Hash(getHashKey(node1.ConsistentHashKey(), 0)).Return(uint64(1)).Times(1),
)
// Try to remove it.
//
// Ring:
// ... -> (empty) -> ...
require.True(t, ring.Remove(node1))
}
// Tests that if we have a collision, the node is replaced.
func TestAddCollisionReplacement(t *testing.T) {
require := require.New(t)
ring, hasher := setupTest(t, 1)
foo := testKey{
key: "foo",
hash: 2,
}
gomock.InOrder(
// node-1 and node-2 occupy the same hash
hasher.EXPECT().Hash(getHashKey(node1.ConsistentHashKey(), 0)).Return(uint64(1)).Times(1),
hasher.EXPECT().Hash(getHashKey(node2.ConsistentHashKey(), 0)).Return(uint64(1)).Times(1),
hasher.EXPECT().Hash(foo.ConsistentHashKey()).Return(uint64(1)).Times(1),
)
// Ring:
// ... -> node-1 -> ...
ring.Add(node1)
// Ring:
// ... -> node-2 -> ...
ring.Add(node2)
ringMember, err := ring.Get(foo)
require.NoError(err)
require.Equal(node2, ringMember)
}
// Tests that virtual nodes are replicated on Add.
func TestAddVirtualNodes(t *testing.T) {
require := require.New(t)
ring, hasher := setupTest(t, 3)
gomock.InOrder(
// we should see 3 virtual nodes created (0, 1, 2) when we insert a node into the ring.
// insert node-1
hasher.EXPECT().Hash(getHashKey(node1.ConsistentHashKey(), 0)).Return(uint64(0)).Times(1),
hasher.EXPECT().Hash(getHashKey(node1.ConsistentHashKey(), 1)).Return(uint64(2)).Times(1),
hasher.EXPECT().Hash(getHashKey(node1.ConsistentHashKey(), 2)).Return(uint64(4)).Times(1),
// insert node-2
hasher.EXPECT().Hash(getHashKey(node2.ConsistentHashKey(), 0)).Return(uint64(1)).Times(1),
hasher.EXPECT().Hash(getHashKey(node2.ConsistentHashKey(), 1)).Return(uint64(3)).Times(1),
hasher.EXPECT().Hash(getHashKey(node2.ConsistentHashKey(), 2)).Return(uint64(5)).Times(1),
// gets that should route to node-1
hasher.EXPECT().Hash([]byte("foo1")).Return(uint64(1)).Times(1),
hasher.EXPECT().Hash([]byte("foo3")).Return(uint64(3)).Times(1),
hasher.EXPECT().Hash([]byte("foo5")).Return(uint64(5)).Times(1),
// gets that should route to node-2
hasher.EXPECT().Hash([]byte("foo0")).Return(uint64(0)).Times(1),
hasher.EXPECT().Hash([]byte("foo2")).Return(uint64(2)).Times(1),
hasher.EXPECT().Hash([]byte("foo4")).Return(uint64(4)).Times(1),
)
// Add node 1.
//
// Ring:
// ... -> node-1-v0 -> node-1-v1 -> node-1-v2 -> ...
ring.Add(node1)
// Add node 2.
//
// Ring:
// ... -> node-1-v0 -> node-2-v0 -> node-1-v1 -> node-2-v1 -> node-1-v2 -> node-2-v2 -> ...
ring.Add(node2)
// Gets that should route to node-1
node, err := ring.Get(testKey{key: "foo1"})
require.NoError(err)
require.Equal(node1, node)
node, err = ring.Get(testKey{key: "foo3"})
require.NoError(err)
require.Equal(node1, node)
node, err = ring.Get(testKey{key: "foo5"})
require.NoError(err)
require.Equal(node1, node)
// Gets that should route to node-2
node, err = ring.Get(testKey{key: "foo0"})
require.NoError(err)
require.Equal(node2, node)
node, err = ring.Get(testKey{key: "foo2"})
require.NoError(err)
require.Equal(node2, node)
node, err = ring.Get(testKey{key: "foo4"})
require.NoError(err)
require.Equal(node2, node)
}
// Tests that the node routed to changes if an Add results in a key shuffle.
func TestGetShuffleOnAdd(t *testing.T) {
require := require.New(t)
ring, hasher := setupTest(t, 1)
foo := testKey{
key: "foo",
hash: 1,
}
gomock.InOrder(
hasher.EXPECT().Hash(getHashKey(node1.ConsistentHashKey(), 0)).Return(uint64(0)).Times(1),
hasher.EXPECT().Hash(foo.ConsistentHashKey()).Return(foo.hash).Times(1),
hasher.EXPECT().Hash(getHashKey(node2.ConsistentHashKey(), 0)).Return(uint64(2)).Times(1),
hasher.EXPECT().Hash(foo.ConsistentHashKey()).Return(foo.hash).Times(1),
)
// Add node-1 into the ring
//
// Ring:
// ... -> node-1 -> ...
ring.Add(node1)
// node-1 is the closest clockwise node (when we wrap around), so we route to it.
//
// Ring:
// ... -> node-1 -> foo -> ...
node, err := ring.Get(foo)
require.NoError(err)
require.Equal(node1, node)
// Add node-2, which results in foo being shuffled from node-1 to node-2.
//
// Ring:
// ... -> node-1 -> node-2 -> ...
ring.Add(node2)
// Now node-2 is our closest clockwise node, so we should route to it.
//
// Ring:
// ... -> node-1 -> foo -> node-2 -> ...
node, err = ring.Get(foo)
require.NoError(err)
require.Equal(node2, node)
}
// Tests that we can iterate around the ring.
func TestIteration(t *testing.T) {
require := require.New(t)
ring, hasher := setupTest(t, 1)
foo := testKey{
key: "foo",
hash: 0,
}
gomock.InOrder(
hasher.EXPECT().Hash(getHashKey(node1.ConsistentHashKey(), 0)).Return(node1.hash).Times(1),
hasher.EXPECT().Hash(getHashKey(node2.ConsistentHashKey(), 0)).Return(node2.hash).Times(1),
hasher.EXPECT().Hash(foo.ConsistentHashKey()).Return(foo.hash).Times(1),
hasher.EXPECT().Hash(node1.ConsistentHashKey()).Return(node1.hash).Times(1),
)
// add node-1 into the ring
//
// Ring:
// ... -> node-1 -> ...
ring.Add(node1)
// add node-2 into the ring
//
// Ring:
// ... -> node-1 -> node-2 -> ...
ring.Add(node2)
// Get the neighbor of foo
//
// Ring:
// ... -> foo -> node-1 -> node-2 -> ...
node, err := ring.Get(foo)
require.NoError(err)
require.Equal(node1, node)
// iterate by re-using node-1 to get node-2
node, err = ring.Get(node)
require.NoError(err)
require.Equal(node2, node)
}
func setupTest(t *testing.T, virtualNodes int) (Ring, *hashingmock.Hasher) {
ctrl := gomock.NewController(t)
hasher := hashingmock.NewHasher(ctrl)
return NewHashRing(RingConfig{
VirtualNodes: virtualNodes,
Hasher: hasher,
Degree: 2,
}), hasher
}