498b235461
Build and test / Build and test AMD64 Ubuntu 22.04 (push) Failing after 0s
Publish Builder / amazonlinux2023 (push) Failing after 1s
Build and test / UT for Go (push) Has been skipped
Publish KRTE Images / KRTE (push) Failing after 1s
Build and test / Integration Test (push) Has been skipped
Build and test / Upload Code Coverage (push) Has been skipped
Publish Builder / rockylinux9 (push) Failing after 1s
Publish Builder / ubuntu22.04 (push) Failing after 0s
Publish Builder / ubuntu24.04 (push) Failing after 0s
Publish Gpu Builder / publish-gpu-builder (push) Failing after 1s
Publish Test Images / PyTest (push) Failing after 0s
Build and test / UT for Cpp (push) Has been cancelled
505 lines
16 KiB
Go
505 lines
16 KiB
Go
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)
|
|
}
|