From afa53434ad98cc14bb469e5a192c7934493ece2e Mon Sep 17 00:00:00 2001 From: Tim Ebert Date: Wed, 19 Feb 2025 23:09:05 +0100 Subject: [PATCH 1/3] Rename existing benchmark test file --- pkg/sharding/consistenthash/{ring_test.go => benchmark_test.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename pkg/sharding/consistenthash/{ring_test.go => benchmark_test.go} (100%) diff --git a/pkg/sharding/consistenthash/ring_test.go b/pkg/sharding/consistenthash/benchmark_test.go similarity index 100% rename from pkg/sharding/consistenthash/ring_test.go rename to pkg/sharding/consistenthash/benchmark_test.go From 608b03d9db36e0909136d3644d21ec7068ec2347 Mon Sep 17 00:00:00 2001 From: Tim Ebert Date: Wed, 19 Feb 2025 23:10:13 +0100 Subject: [PATCH 2/3] Add more comments --- pkg/sharding/consistenthash/ring.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/pkg/sharding/consistenthash/ring.go b/pkg/sharding/consistenthash/ring.go index 10624490..c4dfd496 100644 --- a/pkg/sharding/consistenthash/ring.go +++ b/pkg/sharding/consistenthash/ring.go @@ -32,10 +32,13 @@ var DefaultHash Hash = xxhash.Sum64String // DefaultTokensPerNode is the default number of virtual nodes per node. const DefaultTokensPerNode = 100 -// New creates a new hash ring. -func New(fn Hash, tokensPerNode int, initialNodes ...string) *Ring { - if fn == nil { - fn = DefaultHash +// New creates a new hash ring with the given configuration and adds the given nodes. +// The given Hash (or DefaultHash if nil) is used to hash nodes and keys (strings). +// Each node is assigned tokensPerNode tokens (or DefaultTokensPerNode if <= 0) – aka. virtual nodes – for a more +// uniform key distribution. +func New(hash Hash, tokensPerNode int, initialNodes ...string) *Ring { + if hash == nil { + hash = DefaultHash } if tokensPerNode <= 0 { tokensPerNode = DefaultTokensPerNode @@ -43,7 +46,7 @@ func New(fn Hash, tokensPerNode int, initialNodes ...string) *Ring { numTokens := len(initialNodes) * tokensPerNode r := &Ring{ - hash: fn, + hash: hash, tokensPerNode: tokensPerNode, tokens: make([]uint64, 0, numTokens), @@ -53,8 +56,9 @@ func New(fn Hash, tokensPerNode int, initialNodes ...string) *Ring { return r } -// Ring implements consistent hashing, aka ring hash (not thread-safe). -// It hashes nodes and keys onto a ring of tokens. Keys are mapped to the next node on the ring. +// Ring implements consistent hashing, aka. ring hash (not thread-safe). +// It hashes nodes and keys (strings) onto a ring of tokens. Keys are mapped to the next token (node) on the ring. +// Nodes cannot be removed. Instantiate a new Ring instead. type Ring struct { hash Hash tokensPerNode int @@ -63,10 +67,12 @@ type Ring struct { tokenToNode map[uint64]string } +// IsEmpty returns true if there are no nodes in this Ring. func (r *Ring) IsEmpty() bool { return len(r.tokens) == 0 } +// AddNodes adds hash tokens for the given nodes to this Ring. func (r *Ring) AddNodes(nodes ...string) { for _, node := range nodes { for i := 0; i < r.tokensPerNode; i++ { @@ -80,6 +86,7 @@ func (r *Ring) AddNodes(nodes ...string) { slices.Sort(r.tokens) } +// Hash hashes the given key onto the ring of tokens and returns the node that belongs to the next token on the ring. func (r *Ring) Hash(key string) string { if r.IsEmpty() { return "" From 7daf563dd31e3133f403e0a04a3a5c468eacdb15 Mon Sep 17 00:00:00 2001 From: Tim Ebert Date: Wed, 19 Feb 2025 23:11:54 +0100 Subject: [PATCH 3/3] Add unit tests for `pkg/sharding/consistenthash` --- .../consistenthash_suite_test.go | 29 ++++++ pkg/sharding/consistenthash/ring_test.go | 91 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 pkg/sharding/consistenthash/consistenthash_suite_test.go create mode 100644 pkg/sharding/consistenthash/ring_test.go diff --git a/pkg/sharding/consistenthash/consistenthash_suite_test.go b/pkg/sharding/consistenthash/consistenthash_suite_test.go new file mode 100644 index 00000000..3dfaf892 --- /dev/null +++ b/pkg/sharding/consistenthash/consistenthash_suite_test.go @@ -0,0 +1,29 @@ +/* +Copyright 2025 Tim Ebert. + +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. +*/ + +package consistenthash_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestConsistentHash(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Consistent Hash Suite") +} diff --git a/pkg/sharding/consistenthash/ring_test.go b/pkg/sharding/consistenthash/ring_test.go new file mode 100644 index 00000000..9717e549 --- /dev/null +++ b/pkg/sharding/consistenthash/ring_test.go @@ -0,0 +1,91 @@ +/* +Copyright 2025 Tim Ebert. + +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. +*/ + +package consistenthash_test + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "github.com/timebertt/kubernetes-controller-sharding/pkg/sharding/consistenthash" +) + +var _ = Describe("Ring", func() { + Describe("#New", func() { + It("should initialize a new Ring", func() { + ring := New(nil, 0, "foo") + Expect(ring).NotTo(BeNil()) + Expect(ring.IsEmpty()).To(BeFalse()) + }) + }) + + Describe("#IsEmpty", func() { + It("should true if there are no nodes", func() { + ring := New(nil, 0) + Expect(ring.IsEmpty()).To(BeTrue()) + ring.AddNodes("foo") + Expect(ring.IsEmpty()).To(BeFalse()) + }) + }) + + Describe("#Hash", func() { + It("should use the configured hash function", func() { + ring := New(func(data string) uint64 { + if strings.HasPrefix(data, "foo") { + // map all foo* nodes and keys to 1 + return 1 + } + return 2 + }, 1, "foo", "bar") + + Expect(ring.Hash("foo")).To(Equal("foo")) + Expect(ring.Hash("bar")).To(Equal("bar")) + Expect(ring.Hash("baz")).To(Equal("bar")) + }) + + It("should use the default hash function", func() { + ring := New(nil, 0, "foo", "bar") + + Expect(ring.Hash("1")).NotTo(Equal(ring.Hash("10"))) + }) + + It("should return the empty string if there are no nodes", func() { + ring := New(nil, 0) + + Expect(ring.Hash("foo")).To(BeEmpty()) + }) + + It("should return the first node when walking the whole ring", func() { + ring := New(func(data string) uint64 { + if strings.HasPrefix(data, "foo") { + // map all foo* nodes and keys to 1 + return 1 + } + if strings.HasPrefix(data, "bar") { + // map all bar* nodes and keys to 1 + return 2 + } + return 3 + }, 1, "foo", "bar") + + Expect(ring.Hash("foo")).To(Equal("foo")) + Expect(ring.Hash("bar")).To(Equal("bar")) + Expect(ring.Hash("baz")).To(Equal("foo")) + }) + }) +})