package external import ( "context" "fmt" "io" "path" "strconv" "github.com/apache/arrow/go/v17/arrow" "github.com/apache/arrow/go/v17/arrow/array" "github.com/apache/arrow/go/v17/arrow/memory" "github.com/samber/lo" "github.com/milvus-io/milvus-proto/go-api/v3/schemapb" "github.com/milvus-io/milvus/internal/storage" "github.com/milvus-io/milvus/internal/storagecommon" "github.com/milvus-io/milvus/internal/storagev2/packed" "github.com/milvus-io/milvus/internal/util/function/embedding" "github.com/milvus-io/milvus/pkg/v3/mlog" "github.com/milvus-io/milvus/pkg/v3/proto/indexpb" "github.com/milvus-io/milvus/pkg/v3/util/merr" "github.com/milvus-io/milvus/pkg/v3/util/typeutil" ) // defaultReadBufferSize matches the buffer size used elsewhere for FFIPackedReader. const defaultReadBufferSize = 64 * 1024 * 1024 // ExecuteFunctionsForSegment computes function-output columns for an external // segment and returns a manifest that references both the external original // files (for input columns) and a newly written packed file (for function // output columns). // // Streaming pipeline (no full-segment InsertData materialization): // 1. Build an input manifest referencing the segment's external fragments. // 2. Open storage.RecordReader on input + FFIPackedWriter on outputs. // 3. For each Record batch read: convert to InsertData, run functions, append // BM25 stats, build output Record, write to packed file. // 4. Close writer (commits manifest). // 5. Serialize accumulated BM25 stats and add to manifest. // // Memory: peak ~ one Arrow batch (default 64 MiB) regardless of segment size. // Input columns are never copied; segments reference the external original // files via the same column-group layout Segcore already uses. func ExecuteFunctionsForSegment( ctx context.Context, schema *schemapb.CollectionSchema, fragments []packed.Fragment, format string, storageConfig *indexpb.StorageConfig, collectionID int64, segmentID int64, basePath string, clusterID string, ) (string, error) { log := mlog.With() log.Info(ctx, "executing functions for external table segment", mlog.FieldSegmentID(segmentID), mlog.String("basePath", basePath), mlog.Int("numFragments", len(fragments)), mlog.Int("numFunctions", len(schema.GetFunctions()))) sourceColumns := packed.GetColumnNamesFromSchema(schema) inputManifestPath, err := packed.CreateSegmentManifestWithBasePathAndExtfs( ctx, basePath, format, sourceColumns, fragments, storageConfig, packed.ExternalSpecContext{ CollectionID: collectionID, Source: schema.GetExternalSource(), Spec: schema.GetExternalSpec(), MilvusTablePKMode: packed.MilvusTablePrimaryKeyModeFromSchema(schema), }, ) if err != nil { return "", merr.Wrap(err, "create input manifest") } _, inputVersion, err := packed.UnmarshalManifestPath(inputManifestPath) if err != nil { return "", merr.Wrap(err, "parse input manifest path") } outputFields, outputSchema, err := buildOutputSchema(schema) if err != nil { return "", err } outputArrow, err := storage.ConvertToArrowSchema(outputSchema, true) if err != nil { return "", err } inputSchema, executionSchema, requiredInputFields, err := buildFunctionExecutionSchema(schema) if err != nil { return "", err } reader, err := openInputReader(ctx, schema, inputManifestPath, inputSchema, storageConfig, collectionID) if err != nil { return "", err } defer reader.Close() colGroups := []storagecommon.ColumnGroup{{Columns: lo.Range(len(outputFields))}} writer, err := packed.NewFFIPackedWriter(basePath, outputArrow, colGroups, storageConfig, nil) if err != nil { return "", merr.Wrap(err, "open output writer") } writer.AsNewColumnGroups() bm25Acc := newBM25Accumulators(schema) totalRows, err := streamBatches(ctx, schema, executionSchema, outputSchema, outputArrow, requiredInputFields, reader, writer, bm25Acc, clusterID) if err != nil { return "", err } output, err := writer.Close() if err != nil { return "", merr.Wrap(err, "close output writer") } if output != nil { defer output.Destroy() } updates := &packed.ManifestUpdates{NewFiles: output} if err := appendBM25Stats(ctx, bm25Acc, storageConfig, basePath, updates); err != nil { return "", err } manifestPath, err := packed.CommitManifestUpdates(basePath, inputVersion, storageConfig, updates) if err != nil { return "", merr.Wrap(err, "commit function output manifest") } log.Info(ctx, "function execution completed", mlog.FieldSegmentID(segmentID), mlog.Int64("rows", totalRows), mlog.String("manifestPath", manifestPath)) return manifestPath, nil } // buildOutputSchema returns the output FieldSchema list and a wrapping // CollectionSchema used for arrow conversion. Errors if the schema declares no // function outputs (the executor should not have been invoked at all). func buildOutputSchema(schema *schemapb.CollectionSchema) ([]*schemapb.FieldSchema, *schemapb.CollectionSchema, error) { outputFields, err := functionOutputFields(schema) if err != nil { return nil, nil, err } if len(outputFields) == 0 { return nil, nil, merr.WrapErrServiceInternalMsg("no function output fields; executor should not have been invoked") } return outputFields, &schemapb.CollectionSchema{ Name: schema.GetName(), Fields: outputFields, }, nil } func functionOutputFields(schema *schemapb.CollectionSchema) ([]*schemapb.FieldSchema, error) { if schema == nil { return nil, nil } fieldsByID := make(map[int64]*schemapb.FieldSchema, len(schema.GetFields())) fieldsByName := make(map[string]*schemapb.FieldSchema, len(schema.GetFields())) outputIDs := make(map[int64]struct{}) addOutputID := func(fieldID int64) { if fieldID == 0 { return } outputIDs[fieldID] = struct{}{} } for _, field := range schema.GetFields() { fieldsByID[field.GetFieldID()] = field fieldsByName[field.GetName()] = field if field.GetIsFunctionOutput() { addOutputID(field.GetFieldID()) } } for _, fn := range schema.GetFunctions() { for _, fieldID := range fn.GetOutputFieldIds() { if fieldID == 0 { continue } if _, ok := fieldsByID[fieldID]; !ok { return nil, merr.WrapErrParameterInvalidMsg("function output field id %d not found in schema", fieldID) } addOutputID(fieldID) } for _, fieldName := range fn.GetOutputFieldNames() { if fieldName == "" { continue } field, ok := fieldsByName[fieldName] if !ok { return nil, merr.WrapErrParameterInvalidMsg("function output field %s not found in schema", fieldName) } addOutputID(field.GetFieldID()) } } outputFields := make([]*schemapb.FieldSchema, 0, len(outputIDs)) for _, field := range schema.GetFields() { if _, ok := outputIDs[field.GetFieldID()]; ok { outputFields = append(outputFields, field) } } return outputFields, nil } func openInputReader( ctx context.Context, schema *schemapb.CollectionSchema, manifestPath string, inputSchema *schemapb.CollectionSchema, storageConfig *indexpb.StorageConfig, collectionID int64, ) (storage.RecordReader, error) { reader, err := storage.NewManifestRecordReader(ctx, manifestPath, inputSchema, storage.WithCollectionID(collectionID), storage.WithVersion(storage.StorageV3), storage.WithBufferSize(defaultReadBufferSize), storage.WithStorageConfig(storageConfig), storage.WithExternalReaderContext(packed.ExternalReaderContext{ CollectionID: collectionID, Source: schema.GetExternalSource(), Spec: schema.GetExternalSpec(), }), ) if err != nil { return nil, merr.Wrap(err, "open input manifest") } return reader, nil } // buildFunctionExecutionSchema returns the source schema needed for reading // function inputs, a wider schema for InsertData conversion, and the input // fields that must be present in each read batch. The // wider schema includes function outputs so RunAll can fill them in-place, but // unrelated external fields are not deserialized. func buildFunctionExecutionSchema( schema *schemapb.CollectionSchema, ) (*schemapb.CollectionSchema, *schemapb.CollectionSchema, typeutil.Set[int64], error) { if schema == nil { return nil, nil, nil, merr.WrapErrParameterInvalidMsg("collection schema is nil") } inputIDs := make(map[int64]struct{}) for _, fn := range schema.GetFunctions() { for _, id := range fn.GetInputFieldIds() { inputIDs[id] = struct{}{} } } outputFields, err := functionOutputFields(schema) if err != nil { return nil, nil, nil, err } outputIDs := make(map[int64]struct{}, len(outputFields)) for _, field := range outputFields { outputIDs[field.GetFieldID()] = struct{}{} } inputSchema := &schemapb.CollectionSchema{ Name: schema.GetName(), DbName: schema.GetDbName(), Properties: schema.GetProperties(), } executionSchema := &schemapb.CollectionSchema{ Name: schema.GetName(), DbName: schema.GetDbName(), Properties: schema.GetProperties(), } seenExecutionFields := make(map[int64]struct{}) seenInputFields := make(map[int64]struct{}) requiredInputFields := typeutil.NewSet[int64]() addExecutionField := func(field *schemapb.FieldSchema) { if _, ok := seenExecutionFields[field.GetFieldID()]; ok { return } executionSchema.Fields = append(executionSchema.Fields, field) seenExecutionFields[field.GetFieldID()] = struct{}{} } for _, f := range schema.GetFields() { fieldID := f.GetFieldID() _, isInput := inputIDs[fieldID] _, isOutput := outputIDs[fieldID] if isInput || isOutput { addExecutionField(f) } if !isInput || isOutput || typeutil.IsExternalSystemOrVirtualField(f.GetName()) { continue } seenInputFields[fieldID] = struct{}{} requiredInputFields.Insert(fieldID) inputSchema.Fields = append(inputSchema.Fields, f) if schema.GetExternalSource() != "" && f.GetExternalField() == "" { return nil, nil, nil, merr.WrapErrParameterInvalidMsg("function input field %s has no external_field", f.GetName()) } } for inputID := range inputIDs { if _, ok := seenInputFields[inputID]; !ok { if _, ok := seenExecutionFields[inputID]; !ok { return nil, nil, nil, merr.WrapErrParameterInvalidMsg("function input field id %d not found in schema", inputID) } } } for outputID := range outputIDs { if _, ok := seenExecutionFields[outputID]; !ok { return nil, nil, nil, merr.WrapErrParameterInvalidMsg("function output field id %d not found in schema", outputID) } } if len(inputSchema.GetFields()) == 0 { return nil, nil, nil, merr.WrapErrParameterInvalidMsg("no source input columns for function execution") } return inputSchema, executionSchema, requiredInputFields, nil } func streamBatches( ctx context.Context, schema *schemapb.CollectionSchema, executionSchema *schemapb.CollectionSchema, outputSchema *schemapb.CollectionSchema, outputArrow *arrow.Schema, requiredInputFields typeutil.Set[int64], reader storage.RecordReader, writer *packed.FFIPackedWriter, bm25Acc map[int64]*storage.BM25Stats, clusterID string, ) (int64, error) { var totalRows int64 for { rec, err := reader.Next() if err == io.EOF { break } if err != nil { return totalRows, merr.Wrap(err, "read input batch") } if rec == nil { break } batch, err := storage.RecordToInsertData(rec, executionSchema, requiredInputFields) rec.Release() if err != nil { return totalRows, merr.Wrap(err, "record to InsertData") } if batch.GetRowNum() == 0 { continue } if err := embedding.RunAll(ctx, schema, batch, embedding.RunOptions{ ClusterID: clusterID, DBName: schema.GetDbName(), }); err != nil { return totalRows, merr.Wrap(err, "execute functions") } if err := accumulateBM25Stats(batch, bm25Acc); err != nil { return totalRows, err } if err := writeOutputBatch(batch, outputSchema, outputArrow, writer); err != nil { return totalRows, merr.Wrap(err, "write output batch") } totalRows += int64(batch.GetRowNum()) } return totalRows, nil } func writeOutputBatch( batch *storage.InsertData, outputSchema *schemapb.CollectionSchema, outputArrow *arrow.Schema, writer *packed.FFIPackedWriter, ) error { builder := array.NewRecordBuilder(memory.DefaultAllocator, outputArrow) defer builder.Release() if err := storage.BuildRecord(builder, batch, outputSchema); err != nil { return err } rec := builder.NewRecord() defer rec.Release() return writer.WriteRecordBatch(rec) } // newBM25Accumulators creates a stats accumulator per BM25 output field id. // Returns an empty map if the schema declares no BM25 functions. func newBM25Accumulators(schema *schemapb.CollectionSchema) map[int64]*storage.BM25Stats { acc := make(map[int64]*storage.BM25Stats) for _, fn := range schema.GetFunctions() { if fn.GetType() != schemapb.FunctionType_BM25 { continue } for _, outID := range fn.GetOutputFieldIds() { acc[outID] = storage.NewBM25Stats() } } return acc } // accumulateBM25Stats appends per-batch sparse vectors into the running // per-field stats accumulator. avgdl/IDF need full-segment counts, so the // accumulator persists across batches and is serialized once at the end. func accumulateBM25Stats(batch *storage.InsertData, acc map[int64]*storage.BM25Stats) error { for outID, stats := range acc { raw, present := batch.Data[outID] if !present || raw == nil { return merr.WrapErrFunctionFailedMsg( "BM25 output field %d missing from batch; executeFunctions did not populate it", outID) } fd, ok := raw.(*storage.SparseFloatVectorFieldData) if !ok { return merr.WrapErrFunctionFailedMsg( "BM25 output field %d has wrong type %T (want *SparseFloatVectorFieldData)", outID, raw) } stats.AppendFieldData(fd) } return nil } func appendBM25Stats( ctx context.Context, acc map[int64]*storage.BM25Stats, storageConfig *indexpb.StorageConfig, basePath string, updates *packed.ManifestUpdates, ) error { if len(acc) == 0 { return nil } log := mlog.With() if updates == nil { return merr.WrapErrServiceInternalMsg("manifest updates is nil") } entries := make([]packed.StatEntry, 0, len(acc)) for outID, stats := range acc { blob, err := stats.Serialize() if err != nil { return merr.Wrapf(err, "serialize bm25 stats for field %d", outID) } fullPath := path.Join(basePath, fmt.Sprintf("_stats/bm25.%d/%d", outID, 0)) if err := packed.WriteFile(storageConfig, fullPath, blob); err != nil { return merr.Wrapf(err, "write bm25 stats file %s", fullPath) } entries = append(entries, packed.StatEntry{ Key: fmt.Sprintf("bm25.%d", outID), Files: []string{fullPath}, Metadata: map[string]string{ "memory_size": strconv.FormatInt(int64(len(blob)), 10), }, }) log.Info(ctx, "registered bm25 stats", mlog.FieldFieldID(outID), mlog.Int("bytes", len(blob)), mlog.Int64("numRow", stats.NumRow())) } updates.Stats = append(updates.Stats, entries...) return nil } // finalizeBM25Stats serializes each per-field accumulator, writes the blob // under the packed segment stats path, and registers entries on the manifest. // Required by QueryNode's idf_oracle for BM25 search. // // The stat blob write intentionally happens before manifest registration. // A failure between WriteFile and CommitManifestUpdates can leave an // unreferenced object under the segment base path, but it is not visible to // readers because the manifest is the commit point. A retry rewrites the same // deterministic path for the same field/version pair. func finalizeBM25Stats( ctx context.Context, acc map[int64]*storage.BM25Stats, storageConfig *indexpb.StorageConfig, manifestPath string, ) (string, error) { if len(acc) == 0 { return manifestPath, nil } basePath, version, err := packed.UnmarshalManifestPath(manifestPath) if err != nil { return "", merr.Wrap(err, "parse manifest path") } updates := &packed.ManifestUpdates{} if err := appendBM25Stats(ctx, acc, storageConfig, basePath, updates); err != nil { return "", err } return packed.CommitManifestUpdates(basePath, version, storageConfig, updates) }