项目文件夹

文件
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

1334 行
45 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
// 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 tasks
import (
"context"
"fmt"
"path/filepath"
"runtime"
"strconv"
"testing"
"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/bytedance/mockey"
"github.com/cockroachdb/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"github.com/milvus-io/milvus-proto/go-api/v3/schemapb"
mock_segcore "github.com/milvus-io/milvus/internal/mocks/util/mock_segcore"
"github.com/milvus-io/milvus/internal/querynodev2/segments"
"github.com/milvus-io/milvus/internal/storage"
"github.com/milvus-io/milvus/internal/util/function/chain"
"github.com/milvus-io/milvus/internal/util/initcore"
"github.com/milvus-io/milvus/internal/util/segcore"
"github.com/milvus-io/milvus/pkg/v3/common"
"github.com/milvus-io/milvus/pkg/v3/proto/datapb"
"github.com/milvus-io/milvus/pkg/v3/proto/internalpb"
"github.com/milvus-io/milvus/pkg/v3/proto/querypb"
"github.com/milvus-io/milvus/pkg/v3/util/paramtable"
"github.com/milvus-io/milvus/pkg/v3/util/timerecord"
"github.com/milvus-io/milvus/pkg/v3/util/typeutil"
)
// testSegments holds pre-created segments and search results for testing.
type testSegments struct {
manager *segments.Manager
collection *segments.Collection
segs []segments.Segment
segIDs []int64
insertData []*storage.InsertData // one per segment, aligned with segIDs
searchResults []*segcore.SearchResult
searchReq *segcore.SearchRequest
chunkManager storage.ChunkManager
rootPath string
}
func (ts *testSegments) cleanup() {
for _, r := range ts.searchResults {
r.Release()
}
if ts.searchReq != nil {
ts.searchReq.Delete()
}
for _, seg := range ts.segs {
seg.Release(context.Background())
}
ts.manager.Collection.Unref(ts.collection.ID(), 1)
ts.chunkManager.RemoveWithPrefix(context.Background(), ts.rootPath)
}
// testCollectionID / testPartitionID are shared across every helper and test
// in this file. They must stay aligned with the CollectionID callers pass into
// mock_segcore.GenQueryRequest / GenFilterOnlySearchRequests.
const (
testCollectionID int64 = 100
testPartitionID int64 = 10
)
func TestExportSearchResultsAsArrowReleasesCompletedDataFramesOnMultiSegmentError(t *testing.T) {
pool := memory.NewCheckedAllocator(memory.NewGoAllocator())
defer pool.AssertSize(t, 0)
successResult := new(segments.SearchResult)
failResult := new(segments.SearchResult)
results := []*segments.SearchResult{successResult, failResult}
injectedErr := errors.New("injected export failure")
makeRecord := func() arrow.Record {
idBuilder := array.NewInt64Builder(pool)
idBuilder.AppendValues([]int64{1, 2}, nil)
ids := idBuilder.NewArray()
idBuilder.Release()
scoreBuilder := array.NewFloat32Builder(pool)
scoreBuilder.AppendValues([]float32{0.9, 0.8}, nil)
scores := scoreBuilder.NewArray()
scoreBuilder.Release()
offsetBuilder := array.NewInt64Builder(pool)
offsetBuilder.AppendValues([]int64{10, 20}, nil)
offsets := offsetBuilder.NewArray()
offsetBuilder.Release()
schema := arrow.NewSchema([]arrow.Field{
{Name: "$id", Type: arrow.PrimitiveTypes.Int64},
{Name: "$score", Type: arrow.PrimitiveTypes.Float32},
{Name: "$seg_offset", Type: arrow.PrimitiveTypes.Int64},
}, nil)
record := array.NewRecord(schema, []arrow.Array{ids, scores, offsets}, int64(ids.Len()))
ids.Release()
scores.Release()
offsets.Release()
return record
}
mocker := mockey.Mock(segcore.ExportSearchResultAsArrowRecordBatch).To(
func(ctx context.Context, result *segcore.SearchResult, plan *segcore.SearchPlan, extraFieldIDs []int64) (arrow.Record, []int64, error) {
if result == failResult {
return nil, nil, injectedErr
}
return makeRecord(), []int64{2}, nil
},
).Build()
defer mocker.UnPatch()
task := &SearchTask{ctx: context.Background()}
_, err := task.exportSearchResultsAsArrow(results, nil, nil)
require.ErrorIs(t, err, injectedErr)
}
// setupOpts varies the search request shape across test cases.
// At most one of OutputFieldIDs / GroupByFieldID should be set.
// When SkipSearchReq is true NQ/TopK are ignored and no SearchRequest is built.
// NullableVec marks the float_vector field as Nullable so the insert data
// generator stamps ValidData using mock_segcore.NullablePatternValidData —
// required to cover the nullable-vector output path.
type setupOpts struct {
NQ int64
TopK int64
OutputFieldIDs []int64
GroupByFieldID int64
GroupSize int64
FilterOnly bool
SkipSearchReq bool
NullableVec bool
}
func setupTestSegments(t *testing.T, numSegments int, msgLength int, opts setupOpts) *testSegments {
t.Helper()
require.False(t, opts.GroupByFieldID > 0 && len(opts.OutputFieldIDs) > 0,
"setupOpts: OutputFieldIDs and GroupByFieldID are mutually exclusive")
paramtable.Init()
paramtable.Get().Save(paramtable.Get().LocalStorageCfg.Path.Key, t.TempDir())
localDataRootPath := filepath.Join(paramtable.Get().LocalStorageCfg.Path.GetValue(), typeutil.QueryNodeRole)
initcore.InitLocalChunkManager(localDataRootPath)
if err := initcore.InitMmapManager(paramtable.Get(), 1); err != nil {
t.Fatal(err)
}
if err := initcore.InitTieredStorage(paramtable.Get()); err != nil {
t.Fatal(err)
}
ctx := context.Background()
rootPath := t.Name()
chunkManagerFactory := storage.NewTestChunkManagerFactory(paramtable.Get(), rootPath)
chunkManager, _ := chunkManagerFactory.NewPersistentStorageChunkManager(ctx)
initcore.InitRemoteChunkManager(paramtable.Get())
collectionID := testCollectionID
partitionID := testPartitionID
var schema *schemapb.CollectionSchema
if opts.NullableVec {
schema = mock_segcore.GenTestCollectionSchemaWithNullableVec("test-late-mat", schemapb.DataType_Int64)
} else {
schema = mock_segcore.GenTestCollectionSchema("test-late-mat", schemapb.DataType_Int64, true)
}
indexMeta := mock_segcore.GenTestIndexMeta(collectionID, schema)
manager := segments.NewManager()
manager.Collection.PutOrRef(collectionID, schema, indexMeta, &querypb.LoadMetaInfo{
LoadType: querypb.LoadType_LoadCollection,
CollectionID: collectionID,
PartitionIDs: []int64{partitionID},
})
collection := manager.Collection.Get(collectionID)
ts := &testSegments{
manager: manager,
collection: collection,
chunkManager: chunkManager,
rootPath: rootPath,
segIDs: make([]int64, numSegments),
insertData: make([]*storage.InsertData, numSegments),
}
for i := 0; i < numSegments; i++ {
segmentID := int64(i + 1)
ts.segIDs[i] = segmentID
seg, err := segments.NewSegment(ctx, collection, manager.Segment,
segments.SegmentTypeSealed, 0,
&querypb.SegmentLoadInfo{
SegmentID: segmentID,
CollectionID: collectionID,
PartitionID: partitionID,
NumOfRows: int64(msgLength),
InsertChannel: fmt.Sprintf("by-dev-rootcoord-dml_0_%dv0", collectionID),
Level: datapb.SegmentLevel_Legacy,
})
if err != nil {
t.Fatal(err)
}
// Generate + save via the explicit helper so tests that need to
// verify per-row data (e.g., nullable vector output) can inspect
// ts.insertData[i] as ground truth.
insertData, err := mock_segcore.GenInsertData(msgLength, schema)
if err != nil {
t.Fatal(err)
}
ts.insertData[i] = insertData
binlogs, _, err := mock_segcore.SaveBinLogWithData(ctx,
collectionID, partitionID, segmentID, insertData, schema, chunkManager)
if err != nil {
t.Fatal(err)
}
// Transition segment state from OnlyMeta → DataLoaded so subsequent
// operations see it as "loaded". LoadFieldData itself does not drive
// this transition; production code goes through Loader.loadSegment,
// which wraps the loads in StartLoadData() / guard.Done().
guard, err := seg.(*segments.LocalSegment).StartLoadData()
if err != nil {
t.Fatal(err)
}
for _, binlog := range binlogs {
if err := seg.(*segments.LocalSegment).LoadFieldData(ctx, binlog.FieldID, int64(msgLength), binlog); err != nil {
t.Fatal(err)
}
}
guard.Done(nil)
manager.Segment.Put(ctx, segments.SegmentTypeSealed, seg)
ts.segs = append(ts.segs, seg)
}
if opts.SkipSearchReq {
return ts
}
var searchReq *segcore.SearchRequest
var err error
switch {
case opts.FilterOnly:
searchReq, err = mock_segcore.GenSearchPlanAndRequestsFilterOnly(
collection.GetCCollection(), ts.segIDs, opts.NQ, opts.TopK)
case opts.GroupByFieldID > 0:
searchReq, err = mock_segcore.GenSearchPlanAndRequestsWithGroupBy(
collection.GetCCollection(), ts.segIDs, opts.NQ, opts.TopK, opts.GroupByFieldID, opts.GroupSize)
case len(opts.OutputFieldIDs) > 0:
searchReq, err = mock_segcore.GenSearchPlanAndRequestsWithOutputFields(
collection.GetCCollection(), ts.segIDs, opts.NQ, opts.TopK, opts.OutputFieldIDs)
default:
searchReq, err = mock_segcore.GenSearchPlanAndRequestsWithTopK(
collection.GetCCollection(), ts.segIDs, opts.NQ, opts.TopK)
}
if err != nil {
t.Fatal(err)
}
ts.searchReq = searchReq
for _, seg := range ts.segs {
result, err := seg.Search(ctx, searchReq)
if err != nil {
t.Fatal(err)
}
ts.searchResults = append(ts.searchResults, result)
}
return ts
}
// runGoReducePipeline runs the same pipeline as executeGoReduce
// and returns the merged mergeResult and DataFrames (caller must release).
// Group-by is auto-detected from the first SearchResult, mirroring production.
func runGoReducePipeline(t *testing.T, ts *testSegments) (*mergeResult, []*chain.DataFrame) {
t.Helper()
pool := memory.NewGoAllocator()
plan := ts.searchReq.Plan()
topK := plan.GetTopK()
// Mirror SearchTask.Execute: prep must run before export so PKs are
// filled and invalid rows compacted. Also exercises the full path
// including FilterInvalidSearchResults + FillPrimaryKey.
allSearchCount, err := segcore.PrepareSearchResultsForExport(
context.Background(),
plan,
ts.searchReq.PlaceholderGroup(),
ts.searchResults,
[]int64{ts.searchReq.GetNumOfQuery()},
[]int64{topK},
)
require.NoError(t, err)
require.GreaterOrEqual(t, allSearchCount, int64(0))
segDFs := make([]*chain.DataFrame, 0, len(ts.searchResults))
for _, res := range ts.searchResults {
record, chunkSizes, err := segcore.ExportSearchResultAsArrowRecordBatch(context.Background(), res, plan, nil)
require.NoError(t, err)
defer record.Release()
df, err := dataFrameFromArrowRecordBatch(record, chunkSizes)
require.NoError(t, err)
segDFs = append(segDFs, df)
}
var groupByOpts *groupByOptions
if len(segDFs) > 0 && len(ts.searchResults) > 0 {
groupByColumns := groupByColumnNames(segDFs[0])
if len(groupByColumns) > 0 {
groupByOpts = &groupByOptions{
GroupSize: ts.searchResults[0].GetMetadata().GroupSize,
Columns: groupByColumns,
}
}
}
reduceResult, err := heapMergeReduce(pool, segDFs, topK, groupByOpts)
require.NoError(t, err)
return reduceResult, segDFs
}
func TestResolveGroupSizeFromMetadata(t *testing.T) {
t.Run("uses first positive group size", func(t *testing.T) {
groupSize := resolveGroupSizeFromMetadata([]segcore.SearchResultMetadata{
{HasGroupBy: true, GroupSize: 0},
{HasGroupBy: true, GroupSize: 3},
})
assert.Equal(t, int64(3), groupSize)
})
t.Run("defaults empty group-by results to one", func(t *testing.T) {
groupSize := resolveGroupSizeFromMetadata([]segcore.SearchResultMetadata{
{HasGroupBy: true, GroupSize: 0},
{HasGroupBy: true, GroupSize: 0},
})
assert.Equal(t, int64(1), groupSize)
})
}
func TestPrepareSearchResultsForExportAllSearchCountUsesSearchResultCount(t *testing.T) {
const (
numSegments = 2
msgLength = 100
nq int64 = 1
topK int64 = 5
)
ts := setupTestSegments(t, numSegments, msgLength, setupOpts{SkipSearchReq: true})
defer ts.cleanup()
ctx := context.Background()
searchReq, err := mock_segcore.GenSearchPlanAndRequestsWithNoMatchPKPredicate(
ts.collection.GetCCollection(), ts.segIDs, nq, topK)
require.NoError(t, err)
ts.searchReq = searchReq
for _, seg := range ts.segs {
result, err := seg.Search(ctx, searchReq)
require.NoError(t, err)
ts.searchResults = append(ts.searchResults, result)
}
allSearchCount, err := segcore.PrepareSearchResultsForExport(
ctx,
searchReq.Plan(),
searchReq.PlaceholderGroup(),
ts.searchResults,
[]int64{nq},
[]int64{topK},
)
require.NoError(t, err)
require.Zero(t, allSearchCount)
require.NotEqual(t, int64(numSegments*msgLength), allSearchCount,
"all_search_count must come from SearchResult.total_data_cnt_, not static LoadInfo.NumOfRows")
}
func TestLateMaterializeOutputFields(t *testing.T) {
// Field IDs from GenTestCollectionSchema (100+i):
// 103=Int32, 104=Float (scalar fields easy to verify)
outputFieldIDs := []int64{103, 104}
ts := setupTestSegments(t, 2, 2000, setupOpts{NQ: 2, TopK: 10, OutputFieldIDs: outputFieldIDs})
defer ts.cleanup()
reduceResult, segDFs := runGoReducePipeline(t, ts)
defer func() {
reduceResult.DF.Release()
for _, df := range segDFs {
df.Release()
}
}()
// Marshal base result (ids + scores only)
searchResultData, err := marshalReduceResult(reduceResult)
require.NoError(t, err)
// Before Late Mat: no field data
assert.Empty(t, searchResultData.FieldsData)
// Run Late Materialization
plan := ts.searchReq.Plan()
err = lateMaterializeOutputFields(context.Background(), ts.searchResults, plan, reduceResult.Sources, searchResultData)
require.NoError(t, err)
// After Late Mat: should have field data for each output field
require.Len(t, searchResultData.FieldsData, len(outputFieldIDs),
"should have one FieldData entry per output field")
totalRows := int(reduceResult.DF.NumRows())
require.Greater(t, totalRows, 0, "should have some results")
// Verify each field has correct number of rows
for _, fd := range searchResultData.FieldsData {
switch f := fd.Field.(type) {
case *schemapb.FieldData_Scalars:
switch d := f.Scalars.Data.(type) {
case *schemapb.ScalarField_IntData:
assert.Len(t, d.IntData.Data, totalRows,
"int field %s row count mismatch", fd.FieldName)
case *schemapb.ScalarField_FloatData:
assert.Len(t, d.FloatData.Data, totalRows,
"float field %s row count mismatch", fd.FieldName)
default:
t.Errorf("unexpected scalar type for field %s", fd.FieldName)
}
default:
t.Errorf("expected scalar field, got %T for field %s", fd.Field, fd.FieldName)
}
}
// Verify IDs and scores also match total rows
assert.Len(t, searchResultData.Scores, totalRows)
t.Logf("Late Mat OK: %d output fields, %d total rows", len(searchResultData.FieldsData), totalRows)
}
func TestLateMaterializeOutputFields_NoOutputFields(t *testing.T) {
// No output fields in the plan
ts := setupTestSegments(t, 2, 2000, setupOpts{NQ: 1, TopK: 5})
defer ts.cleanup()
reduceResult, segDFs := runGoReducePipeline(t, ts)
defer func() {
reduceResult.DF.Release()
for _, df := range segDFs {
df.Release()
}
}()
searchResultData, err := marshalReduceResult(reduceResult)
require.NoError(t, err)
plan := ts.searchReq.Plan()
err = lateMaterializeOutputFields(context.Background(), ts.searchResults, plan, reduceResult.Sources, searchResultData)
require.NoError(t, err)
// No output fields → FieldsData remains empty
assert.Empty(t, searchResultData.FieldsData)
}
func TestLateMaterializeOutputFields_EmptySources(t *testing.T) {
outputFieldIDs := []int64{103, 104}
ts := setupTestSegments(t, 1, 100, setupOpts{NQ: 1, TopK: 5, OutputFieldIDs: outputFieldIDs})
defer ts.cleanup()
plan := ts.searchReq.Plan()
searchResultData := &schemapb.SearchResultData{}
err := lateMaterializeOutputFields(context.Background(), ts.searchResults, plan, nil, searchResultData)
require.NoError(t, err)
require.Len(t, searchResultData.FieldsData, len(outputFieldIDs),
"empty sources must still produce typed FieldData entries")
searchResultData2 := &schemapb.SearchResultData{}
err = lateMaterializeOutputFields(context.Background(), ts.searchResults, plan, [][]segmentSource{}, searchResultData2)
require.NoError(t, err)
require.Len(t, searchResultData2.FieldsData, len(outputFieldIDs),
"empty sources must still produce typed FieldData entries")
}
// TestGoReduceGroupBy verifies the end-to-end group-by reduce path: each NQ's
// results respect group_size across two segments.
//
// Uses the bool field (id 100) because mock data alternates true/false, giving
// exactly two well-populated buckets. With topK >= 2*group_size every bucket
// fills exactly to group_size, so the test asserts that strict invariant.
func TestGoReduceGroupBy(t *testing.T) {
const (
groupByFieldID int64 = 100 // Bool: 2 buckets, each well-populated
groupSize int64 = 3
nq int64 = 2
topK int64 = 10
)
ts := setupTestSegments(t, 2, 100,
setupOpts{NQ: nq, TopK: topK, GroupByFieldID: groupByFieldID, GroupSize: groupSize})
defer ts.cleanup()
// Sanity: the C++ side should report group-by enabled on every result.
for i, r := range ts.searchResults {
md := r.GetMetadata()
require.True(t, md.HasGroupBy, "result[%d] missing group_by_values_", i)
require.Equal(t, groupSize, md.GroupSize, "result[%d] group_size mismatch", i)
}
reduceResult, segDFs := runGoReducePipeline(t, ts)
defer func() {
reduceResult.DF.Release()
for _, df := range segDFs {
df.Release()
}
}()
// The merged DataFrame should carry the $group_by_<fieldID> column propagated by
// pickGroupByValues, with one chunk per NQ.
groupByColName := groupByColumnName(groupByFieldID)
gbCol := reduceResult.DF.Column(groupByColName)
require.NotNil(t, gbCol, "merged DataFrame missing %s column", groupByColName)
require.Equal(t, int(nq), reduceResult.DF.NumChunks(),
"expected one chunk per NQ")
for nqIdx := 0; nqIdx < int(nq); nqIdx++ {
chunk := gbCol.Chunk(nqIdx)
boolArr, ok := chunk.(*array.Boolean)
require.True(t, ok, "expected %s chunk[%d] to be Boolean, got %T", groupByColName, nqIdx, chunk)
counts := make(map[bool]int)
for i := 0; i < boolArr.Len(); i++ {
require.False(t, boolArr.IsNull(i),
"NQ %d row %d: bool group_by should not be null", nqIdx, i)
v := boolArr.Value(i)
counts[v]++
require.LessOrEqual(t, int64(counts[v]), groupSize,
"NQ %d: group %v exceeded group_size (%d > %d)",
nqIdx, v, counts[v], groupSize)
}
// Both buckets are well-populated and group_size×buckets (6) ≤ topK (10),
// so each bucket should fill to exactly group_size after merge.
require.Equal(t, 2, len(counts),
"NQ %d: expected both true and false buckets", nqIdx)
require.Equal(t, int(groupSize), counts[true],
"NQ %d: true bucket should fill to group_size", nqIdx)
require.Equal(t, int(groupSize), counts[false],
"NQ %d: false bucket should fill to group_size", nqIdx)
t.Logf("NQ %d: %d rows, true=%d false=%d",
nqIdx, boolArr.Len(), counts[true], counts[false])
}
}
func TestHeapMergeReduceRangeGroupByMixedTopKMatchesPerSliceReduce(t *testing.T) {
const (
groupSize int64 = 2
smallTopK int64 = 2
maxTopK int64 = 3
)
pool := memory.NewGoAllocator()
groupByFieldName := groupByColumnName(100)
df := buildTestDFWithGroupBy(pool,
[][]int64{{1, 2, 3, 4, 5, 6}},
[][]float32{{0.99, 0.98, 0.97, 0.96, 0.95, 0.94}},
[][]int64{{10, 20, 30, 10, 20, 30}},
groupByFieldName)
defer df.Release()
rangeReduce, err := heapMergeReduceRange(pool, []*chain.DataFrame{df}, smallTopK, &groupByOptions{
GroupSize: groupSize,
Columns: []string{groupByFieldName},
}, 0, 1)
require.NoError(t, err)
defer rangeReduce.DF.Release()
maxReduce, err := heapMergeReduce(pool, []*chain.DataFrame{df}, maxTopK, &groupByOptions{
GroupSize: groupSize,
Columns: []string{groupByFieldName},
})
require.NoError(t, err)
defer maxReduce.DF.Release()
gotIDs := collectInt64Chunks(t, rangeReduce.DF.Column(idFieldName))
gotGroups := collectInt64Chunks(t, rangeReduce.DF.Column(groupByFieldName))
maxGroups := collectInt64Chunks(t, maxReduce.DF.Column(groupByFieldName))
require.Equal(t, [][]int64{{1, 2, 4, 5}}, gotIDs,
"mixed-topK group-by range reduce must keep second rows from accepted groups")
require.Equal(t, [][]int64{{10, 20, 10, 20}}, gotGroups,
"mixed-topK group-by range reduce must not admit groups rejected by the smaller topK")
require.NotEqual(t, maxGroups[0][:int(smallTopK*groupSize)], gotGroups[0],
"row truncating a max-topK group reduce is not equivalent to per-slice group reduce")
}
func TestRequiresPerSliceReduce(t *testing.T) {
require.False(t, requiresPerSliceReduce(nil, []int64{10, 20}))
require.False(t, requiresPerSliceReduce(&groupByOptions{GroupSize: 1}, []int64{10, 20}))
require.False(t, requiresPerSliceReduce(&groupByOptions{GroupSize: 2}, []int64{10, 10}))
require.True(t, requiresPerSliceReduce(&groupByOptions{GroupSize: 2}, []int64{10, 20}))
}
func TestAttributeStorageCostZeroNQAndEmptyResults(t *testing.T) {
task := &SearchTask{
originNqs: []int64{0, 0},
result: &internalpb.SearchResults{
ScannedRemoteBytes: 7,
ScannedTotalBytes: 11,
},
others: []*SearchTask{
{
result: &internalpb.SearchResults{
ScannedRemoteBytes: 13,
ScannedTotalBytes: 17,
},
},
},
}
task.attributeStorageCost(nil)
assert.Equal(t, int64(7), task.result.ScannedRemoteBytes)
assert.Equal(t, int64(11), task.result.ScannedTotalBytes)
assert.Equal(t, int64(13), task.others[0].result.ScannedRemoteBytes)
assert.Equal(t, int64(17), task.others[0].result.ScannedTotalBytes)
task.originNqs = []int64{1, 3}
task.attributeStorageCost(nil)
assert.Zero(t, task.result.ScannedRemoteBytes)
assert.Zero(t, task.result.ScannedTotalBytes)
assert.Zero(t, task.others[0].result.ScannedRemoteBytes)
assert.Zero(t, task.others[0].result.ScannedTotalBytes)
}
func collectInt64Chunks(t *testing.T, col *arrow.Chunked) [][]int64 {
t.Helper()
require.NotNil(t, col)
out := make([][]int64, 0, len(col.Chunks()))
for _, chunk := range col.Chunks() {
arr, ok := chunk.(*array.Int64)
require.True(t, ok, "expected *array.Int64, got %T", chunk)
values := make([]int64, 0, arr.Len())
for i := 0; i < arr.Len(); i++ {
values = append(values, arr.Value(i))
}
out = append(out, values)
}
return out
}
func assertNoSustainedJemallocGrowth(t *testing.T, runOnce func()) {
t.Helper()
const (
warmupIterations = 300
windowIterations = 1000
measurementWindows = 5
positiveWindowNoiseMax = 96 * 1024
maxPositiveWindows = 2
)
before := segcore.GetJemallocStats()
if !before.Success {
t.Skip("jemalloc stats not available on this platform")
}
// Let allocator caches reach steady state before sampling.
for i := 0; i < warmupIterations; i++ {
runOnce()
}
runtime.GC()
windowBaseline := segcore.GetJemallocStats()
positiveWindows := 0
windowGrowths := make([]int64, 0, measurementWindows)
for window := 0; window < measurementWindows; window++ {
for i := 0; i < windowIterations; i++ {
runOnce()
}
runtime.GC()
afterWindow := segcore.GetJemallocStats()
growth := int64(afterWindow.Allocated) - int64(windowBaseline.Allocated)
windowGrowths = append(windowGrowths, growth)
if growth > positiveWindowNoiseMax {
positiveWindows++
}
windowBaseline = afterWindow
}
// Assert sustained positive growth instead of a single noisy jemalloc delta.
assert.LessOrEqual(t, positiveWindows, maxPositiveWindows,
"jemalloc allocated had sustained positive growth over %d/%d windows (growths=%v, threshold=%d)",
positiveWindows, measurementWindows, windowGrowths, positiveWindowNoiseMax)
t.Logf("jemalloc C heap growth windows=%v, positiveWindows=%d/%d",
windowGrowths, positiveWindows, measurementWindows)
}
// TestFillOutputFieldsOrdered_NoCMemoryLeak verifies that calling
// FillOutputFieldsOrdered in a loop does not leak C heap memory.
// The C++ side allocates via malloc; the Go side must C.free it after use.
// Uses jemalloc stats to precisely measure C heap growth.
func TestFillOutputFieldsOrdered_NoCMemoryLeak(t *testing.T) {
outputFieldIDs := []int64{103, 104} // Int32, Float
ts := setupTestSegments(t, 2, 2000, setupOpts{NQ: 2, TopK: 10, OutputFieldIDs: outputFieldIDs})
defer ts.cleanup()
reduceResult, segDFs := runGoReducePipeline(t, ts)
defer func() {
reduceResult.DF.Release()
for _, df := range segDFs {
df.Release()
}
}()
// Build the segIndices/segOffsets arrays from Sources (same as lateMaterializeOutputFields).
totalRows := 0
for _, chunk := range reduceResult.Sources {
totalRows += len(chunk)
}
require.Greater(t, totalRows, 0)
segIndices := make([]int32, totalRows)
segOffsets := make([]int64, totalRows)
pos := 0
for _, chunk := range reduceResult.Sources {
for _, src := range chunk {
segIndices[pos] = int32(src.InputIdx)
segOffsets[pos] = src.SegOffset
pos++
}
}
plan := ts.searchReq.Plan()
assertNoSustainedJemallocGrowth(t, func() {
b, err := segcore.FillOutputFieldsOrdered(context.Background(), ts.searchResults, plan, segIndices, segOffsets)
require.NoError(t, err)
_ = b
})
}
func TestExportSearchResultAsArrowRecordBatch_NoCMemoryLeak(t *testing.T) {
extraFieldIDs := []int64{103} // Int32
ts := setupTestSegments(t, 1, 2000, setupOpts{NQ: 2, TopK: 10, OutputFieldIDs: extraFieldIDs})
defer ts.cleanup()
_, err := segcore.PrepareSearchResultsForExport(
context.Background(),
ts.searchReq.Plan(),
ts.searchReq.PlaceholderGroup(),
ts.searchResults,
[]int64{ts.searchReq.GetNumOfQuery()},
[]int64{10},
)
require.NoError(t, err)
before := segcore.GetJemallocStats()
if !before.Success {
t.Skip("jemalloc stats not available on this platform")
}
exportOnce := func() {
for _, res := range ts.searchResults {
record, _, err := segcore.ExportSearchResultAsArrowRecordBatch(context.Background(), res, ts.searchReq.Plan(), extraFieldIDs)
require.NoError(t, err)
record.Release()
}
}
assertNoSustainedJemallocGrowth(t, exportOnce)
}
// TestExecuteFilterOnly verifies that the Execute() method correctly handles
// FilterOnly requests: it should return early with FilterValidCounts (one per
// segment) and no SlicedBlob, supporting the two-stage search protocol.
func TestExecuteFilterOnly(t *testing.T) {
const (
numSegments = 2
msgLength = 100
nq int64 = 1
topK int64 = 10
)
ts := setupTestSegments(t, numSegments, msgLength, setupOpts{SkipSearchReq: true})
defer ts.cleanup()
ctx := context.Background()
queryReq, searchReqFilterOnly, err := mock_segcore.GenFilterOnlySearchRequests(
ts.collection.GetCCollection(), ts.segIDs, nq, topK, testCollectionID)
require.NoError(t, err)
defer searchReqFilterOnly.Delete()
task := NewSearchTask(ctx, ts.collection, ts.manager, queryReq, 1)
require.NoError(t, task.PreExecute())
require.NoError(t, task.Execute())
result := task.SearchResult()
require.NotNil(t, result)
assert.Len(t, result.FilterValidCounts, numSegments)
assert.Len(t, result.SealedSegmentIDsSearched, numSegments)
// brute-force plan has no predicate filter, so every row is valid.
for i, count := range result.FilterValidCounts {
assert.Equal(t, int64(msgLength), count,
"segment %d: expected all %d rows to pass filter",
result.SealedSegmentIDsSearched[i], msgLength)
}
assert.Empty(t, result.SlicedBlob)
assert.Zero(t, result.NumQueries)
assert.Zero(t, result.TopK)
assert.NotNil(t, result.CostAggregation)
}
func TestExecuteEmptySearchReturnsNQEmptyResult(t *testing.T) {
const (
nq int64 = 1
topK int64 = 10
)
paramtable.Init()
schema := mock_segcore.GenTestCollectionSchema("test-empty-search", schemapb.DataType_Int64, true)
indexMeta := mock_segcore.GenTestIndexMeta(testCollectionID, schema)
manager := segments.NewManager()
manager.Collection.PutOrRef(testCollectionID, schema, indexMeta, &querypb.LoadMetaInfo{
LoadType: querypb.LoadType_LoadCollection,
CollectionID: testCollectionID,
PartitionIDs: []int64{testPartitionID},
})
collection := manager.Collection.Get(testCollectionID)
defer manager.Collection.Unref(collection.ID(), 1)
ctx := context.Background()
queryReq, err := mock_segcore.GenQueryRequest(
collection.GetCCollection(), nil, nq, topK, testCollectionID)
require.NoError(t, err)
task := NewSearchTask(ctx, collection, manager, queryReq, 1)
require.NoError(t, task.PreExecute())
require.NoError(t, task.Execute())
result := task.SearchResult()
require.NotNil(t, result)
assert.Equal(t, nq, result.NumQueries)
assert.Equal(t, topK, result.TopK)
var resultData *schemapb.SearchResultData
if result.ResultData != nil {
resultData = result.ResultData
} else {
require.NotEmpty(t, result.SlicedBlob)
resultData = &schemapb.SearchResultData{}
require.NoError(t, proto.Unmarshal(result.SlicedBlob, resultData))
}
assert.Equal(t, nq, resultData.NumQueries)
assert.Equal(t, topK, resultData.TopK)
assert.Equal(t, []int64{0}, resultData.Topks)
assert.Empty(t, resultData.Scores)
assert.Zero(t, typeutil.GetSizeOfIDs(resultData.GetIds()))
assert.Empty(t, resultData.FieldsData)
}
// TestExecuteMergedSubTasks exercises the multi-sub-task slicing path: after
// two SearchTasks with different NQ but identical topK are merged, Execute()
// must run reduce once on the combined group and write a correctly-sliced
// SearchResult into each sub-task.
func TestExecuteMergedSubTasks(t *testing.T) {
const (
numSegments = 2
msgLength = 100
topK int64 = 5
)
subTaskNqs := []int64{1, 2}
ts := setupTestSegments(t, numSegments, msgLength, setupOpts{SkipSearchReq: true})
defer ts.cleanup()
ctx := context.Background()
tasks := make([]*SearchTask, len(subTaskNqs))
for i, nq := range subTaskNqs {
queryReq, err := mock_segcore.GenQueryRequest(
ts.collection.GetCCollection(), ts.segIDs, nq, topK, testCollectionID)
require.NoError(t, err)
tasks[i] = NewSearchTask(ctx, ts.collection, ts.manager, queryReq, 1)
}
require.True(t, tasks[0].Merge(tasks[1]),
"two tasks with identical plan / topK / collection must be mergeable")
require.Equal(t, []int64{subTaskNqs[0], subTaskNqs[1]}, tasks[0].originNqs)
require.Equal(t, []int64{topK, topK}, tasks[0].originTopks)
require.Len(t, tasks[0].others, 1)
require.NoError(t, tasks[0].PreExecute())
require.NoError(t, tasks[0].Execute())
for i, nq := range subTaskNqs {
sub := tasks[0].subTaskAt(i)
res := sub.SearchResult()
mock_segcore.CheckSearchResult(t, res, nq, topK)
slice := &schemapb.SearchResultData{}
require.NoError(t, proto.Unmarshal(res.SlicedBlob, slice))
assert.Equal(t, int64(numSegments*msgLength), slice.GetAllSearchCount(),
"sub %d AllSearchCount must match legacy C++ reduce all_search_count", i)
}
assert.NotEqual(t,
tasks[0].subTaskAt(0).SearchResult().NumQueries,
tasks[0].subTaskAt(1).SearchResult().NumQueries,
"distinct sub-tasks must get distinct slices")
t.Logf("merged slicing OK: sub-task NQs=%v, topK=%d", subTaskNqs, topK)
}
// TestExecuteMergedSubTasks_MixedTopK exercises mixed-topK slicing: when two
// sub-tasks with DIFFERENT originTopks[i] share one reduced result, each slice
// must carry at most originTopks[i] rows per NQ. The old C++ reduce enforced
// this via slice_topKs_[slice_index]; the Go path must match that contract.
//
// The mock request bakes topK into SerializedExprPlan, so Merge() rejects
// different-topK tasks via the bytes.Equal guard — we cannot reach this state
// through Merge(). Instead we drive executeGoReduce directly, which is where
// the per-slice truncation contract lives. Receiver is constructed with
// plan.topK=maxTopK (so segment search + heapMergeReduce emit up to maxTopK
// rows per NQ), and originTopks[0] is set smaller than maxTopK — the missing
// per-slice truncation leaves sub 0 with > smallTopK rows per NQ.
func TestExecuteMergedSubTasks_MixedTopK(t *testing.T) {
const (
numSegments = 2
msgLength = 2000
nqPerSub int64 = 2
smallTopK int64 = 3
maxTopK int64 = 8
)
subTaskNqs := []int64{nqPerSub, nqPerSub}
subTaskTopks := []int64{smallTopK, maxTopK}
totalNq := subTaskNqs[0] + subTaskNqs[1]
ts := setupTestSegments(t, numSegments, int(msgLength), setupOpts{
NQ: totalNq,
TopK: maxTopK,
})
defer ts.cleanup()
ctx := context.Background()
// Receiver holds all totalNq vectors and plan.topK=maxTopK. We drive
// executeGoReduce rather than Execute to bypass combinePlaceHolderGroups.
receiverReq, err := mock_segcore.GenQueryRequest(
ts.collection.GetCCollection(), ts.segIDs, totalNq, maxTopK, testCollectionID)
require.NoError(t, err)
receiver := NewSearchTask(ctx, ts.collection, ts.manager, receiverReq, 1)
// Second sub-task exists only so subTaskAt(1) can write its result.
otherReq, err := mock_segcore.GenQueryRequest(
ts.collection.GetCCollection(), ts.segIDs, subTaskNqs[1], subTaskTopks[1], testCollectionID)
require.NoError(t, err)
other := NewSearchTask(ctx, ts.collection, ts.manager, otherReq, 1)
other.merged = true
// Post-merge state with mixed topKs.
receiver.nq = totalNq
receiver.topk = maxTopK
receiver.originNqs = subTaskNqs
receiver.originTopks = subTaskTopks
receiver.others = []*SearchTask{other}
allSearchCount, err := segcore.PrepareSearchResultsForExport(
ctx,
ts.searchReq.Plan(),
ts.searchReq.PlaceholderGroup(),
ts.searchResults,
subTaskNqs,
subTaskTopks,
)
require.NoError(t, err)
// Build Arrow DataFrames from the per-segment SearchResults.
segDFs, err := receiver.exportSearchResultsAsArrow(ts.searchResults, ts.searchReq.Plan(), nil)
require.NoError(t, err)
defer func() {
for _, df := range segDFs {
df.Release()
}
}()
tr := timerecord.NewTimeRecorder("mixed-topk-test")
require.NoError(t, receiver.executeGoReduce(segDFs, ts.searchResults, ts.searchReq, "IP", tr, 0, allSearchCount))
for i, wantTopK := range subTaskTopks {
sub := receiver.subTaskAt(i)
res := sub.SearchResult()
require.NotNil(t, res, "sub %d: result must be populated", i)
assert.Equal(t, subTaskNqs[i], res.NumQueries, "sub %d NumQueries", i)
assert.Equal(t, wantTopK, res.TopK, "sub %d outer TopK", i)
slice := &schemapb.SearchResultData{}
require.NoError(t, proto.Unmarshal(res.SlicedBlob, slice), "sub %d unmarshal", i)
assert.Equal(t, subTaskNqs[i], slice.NumQueries, "sub %d slice NumQueries", i)
assert.Equal(t, wantTopK, slice.TopK, "sub %d slice TopK", i)
assert.Equal(t, allSearchCount, slice.GetAllSearchCount(), "sub %d AllSearchCount", i)
require.Len(t, slice.Topks, int(subTaskNqs[i]), "sub %d Topks length", i)
for j, perNq := range slice.Topks {
assert.LessOrEqual(t, perNq, wantTopK,
"sub %d NQ %d: got %d rows, must be <= originTopks[%d]=%d",
i, j, perNq, i, wantTopK)
}
// Proxy's checkSearchResultData requires len(Scores) == sum(Topks).
var want int64
for _, k := range slice.Topks {
want += k
}
assert.Equal(t, int(want), len(slice.Scores), "sub %d Scores length", i)
// Ids length must equal Scores length; both come from the same slice.
intIDs, ok := slice.Ids.IdField.(*schemapb.IDs_IntId)
require.True(t, ok, "sub %d: expected int64 PKs", i)
assert.Len(t, intIDs.IntId.Data, int(want), "sub %d Ids length", i)
}
t.Logf("mixed-topK slicing OK: originTopks=%v, originNqs=%v, maxTopK=%d",
subTaskTopks, subTaskNqs, maxTopK)
}
func TestExecuteGoReduceFastPathUsesOriginTopKWhenPlanTopKReduced(t *testing.T) {
paramtable.Init()
pool := memory.NewCheckedAllocator(memory.NewGoAllocator())
defer pool.AssertSize(t, 0)
// Simulate three per-segment result sets produced after the delegator reduced
// plan topK to 2. The worker can still assemble 5 rows from all segment
// candidates, matching the original request topK.
segDFs := []*chain.DataFrame{
buildTestDF(pool, [][]int64{{1, 2}}, [][]float32{{0.99, 0.98}}),
buildTestDF(pool, [][]int64{{3, 4}}, [][]float32{{0.97, 0.96}}),
buildTestDF(pool, [][]int64{{5, 6}}, [][]float32{{0.95, 0.94}}),
}
defer func() {
for _, df := range segDFs {
df.Release()
}
}()
schema := mock_segcore.GenTestCollectionSchema("test-reduced-plan-topk", schemapb.DataType_Int64, true)
indexMeta := mock_segcore.GenTestIndexMeta(testCollectionID, schema)
manager := segments.NewManager()
require.NoError(t, manager.Collection.PutOrRef(testCollectionID, schema, indexMeta, &querypb.LoadMetaInfo{
LoadType: querypb.LoadType_LoadCollection,
CollectionID: testCollectionID,
PartitionIDs: []int64{testPartitionID},
}))
collection := manager.Collection.Get(testCollectionID)
require.NotNil(t, collection)
defer manager.Collection.Unref(collection.ID(), 1)
reducedPlanReq, err := mock_segcore.GenSearchPlanAndRequestsWithTopK(
collection.GetCCollection(), []int64{1, 2, 3}, 1, 2)
require.NoError(t, err)
defer reducedPlanReq.Delete()
reducedPlan := reducedPlanReq.Plan()
require.Equal(t, int64(2), reducedPlan.GetTopK())
mocker := mockey.Mock(lateMaterializeOutputFields).To(
func(ctx context.Context, results []*segments.SearchResult, plan *segcore.SearchPlan, sources [][]segmentSource, searchResultData *schemapb.SearchResultData) error {
return nil
},
).Build()
defer mocker.UnPatch()
task := &SearchTask{
ctx: context.Background(),
topk: 5,
originNqs: []int64{1},
originTopks: []int64{5},
serverID: 1,
}
tr := timerecord.NewTimeRecorder("reduced-plan-topk-test")
require.NoError(t, task.executeGoReduceFastPath(segDFs, nil, reducedPlan, "IP", tr, 0, 6, nil))
require.NotNil(t, task.result)
assert.Equal(t, int64(1), task.result.NumQueries)
assert.Equal(t, int64(5), task.result.TopK)
data := task.result.ResultData
if data == nil {
require.NotEmpty(t, task.result.SlicedBlob)
data = &schemapb.SearchResultData{}
require.NoError(t, proto.Unmarshal(task.result.SlicedBlob, data))
}
assert.Equal(t, int64(1), data.NumQueries)
assert.Equal(t, int64(5), data.TopK)
assert.Equal(t, []int64{5}, data.Topks)
assert.Len(t, data.Scores, 5)
intIDs, ok := data.Ids.IdField.(*schemapb.IDs_IntId)
require.True(t, ok)
assert.Len(t, intIDs.IntId.Data, 5)
}
func TestExecuteGoReduceHonorsEnableResultZeroCopy(t *testing.T) {
const (
numSegments = 1
msgLength = 200
nq = 2
topK = 3
)
key := paramtable.Get().QueryNodeCfg.EnableResultZeroCopy.Key
original := paramtable.Get().QueryNodeCfg.EnableResultZeroCopy.GetValue()
defer paramtable.Get().Save(key, original)
paramtable.Get().Save(key, "true")
ts := setupTestSegments(t, numSegments, msgLength, setupOpts{
NQ: nq,
TopK: topK,
})
defer ts.cleanup()
ctx := context.Background()
queryReq, err := mock_segcore.GenQueryRequest(
ts.collection.GetCCollection(), ts.segIDs, nq, topK, testCollectionID)
require.NoError(t, err)
task := NewSearchTask(ctx, ts.collection, ts.manager, queryReq, 1)
allSearchCount, err := segcore.PrepareSearchResultsForExport(
ctx,
ts.searchReq.Plan(),
ts.searchReq.PlaceholderGroup(),
ts.searchResults,
[]int64{nq},
[]int64{topK},
)
require.NoError(t, err)
segDFs, err := task.exportSearchResultsAsArrow(ts.searchResults, ts.searchReq.Plan(), nil)
require.NoError(t, err)
defer func() {
for _, df := range segDFs {
df.Release()
}
}()
tr := timerecord.NewTimeRecorder("zero-copy-test")
require.NoError(t, task.executeGoReduce(segDFs, ts.searchResults, ts.searchReq, "IP", tr, 0, allSearchCount))
res := task.SearchResult()
require.NotNil(t, res)
require.NotNil(t, res.ResultData)
assert.Empty(t, res.SlicedBlob)
assert.Equal(t, int64(nq), res.NumQueries)
assert.Equal(t, int64(topK), res.TopK)
assert.Equal(t, int64(nq), res.ResultData.NumQueries)
assert.Equal(t, int64(topK), res.ResultData.TopK)
assert.Equal(t, allSearchCount, res.ResultData.GetAllSearchCount())
}
// TestExecuteNullableVectorOutput regression-tests the MergeBase physical
// offset handling in FillOutputFieldsOrdered. When a nullable vector field is
// in the output set, FillTargetEntry compacts the vector buffer (null rows
// dropped) while valid_data keeps its logical length. Without
// setValidDataOffset, MergeDataArray would read at wrong physical positions
// and return the wrong row's vector for any selected row that has null rows
// before it in the same segment's batch.
func TestExecuteNullableVectorOutput(t *testing.T) {
const (
numSegments = 1
msgLength = 30 // 10 null, 20 valid with NullablePatternValidData
nq int64 = 1
topK int64 = 30 // return everything so we exercise null-before-valid ordering
)
// Build the schema up front to resolve the float_vector field id/dim:
// GenTestCollectionSchema assigns sequential FieldIDs so the hard-coded
// SimpleFloatVecField.ID is not what ends up on the schema.
schema := mock_segcore.GenTestCollectionSchemaWithNullableVec("probe", schemapb.DataType_Int64)
var floatVecFieldID int64
var dim int
for _, f := range schema.Fields {
if f.DataType == schemapb.DataType_FloatVector {
floatVecFieldID = f.FieldID
for _, tp := range f.TypeParams {
if tp.Key == common.DimKey {
d, _ := strconv.Atoi(tp.Value)
dim = d
}
}
break
}
}
require.NotZero(t, floatVecFieldID, "float_vector field should exist")
require.NotZero(t, dim, "float_vector dim should be set")
ts := setupTestSegments(t, numSegments, msgLength, setupOpts{
NQ: nq,
TopK: topK,
OutputFieldIDs: []int64{floatVecFieldID},
NullableVec: true,
})
defer ts.cleanup()
// Bypass the search result — segcore's ANN already filters null rows,
// which hides the MergeBase offset bug. Construct a sources list that
// explicitly interleaves null and valid seg_offsets within the same
// segment, the exact shape FillOutputFieldsOrdered must handle
// correctly for L0 rerank / future callers that don't pre-filter.
interleavedOffsets := make([]int64, msgLength)
for i := range interleavedOffsets {
interleavedOffsets[i] = int64(i)
}
sources := [][]segmentSource{make([]segmentSource, 0, msgLength)}
for _, off := range interleavedOffsets {
sources[0] = append(sources[0], segmentSource{
InputIdx: 0,
SegOffset: off,
OriginalIdx: int(off),
})
}
searchResultData := &schemapb.SearchResultData{
NumQueries: nq,
TopK: int64(msgLength),
Ids: &schemapb.IDs{
IdField: &schemapb.IDs_IntId{
IntId: &schemapb.LongArray{Data: interleavedOffsets},
},
},
}
plan := ts.searchReq.Plan()
require.NoError(t, lateMaterializeOutputFields(context.Background(), ts.searchResults, plan, sources, searchResultData))
// Locate the float_vector FieldData and its raw payload.
var vecFD *schemapb.FieldData
for _, fd := range searchResultData.FieldsData {
if fd.FieldId == floatVecFieldID {
vecFD = fd
break
}
}
require.NotNil(t, vecFD, "float_vector must be in output FieldsData")
returnedVecs := vecFD.GetVectors().GetFloatVector().GetData()
validBits := vecFD.GetValidData()
// Ground truth: the inserted FloatVectorFieldData.Data is already compacted
// to valid_count * dim (binlog convention for nullable vectors). Map each
// logical row index to its physical index into that compacted buffer.
insertedVecs := ts.insertData[0].Data[floatVecFieldID].(*storage.FloatVectorFieldData).Data
validPattern := mock_segcore.NullablePatternValidData(msgLength)
logicalToPhys := make([]int, msgLength)
v := 0
for i, ok := range validPattern {
if ok {
logicalToPhys[i] = v
v++
} else {
logicalToPhys[i] = -1
}
}
ids := searchResultData.GetIds().GetIntId().GetData()
require.Equal(t, len(ids), len(validBits),
"valid_data must have one bit per returned row")
// Assert per-row: valid bit matches pattern, and for valid rows the vector
// matches the originally inserted row exactly. Mismatches here indicate
// MergeDataArray read at the wrong physical offset.
physOut := 0
validCount := 0
for i, id := range ids {
require.GreaterOrEqual(t, id, int64(0))
require.Less(t, id, int64(msgLength))
wantValid := validPattern[id]
assert.Equal(t, wantValid, validBits[i],
"row %d (pk=%d): valid_data mismatch", i, id)
if !wantValid {
continue
}
validCount++
require.LessOrEqual(t, (physOut+1)*dim, len(returnedVecs),
"returned vector buffer too short at physical index %d", physOut)
got := returnedVecs[physOut*dim : (physOut+1)*dim]
physIn := logicalToPhys[id]
want := insertedVecs[physIn*dim : (physIn+1)*dim]
assert.Equal(t, want, got,
"row %d (pk=%d, phys_out=%d, phys_in=%d): vector payload mismatch — MergeBase.setValidDataOffset regression",
i, id, physOut, physIn)
physOut++
}
assert.Equal(t, validCount*dim, len(returnedVecs),
"compacted vector buffer size must equal valid_count * dim")
t.Logf("nullable vector OK: %d total rows, %d valid, dim=%d", len(ids), validCount, dim)
}