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

1169 lines
39 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 external
// RefreshExternalCollectionTask handles updating external collection segments by fetching fragments from external sources
// and organizing them into segments with balanced row counts.
//
// SEGMENT ID ALLOCATION WORKFLOW:
// - DataCoord pre-allocates a batch of segment IDs (default 1000) via allocator.AllocN()
// - Pre-allocated ID range is passed to DataNode via RefreshExternalCollectionTaskRequest.PreAllocatedSegmentIds
// - DataNode extracts the IDRange and uses pre-allocated IDs sequentially for each new segment
// - Manifest files are written directly to final StorageV3 insert_log paths
//
// NO TEMPORARY PATHS OR CLEANUP:
// - All segments use real, pre-allocated IDs (no temporary negative IDs)
// - All manifest files are written to final paths immediately
// - No cleanup logic is needed because:
// * IDs are pre-allocated, ensuring uniqueness
// * Paths are final, so failed manifests remain in final locations but are not registered in meta
// * DataCoord validates and registers only successful manifests
//
// KEY IMPROVEMENTS OVER PREVIOUS DESIGN:
// - Eliminates temporary paths and associated cleanup complexity
// - Reduces risk of ID collision or exhaustion
// - Simplifies the segment organization workflow
// - Directly writes to final storage locations
import (
"context"
"fmt"
"path"
"sort"
"strconv"
"strings"
"sync"
"time"
"google.golang.org/protobuf/proto"
"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/pkg/v3/common"
"github.com/milvus-io/milvus/pkg/v3/mlog"
"github.com/milvus-io/milvus/pkg/v3/proto/datapb"
"github.com/milvus-io/milvus/pkg/v3/proto/indexpb"
"github.com/milvus-io/milvus/pkg/v3/util/conc"
"github.com/milvus-io/milvus/pkg/v3/util/externalspec"
"github.com/milvus-io/milvus/pkg/v3/util/merr"
"github.com/milvus-io/milvus/pkg/v3/util/metautil"
"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"
)
func ensureContext(ctx context.Context) error {
if ctx == nil {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
default:
return nil
}
}
// RefreshExternalCollectionTask handles updating external collection segments
type RefreshExternalCollectionTask struct {
ctx context.Context
req *datapb.RefreshExternalCollectionTaskRequest
tr *timerecord.TimeRecorder
state indexpb.JobState
failReason string
// Cached parsed spec (populated in PreExecute, reused in Execute)
parsedSpec *externalspec.ExternalSpec
columns []string
milvusTableSourcePKFieldMu sync.Mutex
milvusTableSourcePKField *schemapb.FieldSchema
milvusTableSourceDeltalogsMu sync.Mutex
milvusTableSourceDeltalogs map[string][]*datapb.FieldBinlog
// Result after execution — tracked separately for correct response building
keptSegmentIDs []int64 // IDs of current segments that were kept unchanged
updatedSegments []*datapb.SegmentInfo // upsert payload: patched current segments plus newly created segments
// Pre-allocated segment IDs
preallocatedIDRange *datapb.IDRange // pre-allocated segment ID range (begin, end)
nextAllocID int64 // next available segment ID from pre-allocated range
}
// NewRefreshExternalCollectionTask creates a new refresh-external-collection task.
// ctx owns the task lifetime; cancellation is driven by the caller's context
// (typically the manager's worker-pool closure).
func NewRefreshExternalCollectionTask(
ctx context.Context,
req *datapb.RefreshExternalCollectionTaskRequest,
) *RefreshExternalCollectionTask {
return &RefreshExternalCollectionTask{
ctx: ctx,
req: req,
tr: timerecord.NewTimeRecorder(fmt.Sprintf("RefreshExternalCollectionTask: %d", req.GetTaskID())),
state: indexpb.JobState_JobStateInit,
}
}
func (t *RefreshExternalCollectionTask) Ctx() context.Context {
return t.ctx
}
func (t *RefreshExternalCollectionTask) Name() string {
return fmt.Sprintf("RefreshExternalCollectionTask-%d", t.req.GetTaskID())
}
func (t *RefreshExternalCollectionTask) OnEnqueue(ctx context.Context) error {
t.tr.RecordSpan()
mlog.Info(ctx, "RefreshExternalCollectionTask enqueued",
mlog.Int64("taskID", t.req.GetTaskID()),
mlog.Int64("collectionID", t.req.GetCollectionID()))
return nil
}
func (t *RefreshExternalCollectionTask) SetState(state indexpb.JobState, failReason string) {
t.state = state
t.failReason = failReason
}
func (t *RefreshExternalCollectionTask) GetState() indexpb.JobState {
return t.state
}
func (t *RefreshExternalCollectionTask) GetSlot() int64 {
return 1
}
func (t *RefreshExternalCollectionTask) Reset() {
t.ctx = nil
t.req = nil
t.tr = nil
t.keptSegmentIDs = nil
t.updatedSegments = nil
}
func (t *RefreshExternalCollectionTask) PreExecute(ctx context.Context) error {
if err := ensureContext(ctx); err != nil {
return err
}
mlog.Info(ctx, "RefreshExternalCollectionTask PreExecute",
mlog.Int64("taskID", t.req.GetTaskID()),
mlog.Int64("collectionID", t.req.GetCollectionID()))
if t.req == nil {
return merr.WrapErrParameterInvalidMsg("request is nil")
}
if t.req.GetSchema() == nil {
return merr.WrapErrParameterInvalidMsg("schema is nil in request")
}
if t.req.GetStorageConfig() == nil {
return merr.WrapErrParameterInvalidMsg("storage config is nil in request")
}
if t.req.GetExternalSource() == "" {
return merr.WrapErrParameterInvalidMsg("external source is empty in request")
}
// Parse and cache external spec for reuse during Execute
spec, err := externalspec.ParseExternalSpec(t.req.GetExternalSpec())
if err != nil {
return merr.Wrap(err, "failed to parse external spec")
}
t.parsedSpec = spec
schema := proto.Clone(t.req.GetSchema()).(*schemapb.CollectionSchema)
if schema.GetExternalSource() == "" {
schema.ExternalSource = t.req.GetExternalSource()
}
if schema.GetExternalSpec() == "" {
schema.ExternalSpec = t.req.GetExternalSpec()
}
t.req.Schema = schema
t.columns = packed.GetColumnNamesFromSchema(schema)
return nil
}
func (t *RefreshExternalCollectionTask) Execute(ctx context.Context) error {
if err := ensureContext(ctx); err != nil {
return err
}
mlog.Info(context.TODO(), "RefreshExternalCollectionTask Execute",
mlog.Int64("taskID", t.req.GetTaskID()),
mlog.Int64("collectionID", t.req.GetCollectionID()))
// Initialize pre-allocated segment IDs from request
if t.req.GetPreAllocatedSegmentIds() == nil {
return merr.WrapErrParameterInvalidMsg("pre-allocated segment IDs not provided in request")
}
t.preallocatedIDRange = t.req.GetPreAllocatedSegmentIds()
t.nextAllocID = t.preallocatedIDRange.Begin
mlog.Info(context.TODO(), "Initialized pre-allocated segment ID range",
mlog.Int64("idBegin", t.preallocatedIDRange.Begin),
mlog.Int64("idEnd", t.preallocatedIDRange.End),
mlog.Int64("count", t.preallocatedIDRange.End-t.preallocatedIDRange.Begin))
// Fetch fragments from external source
newFragments, err := t.fetchFragmentsFromExternalSource(ctx)
if err != nil {
return merr.Wrap(err, "failed to fetch fragments")
}
// Build current segment -> fragments mapping
currentSegmentFragments, err := t.buildCurrentSegmentFragments()
if err != nil {
return merr.Wrap(err, "failed to build current segment fragments")
}
// Compare and organize segments
_, err = t.organizeSegments(ctx, currentSegmentFragments, newFragments)
if err != nil {
return err
}
return nil
}
// fetchFragmentsFromExternalSource reads file info from the explore manifest
// written by DataCoord and returns fragments for the assigned file range.
func (t *RefreshExternalCollectionTask) fetchFragmentsFromExternalSource(ctx context.Context) ([]packed.Fragment, error) {
manifestPath := t.req.GetExploreManifestPath()
if manifestPath == "" {
return nil, merr.WrapErrParameterMissingMsg("explore manifest path is required but not provided")
}
mlog.Info(ctx, "reading file list from explore manifest",
mlog.String("manifestPath", manifestPath),
mlog.Int64("fileIndexBegin", t.req.GetFileIndexBegin()),
mlog.Int64("fileIndexEnd", t.req.GetFileIndexEnd()))
targetRowsPerSegment := paramtable.Get().DataNodeCfg.ExternalCollectionTargetRowsPerSegment.GetAsInt64()
return packed.FetchFragmentsFromExternalSourceWithRange(
ctx,
t.parsedSpec.Format,
t.columns,
t.req.GetExternalSource(),
t.req.GetStorageConfig(),
t.req.GetFileIndexBegin(),
t.req.GetFileIndexEnd(),
manifestPath,
packed.ExternalFetchOptions{
CollectionID: t.req.GetCollectionID(),
ExternalSpec: t.req.GetExternalSpec(),
RowLimit: targetRowsPerSegment,
},
)
}
func (t *RefreshExternalCollectionTask) PostExecute(ctx context.Context) error {
if err := ensureContext(ctx); err != nil {
return err
}
mlog.Info(ctx, "RefreshExternalCollectionTask PostExecute",
mlog.Int64("taskID", t.req.GetTaskID()),
mlog.Int64("collectionID", t.req.GetCollectionID()),
mlog.Int("updatedSegments", len(t.updatedSegments)))
return nil
}
// GetUpdatedSegments returns segments that DataCoord should upsert after execution.
// This includes patched same-ID current segments and newly created segments, but
// excludes unchanged kept segments.
func (t *RefreshExternalCollectionTask) GetUpdatedSegments() []*datapb.SegmentInfo {
return t.updatedSegments
}
// GetKeptSegmentIDs returns IDs of current segments that were kept unchanged
func (t *RefreshExternalCollectionTask) GetKeptSegmentIDs() []int64 {
return t.keptSegmentIDs
}
// fragmentKey identifies the L1 data fragment. Delete overlays are handled as
// manifest-only updates so L0 changes do not force a new target segment ID.
func fragmentKey(f packed.Fragment) string {
return fmt.Sprintf("%s:%d:%d", f.FilePath, f.StartRow, f.EndRow)
}
// buildCurrentSegmentFragments builds segment to fragments mapping from current segments
func (t *RefreshExternalCollectionTask) buildCurrentSegmentFragments() (packed.SegmentFragments, error) {
return packed.BuildCurrentSegmentFragments(t.req.GetCurrentSegments(), t.req.GetStorageConfig(), t.columns)
}
// organizeSegments compares fragments and organizes them into segments
func (t *RefreshExternalCollectionTask) organizeSegments(
ctx context.Context,
currentSegmentFragments packed.SegmentFragments,
newFragments []packed.Fragment,
) ([]*datapb.SegmentInfo, error) {
if err := ensureContext(ctx); err != nil {
return nil, err
}
t.keptSegmentIDs = nil
t.updatedSegments = nil
// Build new fragment map using composite key (FilePath + StartRow + EndRow)
// This is necessary because a single file can be split into multiple fragments
// with different row ranges, and they must be tracked independently
newFragmentMap := make(map[string]packed.Fragment)
for _, f := range newFragments {
key := fragmentKey(f)
newFragmentMap[key] = f
}
// Track which fragments are used by kept segments (use composite key)
usedFragments := make(map[string]bool)
var keptSegments []*datapb.SegmentInfo
var patchedSegments []*datapb.SegmentInfo
var outputColumns []string
if t.hasFunctions() {
var err error
outputColumns, err = functionOutputColumnNames(t.req.GetSchema())
if err != nil {
return nil, merr.Wrap(err, "resolve function output columns")
}
}
// Check each current segment
for _, seg := range t.req.GetCurrentSegments() {
if err := ensureContext(ctx); err != nil {
return nil, err
}
fragments := currentSegmentFragments[seg.GetID()]
allFragmentsExist := true
matchedNewFragments := make([]packed.Fragment, 0, len(fragments))
// Check if all fragments of this segment still exist
for _, f := range fragments {
key := fragmentKey(f)
newFragment, exists := newFragmentMap[key]
if !exists {
allFragmentsExist = false
mlog.Info(context.TODO(), "Fragment removed from segment",
mlog.Int64("segmentID", seg.GetID()),
mlog.String("filePath", f.FilePath),
mlog.Int64("startRow", f.StartRow),
mlog.Int64("endRow", f.EndRow))
break
}
matchedNewFragments = append(matchedNewFragments, newFragment)
}
if !allFragmentsExist {
// Segment invalidated - its remaining fragments become orphans
mlog.Info(context.TODO(), "Segment invalidated due to removed fragments",
mlog.Int64("segmentID", seg.GetID()))
continue
}
reusableSegment := true
if len(outputColumns) > 0 {
hasOutputs, err := t.segmentHasFunctionOutputColumns(seg, outputColumns)
if err != nil {
return nil, err
}
if !hasOutputs {
reusableSegment = false
mlog.Info(context.TODO(), "Segment invalidated due to missing function output columns",
mlog.Int64("segmentID", seg.GetID()),
mlog.String("manifestPath", seg.GetManifestPath()))
}
}
if !reusableSegment {
continue
}
missingColumns := missingExternalColumns(seg, t.req.GetSchema())
shouldRefreshDeltalogs, err := t.shouldRefreshMilvusTableDeltalogs(seg, fragments, matchedNewFragments)
if err != nil {
return nil, err
}
var patchedSegment *datapb.SegmentInfo
if shouldRefreshDeltalogs {
updatedSegment, err := t.refreshMilvusTableSegmentManifest(ctx, seg, matchedNewFragments)
if err != nil {
return nil, err
}
patchedSegment = updatedSegment
mlog.Info(context.TODO(), "Segment kept with refreshed milvus-table deltalogs",
mlog.FieldSegmentID(seg.GetID()),
mlog.String("oldManifestPath", seg.GetManifestPath()),
mlog.String("newManifestPath", updatedSegment.GetManifestPath()))
}
if len(missingColumns) > 0 {
segmentToPatch := seg
patchFragments := fragments
if patchedSegment != nil {
segmentToPatch = patchedSegment
patchFragments = matchedNewFragments
}
patchedWithColumns, err := t.patchSegmentForMissingColumns(ctx, segmentToPatch, patchFragments, missingColumns)
if err != nil {
return nil, err
}
patchedSegment = patchedWithColumns
mlog.Info(context.TODO(), "Segment patched with missing external columns",
mlog.FieldSegmentID(seg.GetID()),
mlog.Strings("missingColumns", missingColumns))
}
for _, f := range fragments {
if err := ensureContext(ctx); err != nil {
return nil, err
}
key := fragmentKey(f)
usedFragments[key] = true
}
if patchedSegment == nil {
// Keep this segment unchanged
keptSegments = append(keptSegments, seg)
mlog.Debug(context.TODO(), "Segment kept unchanged",
mlog.Int64("segmentID", seg.GetID()))
} else {
patchedSegments = append(patchedSegments, patchedSegment)
}
}
// Collect orphan fragments (new + from invalidated segments)
var orphanFragments []packed.Fragment
for _, f := range newFragments {
if err := ensureContext(ctx); err != nil {
return nil, err
}
key := fragmentKey(f)
if !usedFragments[key] {
orphanFragments = append(orphanFragments, f)
}
}
// Organize orphan fragments into new segments with balanced row counts
createdSegments, err := t.balanceFragmentsToSegments(ctx, orphanFragments)
if err != nil {
return nil, err
}
// Track kept vs new separately for correct response building
keptSegmentIDs := make([]int64, 0, len(keptSegments))
for _, seg := range keptSegments {
keptSegmentIDs = append(keptSegmentIDs, seg.GetID())
}
updatedSegments := append(patchedSegments, createdSegments...)
t.keptSegmentIDs = keptSegmentIDs
t.updatedSegments = updatedSegments
// Visible result contains unchanged kept segments plus upsert segments.
result := append(keptSegments, updatedSegments...)
mlog.Info(context.TODO(), "Segment organization complete",
mlog.Int("keptSegments", len(keptSegments)),
mlog.Int("newSegments", len(createdSegments)),
mlog.Int("totalSegments", len(result)))
return result, nil
}
func (t *RefreshExternalCollectionTask) segmentHasFunctionOutputColumns(seg *datapb.SegmentInfo, outputColumns []string) (bool, error) {
if len(outputColumns) == 0 {
return true, nil
}
if segmentChildFieldsContainColumns(seg, outputColumns) {
return true, nil
}
if seg.GetManifestPath() == "" {
return false, nil
}
hasColumns, err := packed.ManifestHasColumns(seg.GetManifestPath(), t.req.GetStorageConfig(), outputColumns)
if err != nil {
return false, merr.Wrapf(err, "check function output columns for segment %d", seg.GetID())
}
return hasColumns, nil
}
func segmentChildFieldsContainColumns(seg *datapb.SegmentInfo, columns []string) bool {
required := make(map[int64]struct{}, len(columns))
for _, column := range columns {
fieldID, err := strconv.ParseInt(column, 10, 64)
if err != nil {
return false
}
required[fieldID] = struct{}{}
}
seen := make(map[int64]struct{}, len(required))
for _, binlog := range seg.GetBinlogs() {
for _, fieldID := range binlog.GetChildFields() {
if _, ok := required[fieldID]; ok {
seen[fieldID] = struct{}{}
}
}
}
return len(seen) == len(required)
}
func functionOutputColumnNames(schema *schemapb.CollectionSchema) ([]string, error) {
outputFields, err := functionOutputFields(schema)
if err != nil {
return nil, err
}
columns := make([]string, 0, len(outputFields))
for _, field := range outputFields {
columns = append(columns, strconv.FormatInt(field.GetFieldID(), 10))
}
return columns, nil
}
func targetExternalFields(schema *schemapb.CollectionSchema) map[int64]string {
result := make(map[int64]string)
if schema == nil {
return result
}
for _, field := range schema.GetFields() {
if field.GetExternalField() == "" {
continue
}
result[field.GetFieldID()] = field.GetExternalField()
}
return result
}
func coveredFieldsFromChildFields(seg *datapb.SegmentInfo) map[int64]struct{} {
result := make(map[int64]struct{})
for _, fieldBinlog := range seg.GetBinlogs() {
childFields := fieldBinlog.GetChildFields()
if len(childFields) == 0 && fieldBinlog.GetFieldID() > 0 {
result[fieldBinlog.GetFieldID()] = struct{}{}
continue
}
for _, fieldID := range childFields {
result[fieldID] = struct{}{}
}
}
return result
}
func missingExternalColumns(seg *datapb.SegmentInfo, schema *schemapb.CollectionSchema) []string {
if schema == nil {
return nil
}
targetFields := targetExternalFields(schema)
coveredFields := coveredFieldsFromChildFields(seg)
var missingColumns []string
for _, field := range schema.GetFields() {
externalField, ok := targetFields[field.GetFieldID()]
if !ok {
continue
}
if _, ok := coveredFields[field.GetFieldID()]; !ok {
missingColumns = append(missingColumns, externalField)
}
}
return missingColumns
}
func (t *RefreshExternalCollectionTask) patchSegmentForMissingColumns(
ctx context.Context,
seg *datapb.SegmentInfo,
fragments []packed.Fragment,
missingColumns []string,
) (*datapb.SegmentInfo, error) {
schema := t.req.GetSchema()
newManifestPath, err := packed.AppendSegmentManifestColumns(
ctx,
seg.GetManifestPath(),
t.parsedSpec.Format,
missingColumns,
fragments,
t.req.GetStorageConfig(),
)
if err != nil {
return nil, merr.Wrapf(err, "failed to append manifest columns for segment %d", seg.GetID())
}
sampleRows := paramtable.Get().QueryNodeCfg.ExternalCollectionSampleRows.GetAsInt()
if int64(sampleRows) > seg.GetNumOfRows() {
sampleRows = int(seg.GetNumOfRows())
}
fieldSizes, err := packed.SampleExternalFieldSizes(
newManifestPath,
sampleRows,
t.req.GetCollectionID(),
t.req.GetExternalSource(),
t.req.GetExternalSpec(),
schema,
t.req.GetStorageConfig(),
)
if err != nil {
return nil, merr.Wrapf(err, "failed to sample external field sizes for segment %d", seg.GetID())
}
externalAvgBytes := sumFieldSizes(fieldSizes, schema)
if externalAvgBytes <= 0 {
return nil, merr.WrapErrParameterInvalidMsg(
fmt.Sprintf("external field size sample for segment %d produced non-positive average size %d", seg.GetID(), externalAvgBytes))
}
functionOutputAvgBytes, err := estimateFunctionOutputBytesPerRow(schema)
if err != nil {
return nil, err
}
memorySize := (externalAvgBytes + functionOutputAvgBytes) * seg.GetNumOfRows()
if memorySize <= 0 {
return nil, merr.WrapErrParameterInvalidMsg(
fmt.Sprintf("external field size sample for segment %d produced non-positive memory size %d", seg.GetID(), memorySize))
}
patched := proto.Clone(seg).(*datapb.SegmentInfo)
patched.ManifestPath = newManifestPath
patched.SchemaVersion = schema.GetVersion()
patched.StorageVersion = storage.StorageV3
patched.Binlogs = buildFakeBinlogs(seg.GetID(), seg.GetNumOfRows(), memorySize, schema, t.parsedSpec.Format)
return patched, nil
}
// balanceFragmentsToSegments organizes fragments into segments with balanced row counts
func (t *RefreshExternalCollectionTask) balanceFragmentsToSegments(ctx context.Context, fragments []packed.Fragment) ([]*datapb.SegmentInfo, error) {
if len(fragments) == 0 {
return nil, nil
}
if err := ensureContext(ctx); err != nil {
return nil, err
}
// Calculate total rows
var totalRows int64
for _, f := range fragments {
if err := ensureContext(ctx); err != nil {
return nil, err
}
totalRows += f.RowCount
}
// Guard against zero-row parquet files that would cause divide-by-zero below.
// Fragment-level RowCount comes from manifest (endRow - startRow) and reflects real row counts,
// unlike datacoord's pre-split FileInfo.NumRows which carries a -1 sentinel from PlainFormat::explore.
if totalRows == 0 {
return nil, merr.WrapErrParameterInvalidMsg(
fmt.Sprintf("external source has %d fragments but zero total rows", len(fragments)))
}
// Phase 1: Allocate segment IDs (sequential, lightweight)
type segmentWork struct {
segmentID int64
binlogLogID int64
rowCount int64
fragments []packed.Fragment
}
var works []segmentWork
isMilvusTableVirtualPKTask := t.parsedSpec != nil &&
t.parsedSpec.Format == externalspec.FormatMilvusTable &&
!packed.HasExternalPrimaryKey(t.req.GetSchema())
isMilvusTableTask := t.parsedSpec != nil && t.parsedSpec.Format == externalspec.FormatMilvusTable
appendWork := func(rowCount int64, fragments []packed.Fragment) error {
// Each segment needs 2 IDs: one for segment, one for fake binlog logID
if t.nextAllocID+1 >= t.preallocatedIDRange.End {
return merr.WrapErrParameterInvalidMsg("insufficient pre-allocated IDs: need 2 more but only have %d IDs in range [%d, %d)",
t.preallocatedIDRange.End-t.nextAllocID,
t.preallocatedIDRange.Begin,
t.preallocatedIDRange.End)
}
segmentID := t.nextAllocID
binlogLogID := t.nextAllocID + 1
t.nextAllocID += 2
workFragments := fragments
if t.parsedSpec != nil && t.parsedSpec.Format == externalspec.FormatMilvusTable {
var err error
workFragments, err = t.prepareMilvusTableDeltalogFragments(fragments)
if err != nil {
return err
}
}
works = append(works, segmentWork{
segmentID: segmentID,
binlogLogID: binlogLogID,
rowCount: rowCount,
fragments: workFragments,
})
return nil
}
if isMilvusTableTask {
// milvus-table maps each source fragment to exactly one target segment
// (1:1) instead of bin-packing fragments by row count. This keeps the
// target segment aligned with a single source segment manifest, which is
// what makes manifest reuse and the virtual-PK offset mapping well defined.
mlog.Info(context.TODO(), "Assigning milvus-table fragments to one segment each",
mlog.Int("numFragments", len(fragments)),
mlog.Int64("totalRows", totalRows),
mlog.Int("numSegments", len(fragments)))
for _, fragment := range fragments {
if err := appendWork(fragment.RowCount, []packed.Fragment{fragment}); err != nil {
return nil, err
}
}
} else {
// Get target rows per segment from configuration
targetRowsPerSegment := paramtable.Get().DataNodeCfg.ExternalCollectionTargetRowsPerSegment.GetAsInt64()
if totalRows < targetRowsPerSegment {
targetRowsPerSegment = totalRows
}
numSegments := (totalRows + targetRowsPerSegment - 1) / targetRowsPerSegment
if numSegments == 0 {
numSegments = 1
}
avgRowsPerSegment := totalRows / numSegments
mlog.Info(context.TODO(), "Balancing fragments to segments",
mlog.Int("numFragments", len(fragments)),
mlog.Int64("totalRows", totalRows),
mlog.Int64("numSegments", numSegments),
mlog.Int64("avgRowsPerSegment", avgRowsPerSegment))
// Sort fragments by row count descending for better bin-packing
sortedFragments := make([]packed.Fragment, len(fragments))
copy(sortedFragments, fragments)
sort.Slice(sortedFragments, func(i, j int) bool {
return sortedFragments[i].RowCount > sortedFragments[j].RowCount
})
// Initialize segment bins
type segmentBin struct {
fragments []packed.Fragment
rowCount int64
}
bins := make([]segmentBin, numSegments)
// Greedy bin-packing: assign each fragment to the bin with lowest current row count
for _, f := range sortedFragments {
if err := ensureContext(ctx); err != nil {
return nil, err
}
// Find bin with minimum row count
minIdx := 0
for i := 1; i < len(bins); i++ {
if bins[i].rowCount < bins[minIdx].rowCount {
minIdx = i
}
}
bins[minIdx].fragments = append(bins[minIdx].fragments, f)
bins[minIdx].rowCount += f.RowCount
}
for _, bin := range bins {
if len(bin.fragments) == 0 {
continue
}
if err := appendWork(bin.rowCount, bin.fragments); err != nil {
return nil, err
}
}
}
mlog.Info(context.TODO(), "Allocated segment IDs, starting manifest creation",
mlog.Int("numSegments", len(works)))
// Phase 2: Create manifests concurrently with a fixed-size worker pool.
const createManifestWorkers = 16
const createManifestVirtualPKWorkers = 4
manifestStart := time.Now()
workerLimit := createManifestWorkers
if isMilvusTableVirtualPKTask {
workerLimit = createManifestVirtualPKWorkers
}
workers := workerLimit
if workers > len(works) {
workers = len(works)
}
pool := conc.NewPool[string](workers)
defer pool.Release()
manifestPaths := make([]string, len(works))
futures := make([]*conc.Future[string], len(works))
for i := range works {
i, work := i, works[i]
futures[i] = pool.Submit(func() (string, error) {
// Honor ctx cancellation so workers bail out quickly once the
// caller gives up instead of running createManifestForSegment
// for every still-queued segment.
if err := ctx.Err(); err != nil {
return "", err
}
var manifestPath string
var err error
if t.hasFunctions() {
manifestPath, err = t.createManifestWithFunctions(ctx, work.segmentID, work.fragments)
} else {
manifestPath, err = t.createManifestForSegment(ctx, work.segmentID, work.fragments)
}
if err != nil {
return "", merr.Wrapf(err, "failed to create manifest for segment %d", work.segmentID)
}
return manifestPath, nil
})
}
// Wait for all futures; record the first error (if any) but still wait
// for every future to finish so no goroutine is left running.
var firstErr error
for i, f := range futures {
path, err := f.Await()
if err != nil && firstErr == nil {
firstErr = err
}
if err == nil {
manifestPaths[i] = path
}
}
manifestDuration := time.Since(manifestStart)
mlog.Info(context.TODO(), "CreateManifest phase completed",
mlog.Int("numSegments", len(works)),
mlog.Int("workers", workers),
mlog.Duration("duration", manifestDuration))
if ctx.Err() != nil {
return nil, ctx.Err()
}
if firstErr != nil {
return nil, firstErr
}
// Phase 3: Sample field sizes so that downstream code (QueryNode memory
// estimation) can use Binlog.MemorySize directly, eliminating the need
// for a separate Take-sampling step at load time. Two modes:
// - samplePerSegment=false (default): sample only the first segment
// and reuse that average for all segments in this task. Cheap, but
// can be off when row density varies across files.
// - samplePerSegment=true: sample each segment independently. More
// I/O, but per-segment MemorySize is accurate.
//
// If a later segment's sampling fails, we fall back to the first
// successful avgBytesPerRow so MemorySize is never written as 0 — a
// zero MemorySize would make QueryNode's memory estimation collapse
// and risk OOM on load. If ALL samples fail (or return a 0 sum because
// the schema has no ExternalField-mapped fields, or the sampled rows
// somehow evaluate to 0 bytes), we fail the task rather than silently
// producing zero-sized segments that would skew QN's resource estimator
// and risk OOM on load.
samplePerSegment := paramtable.Get().QueryNodeCfg.ExternalCollectionSamplePerSegment.GetAsBool()
sampleRows := paramtable.Get().QueryNodeCfg.ExternalCollectionSampleRows.GetAsInt()
segmentAvgBytes := make([]int64, len(works))
var fallbackAvg int64
functionOutputAvgBytes, err := estimateFunctionOutputBytesPerRow(t.req.GetSchema())
if err != nil {
return nil, err
}
// firstSampleErr captures the first underlying sampling failure so we
// can surface the real root cause (e.g. "Column 'xxx' not found in
// schema" from an external_field typo, issue #48637) to the client
// instead of a generic "sampling failed for all N segment(s)" message.
var firstSampleErr error
recordErr := func(err error) {
if firstSampleErr == nil {
firstSampleErr = err
}
}
sampleOne := func(manifestPath string) (int64, bool) {
fieldSizes, err := packed.SampleExternalFieldSizes(
manifestPath, sampleRows,
t.req.GetCollectionID(),
t.req.GetExternalSource(),
t.req.GetExternalSpec(),
t.req.GetSchema(),
t.req.GetStorageConfig(),
)
if err != nil {
mlog.Warn(context.TODO(), "failed to sample external field sizes",
mlog.String("manifestPath", manifestPath),
mlog.Err(err))
recordErr(err)
return 0, false
}
total := sumFieldSizes(fieldSizes, t.req.GetSchema())
if total <= 0 {
// A non-positive total is treated as a sample failure so the
// caller can decide whether to fall back or fail the task. The
// zero can come from (a) a schema with no ExternalField-mapped
// fields, or (b) a Parquet file whose sampled rows really are
// empty — both are degenerate and must not feed QN a zero.
mlog.Warn(context.TODO(), "external field size sample produced non-positive total",
mlog.String("manifestPath", manifestPath),
mlog.Int64("total", total))
recordErr(merr.WrapErrParameterInvalidMsg("sampled field sizes sum to %d (schema may have no external_field mappings, or sampled rows are empty)", total))
return 0, false
}
return total, true
}
if len(manifestPaths) > 0 {
if samplePerSegment {
for i, mp := range manifestPaths {
if avg, ok := sampleOne(mp); ok {
segmentAvgBytes[i] = avg
if fallbackAvg == 0 {
fallbackAvg = avg
}
}
}
mlog.Info(context.TODO(), "per-segment sampling complete",
mlog.Int("numSegments", len(manifestPaths)),
mlog.Int64("fallbackAvgBytesPerRow", fallbackAvg))
} else {
if avg, ok := sampleOne(manifestPaths[0]); ok {
fallbackAvg = avg
for i := range segmentAvgBytes {
segmentAvgBytes[i] = avg
}
}
mlog.Info(context.TODO(), "single-sample complete",
mlog.Int64("avgBytesPerRow", fallbackAvg))
}
// If every sample failed, fail the task rather than emitting
// zero-MemorySize fake binlogs that would collapse QueryNode's
// resource estimator and risk OOM on load. We prefer a loud
// failure (retry-eligible via the task state machine) over a
// silent success that corrupts downstream accounting.
//
// Embed firstSampleErr (issue #48637) so the client-facing
// RefreshFailed reason names the root cause (e.g. column not
// found in parquet) rather than forcing the operator to SSH
// into datanode logs.
if fallbackAvg == 0 {
rootCause := "unknown (no sample attempted)"
if firstSampleErr != nil {
rootCause = firstSampleErr.Error()
}
hint := ""
if strings.Contains(rootCause, "not found in schema") {
hint = "; check external_field mappings in collection schema against actual parquet columns"
}
return nil, merr.WrapErrParameterInvalidMsg(
"external field size sampling failed for all %d segment(s): %s%s",
len(manifestPaths), rootCause, hint)
}
// Fill any zero slots (sampling failed mid-loop) with the first
// successful average so every segment gets a non-zero MemorySize.
for i, v := range segmentAvgBytes {
if v == 0 {
segmentAvgBytes[i] = fallbackAvg
}
}
}
// Phase 4: Build result and mappings (sequential, lightweight)
result := make([]*datapb.SegmentInfo, 0, len(works))
for i, work := range works {
memorySize := (segmentAvgBytes[i] + functionOutputAvgBytes) * work.rowCount
seg := &datapb.SegmentInfo{
ID: work.segmentID,
CollectionID: t.req.GetCollectionID(),
PartitionID: t.req.GetPartitionID(),
NumOfRows: work.rowCount,
ManifestPath: manifestPaths[i],
StorageVersion: storage.StorageV3,
Level: datapb.SegmentLevel_L1,
// Fake binlog so downstream treats external segments like normal
// StorageV3 segments. MemorySize is pre-computed from Take sampling
// so QueryNode skips the external-specific sampling path.
Binlogs: buildFakeBinlogs(work.binlogLogID, work.rowCount, memorySize, t.req.GetSchema(), t.parsedSpec.Format),
}
result = append(result, seg)
}
return result, nil
}
// createManifestForSegment creates a manifest file for the segment
func (t *RefreshExternalCollectionTask) createManifestForSegment(
ctx context.Context,
segmentID int64,
fragments []packed.Fragment,
) (string, error) {
// All segments now use final paths with real IDs (no temporary paths needed)
// Pre-allocated IDs ensure we can write directly to final locations
basePath := segmentInsertLogBasePath(
t.req.GetStorageConfig(),
t.req.GetCollectionID(),
t.req.GetPartitionID(),
segmentID,
)
manifestPath, err := packed.CreateSegmentManifestWithBasePathAndExtfs(
ctx,
basePath,
t.parsedSpec.Format,
t.columns,
fragments,
t.req.GetStorageConfig(),
packed.ExternalSpecContext{
CollectionID: t.req.GetCollectionID(),
Source: t.req.GetExternalSource(),
Spec: t.req.GetExternalSpec(),
MilvusTablePKMode: packed.MilvusTablePrimaryKeyModeFromSchema(t.req.GetSchema()),
},
)
if err != nil {
return "", err
}
if t.parsedSpec.Format == externalspec.FormatMilvusTable {
return t.postProcessMilvusTableDeltalogs(ctx, basePath, manifestPath, segmentID, fragments)
}
return manifestPath, nil
}
func segmentInsertLogBasePath(
storageConfig *indexpb.StorageConfig,
collectionID int64,
partitionID int64,
segmentID int64,
) string {
rootPath := ""
if storageConfig != nil {
rootPath = storageConfig.GetRootPath()
}
return path.Join(rootPath, common.SegmentInsertLogPath, metautil.JoinIDPath(collectionID, partitionID, segmentID))
}
// hasFunctions returns true if the schema defines any functions.
func (t *RefreshExternalCollectionTask) hasFunctions() bool {
return len(t.req.GetSchema().GetFunctions()) > 0
}
// createManifestWithFunctions builds an input manifest from the segment's
// fragments, runs schema functions, and appends a function-output column group
// on top. External input files are referenced, never copied.
func (t *RefreshExternalCollectionTask) createManifestWithFunctions(
ctx context.Context,
segmentID int64,
fragments []packed.Fragment,
) (string, error) {
clusterID := paramtable.Get().CommonCfg.ClusterPrefix.GetValue()
basePath := segmentInsertLogBasePath(
t.req.GetStorageConfig(),
t.req.GetCollectionID(),
t.req.GetPartitionID(),
segmentID,
)
manifestPath, err := ExecuteFunctionsForSegment(
ctx,
t.req.GetSchema(),
fragments,
t.parsedSpec.Format,
t.req.GetStorageConfig(),
t.req.GetCollectionID(),
segmentID,
basePath,
clusterID,
)
if err != nil {
return "", err
}
if t.parsedSpec.Format == externalspec.FormatMilvusTable {
return t.postProcessMilvusTableDeltalogs(ctx, basePath, manifestPath, segmentID, fragments)
}
return manifestPath, nil
}
// buildFakeBinlogs creates a synthetic FieldBinlog slice for an external segment.
// It uses DefaultShortColumnGroupID (0) to match StorageV3 convention, so that
// downstream code (row count calculation, index association, memory estimation)
// treats external segments the same as normal packed segments.
// ChildFields must list all field IDs so that QueryNode can resolve field schemas.
func buildFakeBinlogs(logID, numRows, memorySize int64, schema *schemapb.CollectionSchema, format string) []*datapb.FieldBinlog {
var childFields []int64
if schema != nil {
for _, field := range schema.GetFields() {
childFields = append(childFields, field.GetFieldID())
}
}
return []*datapb.FieldBinlog{
{
FieldID: int64(storagecommon.DefaultShortColumnGroupID),
ChildFields: childFields,
Format: format,
Binlogs: []*datapb.Binlog{
{
LogID: logID,
EntriesNum: numRows,
MemorySize: memorySize,
LogSize: memorySize, // conservative: assume no compression
},
},
},
}
}
// estimateFunctionOutputBytesPerRow computes the per-row memory estimate for
// fields generated during refresh. These fields are not present in external
// source samples, so add them explicitly to the fake binlog memory size.
func estimateFunctionOutputBytesPerRow(schema *schemapb.CollectionSchema) (int64, error) {
var total int64
outputFields, err := functionOutputFields(schema)
if err != nil {
return 0, err
}
for _, field := range outputFields {
size, err := typeutil.EstimateSizePerRecord(&schemapb.CollectionSchema{
Fields: []*schemapb.FieldSchema{field},
})
if err != nil {
return 0, merr.Wrapf(err, "estimate function output field %s", field.GetName())
}
total += int64(size)
}
return total, nil
}
// sumFieldSizes computes total avgBytesPerRow from per-field sampling results.
func sumFieldSizes(fieldSizes map[string]int64, schema *schemapb.CollectionSchema) int64 {
if schema == nil {
var total int64
for _, v := range fieldSizes {
total += v
}
return total
}
columnResolver := typeutil.NewStorageColumnResolver(schema)
var total int64
for _, field := range schema.GetFields() {
columnName, ok := columnResolver.SourceDataColumnName(field)
if !ok {
continue
}
if avgBytes, ok := fieldSizes[columnName]; ok && avgBytes > 0 {
total += avgBytes
}
}
if columnResolver.IsMilvusTable() && packed.HasExternalPrimaryKey(schema) {
timestampColumn := strconv.FormatInt(common.TimeStampField, 10)
if avgBytes, ok := fieldSizes[timestampColumn]; ok && avgBytes > 0 {
total += avgBytes
}
}
return total
}