Skip to content

Commit 56e5b3d

Browse files
authored
Add unit tests for pkg/sharding/consistenthash (#480)
* Rename existing benchmark test file * Add more comments * Add unit tests for `pkg/sharding/consistenthash`
1 parent 394a004 commit 56e5b3d

File tree

4 files changed

+191
-64
lines changed

4 files changed

+191
-64
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
Copyright 2023 Tim Ebert.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package consistenthash
18+
19+
import (
20+
"fmt"
21+
"math"
22+
"testing"
23+
)
24+
25+
func TestDistribution(t *testing.T) {
26+
ring := New(DefaultHash, DefaultTokensPerNode)
27+
28+
hosts := generateHostnames(10)
29+
dist := make(map[string]float64, len(hosts))
30+
ring.AddNodes(hosts...)
31+
for _, host := range hosts {
32+
dist[host] = 0
33+
}
34+
35+
// fmt.Println("Virtual Nodes:")
36+
last := ring.tokens[len(ring.tokens)-1]
37+
for _, token := range ring.tokens {
38+
node := ring.tokenToNode[token]
39+
percentage := float64(token-last) / math.MaxUint64
40+
dist[node] += percentage
41+
42+
// fmt.Printf("\t%016x (%.5f): %.5f -> %s\n", token, float64(token)/math.MaxUint64, percentage, node)
43+
last = token
44+
}
45+
46+
fmt.Println("Nodes distribution:")
47+
for _, host := range hosts {
48+
fmt.Printf("\t%s: %.5f\n", host, dist[host])
49+
}
50+
}
51+
52+
func generateHostnames(n int) []string {
53+
hosts := make([]string, n)
54+
for i := range hosts {
55+
host := fmt.Sprintf("10.42.0.%d", i)
56+
hosts[i] = host
57+
}
58+
return hosts
59+
}
60+
61+
func benchmarkRing(nodes int, tokensPerNode int, b *testing.B) {
62+
hosts := generateHostnames(nodes)
63+
b.ResetTimer()
64+
65+
for n := 0; n < b.N; n++ {
66+
ring := New(DefaultHash, tokensPerNode, hosts...)
67+
ring.Hash("Website.webhosting.timebertt.dev/project-foo/homepage")
68+
}
69+
}
70+
71+
func BenchmarkRing3_100(b *testing.B) { benchmarkRing(3, 100, b) }
72+
func BenchmarkRing3_1000(b *testing.B) { benchmarkRing(3, 1000, b) }
73+
func BenchmarkRing5_100(b *testing.B) { benchmarkRing(5, 100, b) }
74+
func BenchmarkRing5_1000(b *testing.B) { benchmarkRing(5, 1000, b) }
75+
func BenchmarkRing10_100(b *testing.B) { benchmarkRing(10, 100, b) }
76+
func BenchmarkRing10_1000(b *testing.B) { benchmarkRing(10, 1000, b) }
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
Copyright 2025 Tim Ebert.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package consistenthash_test
18+
19+
import (
20+
"testing"
21+
22+
. "github.com/onsi/ginkgo/v2"
23+
. "github.com/onsi/gomega"
24+
)
25+
26+
func TestConsistentHash(t *testing.T) {
27+
RegisterFailHandler(Fail)
28+
RunSpecs(t, "Consistent Hash Suite")
29+
}

pkg/sharding/consistenthash/ring.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,21 @@ var DefaultHash Hash = xxhash.Sum64String
3232
// DefaultTokensPerNode is the default number of virtual nodes per node.
3333
const DefaultTokensPerNode = 100
3434

35-
// New creates a new hash ring.
36-
func New(fn Hash, tokensPerNode int, initialNodes ...string) *Ring {
37-
if fn == nil {
38-
fn = DefaultHash
35+
// New creates a new hash ring with the given configuration and adds the given nodes.
36+
// The given Hash (or DefaultHash if nil) is used to hash nodes and keys (strings).
37+
// Each node is assigned tokensPerNode tokens (or DefaultTokensPerNode if <= 0) – aka. virtual nodes – for a more
38+
// uniform key distribution.
39+
func New(hash Hash, tokensPerNode int, initialNodes ...string) *Ring {
40+
if hash == nil {
41+
hash = DefaultHash
3942
}
4043
if tokensPerNode <= 0 {
4144
tokensPerNode = DefaultTokensPerNode
4245
}
4346

4447
numTokens := len(initialNodes) * tokensPerNode
4548
r := &Ring{
46-
hash: fn,
49+
hash: hash,
4750
tokensPerNode: tokensPerNode,
4851

4952
tokens: make([]uint64, 0, numTokens),
@@ -53,8 +56,9 @@ func New(fn Hash, tokensPerNode int, initialNodes ...string) *Ring {
5356
return r
5457
}
5558

56-
// Ring implements consistent hashing, aka ring hash (not thread-safe).
57-
// It hashes nodes and keys onto a ring of tokens. Keys are mapped to the next node on the ring.
59+
// Ring implements consistent hashing, aka. ring hash (not thread-safe).
60+
// It hashes nodes and keys (strings) onto a ring of tokens. Keys are mapped to the next token (node) on the ring.
61+
// Nodes cannot be removed. Instantiate a new Ring instead.
5862
type Ring struct {
5963
hash Hash
6064
tokensPerNode int
@@ -63,10 +67,12 @@ type Ring struct {
6367
tokenToNode map[uint64]string
6468
}
6569

70+
// IsEmpty returns true if there are no nodes in this Ring.
6671
func (r *Ring) IsEmpty() bool {
6772
return len(r.tokens) == 0
6873
}
6974

75+
// AddNodes adds hash tokens for the given nodes to this Ring.
7076
func (r *Ring) AddNodes(nodes ...string) {
7177
for _, node := range nodes {
7278
for i := 0; i < r.tokensPerNode; i++ {
@@ -80,6 +86,7 @@ func (r *Ring) AddNodes(nodes ...string) {
8086
slices.Sort(r.tokens)
8187
}
8288

89+
// Hash hashes the given key onto the ring of tokens and returns the node that belongs to the next token on the ring.
8390
func (r *Ring) Hash(key string) string {
8491
if r.IsEmpty() {
8592
return ""
Lines changed: 72 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2023 Tim Ebert.
2+
Copyright 2025 Tim Ebert.
33
44
Licensed under the Apache License, Version 2.0 (the "License");
55
you may not use this file except in compliance with the License.
@@ -14,63 +14,78 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
package consistenthash
17+
package consistenthash_test
1818

1919
import (
20-
"fmt"
21-
"math"
22-
"testing"
20+
"strings"
21+
22+
. "github.com/onsi/ginkgo/v2"
23+
. "github.com/onsi/gomega"
24+
25+
. "github.com/timebertt/kubernetes-controller-sharding/pkg/sharding/consistenthash"
2326
)
2427

25-
func TestDistribution(t *testing.T) {
26-
ring := New(DefaultHash, DefaultTokensPerNode)
27-
28-
hosts := generateHostnames(10)
29-
dist := make(map[string]float64, len(hosts))
30-
ring.AddNodes(hosts...)
31-
for _, host := range hosts {
32-
dist[host] = 0
33-
}
34-
35-
// fmt.Println("Virtual Nodes:")
36-
last := ring.tokens[len(ring.tokens)-1]
37-
for _, token := range ring.tokens {
38-
node := ring.tokenToNode[token]
39-
percentage := float64(token-last) / math.MaxUint64
40-
dist[node] += percentage
41-
42-
// fmt.Printf("\t%016x (%.5f): %.5f -> %s\n", token, float64(token)/math.MaxUint64, percentage, node)
43-
last = token
44-
}
45-
46-
fmt.Println("Nodes distribution:")
47-
for _, host := range hosts {
48-
fmt.Printf("\t%s: %.5f\n", host, dist[host])
49-
}
50-
}
51-
52-
func generateHostnames(n int) []string {
53-
hosts := make([]string, n)
54-
for i := range hosts {
55-
host := fmt.Sprintf("10.42.0.%d", i)
56-
hosts[i] = host
57-
}
58-
return hosts
59-
}
60-
61-
func benchmarkRing(nodes int, tokensPerNode int, b *testing.B) {
62-
hosts := generateHostnames(nodes)
63-
b.ResetTimer()
64-
65-
for n := 0; n < b.N; n++ {
66-
ring := New(DefaultHash, tokensPerNode, hosts...)
67-
ring.Hash("Website.webhosting.timebertt.dev/project-foo/homepage")
68-
}
69-
}
70-
71-
func BenchmarkRing3_100(b *testing.B) { benchmarkRing(3, 100, b) }
72-
func BenchmarkRing3_1000(b *testing.B) { benchmarkRing(3, 1000, b) }
73-
func BenchmarkRing5_100(b *testing.B) { benchmarkRing(5, 100, b) }
74-
func BenchmarkRing5_1000(b *testing.B) { benchmarkRing(5, 1000, b) }
75-
func BenchmarkRing10_100(b *testing.B) { benchmarkRing(10, 100, b) }
76-
func BenchmarkRing10_1000(b *testing.B) { benchmarkRing(10, 1000, b) }
28+
var _ = Describe("Ring", func() {
29+
Describe("#New", func() {
30+
It("should initialize a new Ring", func() {
31+
ring := New(nil, 0, "foo")
32+
Expect(ring).NotTo(BeNil())
33+
Expect(ring.IsEmpty()).To(BeFalse())
34+
})
35+
})
36+
37+
Describe("#IsEmpty", func() {
38+
It("should true if there are no nodes", func() {
39+
ring := New(nil, 0)
40+
Expect(ring.IsEmpty()).To(BeTrue())
41+
ring.AddNodes("foo")
42+
Expect(ring.IsEmpty()).To(BeFalse())
43+
})
44+
})
45+
46+
Describe("#Hash", func() {
47+
It("should use the configured hash function", func() {
48+
ring := New(func(data string) uint64 {
49+
if strings.HasPrefix(data, "foo") {
50+
// map all foo* nodes and keys to 1
51+
return 1
52+
}
53+
return 2
54+
}, 1, "foo", "bar")
55+
56+
Expect(ring.Hash("foo")).To(Equal("foo"))
57+
Expect(ring.Hash("bar")).To(Equal("bar"))
58+
Expect(ring.Hash("baz")).To(Equal("bar"))
59+
})
60+
61+
It("should use the default hash function", func() {
62+
ring := New(nil, 0, "foo", "bar")
63+
64+
Expect(ring.Hash("1")).NotTo(Equal(ring.Hash("10")))
65+
})
66+
67+
It("should return the empty string if there are no nodes", func() {
68+
ring := New(nil, 0)
69+
70+
Expect(ring.Hash("foo")).To(BeEmpty())
71+
})
72+
73+
It("should return the first node when walking the whole ring", func() {
74+
ring := New(func(data string) uint64 {
75+
if strings.HasPrefix(data, "foo") {
76+
// map all foo* nodes and keys to 1
77+
return 1
78+
}
79+
if strings.HasPrefix(data, "bar") {
80+
// map all bar* nodes and keys to 1
81+
return 2
82+
}
83+
return 3
84+
}, 1, "foo", "bar")
85+
86+
Expect(ring.Hash("foo")).To(Equal("foo"))
87+
Expect(ring.Hash("bar")).To(Equal("bar"))
88+
Expect(ring.Hash("baz")).To(Equal("foo"))
89+
})
90+
})
91+
})

0 commit comments

Comments
 (0)