-
Notifications
You must be signed in to change notification settings - Fork 33
Expand file tree
/
Copy pathmaglev.go
More file actions
240 lines (205 loc) · 6.9 KB
/
Copy pathmaglev.go
File metadata and controls
240 lines (205 loc) · 6.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
// Package maglev implements Google's Maglev consistent hashing algorithm.
//
// A Maglev instance owns a lookup table of M slots (M must be prime) and
// assigns every slot to one of the N configured backends. Resolving a key is a
// single hash plus one table read, and adding or removing a backend only
// reshuffles roughly 1/N of the slots, so existing keys keep landing on the
// same backend.
//
// See the original paper for the details of the algorithm:
// https://research.google/pubs/pub44824/
package maglev
import (
"errors"
"fmt"
"math/big"
"slices"
"sync"
"github.com/cespare/xxhash/v2"
)
// BigM is a convenient prime lookup table size. The paper recommends a table
// at least ~100x larger than the number of backends so that the slots spread
// evenly.
const BigM uint64 = 65537
// Salts for the two independent hashes each backend needs: one picks where its
// preference list starts, the other how far it steps between preferences.
const (
offsetSalt byte = 0xba
skipSalt byte = 0xbe
)
// Errors reported by this package. They are wrapped with context, so test for
// them with errors.Is rather than comparing error strings.
var (
ErrTableSizeNotPrime = errors.New("maglev: lookup table size is not a prime number")
ErrTooManyBackends = errors.New("maglev: more backends than lookup table slots")
ErrBackendExists = errors.New("maglev: backend already exists")
ErrBackendNotFound = errors.New("maglev: backend not found")
ErrNoBackends = errors.New("maglev: no backends configured")
)
// Maglev is a consistent hash ring. It is safe for concurrent use.
type Maglev struct {
mu sync.RWMutex // guards every field below
m uint64 // number of lookup table slots, always prime
nodeList []string // backend names, always kept sorted
permutation [][]uint64 // permutation[i] is backend i's preference list of slots
lookup []int // lookup[slot] is the index in nodeList owning that slot
}
// NewMaglev returns a Maglev populated with backends and a lookup table of m
// slots. m must be a prime number and no smaller than len(backends).
func NewMaglev(backends []string, m uint64) (*Maglev, error) {
if !big.NewInt(0).SetUint64(m).ProbablyPrime(1) {
return nil, fmt.Errorf("%w: %d", ErrTableSizeNotPrime, m)
}
mg := &Maglev{m: m}
if err := mg.Set(backends); err != nil {
return nil, err
}
return mg, nil
}
// Set replaces the whole backend list and rebuilds the lookup table.
func (mg *Maglev) Set(backends []string) error {
mg.mu.Lock()
defer mg.mu.Unlock()
if uint64(len(backends)) > mg.m {
return fmt.Errorf("%w: %d backends, %d slots", ErrTooManyBackends, len(backends), mg.m)
}
// Clone so that later edits by the caller cannot corrupt the table.
mg.nodeList = slices.Clone(backends)
mg.rebuild()
return nil
}
// Add inserts a single backend. It reports ErrBackendExists if the backend is
// already present.
func (mg *Maglev) Add(backend string) error {
mg.mu.Lock()
defer mg.mu.Unlock()
// nodeList is kept sorted, so a binary search finds both the duplicate and
// the insertion point in one step.
i, found := slices.BinarySearch(mg.nodeList, backend)
if found {
return fmt.Errorf("%w: %q", ErrBackendExists, backend)
}
if uint64(len(mg.nodeList)) >= mg.m {
return fmt.Errorf("%w: %d slots", ErrTooManyBackends, mg.m)
}
mg.nodeList = slices.Insert(mg.nodeList, i, backend)
mg.rebuild()
return nil
}
// Remove deletes a single backend. It reports ErrBackendNotFound if the
// backend is unknown.
func (mg *Maglev) Remove(backend string) error {
mg.mu.Lock()
defer mg.mu.Unlock()
i, found := slices.BinarySearch(mg.nodeList, backend)
if !found {
return fmt.Errorf("%w: %q", ErrBackendNotFound, backend)
}
mg.nodeList = slices.Delete(mg.nodeList, i, i+1)
mg.rebuild()
return nil
}
// Clear drops every backend. The lookup table size is kept, so the Maglev can
// be refilled with Set or Add.
func (mg *Maglev) Clear() {
mg.mu.Lock()
defer mg.mu.Unlock()
mg.nodeList = nil
mg.permutation = nil
mg.lookup = nil
}
// Backends returns a copy of the current backend list, in sorted order.
func (mg *Maglev) Backends() []string {
mg.mu.RLock()
defer mg.mu.RUnlock()
return slices.Clone(mg.nodeList)
}
// Get returns the backend that owns obj. It reports ErrNoBackends when no
// backend is configured.
func (mg *Maglev) Get(obj string) (string, error) {
mg.mu.RLock()
defer mg.mu.RUnlock()
if len(mg.nodeList) == 0 {
return "", ErrNoBackends
}
slot := hashKey(obj) % mg.m
return mg.nodeList[mg.lookup[slot]], nil
}
// rebuild recomputes the preference lists and the lookup table from nodeList.
// The caller must hold mg.mu for writing.
func (mg *Maglev) rebuild() {
// Sorting makes the result depend only on the set of backends, never on
// the order they were added in.
slices.Sort(mg.nodeList)
mg.buildPermutations()
mg.buildLookup()
}
// buildPermutations computes each backend's preference list: backend i prefers
// slot (offset_i + j*skip_i) mod m for j = 0..m-1. Because m is prime and
// 1 <= skip < m, every row is a full permutation of the table, which
// guarantees a backend can always walk down its list until it finds a free
// slot.
func (mg *Maglev) buildPermutations() {
if len(mg.nodeList) == 0 {
mg.permutation = nil
return
}
mg.permutation = make([][]uint64, len(mg.nodeList))
for i, node := range mg.nodeList {
name := []byte(node)
offset := saltedHash(offsetSalt, name) % mg.m
skip := saltedHash(skipSalt, name)%(mg.m-1) + 1
row := make([]uint64, mg.m)
// Stepping instead of computing offset+j*skip keeps the arithmetic
// bounded by m, so it cannot overflow for large tables.
slot := offset
for j := range mg.m {
row[j] = slot
slot = (slot + skip) % mg.m
}
mg.permutation[i] = row
}
}
// buildLookup fills the lookup table by letting the backends take turns
// claiming the most preferred slot they have not lost yet, until every slot is
// owned. This is the "populate" procedure from the paper.
func (mg *Maglev) buildLookup() {
n := uint64(len(mg.nodeList))
if n == 0 {
mg.lookup = nil
return
}
lookup := make([]int, mg.m)
for i := range lookup {
lookup[i] = -1 // -1 marks a slot nobody owns yet
}
next := make([]uint64, n) // next[i]: how far backend i walked down its list
for filled := uint64(0); ; {
for i := range n {
slot := mg.permutation[i][next[i]]
for lookup[slot] >= 0 { // slot already taken, try the next preference
next[i]++
slot = mg.permutation[i][next[i]]
}
lookup[slot] = int(i)
next[i]++
filled++
if filled == mg.m {
mg.lookup = lookup
return
}
}
}
}
func hashKey(obj string) uint64 {
return xxhash.Sum64String(obj)
}
// saltedHash mixes salt into the hash so that offset and skip are derived from
// independent hash values. Reusing a single unsalted hash for both would
// correlate them and weaken the permutation.
func saltedHash(salt byte, data []byte) uint64 {
d := xxhash.New()
d.Write([]byte{salt})
d.Write(data)
return d.Sum64()
}