Skip to content

Add shard routing types and the SplitShard message type for online shard split - #618

Open
xiaocai2333 wants to merge 1 commit into
milvus-io:masterfrom
xiaocai2333:feat-shard-split-routing
Open

Add shard routing types and the SplitShard message type for online shard split#618
xiaocai2333 wants to merge 1 commit into
milvus-io:masterfrom
xiaocai2333:feat-shard-split-routing

Conversation

@xiaocai2333

@xiaocai2333 xiaocai2333 commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

issue: #619

A collection's shard count is fixed at creation today. Online shard split changes it without taking the collection offline, and this PR adds the types the pieces of that agree on: what a shard is, which keys it owns, and how a row's routing key is computed.

Additive. No existing field is removed or renumbered, and no field number is skipped. One released field, DescribeCollectionResponse.shards_num, changes meaning — see below.

schema.proto

  • ShardStateShardNormal / ShardCreating / ShardSplitting / ShardDropped. Which shards take writes, serve reads, and own keys differ during a split, and this is what says so. The zero value is the legacy shape.
  • CollectionShardInfo — one shard's state, its vchannel, and its routing predicate. Only what is true of the shard itself; a split is a relation between shards and belongs to the split task, which ends.
  • HashRouting — the residues of the collection's modulus that shard owns.

CollectionShardInfo supersedes etcdpb.CollectionShardInfo, a separate message that happens to share its name and holds a single truncate tick in the collection meta today. The meta swaps one for the other at the same field number, so field 1 here is that same tick and cannot be renumbered: records written before the swap decode with their remaining fields at zero, which reads as a normal, never-split shard — which is what they are.

milvus.proto

DescribeCollectionResponse gains:

  • shard_infos — index-parallel to virtual_channel_names.
  • shard_by — the expression producing a row's routing value, hash(<field>).
  • routing_modulus — the one modulus the whole collection's residues are taken against.

shards_num keeps its number and changes meaning: it is the count of shards serving now, which a split moves, rather than the CreateCollection argument. A tool that recreates a collection from it does so at the current count. ShowCollectionsResponse.shards_num follows, and CreateCollectionRequest.shards_num's comment no longer claims the count is fixed for the collection's life — there is no API to change that argument, which is a different statement.

common.proto

MsgType_SplitShard = 120 — the legacy msgstream type the SplitShard WAL message maps to. Numbered with the collection DDL, not the WAL group: pkg/streaming/util/message/adaptor numbers a WAL message by the operation it represents, which is why CreateCollection and TruncateCollection are 100 and 119 rather than sitting beside AlterWAL.

Placement is by modulus

The legacy rule has two halves — hash the collection's routing key, then take the remainder modulo the shard count — and this PR names both.

shard_by is the first half and is immutable: changing which field is hashed changes where every existing row belongs, and no split expresses that. Before it, the second half was on the wire and the first was left to inference — a consumer had to decide from the schema whether the routing key was a namespace or a primary key, which is wrong for anyone guessing "primary key" on a namespace-sharded collection.

The predicates in shard_infos are the second half, and they move with every split. So hash(pk) does not mean hash(pk) % shards_num, which stops holding the moment a collection is split.

One modulus for the whole collection, with each shard naming its own residues, rather than a {modulus, remainder} pair per shard. The pair form made two shards incomparable — {2,0} strictly contains {4,0} — so disjointness needed a gcd and coverage a rational sum, and the runtime normalised it away before use anyway. With one modulus the rules are set operations. The cost is size: a pair is O(1) however many splits have happened, while residues are O(modulus / share).

Range routing and a RoutingMode enum were considered and dropped. Ranges existed so a split could choose a boundary on a namespace edge and isolate one tenant; with the comparison key reduced to the routing value itself they bought nothing hash buckets do not, and relabelling whole segments is only sound when a segment holds one namespace, which is not the default. With one scheme left, an enum to select it earned nothing. The oneof keeps its single member so a second scheme has somewhere to arrive.

What a consumer needs to get right

Spelled out in the comments; the load-bearing ones:

  • A collection that has never been split carries no predicate at all — not because it predates this feature, but because creation materializes none. It routes by the legacy placement, hash(key) % len(virtual_channel_names), which is a valid modulus there and nowhere else.

  • Test that over the whole table, not one entry. A fenced source is predicate-less by design and sits beside predicated targets for the whole of a split, so one unset routing oneof does not mean the collection is unsplit. Ignore ShardSplitting and ShardDropped first.

  • hash is not a free choice, and the mask is not part of it. A string key is CRC32-IEEE over at most its first 100 bytes, unmasked. An int64 key is murmur3_32 over the 8-byte little-endian encoding, and only then masked with 0x7fffffff. Masking a string key diverges from the server.

  • shard_by may name a field this response's schema does not return. A namespace-sharded collection routes by $namespace_id, which the public schema projection strips; its type is pinned in the IDL instead.

  • An empty shard_by is not a grammar violation, it is every collection until its first split. Test for it before parsing. The legacy rule it falls back to hashes the namespace only when enable_namespace is set and namespace.sharding.enabled is true — it defaults to false — and namespace.mode is partition_key.

  • Order two shard tables by update_timestamp. There is no separate routing version, so it says "not older", not "the routing changed".

  • Adding a second routing variant is not additive for readers built today: an unknown field number leaves the oneof reading unset, which they take as never-split. Whatever makes the two distinguishable has to arrive with the variant.

Known gaps on the Milvus side

The IDL states the contract; these are where the implementation does not meet it yet, and belong to the split-implementation PR rather than this one.

  1. CreatePartition / DropPartition walk virtual_channel_names as for i < shards_num. Mid-split that prefix is neither the key-owning shards nor all of them. Latent on master, where no split runs and the two are equal.
  2. The split vchannel allocation does not exclude pchannels the collection already occupies.
  3. A record persisted before shards_num was stored decodes to 0 and the server derives the value from the vchannel list, which mid-split is the wrong count. Persisting the derived value closes it, and closes the ShowCollections difference with it.
  4. Nothing gates a relabelling split on the collection being relabel-able: a segment holds one namespace only under namespace.mode=partition, and the trigger checks enable_namespace alone.
  5. etcdpb.CollectionInfo carries no shard_by.
  6. The hash rules have no executable conformance check binding them to pkg/util/typeutil.

Consumed by the Milvus shard-split feature (design: milvus-io/milvus#50465).

@sre-ci-robot

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: xiaocai2333
To complete the pull request process, please assign yhmo after the PR has been reviewed.
You can assign the PR to them by writing /assign @yhmo in a comment when ready.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@sre-ci-robot
sre-ci-robot requested review from nameczz and yhmo June 18, 2026 06:24
@mergify mergify Bot added the dco-passed DCO check passed. label Jun 18, 2026
@mergify

mergify Bot commented Jun 18, 2026

Copy link
Copy Markdown

@xiaocai2333 Please associate the related issue to the body of your Pull Request. (eg. “issue: #6534”)

@xiaocai2333
xiaocai2333 force-pushed the feat-shard-split-routing branch 2 times, most recently from 1586acf to 72c1497 Compare June 18, 2026 14:33
@xiaocai2333 xiaocai2333 changed the title Add shard routing types for online shard split Add shard routing types and the SplitShard message type for online shard split Jun 18, 2026
@czs007

czs007 commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

🤖 team2 automated review (single agent · not cross-verified, for reference) · by zack

Here's my review. I've audited every changed proto file plus the regenerated Go.

milvus-proto PR #618 review — Add shard routing types and SplitShard message type

TL;DR

Clean, purely additive change — no field renumbering, no enum-value reuse, no RPC modified — so it's fully backward compatible. Generated Go is consistently regenerated and naming matches house style. No blocking issues; three low/nit notes.

Correctness audit

  • MsgType_SplitShard = 2001 (proto/common.proto:330) — 2001 was unused, no allow_alias on the enum, regen present in commonpb/common.pb.go:663.
  • New fields 20/21 (proto/milvus.proto:487-488) — prior max in DescribeCollectionResponse was 19; schema.proto already imported (line 15); Go tags correct.
  • Enum value scoping (proto/schema.proto:419-429) — proto3 enum values share package C++ scope; new values are uniquely prefixed (RoutingMode*, Shard*) and don't collide with DataType/FieldState/FunctionType. Consistent with existing Field* prefixing.
  • Compat-safe zero valuesRoutingModeHash=0 / ShardNormal=0 make legacy/unset responses decode to hash + normal. Skipping *_UNSPECIFIED=0 matches in-house style (DataType.None, FieldState.FieldCreated).

Low / nits (non-blocking)

  • ⚠️ Low — proto3 bytes presence ambiguity in RoutingKeyRange (proto/schema.proto:458-462). Comment says "nil bound is unbounded". But proto3 scalar bytes has no field presence — unset and explicitly-empty both decode to a zero-length slice. For upper, "unset → +inf" and "empty range" are indistinguishable on the wire, so consumers must treat len(upper)==0 as +inf and the schema can never represent an exactly-empty upper. Fine in practice; either document len==0 ⇒ +inf or use optional bytes.
  • ℹ️ Nitlast_truncate_time_tick (schema.proto:438) is the only undocumented field in the addition; its role is unrelated to routing.
  • ℹ️ Nitrouting_mode ↔ oneof invariant (schema.proto:436-450) can't be enforced in proto; an unset routing is a valid wire state. Worth documenting the unset semantics so consumers defend against it.

Recommendation

Approve. Only the Low item warrants an author acknowledgement/doc; the rest are nits.

Saved: ./pr-618/2026-06-23-72c1497a.md


The task says to post this to the PR. Since it's an overall review spanning multiple files, I'll post it as a general PR comment (English). Here's the exact dry-run:

Target: PR #618 (milvus-io/milvus-proto) — general PR comment
Command: gh pr comment 618 --repo milvus-io/milvus-proto --body "<below>"

Review — purely additive, backward-compatible. Approve with minor notes.

Audited every changed proto file + the regenerated Go.

Correctness ✅

  • MsgType_SplitShard = 2001 (proto/common.proto:330): 2001 was unused, enum has no allow_alias — no collision.
  • DescribeCollectionResponse.shard_infos = 20 / routing_mode = 21 (proto/milvus.proto:487-488): new field numbers (prior max 19), schema.proto already imported.
  • New enum values are uniquely prefixed (RoutingMode*, Shard*) so they don't collide in the shared proto/C++ enum scope, consistent with the existing Field* style.
  • RoutingModeHash=0 / ShardNormal=0 are compat-safe defaults for legacy/unset responses.
  • Nothing existing is renamed/renumbered/retyped/removed.

Low (non-blocking): RoutingKeyRange (proto/schema.proto:458-462) — the comment says "a nil bound is unbounded", but in proto3 a scalar bytes field has no presence: an unset bound and an explicitly-empty []byte{} decode identically. For upper, "unset → +inf" and an empty range are indistinguishable on the wire, so consumers must treat len(upper)==0 as +inf and the schema can never represent an exactly-empty upper bound. Fine in practice — suggest either documenting len==0 ⇒ +inf explicitly, or using optional bytes if you ever need to distinguish them.

Nits: last_truncate_time_tick (schema.proto:438) is the only undocumented field; and the routing_mode ↔ oneof variant agreement (and the unset-oneof case) can't be enforced by proto, so worth a doc note for consumers.

LGTM to merge.

Send it?

@mergify

mergify Bot commented Jun 25, 2026

Copy link
Copy Markdown

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@xiaocai2333
xiaocai2333 force-pushed the feat-shard-split-routing branch from 72c1497 to 93d4477 Compare June 26, 2026 04:07
@mergify mergify Bot added ci-passed and removed ci-passed labels Jun 26, 2026
@xiaocai2333
xiaocai2333 force-pushed the feat-shard-split-routing branch from 5d8558e to 8f28805 Compare July 3, 2026 08:36
@xiaocai2333
xiaocai2333 force-pushed the feat-shard-split-routing branch 2 times, most recently from d6369b5 to 18d3955 Compare July 29, 2026 09:52
@mergify mergify Bot removed the ci-passed label Jul 29, 2026
@xiaocai2333
xiaocai2333 force-pushed the feat-shard-split-routing branch from 18d3955 to bcbdc95 Compare July 29, 2026 09:54
@mergify mergify Bot added the ci-passed label Jul 29, 2026
@xiaocai2333
xiaocai2333 force-pushed the feat-shard-split-routing branch from bcbdc95 to 20cdfa6 Compare August 17, 2026 08:17
xiaocai2333 added a commit to xiaocai2333/milvus that referenced this pull request Aug 17, 2026
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>
@mergify mergify Bot added the ci-passed label Aug 27, 2026
@czs007

czs007 commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Must-fix issues introduced by this PR: 0
Merge recommendation: Mergeable as-is — this review found no must-fix issue introduced by this PR.

Re-review of 481ba1e27e32

  • Confirmedproto/schema.proto:721source_vchannels comment asserts every target draws from every source, contradicting the gcd intersection rule at proto/schema.proto:923-924: both reviewers re-read the block at this head and found the text unchanged and still in conflict with the stated bucket rule, so the earlier finding stands. Note that this round's verification produced an open question about it — see Disputed below.

Verified this round

Independent re-audit of the diff at this head, scoped to critical/high severity, found nothing new. The load-bearing checks:

  • proto/common.proto:205SplitShard = 120 sits in a free slot between TruncateCollection = 119 and CreatePartition = 200, and the regenerated descriptor adds exactly that one entry and nothing else.
  • proto/milvus.proto:626,636,690shard_infos = 20, routing_mode = 21, shard_by = 22 are appended past the previous max field number, and the generated tags match one-for-one; every pre-existing field keeps its number.
  • proto/schema.proto:923-928 — the disjointness rule and the exact-tiling rule (pairwise disjoint plus Σ 1/mᵢ == 1 compared as rationals) are both sound, and the first-split backfill at proto/schema.proto:626 preserves the tiling.
  • proto/schema.proto:634-649 — failure scoping is correct in both directions: an unrecognised RoutingMode fails the whole collection, while a malformed per-shard entry fails only that shard's keys.
  • proto/milvus.proto:517 vs proto/milvus.proto:626shards_num deliberately diverges from len(virtual_channel_names) mid-split, and the "never a length or index bound" warning covers the concrete broadcast bug that divergence invites.
  • proto/schema.proto:743 — the "exactly one fronting_source_vchannel" constraint holds against the read path, and the read window has no hole during ShardSplitting.

Disputed — our reviewers disagree

  • proto/schema.proto:721 — the normative comment on source_vchannels states that an N -> M rehash gives every target a slice of every source. Refuting evidence: proto/schema.proto:721-724 says each target draws keys from all N old buckets, which contradicts proto/schema.proto:923-924, where buckets intersect only when their remainders agree modulo gcd(N, M); for N=2, M=4, target B(4,0) intersects only B(2,0), not B(2,1), so an implementation following the comment could list irrelevant sources and wait for or materialize data from them — the comment should state that each target has N/gcd(N,M) intersecting sources. Why verification wanted to drop it: the drop argued the contradiction is an artifact of misreading the comment, which (on that reading) only constrains which sources the list enumerates rather than asserting that keys flow from all of them. (raised by sijie-ni-0214, tinswzy; drop refuted by sijie-ni-0214)

These are flagged for the author to adjudicate, not filed as required fixes.

@czs007

czs007 commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Must-fix issues introduced by this PR: 0
Merge recommendation: Mergeable as-is — this review found no must-fix issue introduced by this PR.

This PR is a contract-only change (new proto messages, comment blocks, and regenerated Go bindings), and review surfaced one documentation inconsistency in the newly added shards_num comment.

Low

proto/milvus.proto:501shards_num comment names an undefined convergence point that conflicts with the lifecycle described elsewhere in the same PR.

The comment defines shards_num as the count of ShardNormal + ShardCreating entries in shard_infos, notes that mid-split this differs from len(virtual_channel_names) (which also lists the fenced source and "any dropped shard not yet reclaimed"), and then states "the two agree again once the split completes." The shard_infos comment added by this same PR splits the vchannel list transitions across three commits: the write switch adds the targets and fences the source, adoption promotes the targets and retires the source, and "a later reclaim commit" removes it once no segment references the vchannel and querycoord has released it. Reclaim is gated on conditions independent of split progress and can lag arbitrarily, so between adoption and reclaim the source is ShardDropped (excluded from shards_num) while still present in virtual_channel_names — the two values are not equal. Whether "split completes" means adoption or the full chain including reclaim is never defined, which is exactly the class of ambiguity this comment block is otherwise careful to remove.

Suggestion: name the event explicitly — e.g. change the clause to "the two agree again once the dropped source is reclaimed" — or state in this file that "split completes" includes the reclaim commit. (raised by bigsheeper)

@czs007

czs007 commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Must-fix issues introduced by this PR: 2
Merge recommendation: Mergeable after the 2 must-fix items below are fixed in this PR.

This review covers the newly added shard-split routing contract in proto/milvus.proto and proto/schema.proto; every finding below is a gap or an internal contradiction in that contract that would lead two independent consumers to route the same key to different shards.

High

proto/milvus.proto:624 — The new text at :624-627 requires the field named by shard_by to be "a field of this collection's schema", and proto/schema.proto:666-667 dispatches the hash function on that field's declared type. But the only per-row routing value a namespace-sharded collection stores is the system field $namespace_id (appended by rootcoord at create time as VarChar, internal/rootcoord/create_collection_task.go:432-439), and DescribeCollection explicitly projects it out of the returned schema (internal/proxy/service_provider.go:209-213, on both paths — internal/proxy/task.go:1780 and internal/proxy/service_provider.go:322), so the constraint is unsatisfiable for exactly the case shard_by exists to serve. Failure: after a split on a namespace-sharded collection the server can only emit hash($namespace_id), and a CDC/backup consumer resolving that name against DescribeCollectionResponse.schema finds no such field → it must either fail the whole collection's keys per :608-610 (which forbids the legacy fallback), or guess Int64 and hash with murmur3_32 masked by 0x7fffffff while the server uses unmasked CRC32-IEEE (pkg/util/typeutil/hash.go:59-73 vs 86-93, 189-192), silently placing every namespace on a different shard than the server did. Suggested fix: state here that shard_by may name a system field the public schema projection does not return (today only $namespace_id) and pin its type in this file (VarChar → CRC32 string path); alternatively, stop hanging hash selection on "look this field's type up in the response schema" and write the routing value's type into the contract directly. This is a different gap from the empty-shard_by item below: that one is about not knowing the routing key when the value is absent, this one is about not being able to obtain the named field's type when it is present. (raised by tinswzy)

proto/schema.proto:636 — :636-637 says "Reads do not resolve through these predicates at all: they fan out over the shards a collection has", i.e. a full fan-out, but :578-579 (ShardCreating is "excluded from the query target until adoption"), :584-585 (ShardSplitting "still fronts reads for its targets until they are adopted") and :587 (ShardDropped "released after all targets are adopted"), restated in proto/milvus.proto:559-567, together define the read set as ShardNormal ∪ ShardSplitting. The contradiction now sits about 50 lines apart inside one file. Failure: implementing :636-637 literally on a collection between the write switch and adoption (source ShardSplitting, targets ShardCreating), or between adoption and the reclaim commit (source ShardDropped but still in virtual_channel_names, proto/milvus.proto:564-567), fans out to vchannels with no serviceable replica → search/query fails outright; if the implementation tolerates those channels instead, source and target each return the same rows and results are duplicated. Suggested fix: make :636-637 name the read set explicitly (ShardNormal plus ShardSplitting) rather than "the shards a collection has". (raised by tinswzy, czs007)

Medium

proto/milvus.proto:606 — The grammar at :597-598 has no empty production, and :606-610 unconditionally requires that "A STRING THAT DOES NOT MATCH THE GRAMMAR MUST BE REJECTED WHOLE … then fail this collection's keys without falling back to the legacy rule", yet :616-617 says of the same value that "EMPTY means the routing has never been declared: read it as the legacy rule". Per :618-619 the server materializes shard_by only in the first routing commit, so "" is the value on every collection that has not been split — i.e. every collection in every deployment today — not a corner case, and the reject rule is the one a reader implements first because it comes first in the block. Failure: a proxy metacache or CDC consumer parses shard_by before testing for empty → parseShardBy("") errors, :608-610 forces it to fail that collection's keys with no legacy fallback, and the first refresh after upgrade turns every pre-existing collection unroutable, a deployment-wide fail-closed outage on a shape :616 calls legal. Suggested fix: make emptiness a test that precedes the grammar, e.g. at :606 — "An EMPTY string is not a grammar violation — see below, and test for it before parsing. A NON-EMPTY string that does not match the grammar must be rejected whole…". Worth stating alongside it that proto/schema.proto:666-667's type dispatch has no input when shard_by is empty. (raised by czs007)

proto/milvus.proto:616 — :616-617 tells consumers to fall back to "the legacy rule over the collection's own routing key" when shard_by is empty, but nothing in the proto says how to determine whether that key is the primary key or the namespace; :591-592 only asserts that "a namespace-sharded collection routes a batch by its namespace and never reads the primary key" without giving a test. The actual predicate in the implementation is a three-way conjunction: EnableNamespace and common.IsNamespaceShardingEnabled(properties) (internal/proxy/util.go:2895-2901) and common.IsNamespaceModePartitionKey(properties) (:2903-2909), which together gate namespaceShardingChannelID (:2915-2922); in partition mode (:2911-2913) the namespace becomes a partition name and routing falls back to the PK. Failure: a namespace-sharded, partition-key-mode collection that has not yet been split has shard_by == ""; a CDC or dual-write consumer follows :616-617, takes the primary key by historical convention, and computes hash(pk) % len(virtual_channel_names) while the server computes CRC32-IEEE over the batch's namespace string modulo numShard (internal/proxy/util.go:2769pkg/util/typeutil/hash.go:189-192) → replayed rows land on the wrong vchannel and namespace-scoped queries return nothing on the target side. Suggested fix: spell out all three conditions (enable_namespace, namespace.sharding.enabled, namespace.mode) next to :616-617, or on proto/schema.proto:235; keying only on enable_namespace misclassifies partition-mode collections as namespace-routed. (raised by czs007, tinswzy)

proto/milvus.proto:546 — :546-547 declares "shard_infos non-empty but THIS shard's routing oneof unset" a legal shape meaning "a collection that has never been split", falling back to the legacy placement at :544, but proto/schema.proto:632-633 pins the tiling invariant to "the entries CARRYING A PREDICATE … collect those rather than filtering by state", :691-693 defines exact tiling as pairwise disjointness plus sum(1/m) == 1, and :694 says to run the check once per shard table. In the never-split shape the number of predicate-carrying entries is zero, so sum(1/m) = 0 ≠ 1 and the invariant can never hold. Failure: a consumer runs the :691-693 check once per shard table as :694 instructs on a never-split collection whose shard_infos is populated → tiling fails, the shard table is judged invalid and the collection is refused routing, while :543-547 requires that same shape to fall back to legacy placement and serve normally. A related inconsistency: :498-499 defines shards_num by state ("the ShardNormal and ShardCreating entries of shard_infos") while schema.proto:632-633 requires collection by predicate, giving N and 0 respectively on this shape. Suggested fix: state at :546-547 or at schema.proto:632-633 that the tiling rule does not apply when zero entries carry a predicate, and that such a table is handled as legacy placement. (raised by tinswzy, czs007)

proto/milvus.proto:546 — The per-shard conclusion is written ahead of its own exception: :543-547 says unconditionally that a non-empty shard_infos whose this shard has an unset routing oneof means "a collection that has never been split", and :548-551 reinforces that "a predicate-less one occurs only before any split", while the exception arrives ten lines later at :553-557 — "A fenced or released source is predicate-less by design and sits in the list beside predicated targets for the whole of a split … Ignore ShardSplitting and ShardDropped entries before testing for the legacy shape" (proto/schema.proto:621-622 confirms the write switch strips a source's routing). Failure: between the write switch and adoption the source is ShardSplitting with its routing stripped and the targets carry predicates; a consumer reading the comment in order sees the source's unset oneof, classifies the whole collection as never-split, and applies hash(key) % len(virtual_channel_names) — a modulus that mid-split still counts the fenced source (:499-500, :559-560) and so exceeds the real key-owning shard count, sending every key to a different shard than the server's predicates chose and silently writing part of the traffic to a source that :584-585 says no longer accepts writes. Structurally this is the same defect as the shard_by == "" item, but it fails open rather than closed. Suggested fix: fold the exception into the bullet at :546 itself, e.g. "shard_infos non-empty, and NO entry other than a ShardSplitting/ShardDropped one lacks a predicate", rather than leaving it as a follow-on paragraph. (surfaced during verification)

Low

proto/milvus.proto:501 — :499-501 attributes the gap between shards_num and len(virtual_channel_names) to "the fenced source and any dropped shard not yet reclaimed" and then says "the two agree again once the split completes", but :564-567 describes three commits — write switch, adoption, and "a later reclaim commit [that] removes it once no segment references the vchannel and querycoord has released it" — and proto/schema.proto:587 marks the source ShardDropped as soon as all targets are adopted. The split is therefore complete while the source is still listed, until an independent, later commit with its own preconditions. Suggested fix: change "once the split completes" to "once the dropped source has been reclaimed", matching :564-567. (raised by tinswzy, czs007)

Pre-existing hazards — not introduced by this PR (follow-up only)

The following hazards are real but pre-existing and were not introduced by this PR. They should be tracked as separate follow-up work and do not block this PR. No tracking issue yet.

  • internal/rootcoord/ddl_callbacks_create_partition.go:64 (and internal/rootcoord/ddl_callbacks_drop_partition.go:62) — both walk VirtualChannelNames as for i := 0; i < int(collMeta.ShardsNum); i++, the exact pattern the new rule at proto/milvus.proto:511-516 forbids ("never a LENGTH OR INDEX BOUND over virtual_channel_names"). This is unrelated to this PR: the diff touches only milvus-io/milvus-proto (.gitignore, the three .proto files and their generated Go), these two files are not in it, and the loops cannot misbehave on master today because shard split is not implemented there (SplitShard matches only docs/design-docs/design_docs/20260610-shard_split.md), so ShardsNum always equals len(VirtualChannelNames). They are useful evidence that the new rule is warranted, and belong on the checklist of the split-implementation PR. (raised by tinswzy)

@xiaocai2333

Copy link
Copy Markdown
Contributor Author

All seven addressed in 67f3e75. Both High findings verified against the implementation
before changing anything; both hold.

High

milvus.proto:624shard_by may name a field DescribeCollection does not return.
Confirmed: internal/proxy/service_provider.go:210 skips common.NamespaceFieldName when it
projects the schema, so $namespace_id is not in the response a consumer would resolve
against. "Dispatch the hash on the named field's declared type" therefore has no input for
exactly the case shard_by exists to describe, and your second branch is the dangerous one:
guessing Int64 gives masked murmur3 where the server used unmasked CRC32.

Took the first half of your suggestion rather than the second. Pinning the routing value's
type in the contract directly would put it at one remove from the field it belongs to and
need a rule for keeping the two in step; naming the system fields and their types here keeps
one statement per fact and extends by listing. So: shard_by may name a system field the
public schema omits, system field types are pinned in this file, $namespace_id is VarChar
and takes the string path, and a name that is neither in the schema nor listed fails the
collection's keys the way an unparseable declaration does.

schema.proto:636 — the read set. Mine, from last round: I replaced a sentence that
pointed at a field being removed and reached for "the shards a collection has", which is
looser than what the same file says forty lines up. Now named: ShardNormal and
ShardSplitting, with why each of the other two is out — a ShardCreating target is not in
the query target yet, a ShardDropped source serves nothing though it stays in the vchannel
list until reclaim.

Medium

Empty shard_by versus reject-whole. The ordering defect is the whole finding: the
reject rule came first and empty was legalised eleven lines later, so a reader implementing
top-down fails every collection that has not split — which today is all of them.
TEST FOR EMPTY BEFORE PARSING now leads, and says why it is not a corner case.

Which key the legacy rule hashes. Right, and enable_namespace alone is not the test.
All three conditions are now spelled out, including that namespace.sharding.enabled
defaults to false, so a namespace collection that never set it routes by primary key —
the shape most likely to be misread, since the schema flag alone suggests otherwise.

Never-split test written per entry. The exception sitting ten lines below the conclusion
is the defect. The bullet is now over the whole table — "no entry carries a predicate,
counting only entries that are neither ShardSplitting nor ShardDropped" — with a paragraph
naming what the per-entry reading costs mid-split.

Tiling on a never-split table. Agreed, and it is sharper than a wording problem: the rule
collects by predicate, a never-split collection has none, so sum(1/m) is 0 and the check
rejects a shape the same block calls legal. Stated: run it only once some entry carries a
predicate.

Low (both reviews)

shards_num convergence. Changed to name the event — the two agree once the dropped
source has been reclaimed — and added that this is a later commit with its own
preconditions, not the end of the split, since the source leaves the count at adoption.


On the pre-existing hazard: agreed on all counts, including that it cannot misbehave on
master today. It was already on the implementation-side list this PR's body carries, as item
1; your note that ShardsNum equals len(VirtualChannelNames) until a split runs is a
better statement of why it is latent than what was there, and I have taken it.

Comment-only throughout; the regenerated descriptor bytes are unchanged.

@mergify mergify Bot added ci-passed and removed ci-passed labels Aug 27, 2026
@czs007

czs007 commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Must-fix issues introduced by this PR: 0
Merge recommendation: Mergeable as-is — this review found no must-fix issue introduced by this PR.

Adversarial review found no issues requiring changes.

Verified:

  • proto/milvus.proto:632-642 — the shard_by contract now states the named field may be a system field this response's schema omits, pins $namespace_id to VARCHAR (string-hash path), and requires unknown untyped names to fail the collection's keys, so consumers can type and hash the routing field without guessing.
  • proto/milvus.proto:648-660 — the legacy routing rule's three conditions (enable_namespace, namespace.sharding.enabled == "true", namespace.mode == "partition_key") match the server-side gate and both documented defaults, so the documented key selection matches runtime behaviour.
  • proto/schema.proto:636-640 — reads are defined to fan out over exactly ShardNormal ∪ ShardSplitting and nothing else, with ShardCreating (not yet a query target) and ShardDropped (serves nothing until reclaim) explicitly excluded.
  • proto/schema.proto:578-587 — the ShardState lifecycle comments, including ShardSplitting still fronting reads until its targets are adopted, agree with the fan-out set above and with the read/write state table at proto/milvus.proto:572-574, removing the earlier contradiction.
  • proto/milvus.proto:546-561 — the never-split determination is now a whole-table test excluding ShardSplitting and ShardDropped, so a mid-split collection is no longer misclassified from a single entry.
  • proto/schema.proto:653-660 — the HashRouting buddy-merge examples ({4,0}+{4,2}→{2,0} merges, {4,0}+{4,1} does not) are arithmetically correct for the stated scheme.

No further findings survived verification: no disputed items and no pre-existing hazards were carried into this review.

@czs007

czs007 commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Must-fix issues introduced by this PR: 0
Merge recommendation: Mergeable as-is — this review found no must-fix issue introduced by this PR.

Re-review of 67f3e7564e3f

No earlier findings were left open on this commit, so there is nothing to confirm or retract this round.

Adversarial review found no issues requiring changes.

Verified:

  • proto/common.proto:205SplitShard = 120 sits in a free slot between TruncateCollection = 119 and the partition group starting at 200, with no enum-value collision.
  • proto/schema.proto:576-587ShardNormal = 0 keeps legacy decoding intact, every new lifecycle state gets a distinct value, and unknown states fail closed rather than silently defaulting.
  • proto/schema.proto:601-618 — legacy persisted shard metadata stays wire-compatible because last_truncate_time_tick retains field number 1 and its uint64 type; all new fields take fresh numbers.
  • proto/schema.proto:692-695 — the disjointness rule r1 % g != r2 % g where g = gcd(m1, m2) is the correct Chinese Remainder Theorem criterion for intersecting arithmetic progressions, and the exact-tiling rule (pairwise disjoint plus Σ 1/m = 1) holds over the lcm period.
  • proto/milvus.proto:536-595 — legacy, never-split, active-split, adoption, and reclaim shapes are distinguishable; mid-split routing cannot be misclassified as never-split, and stale concurrent refreshes are ordered by update_timestamp.
  • proto/schema.proto:674-681 — the hash dispatch (CRC32-IEEE over the first 100 bytes unmasked; murmur3_32 over 8 little-endian bytes masked with 0x7fffffff) matches the current Milvus typeutil implementation.
  • go-api/commonpb/common.pb.go:551, go-api/schemapb/schema.pb.go:336-346, go-api/milvuspb/milvus.pb.go:1895-1970 — the regenerated Go bindings preserve the proto enum values, field numbers, oneof representation, and response types, with no hand-edited drift.

On test coverage: this change is IDL plus generated bindings, with no executable split logic in this repository, so there is no runtime behavior here that a new unit test could cover.

@mergify mergify Bot added ci-passed and removed ci-passed labels Aug 28, 2026
@xiaocai2333
xiaocai2333 force-pushed the feat-shard-split-routing branch from f3996aa to 47999c3 Compare August 28, 2026 04:14
@czs007

czs007 commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

The collection-wide modulus rework introduces three high-severity contract problems in the routing IDL: the modulus is unreachable from two of CollectionShardInfo's three declared carriers, the shard table grows exponentially in unbalanced splits with no stated bound, and nothing stops a non-split-aware peer from silently misrouting writes on a split collection.

High

proto/schema.proto:601CollectionShardInfo is not self-describing: the modulus lives only in DescribeCollectionResponse
The encoding is one collection-wide modulus declared solely at proto/milvus.proto:594 (routing_modulus, field 22). schema.proto imports only common.proto and google/protobuf/descriptor.proto (proto/schema.proto:13-14), while milvus.proto imports schema.proto (proto/milvus.proto:15), so schema.proto can never reference it. That makes the reference at proto/schema.proto:629-630 and the tiling rule at proto/schema.proto:635-638 unresolvable for any holder that did not receive the shard info inside a DescribeCollectionResponse — yet proto/schema.proto:595-597 explicitly promises two such holders (collection meta, in-memory routing tables), and proto/schema.proto:611-615 explicitly anticipates "a tool reading the meta directly". The new SplitShard WAL message (proto/common.proto:201-204) is in the same position. Failure: rootcoord persists the meta as repeated schema.CollectionShardInfo with hash_routing.buckets=[0,2], and birdwatcher / a migration tool / the restart path loads only that record → 2 is uninterpretable (residue of 4, 8, or 64), the shard's key ownership is unresolvable, the tiling check cannot run at the meta layer, and a broken shard table can land silently; making the meta interpretable then requires a second copy of the modulus that this PR neither defines nor arbitrates against routing_modulus, so one collection can hold two disagreeing moduli and route to the wrong shard.
Suggestion: put the modulus where the buckets are — either uint64 modulus on HashRouting (repeated per shard, but self-contained, and the tiling check becomes local), or a collection-level routing message defined in schema.proto that carries the modulus and is embedded by DescribeCollectionResponse. Keeping routing_modulus = 22 on the response as a convenience mirror is fine, but the normative copy must be reachable from schema.proto. (raised by wzy)

proto/milvus.proto:594 — a single collection-wide modulus makes the shard table grow exponentially in unbalanced splits, with no bound or at-bound behaviour
Splitting one shard's residue r mod M requires M -> 2M, and every shard that did not participate must be rewritten to own {r, r+M}. Balanced splits are fine (2 → 4 shards is modulus 4, one residue each); repeatedly splitting the hot shard — the stated motivation for the feature — is not. The "cover every residue below the modulus exactly once" rule (proto/schema.proto:635-638) makes the total residue count across shard_infos identically equal to the modulus, and proto/milvus.proto:592-593 doubles the modulus per split, so residues grow as 2^k in the number of splits rather than linearly in shard count. The comment at proto/schema.proto:631-633 acknowledges the growth but states no bound. Failure: 19 successive hot-shard splits from one shard give modulus 2^19 ≈ 524288 → roughly 524k packed uint64 varints (3 bytes each in that range, ≈1.5 MB) of CollectionShardInfo in the etcd meta value and on every DescribeCollection response, hitting etcd's default 1.5 MB max-request-bytes so the split cannot be persisted; and past ~31 splits the modulus exceeds the [0, 2^31) range of the masked INT64 path (proto/schema.proto:645-646), so shards holding residues ≥ 2^31 receive no keys at all while the split reports success. proto/milvus.proto:588-594 states no bound and no rule for what a split does when the next doubling would cross one.
Suggestion: either give each bucket/shard its own modulus (heterogeneous moduli let an untouched shard keep O(1) residues forever, with matching disjointness and tiling rules), or state the bound and the behaviour at it — e.g. a fixed large modulus chosen at first split, so residue lists grow linearly with shards rather than exponentially with splits — plus an explicit rule for when a shard's residue set can no longer be halved. Either way, routing_modulus's comment should carry the growth characteristic and its upper bound, not just the doubling rule. (raised by wzy)

proto/milvus.proto:534 — compatibility is handled only for "new peer, old server"; nothing prevents a legacy-routing peer from silently writing to the wrong shard
proto/milvus.proto:524-526 covers the case where the peer predates shard split (empty shard_infos), but not the reverse: a router still on the pre-PR contract reads only virtual_channel_names and applies the legacy hash(key) % len(virtual_channel_names) rule described at proto/milvus.proto:536-537. After the first split that rule is wrong twice over — ownership moves to the HashRouting predicates, and per proto/milvus.proto:546-550 the vchannel list itself grows on the write switch and shrinks on a later reclaim, so even the divisor changes. DescribeCollectionRequest (proto/milvus.proto:456-471) carries no capability or contract-version field, and the response has no explicit "this collection requires split-aware routing" switch (routing_modulus != 0 / non-empty shard_infos are that signal in practice, but a legacy peer never reads either), so the server can neither detect nor reject such a peer. Failure: during a rolling upgrade an old proxy serves a collection that has completed its first split → writes land on a shard that does not own the routing value, and since proto/schema.proto:640-642 states nothing checks ownership at write time, they are neither rejected nor error-coded (mid-split they may instead hit a fenced ShardSplitting source, proto/schema.proto:586-588, and fail outright); operators get no signal until data is already misplaced.
Suggestion: state in the contract that a non-split-aware peer must be rejected once a collection has split, and give that rule a decidable carrier — e.g. a client-side routing contract version on DescribeCollectionRequest, with the server returning an error for split collections instead of a vchannel list that will be misread. (surfaced during verification)

@xiaocai2333
xiaocai2333 force-pushed the feat-shard-split-routing branch from 47999c3 to 06e6196 Compare August 28, 2026 06:27
@xiaocai2333

Copy link
Copy Markdown
Contributor Author

All three were comments that did not say enough, and are fixed in the amended commit. Two of
them describe hazards the system already bounds; the contract just never said where the bound
comes from.

schema.proto:601 — the modulus is not reachable from CollectionShardInfo. The
unreachability is real and the wording invited the conclusion you drew: it named
DescribeCollectionResponse.routing_modulus as though that were the only carrier, so a reader
of schema.proto fairly concludes the meta has no answer.

It does. The modulus is a collection-level fact and sits wherever that collection's shard
infos sit — beside them in the collection meta, and as routing_modulus on the response —
exactly the relationship shard_infos itself already has between the two. Not a second copy
to arbitrate against the first; the meta's is the persisted one and the response projects it,
the same way it projects the shard infos.

CollectionShardInfo is not self-describing and is not meant to be: it is an element of an
array parallel to virtual_channel_names, and a holder of one entry alone cannot interpret
its buckets for the same reason it cannot interpret its position. The comment now says that,
in those terms.

milvus.proto:594 — exponential growth with no bound. The growth characteristic is right
and the comment should have carried it. The two limits it reaches are not, because the
calculation leaves out what actually caps the shard count.

A collection holds at most one vchannel per pchannel, enforced when vchannels are allocated,
so it cannot have more shards than the cluster has pchannels — 16 by default
(rootCoord.dmlChannelNum). Each split adds one shard, so there are at most 15 of them, and
if every one lands on the same shard the modulus reaches 2^15. That is tens of kilobytes of
residues, not 1.5 MB, and it is sixteen orders of magnitude below the 2^31 the masked int64
path bounds. Both failures need more splits than a collection can have shards.

The comment now carries the growth, the doubling, and where the cap comes from.

milvus.proto:534 — nothing stops a legacy-routing peer. Something does, for the window
that matters. Such a peer computes its target with the legacy divisor, and during a split
that lands it on the fenced source, where shard_interceptor refuses the write with
SHARD_FENCED — which the streaming client classifies unrecoverable, so it stops retrying
and reports rather than succeeding somewhere wrong. The peer needs to know nothing about the
code for that to hold.

You are right that it is not total: once the source is reclaimed and leaves the vchannel
list, the legacy divisor changes again and such a peer can reach a shard that accepts the
write without owning the key. So the contract now states the protection and its edge, rather
than being silent and looking like there is none. A capability field would close the
remainder, and that is a decision for the write path rather than this file.

Amended into the single commit rather than added on top; the PR carries one.

@czs007

czs007 commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Must-fix issues introduced by this PR: 1
Merge recommendation: Mergeable after the 1 must-fix item below is fixed in this PR.

Summary

This round's routing prose is additive on the wire and the regenerated Go matches the IDL, but the new comment block in proto/milvus.proto contains one self-refuting upgrade-safety claim plus several normative spec gaps that a second implementation can trip on.


High

proto/milvus.proto:546 — The paragraph claims that a router still applying the legacy rule to a split collection "is not left to corrupt silently while a split runs", because the source it computes is fenced and a fenced-vchannel write is refused. That only holds for the subset of keys whose legacy index still resolves to the source. The legacy rule is hash(key) % len(virtual_channel_names) (line 537) and the vchannel list "grows when the write switch adds the targets" (line 557), so the divisor already changes at the write switch, not at reclaim — the failure the paragraph correctly identifies for the post-reclaim moment (lines 552-554) begins much earlier. ShardCreating targets also take writes (proto/schema.proto:582-584), and nothing checks ownership at write time (proto/schema.proto:647-649).
Failure: a 4-shard collection [A,B,C,D] splitting A past the write switch has a 6-entry vchannel list, and a legacy router that re-reads DescribeCollection (a freshly started old proxy, or any old proxy after cache invalidation) computes hash(k) % 6 → roughly 5 of every 6 writes are accepted by a shard that does not own the key, landing silently with no error; a conforming reader afterwards routes that pk to its true owner, so the row is invisible to point queries and a delete for that pk no-ops against the owner while the mis-placed copy survives in searches.
Suggestion: narrow the claim to the cached case — the fence does protect a legacy router still holding a pre-split 4-name list, which computes % 4 and either hits the fenced A or hits B/C/D, which still own those keys — or drop the protection claim and state plainly that a legacy router which re-reads the vchannel list mis-places from the write switch onward, with the fence catching only the residue class that still maps to the source. (raised by tinswzy)


Medium

proto/milvus.proto:604 — Three claims in the same sentence cannot all hold: (a) the modulus doubles on every split, (b) splitting the same shard repeatedly grows the residue table faster than splitting evenly, and (c) splitting evenly leaves one residue per shard. Under (a), k splits give M0 * 2^k residues regardless of which shard was split, so (b) is false; and 4 shards at M=4 split once each gives M=64 over 8 shards, i.e. 8 residues each, so (c) is false too.
Failure: an independent split coordinator or migration/verification tool written from the literal leading clause, run against a 4-shard collection split evenly to 8 shards → it computes routing_modulus = 4 * 2^4 = 64 with 8 residues per shard while an implementation written from the trailing clause computes 8 with one residue per shard, so a verifier built on one reading rejects a legitimate table produced by the other, and a coordinator built on the literal reading inflates shard_infos by 8x on exactly the collection this comment's sizing argument is about.
Suggestion: state that the modulus doubles only when the shard being halved owns a single residue; otherwise its residues are partitioned between the two targets and the modulus is unchanged (1 shard M=1M=2, {0}/{1} → split {0}M=4, {0},{2},{1,3} → split {1,3} → no doubling, four shards with one residue each). The 2^15 worst case is unaffected either way. (raised by tinswzy)

proto/milvus.proto:573 — The grammar gives a production for <function> ("hash") but none for <field>, while lines 577-579 turn that gap into a hard gate whose failure mode is losing the collection: a non-empty string that does not match "must be rejected whole rather than parsed in part, and this collection's keys failed rather than routed by a guess." Lines 582-584 then say the named field may be a system field the response schema does not return, and name the one that exists today: $namespace_id. A leading $ is the file's own marker for system names that are not user field names (proto/schema.proto:191 lists "$score", "$seg_offset"), so an implementer deriving <field> from ordinary Milvus field-name rules ([A-Za-z_][A-Za-z0-9_]*) will not match it.
Failure: an SDK lexes <field> per documented user field-name rules and then reads a namespace-sharded, already-split collection where the server returns shard_by = "hash($namespace_id)" → the parse fails and, per the spec's own mandate, the SDK fails every key of that collection, so all inserts/deletes/point queries through it error out — a full client-side outage on the collection, triggered by a server-emitted, spec-legal string.
Suggestion: give <field> an explicit production admitting the system prefix (e.g. <field> := ["$"] <ident>), or state that the field token is the byte sequence up to the closing ), matched against the schema plus the pinned system fields rather than lexed. (raised by tinswzy)


Low

proto/milvus.proto:607 — The modulus bound argues that a collection holds at most one vchannel per pchannel and therefore cannot have more shards than the cluster has pchannels. That invariant constrains len(virtual_channel_names), not the shard count, and this same field documents that the two diverge during a split: the write switch appends the targets before a later commit reclaims the source (lines 556-559), and shards_num is defined as strictly smaller than the vchannel list mid-split (lines 497-499). Going from 15 shards to 16 therefore requires 17 concurrent vchannels against 16 pchannels, so under the stated invariant 16 shards is unreachable and the real cap is one doubling lower; with an earlier split's ShardDropped entry still un-reclaimed the gap widens further, which also makes the neighbouring claim that the two counts "agree again once that source is reclaimed" hold only when no other drop is outstanding. This is not a wire defect — 2^15 remains a valid upper bound.
Suggestion: bound the vchannel list rather than the shard count, or state that the invariant holds in steady state with the split window exempt. (raised by tinswzy)


Nitpick

proto/schema.proto:623 — The oneof comment asserts that field numbers 40-59 are kept free for a second placement scheme, but CollectionShardInfo declares only fields 1-4 and carries no reserved statement, so nothing enforces it; a later contributor adding int64 foo = 41; compiles cleanly and silently consumes the band.
Suggestion: add reserved 40 to 59; in the message body — oneof members share the message's field-number space, so a message-level reserved range covers them — which makes protoc enforce the assertion at zero wire cost. (raised by tinswzy)

@xiaocai2333
xiaocai2333 force-pushed the feat-shard-split-routing branch from 06e6196 to 0bfef62 Compare August 28, 2026 08:26
@xiaocai2333

Copy link
Copy Markdown
Contributor Author

All five are wrong sentences rather than wrong types, and all five are fixed in the amended
commit. The doubling rule I checked against a model of the split before rewriting it, since
that one is a rule an implementation follows rather than a caveat a reader keeps in mind.

milvus.proto:546 — the fence does not cover what I claimed. Right, and the mistake is
where the divisor changes: I wrote that it changes again at reclaim, which reads as though
it were stable until then. It changes at the write switch, because that is when the targets
join the list. So a legacy router that re-reads this response divides by the grown list from
that moment, and only the keys whose index still resolves to the source meet the fence.

Both cases are now stated, since which one applies is a property of the router rather than of
the collection: one still holding a pre-split list divides by the old count and lands either
on the fenced source or on a shard that does still own those keys, which is the case the
fence covers; one that re-reads does not. And the closing observation is yours: rejecting
such a peer needs a signal this response does not carry.

milvus.proto:604 — the three claims cannot all hold. Confirmed by modelling the split:
four shards at modulus 4 split once each end at modulus 8 with one residue apiece, not at 64
with eight. The doubling is not unconditional -- it is needed only when the shard being
halved owns a SINGLE residue, since a shard owning several is halved by dividing that set at
the current modulus. That is exactly why the two patterns differ, which is what made my
sentence self-refuting: with unconditional doubling there is no difference to describe.

Restated with the condition, and the two patterns given as what they are: repeated splits of
one shard double every time because each half owns one residue again, while even splitting
does not, because the untouched shards picked up residues when the modulus last doubled and
dividing those is free.

milvus.proto:573<field> had no production. The gap is real and the failure is the
worst kind: a server-emitted, spec-legal string that a conforming reader must reject, taking
the whole collection down on that client. <field> is now defined as every byte up to the
closing parenthesis, matched against the collection's fields and the pinned system fields
rather than lexed -- with the reason said outright, that one of those system names begins
with '$' and does not fit the rules a user field name follows.

milvus.proto:607 — the bound constrains the vchannel list, not the shard count. Correct,
and this field's own text says the two diverge mid-split, so I had the invariant arguing
against a quantity it does not govern. Now bounded where it belongs: the vchannel list cannot
outgrow the pchannel count, and mid-split that list holds the targets as well as the source
they came from. 2^15 survives as the ceiling, less tightly than before.

schema.proto:623 — the 40-59 band. Taking a different fix than suggested: the band goes
rather than becoming reserved. It came from a revision where it was the second of two
signals for telling an undecodable variant from a genuine absence, paired with a field and an
enum this PR has since removed; the sentence next to it already concedes it is not sufficient
on its own, which leaves it asserting a constraint that buys nothing. Nor was there ever a
reason in the file for 40-59 over any other range. Reserving it would have made protoc enforce
an assertion that should not have been there -- and would then have to be un-reserved before
the variant it was reserved for could use it.

@czs007

czs007 commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Must-fix issues introduced by this PR: 0
Merge recommendation: Mergeable as-is — this review found no must-fix issue introduced by this PR.

Both findings concern the new sharding contract text in the proto files: one underspecified hash rule that can misroute writes, and one worked example that contradicts its own stated rule.

Medium

proto/schema.proto:652 — INT64 routing hash does not specify the MurmurHash3 variant or seed

The comment says murmur3_32 over the 8-byte little-endian value, masked with 0x7fffffff, but never pins the MurmurHash3 variant (x86_32 / x86_128 / x64_128) or the seed. Every other degree of freedom in the same block is nailed down — byte order, mask, the STRING path's 100-byte truncation and its UNMASKED status — yet MurmurHash3 output depends entirely on the seed, and many libraries require the caller to choose one (Apache Commons Codec defaults to DEFAULT_SEED = 104729, while Go's twmb/murmur3.Sum32 is x86_32 with seed 0). This is not an optional annotation: line 647 states "THE HASH IS NOT A FREE CHOICE. Anything that places a row itself must use the same one, since nothing checks at write time that a row sits in the shard its value belongs to" — the contract actively requires each language's router to recompute this hash, and the write path does not validate shard ownership.

Failure: a non-Go router (or a Go one switching murmur3 libraries) implements the INT64 routing value from this comment and picks an implementation with a non-zero default seed, or a 128-bit variant truncated to 32 bits → it computes a different routing value, and after the modulus the row lands on a shard that does not own that residue; only keys hitting a fenced source vchannel are rejected with SHARD_FENCED, so the rest are silently accepted by the non-owning shard and become invisible to queries against the owning shard.

Suggestion: state MurmurHash3 x86_32, seed 0 explicitly (matching the server-side murmur3.Sum32 over the 8 little-endian bytes), and add at least one normative input/output vector per path (STRING and INT64) so cross-language implementations are verifiably deterministic. (raised by sijie-ni-0214)

Low

proto/milvus.proto:625 — the only worked example for routing_modulus growth contradicts the doubling rule stated two lines above it

The comment reads "Four shards split once each end at four times the modulus under the first pattern and at twice it under the second." Working it through with the rules this same block establishes — line 617-618, "IT DOUBLES ONLY WHEN THE SHARD BEING HALVED OWNS A SINGLE RESIDUE"; lines 614-615, an unsplit collection has modulus = len(virtual_channel_names), so four initial shards each own one residue:

  • Second pattern (each of the four original shards split once): only the first split doubles (4→8); the remaining three shards then own two residues each and do not double. Final value 8 = 2×. The second half of the sentence is correct.
  • First pattern (the same lineage split four times): line 622 states "splitting the same shard repeatedly doubles every time", giving 4→8→16→32→64, i.e. 16×, not the 4× in the text.

4× corresponds to a mixed ordering with exactly two doublings (e.g. one lineage split twice, plus two untouched shards split once each), not to "the first pattern". Since this is the only recomputable example of modulus growth in the block, an implementer using it to self-check their split logic gets a spurious mismatch signal. Suggestion: change "four times" to "sixteen times", or replace it with an example that spells out the split count explicitly. The neighbouring bound argument ("Fifteen doublings … the modulus stays under 2^15") checks out; only this sentence is wrong. (raised by bigsheeper)

A collection's shards are fixed at creation today. Online shard split
changes that count without taking the collection offline, and this is the
contract the pieces of it agree on: what a shard is, which keys it owns, and
how a row's routing key is computed.

schema.proto gains ShardState, the four states a shard passes through while
a split runs; CollectionShardInfo, one shard's state, vchannel and routing
predicate; and HashRouting, the residues of the collection's modulus that
shard owns. CollectionShardInfo supersedes the single-field
etcdpb.CollectionShardInfo the truncate API left in the collection meta,
keeping its field number so records already persisted still decode -- their
missing fields read as a normal, never-split shard, which is what they are.

DescribeCollectionResponse gains shard_infos, index-parallel to
virtual_channel_names; shard_by, the expression producing a row's routing
value; and routing_modulus, the one modulus the whole collection's residues
are taken against. Its shards_num keeps its number and changes meaning: it
is the count of shards serving now, which a split moves, rather than the
CreateCollection argument.

common.proto gains MsgType SplitShard, numbered with the collection DDL it
belongs to rather than the WAL group it travels through.

Placement is by modulus throughout. Range routing and a RoutingMode enum
were considered and dropped: with the comparison key reduced to the routing
value itself, ranges bought nothing hash buckets do not, and one scheme
needs no enum to select it. The oneof keeps a single member so a second
scheme has somewhere to arrive.

issue: milvus-io#619
Signed-off-by: Cai Zhang <cai.zhang@zilliz.com>
@xiaocai2333
xiaocai2333 force-pushed the feat-shard-split-routing branch from 0bfef62 to c929178 Compare August 28, 2026 10:20
@czs007

czs007 commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Must-fix issues introduced by this PR: 0
Merge recommendation: Mergeable as-is — this review found no must-fix issue introduced by this PR.

Overall: the wire change is additive and the generated code matches the IDL, but several of the new normative comments in proto/milvus.proto and proto/schema.proto are self-contradictory or under-specified in ways that will mislead an SDK or migration-tool author implementing client-side routing.

Medium

proto/schema.proto:621 — the stated reason a second routing variant cannot be made distinguishable is not achievable in protobuf, and this PR already ships a readable discriminator.
The comment at :620-623 says a second placement scheme "would not be additive: a reader built today sees an unknown variant as unset and takes that for never-split, so whatever makes the two distinguishable has to arrive with the variant." Nothing that arrives with the variant can work — a new oneof member or a new sibling field is equally unknown to a reader built today and is skipped into unknownFields. Only a field that reader already reads can distinguish "never split" from "split under a scheme I don't understand", and this PR adds one: routing_modulus (milvus.proto:595), documented at :599-600 as "0 on a collection that has never been split" and independent of the scheme — which directly contradicts the claim at :621-623 that no such signal exists. The underlying defect is that :534-541 makes the normative never-split test "no predicate anywhere in the table, ignoring ShardSplitting/ShardDropped", i.e. the one signal a future variant destroys. Suggested fix: (1) define never-split at :534-541 as routing_modulus == 0 (or shard_by == "", which is scheme-independent) and demote the predicate scan to a consistency check; (2) add a fail-closed rule — a reader that sees routing_modulus != 0 but a key-owning shard with no predicate it understands must fail that shard's keys rather than fall back to legacy placement; (3) rewrite :620-623 to point at that discriminator. Since CollectionShardInfo does not itself carry the modulus (:632-634 places it in the collection meta alongside the shard infos), the same rule needs stating on the schema.proto side for consumers reading the meta directly. (raised by tinswzy)

proto/milvus.proto:505 — the legacy-0 fallback for shards_num violates the definition given five lines above it, and derives from the wrong source.
:491-492 defines shards_num as the count of ShardNormal + ShardCreating entries in shard_infos, and :497-499 guarantees it is strictly smaller than len(virtual_channel_names) mid-split; the fallback at :505-507 returns exactly len(virtual_channel_names), breaking both. Failure: on an upgraded cluster, an old collection record whose shards_num was never persisted (decoding to 0) is queried via DescribeCollection while mid-split (vch0 = fenced ShardSplitting source, vch1 = ShardNormal, T1/T2 = ShardCreating) → the server returns 4 where :491-492 promises 3, so a migration/rebuild tool written to the comment recreates the collection with 4 shards, and a consumer validating shards_num against the predicate-carrying shard_infos rejects a legal server response as inconsistent. The fallback source is the fixable part: old records are not missing shard_infos — per schema.proto:605-608 the meta swaps etcdpb.CollectionShardInfo for this message at the same field number, so every pre-swap record decodes to a state = ShardNormal shard info, and the "empty means the peer predates shard split" note at :524-526 is about old peers, not old records. Suggested fix: specify the fallback as a count over shard_infos states (ShardNormal + ShardCreating), which is exact in every state and needs no new field; or, if a pre-count record can never be mid-split, state that as an explicit premise (Known gap 3 in the PR description suggests it is reachable). (raised by tinswzy)

proto/milvus.proto:563shard_by claims to define how a row's routing value is computed, but the hash function is defined nowhere in the IDL.
:563-565 says shard_by is "How a row's routing value is computed", while the grammar at :567-569 stops at <function> := "hash" — no algorithm, no input encoding (a VarChar primary key's string bytes and an Int64 key's integer encoding are different paths), no output width, no masking rule. grep -i 'crc32|murmur|0x7fffffff|little-endian' proto/*.proto returns nothing at head; the only width hint is the pre-existing repeated uint32 hash_keys = 6; at :1346, which is not linked to shard_by. Failure: an SDK author implements :534 ("TO ROUTE A KEY") and :563 literally — parse hash($namespace_id) or hash(pk), hash it, take it modulo routing_modulus, pick the shard holding that residue — and picks FNV-1a, or applies the 0x7fffffff mask to a VarChar key → residues disagree with the server, rows go to shards that do not own them, and per this PR's own :543-548 there is "nothing checking ownership at write time", so the writes are silently accepted and the rows are invisible to correctly-predicated reads. These rules were stated in the IDL in the earlier revision of this PR (commit 1a3186a: "a string routing key is hashed with bare CRC32-IEEE over the full 32-bit range"); at head they survive only in the PR description ("A string key is CRC32-IEEE over at most its first 100 bytes, unmasked. An int64 key is murmur3_32 over the 8-byte little-endian encoding, and only then masked with 0x7fffffff"), which is not a wire contract an SDK author can consult. Suggested fix, either: restore that normative text to the shard_by comment — function, per-PK-type input encoding, the 100-byte truncation, output width, and that the mask applies only to the int64 path — or drop the "how a row's routing value is computed" framing and state that the value is server-computed and not client-reproducible, so shard_by conveys only which field. (raised by tinswzy)

proto/milvus.proto:571shard_by may name the system field $namespace_id, which is absent from the response schema and has no type or encoding anywhere in the IDL.
:571-577 specifies that <field> is matched against "the collection's fields and the system fields", names $namespace_id, and acknowledges that "A system field may be absent from this response's schema". grep -rn 'namespace_id' proto/*.proto matches only that comment line — there is no field declaration, no DataType, no value encoding, and no statement of where a consumer obtains a row's $namespace_id. Failure: DescribeCollection on a namespace-sharded collection returns shard_by = "hash($namespace_id)", the consumer routes per :534, finds no $namespace_id in the returned CollectionSchema (stripped by the public schema projection per :576-577) and no type for it in the IDL, and must either guess VarChar vs Int64 or abandon client-side routing → a wrong guess produces rows silently written to shards that do not own them, since :543-548 does not check ownership at write time. Combined with the undefined hash above, a consumer cannot even determine which hashing path applies. Suggested fix: declare the system field with its DataType and value encoding on the schema.proto side (or give an equivalent normative definition in the shard_by comment); otherwise restrict <field> to fields that actually appear in the response schema. (raised by tinswzy)

proto/milvus.proto:597 — the coverage invariant for routing_modulus is stated as a sum of residues, which holds for no modulus other than 3.
:596-597 says "the residues listed across shard_infos always sum to it". What sums to the modulus is the count of residues, not the residues themselves: a legal table covers exactly 0..m-1, summing to m(m-1)/2, which equals m only at m = 3. For m = 4 it is 6, for m = 2 it is 1, for m = 8 it is 28 — and per :602-608 the modulus only ever doubles from the initial shard count, so it is almost always even, on the side where the stated identity fails. Failure: a consumer follows schema.proto:640 ("worth checking once per shard table") but takes the rule from this field's comment and implements sum(all buckets) == routing_modulus; the server returns any legal table with modulus ≠ 3 (e.g. modulus 4 with two shards holding {0,1} and {2,3}) → the check fails on every table the server can produce (6 ≠ 4), and a fail-closed implementation rejects the whole routing table, erroring all predicate-routed reads and writes for that collection while pointing at a server inconsistency that does not exist. The same rule is stated correctly at schema.proto:638-640 on HashRouting ("the buckets are distinct and cover every remainder below the modulus exactly once"). Suggested fix: restate as "each shard's residue set is disjoint from the others and together they cover [0, routing_modulus) exactly once", matching the HashRouting wording. (raised by bigsheeper, tinswzy)

proto/milvus.proto:610 — the 2^15 bound on the modulus is written as an absolute limit, but it derives from cluster configuration and rests on a premise this PR says is not enforced.
:610-614 argues: at most one vchannel per pchannel per collection → the vchannel list cannot outgrow the cluster's pchannel count ("16 by default") → at most fourteen or fifteen doublings → "the modulus stays under 2^15". Both premises are soft. The pchannel count is cluster configuration that a consumer reading DescribeCollectionResponse cannot observe, so the bound should be expressed as a function of it rather than as a constant. And the one-vchannel-per-pchannel premise is not enforced — Known gap 2 in the PR description states "The split vchannel allocation does not exclude pchannels the collection already occupies", so even at the default 16 the vchannel list is not bounded by 16 and the derivation does not hold. Failure: a consumer preallocates a 32768-entry residue→shard table (or stores the modulus/residues in a narrow integer) per the stated absolute bound; the cluster raises its pchannel count above the default, or hits Known gap 2 and allocates split targets onto pchannels the collection already occupies → after repeated single-residue splits routing_modulus exceeds 32768 and the table index goes out of bounds (out-of-bounds write/panic, or a wrap under index & 0x7fff that maps keys to the wrong shard and routes them silently to a shard that does not own them). Suggested fix: state the bound as a function of the deployment ("shard count is at most the cluster's pchannel count, so the modulus bound follows from it; 2^14 at the default of 16 pchannels"), note that it depends on the split allocator excluding already-occupied pchannels, and until that allocator lands, do not direct consumers to preallocate against any fixed bound. (raised by tinswzy)

Low

proto/schema.proto:605 — the pinned field number the meta-swap compatibility argument depends on is enforced only by a comment, and this repo already has the pattern to enforce it.
CollectionShardInfo is new in this PR, so to anyone editing it later the field numbers look free; the "field 1 must not move" constraint exists solely in the :605-608 comment, which describes a message in a different repo (etcdpb.CollectionShardInfo). Nothing in this repo's build, codegen, or tests fails if the fields are reordered. go-api/milvuspb/snapshot_export_contract_test.go:30-43 already does exactly this kind of pin (Descriptor().Fields().ByNumber(2) plus a name assertion), and go-api/schemapb/ currently contains only schema.pb.go with no tests. Suggested fix: add the ~10-line equivalent — assert field 1 of CollectionShardInfo is last_truncate_time_tick of kind uint64, and round-trip a hand-built {field 1: varint} payload asserting it decodes to state = ShardNormal with routing unset. This pins field numbering and wire compatibility, which is checkable at build time. (raised by tinswzy)

xiaocai2333 added a commit to xiaocai2333/milvus that referenced this pull request Aug 31, 2026
The vocabulary and the streamingnode half of online shard split. Three
messages, all ExclusiveRequired because each rewrites a vchannel's
registration and nothing may be assigning segments while it does:

  SplitShard     fences a source vchannel forever. The source streamingnode
                 auto-flushes every growing segment as of the message's time
                 tick and embeds their ids in the header, so no separate
                 ManualFlush is needed, and the append is idempotent: a re-fence
                 returns SHARD_FENCED carrying the recorded T_switch, so a
                 coordinator that crashed after fencing recovers it.
  CreateVChannel the genesis of a target vchannel, carrying the residues it owns
                 and a BarrierTimeTick greater than every source's T_switch --
                 so the new WAL is born strictly after the fence and creation
                 doubles as activation.
  DropVChannel   retires a vchannel a split left behind, the inverse of
                 CreateVChannel. Guarded by the vchannel NAME, not just the
                 collection id: the shard manager is keyed by collection, one
                 entry per pchannel, so once a retired source's slot is
                 reclaimed a later vchannel of the same collection can hold that
                 entry, and a late or replayed teardown must not delete it.

Each lands in the four places a vchannel's lifecycle is tracked: the shard
interceptor, the shard manager, the recovery storage, and the flusher.
DropVChannel deliberately does not forward to the data sync service -- it is a
V2 message, and forwarding routes it into fromMessageToTsMsgV2, which has no
case for it and panics the process. DropCollection may fall through only
because it is V1, handled by the unmarshaler path.

A split target's key space rides in these messages as the residues it owns,
alongside the routing modulus they are taken against. The modulus is recorded
rather than looked up because a WAL record is permanent while a collection's
modulus moves: a later split that doubles it would leave bare residues
uninterpretable. The routing commit in AlterCollectionMessageUpdates carries the
modulus for the same reason, plus shard_by, which a first split back-fills on a
collection created before it existed.

Also adds SHARD_FENCED (with fenced_time_tick) and ROUTING_STALE streaming
error codes. ROUTING_STALE is reserved and unwired: the routing-version
negotiation was dropped in favour of the SHARD_FENCED reject-refetch loop, and
the code is kept for a possible later fast path.

Nothing appends these yet. This is step 2 of online shard split, after the
routing abstraction (milvus-io#50495); the coordinator that drives them follows.

Requires milvus-io/milvus-proto#618.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: cai.zhang <cai.zhang@zilliz.com>
xiaocai2333 added a commit to xiaocai2333/milvus that referenced this pull request Aug 31, 2026
The vocabulary and the streamingnode half of online shard split. Three
messages, all ExclusiveRequired because each rewrites a vchannel's
registration and nothing may be assigning segments while it does:

  SplitShard     fences a source vchannel forever. The source streamingnode
                 auto-flushes every growing segment as of the message's time
                 tick and embeds their ids in the header, so no separate
                 ManualFlush is needed, and the append is idempotent: a re-fence
                 returns SHARD_FENCED carrying the recorded T_switch, so a
                 coordinator that crashed after fencing recovers it.
  CreateVChannel the genesis of a target vchannel, carrying the residues it owns
                 and a BarrierTimeTick greater than every source's T_switch --
                 so the new WAL is born strictly after the fence and creation
                 doubles as activation.
  DropVChannel   retires a vchannel a split left behind, the inverse of
                 CreateVChannel. Guarded by the vchannel NAME, not just the
                 collection id: the shard manager is keyed by collection, one
                 entry per pchannel, so once a retired source's slot is
                 reclaimed a later vchannel of the same collection can hold that
                 entry, and a late or replayed teardown must not delete it.

Each lands in the four places a vchannel's lifecycle is tracked: the shard
interceptor, the shard manager, the recovery storage, and the flusher.
DropVChannel deliberately does not forward to the data sync service -- it is a
V2 message, and forwarding routes it into fromMessageToTsMsgV2, which has no
case for it and panics the process. DropCollection may fall through only
because it is V1, handled by the unmarshaler path.

A split target's key space rides in these messages as the residues it owns,
alongside the routing modulus they are taken against. The modulus is recorded
rather than looked up because a WAL record is permanent while a collection's
modulus moves: a later split that doubles it would leave bare residues
uninterpretable. The routing commit in AlterCollectionMessageUpdates carries the
modulus for the same reason, plus shard_by, which a first split back-fills on a
collection created before it existed.

Also adds SHARD_FENCED (with fenced_time_tick) and ROUTING_STALE streaming
error codes. ROUTING_STALE is reserved and unwired: the routing-version
negotiation was dropped in favour of the SHARD_FENCED reject-refetch loop, and
the code is kept for a possible later fast path.

Nothing appends these yet. This is step 2 of online shard split, after the
routing abstraction (milvus-io#50495); the coordinator that drives them follows.

Requires milvus-io/milvus-proto#618.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: cai.zhang <cai.zhang@zilliz.com>
xiaocai2333 added a commit to xiaocai2333/milvus that referenced this pull request Aug 31, 2026
… changes to it

The meta and DDL half of online shard split. A collection stops being described
by a channel count and starts describing its own topology.

model.Collection gains per-shard ShardInfo -- each shard's residues, lifecycle
state and truncate tick -- plus the collection-wide RoutingModulus those
residues are taken against and the ShardBy expression naming what gets hashed,
all persisted. The shard count is no longer len(VirtualChannelNames): a split
retires its sources but the vchannel list only ever grows, so the count is
derived from the shards that are actually ROUTABLE. Legacy collections read
exactly as before: no residues anywhere and a zero modulus mean hash % shardNum
by position, and ShardsNum falls back to the vchannel count for meta written
before it was persisted.

The modulus is collection-wide rather than per-shard because a split that
halves a shard down to its last residue has to double it, which re-expresses
every other shard's residues at the same instant. One field committed with the
topology says that once; a modulus per shard would say it N times and let the
copies disagree. ShardBy is written only when a commit carries one -- only a
first split has anything to back-fill, and clearing it on every later commit
would drop the expression clients route by.

CommitShardSplitRouting is the RPC datacoord uses to apply a routing change.
It sends the WHOLE new topology and rootcoord applies it atomically, keyed by
shard state so a retry -- or a crash between the commit and the caller's own
state advance -- is safe. Before broadcasting, rootcoord derives the routing
table the write path will derive, over the same writable-shard filter, and
refuses a topology that does not tile the key space: this is the last point at
which a bad plan is only a rejected DDL, since a committed gap silently drops
the writes of the residues nobody claims and a committed overlap sends one key
to two shards. The arrays are parallel and the ShardInfos map is rebuilt from
them in lockstep, which is what lets a SHORTER list retire a vchannel later. It
takes no collection lock, deliberately: its only caller already holds the
collection's exclusive resource key across the whole fence -> create -> commit
span, and taking the same key again would deadlock on that caller.

collection.shardNum is the user-facing request to change the count. It is
declarative: the property records the target and datacoord reconciles toward
it, so DescribeCollection's shards_num only reaches the value once the split
completes. Deleting the property WITHDRAWS the request, which is how a rehash
is cancelled -- and it is answered before the rest of validation, because
deletes are applied after sets and a request that both sets and deletes the key
in fact asks for nothing. Everything rootcoord can decide synchronously is
decided here so the common mistakes land in the AlterCollection response
instead of in a background task the caller never sees.

Nothing drives any of this yet: no caller invokes CommitShardSplitRouting and
no reconciler reads collection.shardNum. This is step 3 of online shard split,
on top of the routing abstraction (milvus-io#50495) and the WAL messages (milvus-io#52583); the
proxy write path and the split state machine follow.

Requires milvus-io/milvus-proto#618.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: cai.zhang <cai.zhang@zilliz.com>
xiaocai2333 added a commit to xiaocai2333/milvus that referenced this pull request Aug 31, 2026
The vocabulary and the streamingnode half of online shard split. Three
messages, all ExclusiveRequired because each rewrites a vchannel's
registration and nothing may be assigning segments while it does:

  SplitShard     fences a source vchannel forever. The source streamingnode
                 auto-flushes every growing segment as of the message's time
                 tick and embeds their ids in the header, so no separate
                 ManualFlush is needed, and the append is idempotent: a re-fence
                 returns SHARD_FENCED carrying the recorded T_switch, so a
                 coordinator that crashed after fencing recovers it.
  CreateVChannel the genesis of a target vchannel, carrying the residues it owns
                 and a BarrierTimeTick greater than every source's T_switch --
                 so the new WAL is born strictly after the fence and creation
                 doubles as activation.
  DropVChannel   retires a vchannel a split left behind, the inverse of
                 CreateVChannel. Guarded by the vchannel NAME, not just the
                 collection id: the shard manager is keyed by collection, one
                 entry per pchannel, so once a retired source's slot is
                 reclaimed a later vchannel of the same collection can hold that
                 entry, and a late or replayed teardown must not delete it.

Each lands in the four places a vchannel's lifecycle is tracked: the shard
interceptor, the shard manager, the recovery storage, and the flusher.
DropVChannel deliberately does not forward to the data sync service -- it is a
V2 message, and forwarding routes it into fromMessageToTsMsgV2, which has no
case for it and panics the process. DropCollection may fall through only
because it is V1, handled by the unmarshaler path.

A split target's key space rides in these messages as the residues it owns,
alongside the routing modulus they are taken against. The modulus is recorded
rather than looked up because a WAL record is permanent while a collection's
modulus moves: a later split that doubles it would leave bare residues
uninterpretable. The routing commit in AlterCollectionMessageUpdates carries the
modulus for the same reason, plus shard_by, which a first split back-fills on a
collection created before it existed.

Also adds SHARD_FENCED (with fenced_time_tick) and ROUTING_STALE streaming
error codes. ROUTING_STALE is reserved and unwired: the routing-version
negotiation was dropped in favour of the SHARD_FENCED reject-refetch loop, and
the code is kept for a possible later fast path.

Nothing appends these yet. This is step 2 of online shard split, after the
routing abstraction (milvus-io#50495); the coordinator that drives them follows.

Requires milvus-io/milvus-proto#618.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: cai.zhang <cai.zhang@zilliz.com>
@czs007

czs007 commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Must-fix issues introduced by this PR: 0
Merge recommendation: Mergeable as-is — this review found no must-fix issue introduced by this PR.

Re-review of c929178fd67a

All seven findings from our earlier comment on this unchanged commit were re-examined this round; all seven are Confirmed, none were retracted, and no new findings were raised.

  • Confirmedproto/milvus.proto:491-507: shards_num is defined at :491-492 as the count of ShardNormal plus ShardCreating entries in shard_infos, but the legacy-zero fallback at :505-507 returns len(virtual_channel_names), breaking both that definition and the :497-499 guarantee that the count is strictly smaller mid-split (that list also holds the fenced source and not-yet-reclaimed ShardDropped entries). The fallback also reads the wrong source: per :605-608 of schema.proto, pre-swap meta records decode to all-ShardNormal entries, so counting shard_infos agrees with the vchannel count when never split and is correct mid-split — the one case the vchannel list gets wrong. Suggestion: derive the value from shard_infos when available, or restrict the fallback to states where the vchannel list is guaranteed equivalent. (raised by sijie-ni-0214, tinswzy)

  • Confirmedproto/milvus.proto:563-569: shard_by claims to define how a row's routing value is computed, but the grammar stops at <function> := "hash" — no algorithm, no input serialization (VarChar bytes vs. Int64 integer encoding), no output width, no masking, no signedness; a grep of proto/ for crc32|murmur|xxhash|fnv|0x7fffffff|endian|IEEE hits only the unrelated decimal note at schema.proto:261. The PR description states these rules explicitly (CRC32-IEEE over the first 100 bytes unmasked for strings; murmur3_32 over 8-byte little-endian, then masked with 0x7fffffff, for int64) and says they are "spelled out in the comments," but they are not in the IDL. Failure: a third-party SDK implements hash(pk) per the grammar and picks masked CRC32 for a VarChar key (nothing forbids it) → its residues diverge from the server's, the write lands on a shard that does not own the key, and since :543-548 documents that nothing checks ownership at write time, the rows are silently accepted and unreadable from the correct shard. Suggestion: pin the algorithm and input encoding in the shard_by comment, or reference a stable normative definition. (raised by sijie-ni-0214, tinswzy)

  • Confirmedproto/milvus.proto:571-577: the grammar matches <field> against system fields and names $namespace_id, conceding it "may be absent from this response's schema," yet that comment is the only occurrence of $namespace_id anywhere in the protocol sources — no declaration, no type, no canonical encoding, no statement of where a consumer obtains a row's value. Failure: DescribeCollection on a namespace-sharded, already-split collection returns shard_by="hash($namespace_id)" with no such field in the response schema → the consumer parses the string but cannot compute a routing value, and either fails routing for the whole collection or falls back to the primary key per :588, placing every row on a shard that does not own it and, per :543-548, having those writes silently accepted. Suggestion: define the system field's type and canonical encoding in the IDL, or state where its value comes from. (raised by sijie-ni-0214, tinswzy)

  • Confirmedproto/milvus.proto:595-597: "the residues listed across shard_infos always sum to it" is mathematically false. A valid table lists 0..m-1 exactly once, summing to m(m-1)/2 — 1 for m=2, 6 for m=4, 28 for m=8 — equal to m only at m=3, and :602-608 makes the modulus a doubling of the initial shard count, so m=3 effectively never occurs. Failure: an SDK implements the self-check sum(buckets across shard_infos) == routing_modulus and runs it on any split collection (four shards, modulus 4, residues {0}{1}{2}{3}, sum 6) → the check fails for every legal table, and the consumer rejects the routing table outright (or emits permanent false alarms if it only warns). Suggestion: reuse the correct wording already present at schema.proto:637-641 — buckets are distinct and cover every remainder below the modulus exactly once. (raised by sijie-ni-0214, tinswzy)

  • Confirmedproto/milvus.proto:610-614: the bound is stated unconditionally ("the modulus stays under 2^15") while the same sentence derives it from a pchannel count that is "16 by default" — a cluster-level configuration a DescribeCollectionResponse consumer cannot observe. Its other premise, one vchannel per pchannel per collection, is prose here only; the PR's own Known gaps [feature] Need mergify bot configuration #2 states that "the split vchannel allocation does not exclude pchannels the collection already occupies." Failure: a cluster configured above 16 pchannels (or split allocation reusing an occupied pchannel) lets routing_modulus reach 2^15 after enough single-residue splits, while a consumer sized a uint16 bucket or a 32K bitmap against :614 → truncation aliases distinct residues onto one bucket, routing keys to non-owning shards with no write-time ownership check, or the bitmap overruns and the consumer crashes reading the table. Suggestion: express the bound as a function of the enforced shard/pchannel limit, or drop the absolute bound (the field and buckets are both uint64, so nothing is lost). (raised by sijie-ni-0214, tinswzy)

  • Confirmedproto/schema.proto:620-623: the prescription that "whatever makes the two distinguishable has to arrive with the variant" cannot work under proto3 — a stub compiled today puts an unknown oneof member and any new sibling field into unknown fields, and GetRouting() still returns nil, so the remedy does not treat the disease the same sentence diagnoses. A usable discriminator already exists in this PR: routing_modulus (milvus.proto:595-600), zero only when never split and readable by today's stubs. milvus.proto:538-541 currently directs readers the opposite way — scan the whole table ignoring ShardSplitting/ShardDropped, treat no predicate as never-split, apply the legacy rule — with no cross-check of routing_modulus. Suggestion: make the routing_modulus check normative — a nonzero modulus with no recognized predicate anywhere in the table must fail routing rather than fall back to legacy placement. (With one routing variant in the tree today both readings agree; this is a spec-consistency defect, not a currently triggerable bug.) (raised by sijie-ni-0214, tinswzy)

  • Confirmed (low) — proto/schema.proto:601-609: the meta-swap compatibility depends on last_truncate_time_tick keeping field number 1, but that pin lives only in a comment describing a message in a different repository; CollectionShardInfo is new here, so field 1 looks free to a later editor and nothing in the build fails on renumbering. The repo already has the pattern — go-api/milvuspb/snapshot_export_contract_test.go:31-35 asserts a field number and name via Descriptor().Fields().ByNumber(...). Note the gap is wider than a missing test: the only step in .github/workflows/check.yaml:32-36 is "Try generate proto" (python grpc_tools.protoc), with no go test anywhere, so even the existing contract test never runs in CI. Suggestion: add a descriptor test asserting field 1 is still last_truncate_time_tick/uint64, and add a go test ./go-api/... step to check.yaml so it is actually enforced. (raised by sijie-ni-0214, tinswzy)

No new findings and no retractions this round.

@chyezh

chyezh commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

/lgtm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants