Skip to content

Commit 3139726

Browse files
committed
autosharding: implement the sliceMap
1 parent 6d697e4 commit 3139726

3 files changed

Lines changed: 370 additions & 0 deletions

File tree

balancer/autosharding/autosharding.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ import (
2525
"time"
2626

2727
"google.golang.org/grpc/balancer"
28+
"google.golang.org/grpc/balancer/endpointsharding"
2829
iserviceconfig "google.golang.org/grpc/internal/serviceconfig"
30+
"google.golang.org/grpc/resolver"
2931
"google.golang.org/grpc/serviceconfig"
3032
)
3133

@@ -74,6 +76,36 @@ func (bb) Build(balancer.ClientConn, balancer.BuildOptions) balancer.Balancer {
7476
return &autoshardingBalancer{}
7577
}
7678

79+
// slice represents a key range and its assigned endpoints.
80+
//
81+
//lint:ignore U1000 Struct fields planned for future implementation
82+
type slice struct {
83+
startKey []byte // Inclusive start key of the key-range
84+
endKey []byte // Exclusive, nil for sentinel/infinity
85+
endpoints []int // Indices into assignment.endpointNames
86+
}
87+
88+
// assignment represents a complete snapshot of sharding assignments.
89+
type assignment struct {
90+
slices []slice // Sorted by startKey
91+
endpointNames []string // Complete list of endpoint names
92+
generation int64
93+
}
94+
95+
// endpointState represents the state associated with an endpoint in the LB policy.
96+
//
97+
//lint:ignore U1000 Struct fields planned for future implementation
98+
type endpointState struct {
99+
index int // Index of the endpoint within the NR update
100+
endpoint resolver.Endpoint // The actual endpoint returned by the NR
101+
childState endpointsharding.ChildState // State as reported by the child policy
102+
}
103+
104+
// endpointMap maps from endpoint hostname to endpoint state.
105+
type endpointMap struct {
106+
m map[string]*endpointState
107+
}
108+
77109
type autoshardingBalancer struct {
78110
balancer.Balancer
79111
}

balancer/autosharding/slice_map.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/*
2+
*
3+
* Copyright 2026 gRPC authors.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
package autosharding
20+
21+
import (
22+
"bytes"
23+
"slices"
24+
"sort"
25+
)
26+
27+
// sliceMapEntry represents an entry for a key-range in the sliceMap.
28+
type sliceMapEntry struct {
29+
startKey []byte // Inclusive start key of the key-range
30+
endpoints []int // Indices into list[PickerEndpoint] in the Picker
31+
}
32+
33+
// sliceMap is a data structure optimized for lookups. Given a key, it returns a
34+
// matching key-range.
35+
//
36+
// The sliceMap must be immutable, allowing the Picker to access it without any
37+
// explicit synchronization with the LB policy.
38+
//
39+
// The sliceMap is meant to be used by the Picker in conjunction with a list of
40+
// pickerEndpoints such that the list can be swapped out, as long as the number
41+
// and order of endpoints don't change.
42+
type sliceMap struct {
43+
slices []sliceMapEntry // Sorted by startKey
44+
fallbackPool []int // Indices into list[PickerEndpoint] for all endpoints
45+
generation int64 // Snapshot generation number
46+
}
47+
48+
// lookup returns the index of the slice covering the given key.
49+
//
50+
// Because assignments are pre-validated to have no gaps and cover the full key
51+
// range, and since slices is sorted by startKey, lookup boils down to a binary
52+
// search, looking for the insertion point.
53+
//
54+
// If the key matches the startKey of a slice, that slice index is returned.
55+
// Otherwise, the index of the slice immediately preceding the insertion point
56+
// is returned (i.e., the slice covering the range [startKey, nextStartKey)).
57+
//
58+
// Returns -1 when the sliceMap is empty, which is the case before the first
59+
// assignment is received.
60+
func (sm *sliceMap) lookup(key []byte) int {
61+
if len(sm.slices) == 0 {
62+
return -1
63+
}
64+
65+
idx, found := slices.BinarySearchFunc(sm.slices, key, func(e sliceMapEntry, k []byte) int {
66+
return bytes.Compare(e.startKey, k)
67+
})
68+
69+
if found {
70+
return idx
71+
}
72+
73+
// Key falls in range [slices[idx - 1].startKey, slices[idx].startKey).
74+
return idx - 1
75+
}
76+
77+
// buildSliceMap is used to generate a new sliceMap from the EndpointMap and
78+
// Assignment when either of them change.
79+
func buildSliceMap(endpointMap *endpointMap, assignment *assignment) *sliceMap {
80+
sm := &sliceMap{}
81+
82+
// Populate fallbackPool deterministically sorted by endpoint index.
83+
states := make([]*endpointState, 0, len(endpointMap.m))
84+
for _, es := range endpointMap.m {
85+
states = append(states, es)
86+
}
87+
sort.Slice(states, func(i, j int) bool {
88+
return states[i].index < states[j].index
89+
})
90+
sm.fallbackPool = make([]int, len(states))
91+
for i, es := range states {
92+
sm.fallbackPool[i] = es.index
93+
}
94+
95+
// If no assignment has been received yet (startup case), return early with
96+
// empty slices.
97+
if assignment == nil {
98+
return sm
99+
}
100+
101+
sm.generation = assignment.generation
102+
103+
// Build sliceMapEntry for each Slice in the assignment.
104+
sm.slices = make([]sliceMapEntry, 0, len(assignment.slices))
105+
for _, s := range assignment.slices {
106+
entry := sliceMapEntry{
107+
startKey: s.startKey,
108+
endpoints: []int{},
109+
}
110+
111+
for _, idx := range s.endpoints {
112+
hostname := assignment.endpointNames[idx]
113+
if es, ok := endpointMap.m[hostname]; ok {
114+
entry.endpoints = append(entry.endpoints, es.index)
115+
}
116+
}
117+
118+
sm.slices = append(sm.slices, entry)
119+
}
120+
121+
return sm
122+
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/*
2+
*
3+
* Copyright 2026 gRPC authors.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
package autosharding
20+
21+
import (
22+
"bytes"
23+
"fmt"
24+
"slices"
25+
"testing"
26+
27+
"github.com/google/go-cmp/cmp"
28+
)
29+
30+
func (s) TestSliceMap_Lookup(t *testing.T) {
31+
sm := &sliceMap{
32+
slices: []sliceMapEntry{
33+
{startKey: []byte("b"), endpoints: []int{1}},
34+
{startKey: []byte("d"), endpoints: []int{2}},
35+
{startKey: []byte("f"), endpoints: []int{3}},
36+
},
37+
}
38+
39+
tests := []struct {
40+
name string
41+
key []byte
42+
want int
43+
}{
44+
{
45+
name: "exact-match-first",
46+
key: []byte("b"),
47+
want: 0,
48+
},
49+
{
50+
name: "exact-match-middle",
51+
key: []byte("d"),
52+
want: 1,
53+
},
54+
{
55+
name: "exact-match-last",
56+
key: []byte("f"),
57+
want: 2,
58+
},
59+
{
60+
name: "between-first-and-middle",
61+
key: []byte("c"),
62+
want: 0,
63+
},
64+
{
65+
name: "between-middle-and-last",
66+
key: []byte("e"),
67+
want: 1,
68+
},
69+
{
70+
name: "after-last",
71+
key: []byte("g"),
72+
want: 2,
73+
},
74+
}
75+
76+
for _, tc := range tests {
77+
t.Run(tc.name, func(t *testing.T) {
78+
if got := sm.lookup(tc.key); got != tc.want {
79+
t.Errorf("lookup(%q) = %d, want %d", tc.key, got, tc.want)
80+
}
81+
})
82+
}
83+
}
84+
85+
func (s) TestSliceMap_Lookup_Empty(t *testing.T) {
86+
sm := &sliceMap{}
87+
if got := sm.lookup([]byte("a")); got != -1 {
88+
t.Errorf("lookup() on empty map = %d, want -1", got)
89+
}
90+
}
91+
92+
func (s) TestBuildSliceMap(t *testing.T) {
93+
// Setup endpointMap with unsorted order in map to verify deterministic
94+
// sorting of fallbackPool.
95+
epMap := &endpointMap{
96+
m: map[string]*endpointState{
97+
"hostC": {index: 2},
98+
"hostA": {index: 0},
99+
"hostB": {index: 1},
100+
},
101+
}
102+
103+
tests := []struct {
104+
name string
105+
assignment *assignment
106+
want *sliceMap
107+
}{
108+
{
109+
name: "nil-assignment-startup",
110+
assignment: nil,
111+
want: &sliceMap{fallbackPool: []int{0, 1, 2}},
112+
},
113+
{
114+
name: "valid-assignment",
115+
assignment: &assignment{
116+
endpointNames: []string{"hostA", "hostB", "hostC", "hostD"},
117+
slices: []slice{
118+
{startKey: []byte("a"), endpoints: []int{0, 1}}, // hostA, hostB -> indices 0, 1
119+
{startKey: []byte("m"), endpoints: []int{1, 2}}, // hostB, hostC -> indices 1, 2
120+
},
121+
generation: 42,
122+
},
123+
want: &sliceMap{
124+
slices: []sliceMapEntry{
125+
{startKey: []byte("a"), endpoints: []int{0, 1}},
126+
{startKey: []byte("m"), endpoints: []int{1, 2}},
127+
},
128+
fallbackPool: []int{0, 1, 2},
129+
generation: 42,
130+
},
131+
},
132+
{
133+
name: "assignment-with-unknown-host",
134+
assignment: &assignment{
135+
endpointNames: []string{"hostA", "hostUnknown", "hostC"},
136+
slices: []slice{
137+
{startKey: []byte("a"), endpoints: []int{0, 1}}, // hostUnknown is skipped
138+
},
139+
generation: 43,
140+
},
141+
want: &sliceMap{
142+
slices: []sliceMapEntry{
143+
{startKey: []byte("a"), endpoints: []int{0}}, // Only hostA (index 0)
144+
},
145+
fallbackPool: []int{0, 1, 2},
146+
generation: 43,
147+
},
148+
},
149+
}
150+
151+
for _, tc := range tests {
152+
t.Run(tc.name, func(t *testing.T) {
153+
got := buildSliceMap(epMap, tc.assignment)
154+
if diff := cmp.Diff(tc.want, got); diff != "" {
155+
t.Errorf("buildSliceMap() diff (-want +got):\n%s", diff)
156+
}
157+
})
158+
}
159+
}
160+
161+
func (e sliceMapEntry) Equal(b sliceMapEntry) bool {
162+
return bytes.Equal(e.startKey, b.startKey) && slices.Equal(e.endpoints, b.endpoints)
163+
}
164+
165+
func (sm *sliceMap) Equal(b *sliceMap) bool {
166+
if sm == nil || b == nil {
167+
return sm == b
168+
}
169+
fallbackPoolEqual := slices.Equal(sm.fallbackPool, b.fallbackPool)
170+
slicesEqual := slices.EqualFunc(sm.slices, b.slices, func(x, y sliceMapEntry) bool { return x.Equal(y) })
171+
return sm.generation == b.generation && fallbackPoolEqual && slicesEqual
172+
}
173+
174+
func BenchmarkSliceMap_Lookup(b *testing.B) {
175+
for _, numSlices := range []int{1, 10, 100, 1000, 10000} {
176+
for _, keySize := range []int{16, 32, 64, 128, 256, 512} {
177+
b.Run(fmt.Sprintf("slices_%d_keySize_%d", numSlices, keySize), func(b *testing.B) {
178+
sm := &sliceMap{
179+
slices: make([]sliceMapEntry, numSlices),
180+
}
181+
for i := 0; i < numSlices; i++ {
182+
// Generate lexicographically sorted keys to serve as slice
183+
// boundaries. We multiply by 1000 to create ranges (gaps) between
184+
// successive slices (e.g., Slice 0 covers [0, 1000), Slice 1 covers
185+
// [1000, 2000)). This allows us to test lookups that fall
186+
// *mid-slice*, rather than just exact matches.
187+
//
188+
// We use zero-padding on the left via "%0*d" (where * is the keySize
189+
// width). This fills the key to its full length (e.g., 512 bytes)
190+
// with leading zeros. Scanning long identical prefixes forces
191+
// bytes.Compare to traverse the full length, simulating a
192+
// conservative, worst-case latency scenario.
193+
key := fmt.Appendf(nil, "%0*d", keySize, i*1000)
194+
sm.slices[i] = sliceMapEntry{startKey: key, endpoints: []int{i}}
195+
}
196+
197+
// Pre-generate a pool of lookup keys. This helps avoid
198+
// measuring string formatting overhead inside the timer loop.
199+
lookupKeys := make([][]byte, 10000)
200+
for i := 0; i < 10000; i++ {
201+
// Distribute the 10,000 lookup keys proportionally across the entire
202+
// synthetic keyspace [0, numSlices * 1000). This ensures a good mix
203+
// of boundary hits and interior hits.
204+
val := i * (numSlices * 1000) / 10000
205+
lookupKeys[i] = fmt.Appendf(nil, "%0*d", keySize, val)
206+
}
207+
208+
var i int
209+
for b.Loop() {
210+
sm.lookup(lookupKeys[i%10000])
211+
i++
212+
}
213+
})
214+
}
215+
}
216+
}

0 commit comments

Comments
 (0)