Files
milvus/internal/rootcoord/ddl_callbacks_alter_collection_field.go
T
5def5ced4a enhance: enforce function-field binding principle for function DDL (#51360)
## What

Enforce a function–field binding principle for function DDL (add / drop
/ alter function on an existing collection), so a function and its
output field stay coupled and already-stored vectors can never be
silently invalidated by DDL.

BM25 and MinHash follow the strict binding: add/drop only via the
unified `add_function_field` / `drop_function_field`, output field
always coupled. **TextEmbedding is a temporary carve-out** — its legacy
`AddCollectionFunction` / `DropCollectionFunction` paths are retained,
so attach-over-existing-field and detach are still possible for it,
because `add_function_field` embedding backfill is not yet implemented.
It will be folded into the unified path in a follow-up. So the "always
coupled" invariant currently holds for BM25/MinHash, not TextEmbedding.

## Changes

- **AlterCollectionSchema invariant**: reject standalone add-function (a
function must be added together with its new output field), reject
detaching a function without dropping its output field, and reject
function cascade (a new function's input being another function's
output).
- **Legacy RPCs**: `AddCollectionFunction` / `DropCollectionFunction`
are rejected for BM25/MinHash (must use `add_function_field` /
`drop_function_field`) and retained only for TextEmbedding (see
carve-out). `AlterCollectionFunction` is kept and gains the whitelist
below. In the alter and legacy-add paths, function field IDs are always
re-derived from field names (never trusted from the request), so a
request cannot inject an unrelated field ID that a later
`drop_function_field` would delete.
- **alter_function whitelist**: only connection/runtime params may
change (TextEmbedding: `url`, `credential`, `timeout_ms`,
`max_client_batch_size`, `region`, `location`, `projectid`, `user`);
BM25/MinHash have no alterable params. Function identity (type, name,
input/output fields) and output-shaping params (`dim`, `model_name`,
`endpoint` — the TEI model identity, `normalize`, `truncate*`, prompts,
...) are immutable. Params are normalized (keys lowercased, duplicate
keys rejected) so a crafted duplicate key cannot bypass the diff.
- **drop_function_field**: allow any output-producing function (dropping
needs no backfill), removing the previous BM25/MinHash-only restriction.

## Not in scope / follow-up

- Extending `add_function_field` to TextEmbedding (post-creation add
would require backfilling every existing row through the external
embedding model) — deferred; folding the TextEmbedding legacy paths into
the unified binding follows this.
- MinHash input-field analyzer immutability lives on a different DDL
path (`AlterCollectionField`); tracked as a separate follow-up.

## Breaking changes

- `DropCollectionFunction` on a **MinHash** function (detach — remove
the function but keep its output field) is no longer supported; use
`drop_function_field` instead (drops the function together with its
output field). If a released version supported MinHash detach, migrate
any such call to `drop_function_field`.

issue: #51348

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: MrPresent-Han <chun.han@gmail.com>
Co-authored-by: MrPresent-Han <chun.han@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 05:04:39 +08:00

177 lines
5.6 KiB
Go

package rootcoord
import (
"context"
"strconv"
"google.golang.org/protobuf/types/known/fieldmaskpb"
"github.com/milvus-io/milvus-proto/go-api/v3/commonpb"
"github.com/milvus-io/milvus-proto/go-api/v3/milvuspb"
"github.com/milvus-io/milvus-proto/go-api/v3/schemapb"
"github.com/milvus-io/milvus/internal/distributed/streaming"
"github.com/milvus-io/milvus/pkg/v3/common"
"github.com/milvus-io/milvus/pkg/v3/proto/messagespb"
"github.com/milvus-io/milvus/pkg/v3/streaming/util/message"
"github.com/milvus-io/milvus/pkg/v3/util/merr"
"github.com/milvus-io/milvus/pkg/v3/util/typeutil"
)
func hasAnalyzerFieldParamMutation(req *milvuspb.AlterCollectionFieldRequest) bool {
for _, prop := range req.GetProperties() {
if prop.GetKey() == common.EnableAnalyzerKey || prop.GetKey() == common.AnalyzerParamKey {
return true
}
}
for _, key := range req.GetDeleteKeys() {
if key == common.EnableAnalyzerKey || key == common.AnalyzerParamKey {
return true
}
}
return false
}
func validateAlterCollectionFieldAnalyzerParams(req *milvuspb.AlterCollectionFieldRequest) error {
for _, prop := range req.GetProperties() {
if prop.GetKey() != common.EnableAnalyzerKey {
continue
}
if _, err := strconv.ParseBool(prop.GetValue()); err != nil {
return merr.WrapErrParameterInvalidMsg("%s should be a boolean, but got %s", prop.GetKey(), prop.GetValue())
}
}
return nil
}
func getAlterCollectionField(schema *schemapb.CollectionSchema, fieldName string) *schemapb.FieldSchema {
for _, field := range schema.GetFields() {
if field.GetName() == fieldName {
return field
}
}
return nil
}
func validateAlterCollectionFieldAnalyzerMutation(schema *schemapb.CollectionSchema, fieldName string) error {
field := getAlterCollectionField(schema, fieldName)
if field == nil {
return merr.WrapErrParameterInvalidMsg("field not found: %s", fieldName)
}
if !typeutil.IsStringType(field.GetDataType()) {
return merr.WrapErrParameterInvalidMsg("can not alter analyzer params for non-string field %s", fieldName)
}
fieldHelper := typeutil.CreateFieldSchemaHelper(field)
if fieldHelper.EnableMatch() ||
typeutil.IsBm25FunctionInputField(schema, field) ||
typeutil.IsMinHashFunctionInputField(schema, field) {
return merr.WrapErrParameterInvalidMsg(
"can not alter analyzer params for field %s after text match is enabled or a BM25/MinHash function depends on it",
fieldName,
)
}
return nil
}
func (c *Core) broadcastAlterCollectionV2ForAlterCollectionField(ctx context.Context, req *milvuspb.AlterCollectionFieldRequest) error {
broadcastAPI, err := c.startBroadcastWithAliasOrCollectionLock(ctx, req.GetDbName(), req.GetCollectionName())
if err != nil {
return err
}
defer broadcastAPI.Close()
coll, err := c.meta.GetCollectionByName(ctx, req.GetDbName(), req.GetCollectionName(), typeutil.MaxTimestamp, false)
if err != nil {
return err
}
oldFieldProperties, err := GetFieldProperties(coll, req.GetFieldName())
if err != nil {
return err
}
oldFieldPropertiesMap := common.CloneKeyValuePairs(oldFieldProperties).ToMap()
var desc *commonpb.KeyValuePair = nil
for _, prop := range req.GetProperties() {
// field.description is a special property to change field's description, skip it here, apply it later.
if prop.GetKey() == common.FieldDescriptionKey {
desc = prop
continue
}
oldFieldPropertiesMap[prop.GetKey()] = prop.GetValue()
}
for _, deleteKey := range req.GetDeleteKeys() {
delete(oldFieldPropertiesMap, deleteKey)
}
newFieldProperties := common.NewKeyValuePairs(oldFieldPropertiesMap)
if newFieldProperties.Equal(oldFieldProperties) && desc == nil {
// if there's no change, return nil directly to promise idempotent.
return errIgnoredAlterCollection
}
// build new collection schema.
schema := coll.ToCollectionSchemaPB()
schema.Version = coll.SchemaVersion + 1
for _, field := range schema.Fields {
if field.Name == req.GetFieldName() {
field.TypeParams = newFieldProperties
if desc != nil {
field.Description = desc.GetValue()
}
break
}
}
if err := validateSchemaEvolution(coll, schema); err != nil {
return err
}
cacheExpirations, err := c.getCacheExpireForCollection(ctx, req.GetDbName(), req.GetCollectionName())
if err != nil {
return err
}
analyzerFieldParamMutated := hasAnalyzerFieldParamMutation(req)
if analyzerFieldParamMutated {
if err := validateAlterCollectionFieldAnalyzerParams(req); err != nil {
return err
}
if err := validateAlterCollectionFieldAnalyzerMutation(schema, req.GetFieldName()); err != nil {
return err
}
}
header := &messagespb.AlterCollectionMessageHeader{
DbId: coll.DBID,
CollectionId: coll.CollectionID,
UpdateMask: &fieldmaskpb.FieldMask{
Paths: []string{message.FieldMaskCollectionSchema},
},
CacheExpirations: cacheExpirations,
}
body := &messagespb.AlterCollectionMessageBody{
Updates: &messagespb.AlterCollectionMessageUpdates{
Schema: schema,
},
}
channels := make([]string, 0, len(coll.VirtualChannelNames)+1)
channels = append(channels, streaming.WAL().ControlChannel())
channels = append(channels, coll.VirtualChannelNames...)
var addedFileResourceIds []int64
if analyzerFieldParamMutated {
addedFileResourceIds, err = c.prepareAlterCollectionAnalyzerFileResources(ctx, coll, schema)
if err != nil {
return err
}
}
msg := message.NewAlterCollectionMessageBuilderV2().
WithHeader(header).
WithBody(body).
WithBroadcast(channels).
MustBuildBroadcast()
if _, err := broadcastAPI.Broadcast(ctx, msg); err != nil {
rollbackAlterCollectionAnalyzerFileResourceReservation(ctx, c.meta, coll.CollectionID, addedFileResourceIds, err)
return err
}
return nil
}