chore: import upstream snapshot with attribution
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
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
This commit is contained in:
+504
@@ -0,0 +1,504 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user