Skip to content

Commit 41a3763

Browse files
xiaocai2333claude
andcommitted
feat: add the shard routing abstraction for online shard split
Where a write goes is decided in one place instead of at each caller. Today the proxy computes hash(pk) % len(vchannels) inline, which is only correct while every shard owns an equal, position-derived slice of the key space -- exactly what a shard split stops being true. A shard now carries an explicit PREDICATE and the table is derived from the predicates rather than from a channel count: - HashRouting: hash % modulus == remainder, so a split shard's two halves are describable ({2,0} becomes {4,0} and {4,2}) while untouched shards keep the bucket they already had, bit for bit. - RangeRouting: byte-comparable key ranges, for collections sharded by namespace. Both are normalized into a flat lookup. Hash buckets of different moduli -- which a sequence of doublings produces -- are put on M = lcm(all moduli) so a route is one array index rather than a scan over predicates, and DeriveHash rejects a shard set that does not tile the key space exactly: a gap (some key routes nowhere) or an overlap (some key routes to two shards) fails loudly instead of silently misrouting writes. Behaviour is unchanged for every existing collection. A collection whose shards carry no predicate at all is the legacy case, and the table built for it is exactly hash % shardNum by position -- the same placement HashPK2Channels produces, verified against it in the tests. Nothing calls this yet. It is the first step of online shard split (design doc docs/design-docs/design_docs/20260805-shard_split_primary_key_tables.md); the write path, the split state machine and the read-side handover follow in separate PRs, all behind dataCoord.shardSplit.enable. Requires milvus-io/milvus-proto#618 for the schemapb routing types. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: cai.zhang <cai.zhang@zilliz.com>
1 parent dece0ad commit 41a3763

9 files changed

Lines changed: 1828 additions & 0 deletions

File tree

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Licensed to the LF AI & Data foundation under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. 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+
package routing
18+
19+
import (
20+
"github.com/cockroachdb/errors"
21+
)
22+
23+
// HashBucket is one shard's hash predicate: it owns the keys whose hash
24+
// satisfies hash % Modulus == Remainder. An unsplit N-shard collection is
25+
// exactly {Modulus: N, Remainder: k} for shard k, so the legacy hash%N routing
26+
// is expressible with no metadata rewrite (design §2, §3.1).
27+
type HashBucket struct {
28+
Modulus uint64
29+
Remainder uint64
30+
}
31+
32+
// HashShard describes one shard's ownership under hash routing: its vchannel
33+
// and the hash buckets it owns.
34+
type HashShard struct {
35+
Vchannel string
36+
Buckets []HashBucket
37+
}
38+
39+
// HashRoutingTable routes a primary key to a vchannel by a flat hash-bucket
40+
// lookup.
41+
//
42+
// The buckets of the shards may have different moduli after a sequence of
43+
// doubling splits (N, 2N, 4N, ...). They are normalized onto a single modulus
44+
// M = lcm(all moduli) — always a power-of-two multiple of the original shard
45+
// count — so the lookup is one array index instead of a scan over predicates:
46+
//
47+
// route(pk): slots[hash % M]
48+
//
49+
// M is bounded by the collection's shard-count cap, and the table is re-derived
50+
// from the collection meta on every routing-version change.
51+
type HashRoutingTable struct {
52+
// modulus is M, the normalized modulus. Always > 0 for a valid table.
53+
modulus uint64
54+
// slots[i] is the vchannel owning the keys with hash % M == i. A table built
55+
// by DeriveHash has every slot filled — it rejects a shard set that leaves
56+
// one uncovered. A table built by DeriveHashPartial may leave slots empty,
57+
// for a shard set that deliberately covers only part of the key space.
58+
slots []string
59+
}
60+
61+
// DeriveHash builds a HashRoutingTable from the shards of a hash-routed
62+
// collection.
63+
//
64+
// It validates that the buckets tile the key space exactly: normalized onto
65+
// M = lcm(moduli), every residue must be claimed by exactly one shard. A gap
66+
// (some key routes nowhere) or an overlap (some key routes to two shards) is
67+
// rejected, so a malformed routing meta fails loudly instead of silently
68+
// mis-routing writes.
69+
func DeriveHash(shards []HashShard) (*HashRoutingTable, error) {
70+
table, err := DeriveHashPartial(shards)
71+
if err != nil {
72+
return nil, err
73+
}
74+
// Every slot must be owned by someone, or some key would route nowhere.
75+
for r, ch := range table.slots {
76+
if ch == "" {
77+
return nil, errors.Newf("hash routing gap: residue %d (mod %d) is unowned", r, table.modulus)
78+
}
79+
}
80+
return table, nil
81+
}
82+
83+
// DeriveHashPartial builds a HashRoutingTable from shards that need only be
84+
// mutually disjoint, not a cover of the whole key space. Residues no shard
85+
// claims stay unowned, and LookupOK reports them.
86+
//
87+
// This is the shape a shard split's targets have while the split is in flight:
88+
// a doubling's two targets, say {4,0} and {4,2}, tile exactly the keys of their
89+
// source's {2,0} bucket and deliberately claim nothing else. Rejecting that as a
90+
// gap — which is what a whole-space cover requires — would be wrong; what must
91+
// still be rejected is an overlap, since that would send one key to two shards.
92+
func DeriveHashPartial(shards []HashShard) (*HashRoutingTable, error) {
93+
if len(shards) == 0 {
94+
return nil, errors.New("hash routing table needs at least one shard")
95+
}
96+
97+
// M = lcm of every bucket modulus.
98+
m := uint64(1)
99+
for _, s := range shards {
100+
for _, b := range s.Buckets {
101+
if b.Modulus == 0 {
102+
return nil, errors.Newf("shard %q has a zero-modulus hash bucket", s.Vchannel)
103+
}
104+
if b.Remainder >= b.Modulus {
105+
return nil, errors.Newf("shard %q has bucket remainder %d >= modulus %d",
106+
s.Vchannel, b.Remainder, b.Modulus)
107+
}
108+
var err error
109+
if m, err = lcm(m, b.Modulus); err != nil {
110+
return nil, errors.Wrapf(err, "shard %q modulus %d", s.Vchannel, b.Modulus)
111+
}
112+
}
113+
}
114+
if m > maxNormalizedModulus {
115+
return nil, errors.Newf("normalized hash modulus %d exceeds the cap %d", m, maxNormalizedModulus)
116+
}
117+
118+
slots := make([]string, m)
119+
for _, s := range shards {
120+
for _, b := range s.Buckets {
121+
// Expand bucket (modulus, remainder) onto the normalized modulus:
122+
// every residue r < M with r % modulus == remainder belongs to it.
123+
for r := b.Remainder; r < m; r += b.Modulus {
124+
if slots[r] != "" {
125+
return nil, errors.Newf(
126+
"hash routing overlap at residue %d (mod %d): shards %q and %q",
127+
r, m, slots[r], s.Vchannel)
128+
}
129+
slots[r] = s.Vchannel
130+
}
131+
}
132+
}
133+
134+
return &HashRoutingTable{modulus: m, slots: slots}, nil
135+
}
136+
137+
// DeriveHashCompat builds the table of a never-split collection: shard i owns
138+
// {Modulus: len(channels), Remainder: i}, i.e. exactly the legacy
139+
// typeutil.HashPK2Channels behaviour. Used for collections whose meta carries no
140+
// explicit routing predicate yet.
141+
func DeriveHashCompat(channels []string) (*HashRoutingTable, error) {
142+
shards := make([]HashShard, 0, len(channels))
143+
for i, ch := range channels {
144+
shards = append(shards, HashShard{
145+
Vchannel: ch,
146+
Buckets: []HashBucket{{Modulus: uint64(len(channels)), Remainder: uint64(i)}},
147+
})
148+
}
149+
return DeriveHash(shards)
150+
}
151+
152+
// NumSlots returns the normalized modulus M.
153+
func (t *HashRoutingTable) NumSlots() uint64 { return t.modulus }
154+
155+
// Lookup returns the vchannel owning the given key hash. On a table from
156+
// DeriveHash every hash has an owner; on a partial table an unowned residue
157+
// returns "", which LookupOK distinguishes explicitly.
158+
func (t *HashRoutingTable) Lookup(rawHash uint64) string {
159+
return t.slots[rawHash%t.modulus]
160+
}
161+
162+
// LookupOK returns the vchannel owning the given key hash, and whether any shard
163+
// claims it. Callers over a partial table must use this rather than testing
164+
// Lookup against "": a key that belongs to no target is a malformed plan, and
165+
// guessing an owner would silently misplace rows.
166+
func (t *HashRoutingTable) LookupOK(rawHash uint64) (string, bool) {
167+
ch := t.slots[rawHash%t.modulus]
168+
return ch, ch != ""
169+
}
170+
171+
// SplitBuckets returns the two buckets a doubling split of b produces: the same
172+
// remainder at twice the modulus, and that remainder shifted by the old modulus.
173+
// Together they cover exactly the keys b covered, cut on the next hash bit
174+
// (design §3.1).
175+
func SplitBuckets(b HashBucket) (HashBucket, HashBucket) {
176+
return HashBucket{Modulus: b.Modulus * 2, Remainder: b.Remainder},
177+
HashBucket{Modulus: b.Modulus * 2, Remainder: b.Remainder + b.Modulus}
178+
}
179+
180+
// maxNormalizedModulus caps the normalized modulus M so a malformed or
181+
// pathological meta cannot allocate an enormous slot array. It is far above the
182+
// reachable shard count (which is itself capped by the pchannel count).
183+
const maxNormalizedModulus = 1 << 20
184+
185+
// lcm returns the least common multiple of a and b, erroring on overflow.
186+
func lcm(a, b uint64) (uint64, error) {
187+
g := gcd(a, b)
188+
q := a / g
189+
if q != 0 && b > maxNormalizedModulus/q {
190+
return 0, errors.Newf("hash modulus lcm(%d, %d) overflows the cap", a, b)
191+
}
192+
return q * b, nil
193+
}
194+
195+
func gcd(a, b uint64) uint64 {
196+
for b != 0 {
197+
a, b = b, a%b
198+
}
199+
return a
200+
}

0 commit comments

Comments
 (0)