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
548 lines
17 KiB
Go
548 lines
17 KiB
Go
/*
|
||
* # Licensed to the LF AI & Data foundation under one
|
||
* # or more contributor license agreements. See the NOTICE file
|
||
* # distributed with this work for additional information
|
||
* # regarding copyright ownership. The ASF licenses this file
|
||
* # to you under the Apache License, Version 2.0 (the
|
||
* # "License"); you may not use this file except in compliance
|
||
* # with the License. You may obtain a copy of the License at
|
||
* #
|
||
* # http://www.apache.org/licenses/LICENSE-2.0
|
||
* #
|
||
* # Unless required by applicable law or agreed to in writing, software
|
||
* # distributed under the License is distributed on an "AS IS" BASIS,
|
||
* # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
* # See the License for the specific language governing permissions and
|
||
* # limitations under the License.
|
||
*/
|
||
|
||
package chain
|
||
|
||
import (
|
||
"github.com/apache/arrow/go/v17/arrow"
|
||
|
||
"github.com/milvus-io/milvus-proto/go-api/v3/schemapb"
|
||
"github.com/milvus-io/milvus/internal/util/function/chain/types"
|
||
"github.com/milvus-io/milvus/pkg/v3/util/merr"
|
||
)
|
||
|
||
// =============================================================================
|
||
// DataFrame - Immutable Data Container
|
||
// =============================================================================
|
||
|
||
// DataFrame is an immutable data container that stores Milvus data using Arrow Chunked Arrays.
|
||
// Each chunk corresponds to a query result (NQ), enabling per-query access.
|
||
//
|
||
// DataFrame is similar to Arrow Table - it is read-only after creation.
|
||
// To create or modify a DataFrame, use DataFrameBuilder.
|
||
//
|
||
// Memory management: Call Release() exactly once when done with the DataFrame.
|
||
//
|
||
// Structure:
|
||
//
|
||
// DataFrame
|
||
// ├── schema: Arrow schema with field metadata
|
||
// ├── columns: []arrow.Chunked (one per column, each has NQ chunks)
|
||
// ├── chunkSizes: []int64 (row count per chunk, corresponds to Topks)
|
||
// ├── fieldTypes: Milvus DataType per column (for export back to protobuf)
|
||
// ├── fieldIDs: field ID per column (for export back to protobuf)
|
||
// └── metadata: key-value pairs (e.g., metric_type)
|
||
//
|
||
// Data layout example (2 columns, 3 chunks/NQ):
|
||
//
|
||
// chunk0(nq0) chunk1(nq1) chunk2(nq2)
|
||
// $id [1,2,3] [4,5] [6]
|
||
// $score [0.9,0.8,0.7] [0.6,0.5] [0.4]
|
||
// chunkSizes: [3, 2, 1]
|
||
type DataFrame struct {
|
||
schema *arrow.Schema // Arrow schema with field metadata
|
||
columns []*arrow.Chunked // Chunked arrays, one per column
|
||
chunkSizes []int64 // Row count per chunk (corresponds to Topks)
|
||
nameIndex map[string]int // Column name to index mapping
|
||
fieldTypes map[string]schemapb.DataType // Preserve Milvus type info for export
|
||
fieldIDs map[string]int64 // Field IDs for export
|
||
fieldNullables map[string]bool // Field nullable info for schema creation
|
||
metadata map[string]string // Arbitrary key-value metadata (e.g., metric_type)
|
||
}
|
||
|
||
// =============================================================================
|
||
// Metadata Methods (Read-only)
|
||
// =============================================================================
|
||
|
||
// NumRows returns the total number of rows across all chunks.
|
||
func (df *DataFrame) NumRows() int64 {
|
||
var total int64
|
||
for _, size := range df.chunkSizes {
|
||
total += size
|
||
}
|
||
return total
|
||
}
|
||
|
||
// NumChunks returns the number of chunks (NQ for search results).
|
||
func (df *DataFrame) NumChunks() int {
|
||
return len(df.chunkSizes)
|
||
}
|
||
|
||
// NumColumns returns the number of columns.
|
||
func (df *DataFrame) NumColumns() int {
|
||
return len(df.columns)
|
||
}
|
||
|
||
// ChunkSizes returns the row count per chunk (same as Topks for search results).
|
||
func (df *DataFrame) ChunkSizes() []int64 {
|
||
result := make([]int64, len(df.chunkSizes))
|
||
copy(result, df.chunkSizes)
|
||
return result
|
||
}
|
||
|
||
// Schema returns the Arrow schema.
|
||
func (df *DataFrame) Schema() *arrow.Schema {
|
||
return df.schema
|
||
}
|
||
|
||
// =============================================================================
|
||
// Column Access Methods (Read-only)
|
||
// =============================================================================
|
||
|
||
// Column returns a column by name.
|
||
// Returns nil if the column does not exist.
|
||
func (df *DataFrame) Column(name string) *arrow.Chunked {
|
||
idx, exists := df.nameIndex[name]
|
||
if !exists {
|
||
return nil
|
||
}
|
||
return df.columns[idx]
|
||
}
|
||
|
||
// ColumnNames returns all column names in schema order.
|
||
func (df *DataFrame) ColumnNames() []string {
|
||
if df.schema == nil {
|
||
return nil
|
||
}
|
||
fields := df.schema.Fields()
|
||
names := make([]string, len(fields))
|
||
for i, f := range fields {
|
||
names[i] = f.Name
|
||
}
|
||
return names
|
||
}
|
||
|
||
// HasColumn checks if a column exists.
|
||
func (df *DataFrame) HasColumn(name string) bool {
|
||
_, exists := df.nameIndex[name]
|
||
return exists
|
||
}
|
||
|
||
// =============================================================================
|
||
// Field Metadata Methods (Read-only)
|
||
// =============================================================================
|
||
|
||
// FieldType returns the Milvus DataType for a column.
|
||
func (df *DataFrame) FieldType(name string) (schemapb.DataType, bool) {
|
||
dt, exists := df.fieldTypes[name]
|
||
return dt, exists
|
||
}
|
||
|
||
// FieldID returns the field ID for a column.
|
||
func (df *DataFrame) FieldID(name string) (int64, bool) {
|
||
id, exists := df.fieldIDs[name]
|
||
return id, exists
|
||
}
|
||
|
||
// Metadata returns a metadata value by key.
|
||
func (df *DataFrame) Metadata(key string) (string, bool) {
|
||
val, ok := df.metadata[key]
|
||
return val, ok
|
||
}
|
||
|
||
// MetricType returns the metric type from DataFrame metadata.
|
||
func (df *DataFrame) MetricType() (string, bool) {
|
||
return df.Metadata(types.MetadataKeyMetricType)
|
||
}
|
||
|
||
// =============================================================================
|
||
// Lifecycle Methods
|
||
// =============================================================================
|
||
|
||
// Release releases all Arrow resources held by the DataFrame.
|
||
// Call exactly once when done. After Release(), the DataFrame should not be used.
|
||
func (df *DataFrame) Release() {
|
||
for _, col := range df.columns {
|
||
if col != nil {
|
||
col.Release()
|
||
}
|
||
}
|
||
df.columns = nil
|
||
df.schema = nil
|
||
}
|
||
|
||
// =============================================================================
|
||
// DataFrameBuilder
|
||
// =============================================================================
|
||
|
||
// DataFrameBuilder helps build a DataFrame with proper resource cleanup.
|
||
// Use defer builder.Release() right after creation, then call Build() to get the result.
|
||
// Build() transfers ownership, making Release() a no-op.
|
||
//
|
||
// Typical usage pattern:
|
||
//
|
||
// builder := NewDataFrameBuilder()
|
||
// defer builder.Release() // safety net: releases resources if Build() is never called
|
||
// builder.SetChunkSizes(sizes)
|
||
// builder.AddColumnFromChunks("col", chunks)
|
||
// return builder.Build(), nil // transfers ownership, Release() becomes no-op
|
||
//
|
||
// Key methods:
|
||
// - SetChunkSizes: set chunk sizes (required)
|
||
// - AddColumnFromChunks: add a column from Arrow Array slices (takes ownership of chunks)
|
||
// - AddColumnFrom: copy a column from another DataFrame (retains and copies metadata)
|
||
// - AddColumns: batch add multiple columns (all-or-nothing with rollback on error)
|
||
// - CopyFieldMetadata: copy field type/ID/nullable from source DataFrame
|
||
// - Build: construct the DataFrame and invalidate the builder
|
||
type DataFrameBuilder struct {
|
||
result *DataFrame
|
||
fields []arrow.Field // accumulated fields, schema created in Build()
|
||
}
|
||
|
||
// NewDataFrameBuilder creates a new empty DataFrameBuilder.
|
||
func NewDataFrameBuilder() *DataFrameBuilder {
|
||
return &DataFrameBuilder{
|
||
result: &DataFrame{
|
||
columns: make([]*arrow.Chunked, 0),
|
||
chunkSizes: make([]int64, 0),
|
||
nameIndex: make(map[string]int),
|
||
fieldTypes: make(map[string]schemapb.DataType),
|
||
fieldIDs: make(map[string]int64),
|
||
fieldNullables: make(map[string]bool),
|
||
metadata: make(map[string]string),
|
||
},
|
||
}
|
||
}
|
||
|
||
// SetChunkSizes sets the chunk sizes on the result DataFrame.
|
||
func (b *DataFrameBuilder) SetChunkSizes(sizes []int64) *DataFrameBuilder {
|
||
if b.result == nil {
|
||
return b
|
||
}
|
||
b.result.chunkSizes = make([]int64, len(sizes))
|
||
copy(b.result.chunkSizes, sizes)
|
||
return b
|
||
}
|
||
|
||
// SetFieldType sets the Milvus data type for a column.
|
||
func (b *DataFrameBuilder) SetFieldType(name string, dataType schemapb.DataType) *DataFrameBuilder {
|
||
if b.result == nil {
|
||
return b
|
||
}
|
||
b.result.fieldTypes[name] = dataType
|
||
return b
|
||
}
|
||
|
||
// SetFieldID sets the field ID for a column.
|
||
func (b *DataFrameBuilder) SetFieldID(name string, fieldID int64) *DataFrameBuilder {
|
||
if b.result == nil {
|
||
return b
|
||
}
|
||
b.result.fieldIDs[name] = fieldID
|
||
return b
|
||
}
|
||
|
||
// SetFieldNullable sets whether a column is nullable.
|
||
func (b *DataFrameBuilder) SetFieldNullable(name string, nullable bool) *DataFrameBuilder {
|
||
if b.result == nil {
|
||
return b
|
||
}
|
||
b.result.fieldNullables[name] = nullable
|
||
return b
|
||
}
|
||
|
||
// SetMetadata sets a metadata key-value pair.
|
||
func (b *DataFrameBuilder) SetMetadata(key, value string) *DataFrameBuilder {
|
||
if b.result == nil {
|
||
return b
|
||
}
|
||
b.result.metadata[key] = value
|
||
return b
|
||
}
|
||
|
||
// SetMetricType sets the metric type metadata on the builder.
|
||
func (b *DataFrameBuilder) SetMetricType(metricType string) *DataFrameBuilder {
|
||
return b.SetMetadata(types.MetadataKeyMetricType, metricType)
|
||
}
|
||
|
||
// addColumn adds a chunked column to the DataFrame, taking ownership.
|
||
// On error, the column is released.
|
||
func (b *DataFrameBuilder) addColumn(name string, col *arrow.Chunked) error {
|
||
if b.result == nil {
|
||
if col != nil {
|
||
col.Release()
|
||
}
|
||
return merr.WrapErrServiceInternal("builder already built")
|
||
}
|
||
|
||
if _, exists := b.result.nameIndex[name]; exists {
|
||
if col != nil {
|
||
col.Release()
|
||
}
|
||
return merr.WrapErrServiceInternalMsg("column %s already exists", name)
|
||
}
|
||
|
||
if col == nil {
|
||
return merr.WrapErrServiceInternalMsg("column %s is nil", name)
|
||
}
|
||
|
||
b.addColumnUnchecked(name, col)
|
||
return nil
|
||
}
|
||
|
||
// addColumnUnchecked adds a column without validation (internal use).
|
||
func (b *DataFrameBuilder) addColumnUnchecked(name string, col *arrow.Chunked) {
|
||
// Accumulate field for deferred schema creation in Build()
|
||
b.fields = append(b.fields, arrow.Field{Name: name, Type: col.DataType(), Nullable: true})
|
||
|
||
// Add column
|
||
b.result.columns = append(b.result.columns, col)
|
||
b.result.nameIndex[name] = len(b.result.columns) - 1
|
||
}
|
||
|
||
// AddColumns adds multiple columns at once, taking ownership of all.
|
||
// Either all columns are added successfully, or none are added and all are released.
|
||
// This is the preferred method when adding function outputs to avoid partial failures.
|
||
func (b *DataFrameBuilder) AddColumns(names []string, cols []*arrow.Chunked) error {
|
||
// Helper to release all columns
|
||
releaseAll := func() {
|
||
for _, c := range cols {
|
||
if c != nil {
|
||
c.Release()
|
||
}
|
||
}
|
||
}
|
||
|
||
if b.result == nil {
|
||
releaseAll()
|
||
return merr.WrapErrServiceInternal("builder already built")
|
||
}
|
||
|
||
if len(names) != len(cols) {
|
||
releaseAll()
|
||
return merr.WrapErrServiceInternalMsg("names count (%d) != cols count (%d)", len(names), len(cols))
|
||
}
|
||
|
||
// Validate all before adding any
|
||
seen := make(map[string]bool, len(names))
|
||
for i, name := range names {
|
||
if _, exists := b.result.nameIndex[name]; exists {
|
||
releaseAll()
|
||
return merr.WrapErrServiceInternalMsg("column %s already exists", name)
|
||
}
|
||
if seen[name] {
|
||
releaseAll()
|
||
return merr.WrapErrServiceInternalMsg("duplicate column name %s in batch", name)
|
||
}
|
||
seen[name] = true
|
||
if cols[i] == nil {
|
||
releaseAll()
|
||
return merr.WrapErrServiceInternalMsg("column %s is nil", name)
|
||
}
|
||
}
|
||
|
||
// All validation passed, add all columns
|
||
for i, name := range names {
|
||
b.addColumnUnchecked(name, cols[i])
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// AddColumnFrom copies a column from source DataFrame, including metadata.
|
||
// This is a convenience method that combines Retain + addColumn + CopyFieldMetadata.
|
||
func (b *DataFrameBuilder) AddColumnFrom(source *DataFrame, colName string) error {
|
||
if b.result == nil {
|
||
return merr.WrapErrServiceInternal("builder already built")
|
||
}
|
||
|
||
col := source.Column(colName)
|
||
if col == nil {
|
||
return merr.WrapErrServiceInternalMsg("column %s not found in source", colName)
|
||
}
|
||
|
||
col.Retain()
|
||
if err := b.addColumn(colName, col); err != nil {
|
||
return err
|
||
}
|
||
|
||
b.CopyFieldMetadata(source, colName)
|
||
return nil
|
||
}
|
||
|
||
// AddColumnFromChunks creates a chunked column from arrays and adds it.
|
||
// Takes ownership of chunks - they are released after creating the chunked array.
|
||
func (b *DataFrameBuilder) AddColumnFromChunks(name string, chunks []arrow.Array) error {
|
||
if b.result == nil {
|
||
for _, chunk := range chunks {
|
||
if chunk != nil {
|
||
chunk.Release()
|
||
}
|
||
}
|
||
return merr.WrapErrServiceInternal("builder already built")
|
||
}
|
||
|
||
if len(chunks) == 0 {
|
||
return nil
|
||
}
|
||
|
||
arrowType := chunks[0].DataType()
|
||
chunked := arrow.NewChunked(arrowType, chunks)
|
||
|
||
// Release individual arrays after creating chunked
|
||
for _, chunk := range chunks {
|
||
chunk.Release()
|
||
}
|
||
|
||
// Infer Milvus type if not set
|
||
if _, exists := b.result.fieldTypes[name]; !exists {
|
||
if milvusType, err := ToMilvusType(arrowType); err == nil {
|
||
b.result.fieldTypes[name] = milvusType
|
||
}
|
||
}
|
||
|
||
return b.addColumn(name, chunked)
|
||
}
|
||
|
||
// CopyFieldMetadata copies field type, ID, and nullable from source DataFrame.
|
||
func (b *DataFrameBuilder) CopyFieldMetadata(source *DataFrame, colName string) *DataFrameBuilder {
|
||
if b.result == nil {
|
||
return b
|
||
}
|
||
if ft, ok := source.FieldType(colName); ok {
|
||
b.result.fieldTypes[colName] = ft
|
||
}
|
||
if fid, ok := source.FieldID(colName); ok {
|
||
b.result.fieldIDs[colName] = fid
|
||
}
|
||
if nullable, ok := source.fieldNullables[colName]; ok {
|
||
b.result.fieldNullables[colName] = nullable
|
||
}
|
||
return b
|
||
}
|
||
|
||
// CopyAllMetadata copies all metadata entries from source DataFrame.
|
||
func (b *DataFrameBuilder) CopyAllMetadata(source *DataFrame) *DataFrameBuilder {
|
||
if b.result == nil || source == nil {
|
||
return b
|
||
}
|
||
for k, v := range source.metadata {
|
||
b.result.metadata[k] = v
|
||
}
|
||
return b
|
||
}
|
||
|
||
// Build returns the constructed DataFrame and invalidates the builder.
|
||
// After Build(), Release() becomes a no-op.
|
||
func (b *DataFrameBuilder) Build() *DataFrame {
|
||
// Create schema from accumulated fields with correct nullable settings
|
||
if len(b.fields) > 0 {
|
||
finalFields := make([]arrow.Field, len(b.fields))
|
||
for i, f := range b.fields {
|
||
// Look up nullable setting, default to false (Milvus default)
|
||
finalFields[i] = arrow.Field{
|
||
Name: f.Name,
|
||
Type: f.Type,
|
||
Nullable: b.result.fieldNullables[f.Name],
|
||
}
|
||
}
|
||
b.result.schema = arrow.NewSchema(finalFields, nil)
|
||
}
|
||
|
||
result := b.result
|
||
b.result = nil
|
||
b.fields = nil
|
||
return result
|
||
}
|
||
|
||
// Release releases all resources held by the builder.
|
||
// Safe to call multiple times. After Build(), this is a no-op.
|
||
func (b *DataFrameBuilder) Release() {
|
||
if b.result != nil {
|
||
// Directly release columns without going through refCount
|
||
// since the DataFrame hasn't been officially "built" yet
|
||
for _, col := range b.result.columns {
|
||
if col != nil {
|
||
col.Release()
|
||
}
|
||
}
|
||
b.result.columns = nil
|
||
b.result.schema = nil
|
||
b.result = nil
|
||
}
|
||
b.fields = nil
|
||
}
|
||
|
||
// =============================================================================
|
||
// ChunkCollector
|
||
// =============================================================================
|
||
|
||
// ChunkCollector is a temporary storage with ownership tracking for Arrow arrays
|
||
// produced during per-chunk transformations. It solves the problem of safely managing
|
||
// N columns × M chunks of intermediate Arrow arrays, ensuring proper cleanup on error.
|
||
//
|
||
// Workflow:
|
||
//
|
||
// 1. Create: collector := NewChunkCollector(colNames, numChunks)
|
||
// defer collector.Release()
|
||
// 2. Fill: collector.Set(colName, chunkIdx, transformedArray)
|
||
// 3. Consume: chunks := collector.Consume(colName) // ownership transfers to caller
|
||
// builder.AddColumnFromChunks(colName, chunks)
|
||
// 4. Cleanup: collector.Release() // releases only non-consumed arrays
|
||
//
|
||
// On error before all columns are consumed, Release() frees unconsumed arrays
|
||
// while consumed arrays are managed by their new owner (typically DataFrameBuilder).
|
||
//
|
||
// Used by operators that transform data per-chunk: filter, sort, select, limit,
|
||
// group_by, merge.
|
||
type ChunkCollector struct {
|
||
chunks map[string][]arrow.Array
|
||
consumed map[string]bool
|
||
}
|
||
|
||
// NewChunkCollector creates a new ChunkCollector.
|
||
func NewChunkCollector(colNames []string, numChunks int) *ChunkCollector {
|
||
cc := &ChunkCollector{
|
||
chunks: make(map[string][]arrow.Array),
|
||
consumed: make(map[string]bool),
|
||
}
|
||
for _, name := range colNames {
|
||
cc.chunks[name] = make([]arrow.Array, numChunks)
|
||
}
|
||
return cc
|
||
}
|
||
|
||
// Set sets the chunk at the given index for a column.
|
||
func (cc *ChunkCollector) Set(colName string, chunkIdx int, chunk arrow.Array) {
|
||
cc.chunks[colName][chunkIdx] = chunk
|
||
}
|
||
|
||
// Consume returns the chunks for a column and marks it as consumed.
|
||
// Consumed columns will not be released by Release().
|
||
// The caller takes ownership of the returned chunks.
|
||
func (cc *ChunkCollector) Consume(colName string) []arrow.Array {
|
||
cc.consumed[colName] = true
|
||
return cc.chunks[colName]
|
||
}
|
||
|
||
// Release releases all non-consumed chunks.
|
||
// Safe to call multiple times. Consumed columns are not affected.
|
||
func (cc *ChunkCollector) Release() {
|
||
for colName, chunks := range cc.chunks {
|
||
if cc.consumed[colName] {
|
||
continue
|
||
}
|
||
for _, chunk := range chunks {
|
||
if chunk != nil {
|
||
chunk.Release()
|
||
}
|
||
}
|
||
}
|
||
// Clear to prevent double-release
|
||
cc.chunks = nil
|
||
}
|