Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:31:17 +08:00

548 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* # 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
}