Files
crypto/hash/consistent/ring.go
T

283 lines
7.2 KiB
Go

// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package consistent
import (
"errors"
"sync"
"github.com/google/btree"
"github.com/luxfi/crypto/hash"
)
var (
_ Ring = (*hashRing)(nil)
_ btree.LessFunc[ringItem] = ringItem.Less
errEmptyRing = errors.New("ring doesn't have any members")
)
// Ring is an interface for a consistent hashing ring
// (ref: https://en.wikipedia.org/wiki/Consistent_hashing).
//
// Consistent hashing is a method of distributing keys across an arbitrary set
// of destinations.
//
// Consider a naive approach, which uses modulo to map a key to a node to serve
// cached requests. Let N be the number of keys and M is the amount of possible
// hashing destinations. Here, a key would be routed to a node based on
// h(key) % M = node assigned.
//
// With this approach, we can route a key to a node in O(1) time, but this
// results in cache misses as nodes are introduced and removed, which results in
// the keys being reshuffled across nodes, as modulo's output changes with M.
// This approach results in O(N) keys being shuffled, which is undesirable for a
// caching use-case.
//
// Consistent hashing works by hashing all keys into a circle, which can be
// visualized as a clock. Keys are routed to a node by hashing the key, and
// searching for the first clockwise neighbor. This requires O(N) memory to
// maintain the state of the ring and log(N) time to route a key using
// binary-search, but results in O(N/M) amount of keys being shuffled when a
// node is added/removed because the addition/removal of a node results in a
// split/merge of its counter-clockwise neighbor's hash space.
//
// As an example, assume we have a ring that supports hashes from 1-12.
//
// 12
// 11 1
//
// 10 2
//
// 9 3
//
// 8 4
//
// 7 5
// 6
//
// Add node 1 (n1). Let h(n1) = 12.
// First, we compute the hash the node, and insert it into its corresponding
// location on the ring.
//
// 12 (n1)
// 11 1
//
// 10 2
//
// 9 3
//
// 8 4
//
// 7 5
// 6
//
// Now, to see which node a key (k1) should map to, we hash the key and search
// for its closest clockwise neighbor.
// Let h(k1) = 3. Here, we see that since n1 is the closest neighbor, as there
// are no other nodes in the ring.
//
// 12 (n1)
// 11 1
//
// 10 2
//
// 9 3 (k1)
//
// 8 4
//
// 7 5
// 6
//
// Now, let's insert another node (n2), such that h(n2) = 6.
// Here we observe that k1 has shuffled to n2, as n2 is the closest clockwise
// neighbor to k1.
//
// 12 (n1)
// 11 1
//
// 10 2
//
// 9 3 (k1)
//
// 8 4
//
// 7 5
// 6 (n2)
//
// Other optimizations can be made to help reduce blast radius of failures and
// the variance in keys (hot shards). One such optimization is introducing
// virtual nodes, which is to replicate a single node multiple times by salting
// it, (e.g n1-0, n1-1...).
//
// Without virtualization, failures of a node cascade as each node failing
// results in the load of the failed node being shuffled into its clockwise
// neighbor, which can result in a sampling effect across the network.
type Ring interface {
RingReader
ringMutator
}
// RingReader is an interface to read values from Ring.
type RingReader interface {
// Get gets the closest clockwise node for a key in the ring.
//
// Each ring member is responsible for the hashes which fall in the range
// between (myself, clockwise-neighbor].
// This behavior is desirable so that we can re-use the return value of Get
// to iterate around the ring (Ex. replication, retries, etc).
//
// Returns the node routed to and an error if we're unable to resolve a node
// to map to.
Get(Hashable) (Hashable, error)
}
// ringMutator defines an interface that mutates Ring.
type ringMutator interface {
// Add adds a node to the ring.
Add(Hashable)
// Remove removes the node from the ring.
//
// Returns true if the node was removed, and false if it wasn't present to
// begin with.
Remove(Hashable) bool
}
// hashRing is an implementation of Ring
type hashRing struct {
// Hashing algorithm to use when hashing keys.
hasher hash.Hasher
// Replication factor for nodes; must be greater than zero.
virtualNodes int
lock sync.RWMutex
ring *btree.BTreeG[ringItem]
}
// RingConfig configures settings for a Ring.
type RingConfig struct {
// Replication factor for nodes in the ring.
VirtualNodes int
// Hashing implementation to use.
Hasher hash.Hasher
// Degree represents the degree of the b-tree
Degree int
}
// NewHashRing instantiates an instance of hashRing.
func NewHashRing(config RingConfig) Ring {
return &hashRing{
hasher: config.Hasher,
virtualNodes: config.VirtualNodes,
ring: btree.NewG(config.Degree, ringItem.Less),
}
}
func (h *hashRing) Get(key Hashable) (Hashable, error) {
h.lock.RLock()
defer h.lock.RUnlock()
return h.get(key)
}
func (h *hashRing) get(key Hashable) (Hashable, error) {
// If we have no members in the ring, it's not possible to find where the
// key belongs.
if h.ring.Len() == 0 {
return nil, errEmptyRing
}
var (
// Compute this key's hash
hash = h.hasher.Hash(key.ConsistentHashKey())
result Hashable
)
h.ring.AscendGreaterOrEqual(
ringItem{
hash: hash,
value: key,
},
func(item ringItem) bool {
if hash < item.hash {
result = item.value
return false
}
return true
},
)
// If found nothing ascending the tree, we need to wrap around the ring to
// the left-most (min) node.
if result == nil {
minNode, _ := h.ring.Min()
result = minNode.value
}
return result, nil
}
func (h *hashRing) Add(key Hashable) {
h.lock.Lock()
defer h.lock.Unlock()
h.add(key)
}
func (h *hashRing) add(key Hashable) {
// Replicate the node in the ring.
hashKey := key.ConsistentHashKey()
for i := 0; i < h.virtualNodes; i++ {
virtualNode := getHashKey(hashKey, i)
virtualNodeHash := h.hasher.Hash(virtualNode)
// Insert it into the ring.
h.ring.ReplaceOrInsert(ringItem{
hash: virtualNodeHash,
value: key,
})
}
}
func (h *hashRing) Remove(key Hashable) bool {
h.lock.Lock()
defer h.lock.Unlock()
return h.remove(key)
}
func (h *hashRing) remove(key Hashable) bool {
var (
hashKey = key.ConsistentHashKey()
removed = false
)
// We need to delete all virtual nodes created for a single node.
for i := 0; i < h.virtualNodes; i++ {
virtualNode := getHashKey(hashKey, i)
virtualNodeHash := h.hasher.Hash(virtualNode)
item := ringItem{
hash: virtualNodeHash,
}
_, removed = h.ring.Delete(item)
}
return removed
}
// getHashKey builds a key given a base key and a virtual node number.
func getHashKey(key []byte, virtualNode int) []byte {
return append(key, byte(virtualNode))
}
// ringItem is a helper class to represent ring nodes in the b-tree.
type ringItem struct {
hash uint64
value Hashable
}
func (r ringItem) Less(than ringItem) bool {
return r.hash < than.hash
}