mirror of
https://github.com/milvus-io/milvus.git
synced 2026-07-21 10:15:43 +00:00
issue: #49710 related: #49069 BulkPackWriterV3.Write currently bumps the loon manifest version up to four times per call (inserts, stats, delta, bm25 each open and commit their own transaction). That breaks atomicity for concurrent readers, leaves orphan manifest versions behind on partial failure, and churns loon's manifest history. This PR refactors the V3 sync write path into a do-then-commit shape: Phase 1 (slow, runs once outside the retry loop): writeInserts, writeStats, writeDelta, writeBM25Stasts write all the data files (parquet/LOB via the loon writer, deltalog, stat blobs) and each helper returns its contribution (ColumnGroups, SegmentOutput, StatEntry list, DeltaLogEntry list) without touching shared state. Phase 2 (fast, retried on transient loon errors): Write assembles one packed.ManifestUpdates from the helper returns and calls a single new primitive packed.CommitManifestUpdates, which opens a short loon transaction, stages every change, and commits once. To make this clean the loon FFI writers are decoupled from manifest concerns entirely: FFIPackedWriter and FFISegmentWriter no longer take a baseVersion at construction. Their Close primitives return data carriers (packed.ColumnGroups, packed.SegmentOutput) that own the C output. packed.CommitManifestUpdates is the only Go-level entry point that mutates a manifest. Empty payload short-circuits to the unchanged manifest path without opening a transaction. The same do-then-commit shape applies to the V2-style binlog import writers (PackedManifestRecordWriter, PackedTextManifestRecordWriter): they assemble inserts plus bloom-filter and BM25 stat blobs into one ManifestUpdates and call CommitManifestUpdates once. Internal renames: the low-level adapters packedRecordManifestWriter and packedTextManifestWriter are renamed to packedRecordBatchWriter and packedTextBatchWriter because they don't touch the manifest themselves — they translate storage.Record into arrow.Record and track per-column-group sizes. The interface manifestRecordWriter is renamed to packedBatchWriter to match. New tests in pack_writer_v3_test.go: TestWrite_SingleVersionBumpAcrossSections drives an insert + delta + flush so all four sections fire and asserts the new manifest version equals baseVer + 1. TestWrite_RetryDoesNotLeakVersionBumps mockey-patches CommitManifestUpdates to fail once with packed.ErrLoonTransient and asserts the eventual successful commit still yields baseVer + 1 rather than baseVer + N_retries. All 11 V3 tests pass. Existing packed and storage tests pass. Out of scope (filed as follow-ups in #49710's discussion): backfill_compactor.go's runBm25Function still issues a separate AddStatsToManifest after the column-groups commit, producing two version bumps per backfill. The architecture supports folding the two but the change isn't strictly needed for the V3 sync atomicity fix. FFIPackedWriter lacks a Destroy method; on loon_writer_close failure the handle and cProperties leak. Pre-existing on master. --------- Signed-off-by: Ted Xu <ted.xu@zilliz.com>
931 lines
34 KiB
Go
931 lines
34 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 compactor
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
sio "io"
|
|
"path"
|
|
"strconv"
|
|
"time"
|
|
|
|
"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/cockroachdb/errors"
|
|
"github.com/samber/lo"
|
|
"go.opentelemetry.io/otel"
|
|
"go.uber.org/zap"
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
"github.com/milvus-io/milvus-proto/go-api/v3/schemapb"
|
|
"github.com/milvus-io/milvus/internal/allocator"
|
|
"github.com/milvus-io/milvus/internal/compaction"
|
|
"github.com/milvus-io/milvus/internal/metastore/kv/binlog"
|
|
"github.com/milvus-io/milvus/internal/storage"
|
|
"github.com/milvus-io/milvus/internal/storagecommon"
|
|
"github.com/milvus-io/milvus/internal/storagev2/packed"
|
|
"github.com/milvus-io/milvus/internal/util/function"
|
|
"github.com/milvus-io/milvus/internal/util/hookutil"
|
|
"github.com/milvus-io/milvus/pkg/v3/common"
|
|
"github.com/milvus-io/milvus/pkg/v3/log"
|
|
"github.com/milvus-io/milvus/pkg/v3/proto/datapb"
|
|
"github.com/milvus-io/milvus/pkg/v3/proto/indexcgopb"
|
|
"github.com/milvus-io/milvus/pkg/v3/proto/indexpb"
|
|
"github.com/milvus-io/milvus/pkg/v3/util/funcutil"
|
|
"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/typeutil"
|
|
)
|
|
|
|
// backfillWriter abstracts the packed writer interface for V2/V3 compatibility.
|
|
// Both packed.PackedWriter (V2) and packed.FFIPackedWriter (V3) implement WriteRecordBatch,
|
|
// but their Close signatures differ, so we use wrappers for both.
|
|
// Manifest() returns the V3 manifest path after Close(); V2 always returns "".
|
|
type backfillWriter interface {
|
|
WriteRecordBatch(arrow.Record) error
|
|
Close() error
|
|
Manifest() string
|
|
}
|
|
|
|
// ffiWriterWrapper wraps packed.FFIPackedWriter to implement backfillWriter.
|
|
// FFIPackedWriter itself knows nothing about manifests; this wrapper closes
|
|
// the writer to collect column groups, then commits them to the existing
|
|
// manifest via packed.CommitManifestUpdates.
|
|
type ffiWriterWrapper struct {
|
|
writer *packed.FFIPackedWriter
|
|
basePath string
|
|
baseVersion int64
|
|
storageConfig *indexpb.StorageConfig
|
|
manifest string
|
|
}
|
|
|
|
func (w *ffiWriterWrapper) WriteRecordBatch(r arrow.Record) error {
|
|
return w.writer.WriteRecordBatch(r)
|
|
}
|
|
|
|
func (w *ffiWriterWrapper) Close() error {
|
|
out, err := w.writer.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
defer out.Destroy()
|
|
newPath, err := packed.CommitManifestUpdates(w.basePath, w.baseVersion, w.storageConfig,
|
|
&packed.ManifestUpdates{NewFiles: out})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
w.manifest = newPath
|
|
return nil
|
|
}
|
|
|
|
func (w *ffiWriterWrapper) Manifest() string {
|
|
return w.manifest
|
|
}
|
|
|
|
// v2WriterWrapper wraps packed.PackedWriter to implement backfillWriter.
|
|
// Manifest() always returns "" for V2 segments (no manifest concept).
|
|
// fileSizes is populated by Close() via CloseAndTell, indexed by column group order.
|
|
type v2WriterWrapper struct {
|
|
writer *packed.PackedWriter
|
|
numGroups int
|
|
fileSizes []int64
|
|
}
|
|
|
|
func (w *v2WriterWrapper) WriteRecordBatch(r arrow.Record) error {
|
|
return w.writer.WriteRecordBatch(r)
|
|
}
|
|
|
|
func (w *v2WriterWrapper) Close() error {
|
|
sizes, err := w.writer.CloseAndTell(w.numGroups)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
w.fileSizes = sizes
|
|
return nil
|
|
}
|
|
|
|
func (w *v2WriterWrapper) Manifest() string {
|
|
return ""
|
|
}
|
|
|
|
type backfillCompactionTask struct {
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
plan *datapb.CompactionPlan
|
|
compactionParams compaction.Params
|
|
done chan struct{}
|
|
logIDAlloc allocator.Interface
|
|
functionRunner function.FunctionRunner
|
|
chunkManager storage.ChunkManager
|
|
}
|
|
|
|
func (t *backfillCompactionTask) Compact() (*datapb.CompactionPlanResult, error) {
|
|
if !funcutil.CheckCtxValid(t.ctx) {
|
|
return nil, t.ctx.Err()
|
|
}
|
|
ctx, span := otel.Tracer(typeutil.DataNodeRole).Start(t.ctx, fmt.Sprintf("BackfillCompact-%d", t.GetPlanID()))
|
|
defer span.End()
|
|
|
|
if err := t.preCompact(); err != nil {
|
|
log.Ctx(ctx).Warn("failed to preCompact", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
defer t.functionRunner.Close()
|
|
|
|
compactStart := time.Now()
|
|
log := log.Ctx(ctx).With(
|
|
zap.Int64("planID", t.GetPlanID()),
|
|
zap.Int64("collectionID", t.GetCollection()),
|
|
)
|
|
|
|
log.Info("backfill compact start",
|
|
zap.Int64("segmentID", t.plan.GetSegmentBinlogs()[0].GetSegmentID()),
|
|
zap.Int32("collectionSchemaVersion", t.plan.GetSchema().GetVersion()),
|
|
zap.Int("numFunctions", len(t.plan.GetFunctions())),
|
|
)
|
|
|
|
result, err := t.runBackfillFunction(ctx, t.functionRunner)
|
|
if err != nil {
|
|
log.Warn("backfill compact failed", zap.Error(err), zap.Duration("compact cost", time.Since(compactStart)))
|
|
return nil, err
|
|
}
|
|
|
|
log.Info("backfill compact done", zap.Duration("compact cost", time.Since(compactStart)))
|
|
return result, nil
|
|
}
|
|
|
|
// preCompact validates the compaction plan, checks context, and sets up the function runner.
|
|
func (t *backfillCompactionTask) preCompact() error {
|
|
if ok := funcutil.CheckCtxValid(t.ctx); !ok {
|
|
return t.ctx.Err()
|
|
}
|
|
|
|
// Check segment binlogs: must have exactly one segment
|
|
if len(t.plan.GetSegmentBinlogs()) != 1 {
|
|
return errors.Newf("backfill compaction plan is illegal, must have exactly one segment, but got %d segments, planID = %d", len(t.plan.GetSegmentBinlogs()), t.GetPlanID())
|
|
}
|
|
|
|
segment := t.plan.GetSegmentBinlogs()[0]
|
|
if segment.GetManifest() == "" && len(segment.GetFieldBinlogs()) == 0 {
|
|
return errors.Newf("compaction plan is illegal, segment's field binlogs are empty, planID = %d, segmentID = %d", t.GetPlanID(), segment.GetSegmentID())
|
|
}
|
|
|
|
backfillFunctions := t.plan.GetFunctions()
|
|
if len(backfillFunctions) != 1 {
|
|
return errors.New("backfill functions should be exactly one")
|
|
}
|
|
backfillFunction := backfillFunctions[0]
|
|
|
|
functionRunner, err := function.NewFunctionRunner(t.plan.GetSchema(), backfillFunction)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if functionRunner == nil {
|
|
return errors.New("failed to set up backfill function runner")
|
|
}
|
|
|
|
// Validate function runner
|
|
if err := t.checkFunctionRunner(functionRunner); err != nil {
|
|
functionRunner.Close()
|
|
return err
|
|
}
|
|
|
|
t.functionRunner = functionRunner
|
|
return nil
|
|
}
|
|
|
|
func (t *backfillCompactionTask) checkFunctionRunner(functionRunner function.FunctionRunner) error {
|
|
switch functionRunner.GetSchema().GetType() {
|
|
case schemapb.FunctionType_BM25:
|
|
functionSchema := functionRunner.GetSchema()
|
|
|
|
// 1. Check inputFieldIDs: must have exactly one, and type must be varchar
|
|
inputFieldIDs := functionSchema.GetInputFieldIds()
|
|
if len(inputFieldIDs) != 1 {
|
|
return errors.New("bm25 function should have exactly one input field")
|
|
}
|
|
inputFieldID := inputFieldIDs[0]
|
|
inputField := typeutil.GetField(t.plan.GetSchema(), inputFieldID)
|
|
if inputField == nil {
|
|
return errors.New("input field not found in schema")
|
|
}
|
|
if inputField.GetDataType() != schemapb.DataType_VarChar && inputField.GetDataType() != schemapb.DataType_Text {
|
|
return errors.New("input field data type must be varchar or text for bm25 function backfill")
|
|
}
|
|
|
|
// 2. Check outputFieldIDs: must have exactly one, and type must be SparseFloatVector
|
|
outputFieldIDs := functionSchema.GetOutputFieldIds()
|
|
if len(outputFieldIDs) != 1 {
|
|
return errors.New("bm25 function should have exactly one output field")
|
|
}
|
|
outputFieldID := outputFieldIDs[0]
|
|
outputField := typeutil.GetField(t.plan.GetSchema(), outputFieldID)
|
|
if outputField == nil {
|
|
return errors.New("output field not found in schema")
|
|
}
|
|
if outputField.GetDataType() != schemapb.DataType_SparseFloatVector {
|
|
return errors.New("output field data type must be sparse float vector for bm25 function backfill")
|
|
}
|
|
|
|
return nil
|
|
default:
|
|
return errors.New("unsupported function type")
|
|
}
|
|
}
|
|
|
|
func (t *backfillCompactionTask) runBackfillFunction(ctx context.Context, functionRunner function.FunctionRunner) (*datapb.CompactionPlanResult, error) {
|
|
switch functionRunner.GetSchema().GetType() {
|
|
case schemapb.FunctionType_BM25:
|
|
return t.runBm25Function(ctx, functionRunner)
|
|
default:
|
|
return nil, errors.New("unsupported function type")
|
|
}
|
|
}
|
|
|
|
func (t *backfillCompactionTask) openBinlogReader(inputFieldID int64) (storage.RecordReader, error) {
|
|
inputField := typeutil.GetField(t.plan.GetSchema(), inputFieldID)
|
|
|
|
segment := t.plan.GetSegmentBinlogs()[0]
|
|
collectionID := segment.GetCollectionID()
|
|
partitionID := segment.GetPartitionID()
|
|
segmentID := segment.GetSegmentID()
|
|
|
|
inputSchema := &schemapb.CollectionSchema{Fields: []*schemapb.FieldSchema{
|
|
inputField,
|
|
}}
|
|
|
|
if segment.GetManifest() != "" {
|
|
return storage.NewManifestRecordReader(t.ctx,
|
|
segment.GetManifest(),
|
|
inputSchema,
|
|
storage.WithCollectionID(collectionID),
|
|
storage.WithVersion(segment.GetStorageVersion()),
|
|
storage.WithDownloader(t.chunkManager.MultiRead),
|
|
storage.WithStorageConfig(t.compactionParams.StorageConfig),
|
|
)
|
|
}
|
|
|
|
if err := binlog.DecompressBinLogWithRootPath(t.compactionParams.StorageConfig.GetRootPath(),
|
|
storage.InsertBinlog, collectionID, partitionID,
|
|
segmentID, segment.GetFieldBinlogs()); err != nil {
|
|
log.Ctx(t.ctx).Warn("Decompress insert binlog error", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
return storage.NewBinlogRecordReader(t.ctx, segment.GetFieldBinlogs(), inputSchema,
|
|
storage.WithCollectionID(collectionID),
|
|
storage.WithVersion(segment.GetStorageVersion()),
|
|
storage.WithDownloader(t.chunkManager.MultiRead),
|
|
storage.WithStorageConfig(t.compactionParams.StorageConfig),
|
|
)
|
|
}
|
|
|
|
func (t *backfillCompactionTask) processBatch(functionRunner function.FunctionRunner, inputStrs []string, outputFieldID int64) (*storage.InsertData, int, error) {
|
|
// run function on this batch
|
|
output, err := functionRunner.BatchRun(inputStrs)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if len(output) != 1 {
|
|
return nil, 0, errors.New("bm25 function backfill should return exactly one output")
|
|
}
|
|
outputSparseArray, ok := output[0].(*schemapb.SparseFloatArray)
|
|
if !ok {
|
|
return nil, 0, errors.New("unexpected output type from BM25 function runner, expected SparseFloatArray")
|
|
}
|
|
|
|
// build output field data
|
|
outputFieldData := &storage.SparseFloatVectorFieldData{
|
|
SparseFloatArray: schemapb.SparseFloatArray{
|
|
Contents: outputSparseArray.GetContents(),
|
|
Dim: outputSparseArray.GetDim(),
|
|
},
|
|
}
|
|
|
|
// build insert data
|
|
insertData := &storage.InsertData{
|
|
Data: make(map[int64]storage.FieldData),
|
|
}
|
|
insertData.Data[outputFieldID] = outputFieldData
|
|
sparseFieldMemorySize := proto.Size(&outputFieldData.SparseFloatArray)
|
|
|
|
return insertData, sparseFieldMemorySize, nil
|
|
}
|
|
|
|
// backfillWriterResult holds the writer and associated metadata needed after writing.
|
|
type backfillWriterResult struct {
|
|
writer backfillWriter
|
|
arrowSchema *arrow.Schema
|
|
outputSchema *schemapb.CollectionSchema
|
|
columnGroups []storagecommon.ColumnGroup
|
|
paths []string // V2 only: log paths for buildMergedLogs
|
|
truePaths []string // V2 only: true paths for log path resolution
|
|
logIDs []int64 // V2 only: log IDs corresponding to each column group path, for LogID in FieldBinlog
|
|
fileSizes []int64 // V2 only: on-disk compressed sizes from CloseAndTell, indexed by column group order
|
|
storageVersion int64
|
|
basePath string // V3 only: parsed from existing manifest in setupWriter
|
|
v3Stats []packed.StatEntry // V3 only: stats to commit atomically with column groups
|
|
}
|
|
|
|
func (t *backfillCompactionTask) setupWriter(outputField *schemapb.FieldSchema, outputFieldID int64, segment *datapb.CompactionSegmentBinlogs, collectionID, partitionID, segmentID int64) (*backfillWriterResult, error) {
|
|
outputSchema := &schemapb.CollectionSchema{Fields: []*schemapb.FieldSchema{
|
|
outputField,
|
|
}}
|
|
arrowSchema, err := storage.ConvertToArrowSchema(outputSchema, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if segment.GetManifest() == "" && len(segment.GetFieldBinlogs()) == 0 {
|
|
return nil, errors.New("segment field binlogs is empty, wrong state for compaction segments")
|
|
}
|
|
newColumnGroups := []storagecommon.ColumnGroup{
|
|
{
|
|
GroupID: outputFieldID,
|
|
Columns: []int{0},
|
|
Fields: []int64{outputFieldID},
|
|
},
|
|
}
|
|
|
|
var pluginContext *indexcgopb.StoragePluginContext
|
|
if hookutil.IsClusterEncryptionEnabled() {
|
|
ez := hookutil.GetEzByCollProperties(t.plan.GetSchema().GetProperties(), collectionID)
|
|
if ez != nil {
|
|
unsafe := hookutil.GetCipher().GetUnsafeKey(ez.EzID, ez.CollectionID)
|
|
if len(unsafe) > 0 {
|
|
pluginContext = &indexcgopb.StoragePluginContext{
|
|
EncryptionZoneId: ez.EzID,
|
|
CollectionId: ez.CollectionID,
|
|
EncryptionKey: string(unsafe),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Determine effective storage version: V3 only if segment already has a manifest.
|
|
// V2 segments on V3 clusters must stay V2 — a partial manifest (only new columns)
|
|
// would cause segcore to ignore original binlog data, corrupting the segment.
|
|
storageVersion := t.compactionParams.StorageVersion
|
|
existingManifest := segment.GetManifest()
|
|
if storageVersion == storage.StorageV3 && existingManifest == "" {
|
|
storageVersion = storage.StorageV2 // force V2 path for V2 segment on V3 cluster
|
|
}
|
|
|
|
result := &backfillWriterResult{
|
|
arrowSchema: arrowSchema,
|
|
outputSchema: outputSchema,
|
|
columnGroups: newColumnGroups,
|
|
storageVersion: storageVersion,
|
|
}
|
|
|
|
if storageVersion == storage.StorageV3 {
|
|
// V3: extend existing manifest by appending new column groups.
|
|
// Parse basePath and version from the segment's current manifest,
|
|
// so the transaction reads the existing manifest and merges new columns in.
|
|
basePath, existingVersion, err := packed.UnmarshalManifestPath(existingManifest)
|
|
if err != nil {
|
|
return nil, merr.WrapErrServiceInternal("failed to parse existing manifest for V3 backfill", err.Error())
|
|
}
|
|
ffiWriter, err := packed.NewFFIPackedWriter(basePath, arrowSchema, newColumnGroups, t.compactionParams.StorageConfig, pluginContext)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// The output field is a new addition to the existing manifest (backfill).
|
|
// Use AddColumnGroup semantics so Loon does not require the count to match
|
|
// the existing groups in the manifest.
|
|
ffiWriter.AsNewColumnGroups()
|
|
result.writer = &ffiWriterWrapper{
|
|
writer: ffiWriter,
|
|
basePath: basePath,
|
|
baseVersion: existingVersion,
|
|
storageConfig: t.compactionParams.StorageConfig,
|
|
}
|
|
result.basePath = basePath
|
|
} else {
|
|
// V2: use PackedWriter with explicit file paths.
|
|
// TODO(backfill): no file-size rotation — all rows land in one Parquet file per
|
|
// column group regardless of segment size. Fix by splitting at BinLogMaxSize (64MB)
|
|
// and allocating a new LogID+path per chunk, like MultiSegmentWriter.rotateWriter.
|
|
logIdStart, _, err := t.logIDAlloc.Alloc(uint32(len(newColumnGroups)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
paths := []string{}
|
|
logIDs := []int64{}
|
|
for _, columnGroup := range newColumnGroups {
|
|
p := metautil.BuildInsertLogPath(t.compactionParams.StorageConfig.GetRootPath(), collectionID, partitionID, segmentID, columnGroup.GroupID, logIdStart)
|
|
paths = append(paths, p)
|
|
logIDs = append(logIDs, logIdStart)
|
|
logIdStart++
|
|
}
|
|
truePaths := lo.Map(paths, func(p string, _ int) string {
|
|
if t.compactionParams.StorageConfig.GetStorageType() == "local" {
|
|
return p
|
|
}
|
|
return path.Join(t.compactionParams.StorageConfig.GetBucketName(), p)
|
|
})
|
|
writer, err := packed.NewPackedWriter(truePaths, arrowSchema, packed.DefaultWriteBufferSize, packed.DefaultMultiPartUploadSize, newColumnGroups, t.compactionParams.StorageConfig, pluginContext)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.writer = &v2WriterWrapper{writer: writer, numGroups: len(newColumnGroups)}
|
|
result.paths = paths
|
|
result.truePaths = truePaths
|
|
result.logIDs = logIDs
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (t *backfillCompactionTask) writeBatch(writer backfillWriter, arrowSchema *arrow.Schema, insertData *storage.InsertData, outputSchema *schemapb.CollectionSchema) error {
|
|
builder := array.NewRecordBuilder(memory.DefaultAllocator, arrowSchema)
|
|
defer builder.Release()
|
|
err := storage.BuildRecord(builder, insertData, outputSchema)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
arrowRecord := builder.NewRecord()
|
|
defer arrowRecord.Release()
|
|
return writer.WriteRecordBatch(arrowRecord)
|
|
}
|
|
|
|
func (t *backfillCompactionTask) updateStats(stats *storage.BM25Stats, collectionID, partitionID, segmentID, outputFieldID int64, writerResult *backfillWriterResult) ([]byte, string, int64, error) {
|
|
cli := t.chunkManager
|
|
statsID, _, err := t.logIDAlloc.Alloc(uint32(1))
|
|
if err != nil {
|
|
return nil, "", 0, err
|
|
}
|
|
|
|
bytes, err := stats.Serialize()
|
|
if err != nil {
|
|
return nil, "", 0, err
|
|
}
|
|
|
|
if writerResult.storageVersion == storage.StorageV3 {
|
|
// V3: write stats file under manifest basePath/_stats/ directory.
|
|
// Stats will be registered in manifest via AddStatsToManifest after Close().
|
|
// basePath was already parsed from the manifest in setupWriter — reuse it here.
|
|
basePath := writerResult.basePath
|
|
statsRelPath := fmt.Sprintf("_stats/bm25.%d/%d", outputFieldID, statsID)
|
|
absStatsPath := path.Join(basePath, statsRelPath)
|
|
if err := cli.Write(t.ctx, absStatsPath, bytes); err != nil {
|
|
return nil, "", 0, merr.WrapErrServiceInternal("failed to write V3 BM25 stats", err.Error())
|
|
}
|
|
writerResult.v3Stats = append(writerResult.v3Stats, packed.StatEntry{
|
|
Key: fmt.Sprintf("bm25.%d", outputFieldID),
|
|
Files: []string{absStatsPath}, // C++ converts absolute to relative at commit
|
|
Metadata: map[string]string{
|
|
"memory_size": strconv.FormatInt(int64(len(bytes)), 10),
|
|
},
|
|
})
|
|
return bytes, "", 0, nil // no separate bm25LogPathForResult for V3; statsID unused
|
|
}
|
|
|
|
// V2: write stats file to bm25_stats path, return path and ID for result metadata
|
|
statsPath := metautil.JoinIDPath(collectionID, partitionID, segmentID, outputFieldID, statsID)
|
|
bm25LogPath := path.Join(cli.RootPath(), common.SegmentBm25LogPath, statsPath)
|
|
if err := cli.Write(t.ctx, bm25LogPath, bytes); err != nil {
|
|
return nil, "", 0, err
|
|
}
|
|
|
|
bm25LogPathForResult := metautil.BuildBm25LogPath(
|
|
t.compactionParams.StorageConfig.GetRootPath(),
|
|
collectionID, partitionID, segmentID, outputFieldID, statsID)
|
|
if t.compactionParams.StorageConfig.GetStorageType() != "local" {
|
|
bm25LogPathForResult = path.Join(t.compactionParams.StorageConfig.GetBucketName(), bm25LogPathForResult)
|
|
}
|
|
|
|
return bytes, bm25LogPathForResult, statsID, nil
|
|
}
|
|
|
|
func (t *backfillCompactionTask) buildMergedLogsV2(segment *datapb.CompactionSegmentBinlogs, writerResult *backfillWriterResult, sparseFieldMemorySize int, totalRows int64, bytes []byte, bm25LogPathForResult string, bm25LogID int64, outputFieldID int64) ([]*datapb.FieldBinlog, []*datapb.FieldBinlog, error) {
|
|
newInsertLogs := make(map[int64]*datapb.FieldBinlog)
|
|
for i, columnGroup := range writerResult.columnGroups {
|
|
fileSize := writerResult.fileSizes[i]
|
|
logPath := writerResult.paths[i]
|
|
truePath := writerResult.truePaths[i]
|
|
if t.compactionParams.StorageConfig.GetStorageType() != "local" {
|
|
logPath = truePath
|
|
}
|
|
fieldBinlog := &datapb.FieldBinlog{
|
|
FieldID: columnGroup.GroupID,
|
|
ChildFields: columnGroup.Fields,
|
|
Binlogs: []*datapb.Binlog{
|
|
{
|
|
LogSize: fileSize,
|
|
MemorySize: int64(sparseFieldMemorySize),
|
|
LogPath: logPath,
|
|
LogID: writerResult.logIDs[i],
|
|
EntriesNum: totalRows,
|
|
},
|
|
},
|
|
}
|
|
newInsertLogs[columnGroup.GroupID] = fieldBinlog
|
|
}
|
|
|
|
return t.finalizeMergedLogs(segment, newInsertLogs, totalRows, bytes, bm25LogPathForResult, bm25LogID, outputFieldID)
|
|
}
|
|
|
|
func (t *backfillCompactionTask) buildMergedLogsV3(segment *datapb.CompactionSegmentBinlogs, writerResult *backfillWriterResult, sparseFieldMemorySize int, totalRows int64, bytes []byte, bm25LogPathForResult string, bm25LogID int64, outputFieldID int64) ([]*datapb.FieldBinlog, []*datapb.FieldBinlog, error) {
|
|
// V3: data is tracked by the manifest. Binlog entries in etcd serve as field-presence
|
|
// markers used by getSegmentBinlogFields (backfill detection). Each entry must have
|
|
// LogID!=0 and LogPath=="" to pass buildBinlogKvs validation — allocate a real ID from
|
|
// logIDAlloc (same allocator used for V2 LogIDs) so the entry is properly persisted.
|
|
logIDStart, _, err := t.logIDAlloc.Alloc(uint32(len(writerResult.columnGroups)))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
newInsertLogs := make(map[int64]*datapb.FieldBinlog)
|
|
for i, columnGroup := range writerResult.columnGroups {
|
|
fieldBinlog := &datapb.FieldBinlog{
|
|
FieldID: columnGroup.GroupID,
|
|
ChildFields: columnGroup.Fields,
|
|
Binlogs: []*datapb.Binlog{
|
|
{
|
|
MemorySize: int64(sparseFieldMemorySize),
|
|
EntriesNum: totalRows,
|
|
LogID: logIDStart + int64(i),
|
|
},
|
|
},
|
|
}
|
|
newInsertLogs[columnGroup.GroupID] = fieldBinlog
|
|
}
|
|
|
|
return t.finalizeMergedLogs(segment, newInsertLogs, totalRows, bytes, bm25LogPathForResult, bm25LogID, outputFieldID)
|
|
}
|
|
|
|
func (t *backfillCompactionTask) finalizeMergedLogs(segment *datapb.CompactionSegmentBinlogs, newInsertLogs map[int64]*datapb.FieldBinlog, totalRows int64, bytes []byte, bm25LogPathForResult string, bm25LogID int64, outputFieldID int64) ([]*datapb.FieldBinlog, []*datapb.FieldBinlog, error) {
|
|
// build new Bm25Logs
|
|
bm25FileSize := int64(len(bytes))
|
|
newBm25Logs := []*datapb.FieldBinlog{
|
|
{
|
|
FieldID: outputFieldID,
|
|
Binlogs: []*datapb.Binlog{
|
|
{
|
|
LogSize: bm25FileSize,
|
|
MemorySize: bm25FileSize,
|
|
LogPath: bm25LogPathForResult,
|
|
LogID: bm25LogID,
|
|
EntriesNum: totalRows,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Crash-replay guard: if DC crashed after AlterSegments but before the task-state
|
|
// transition, the output field is already in etcd — skip it to stay idempotent.
|
|
originalInsertLogs := segment.GetFieldBinlogs()
|
|
existingFields := make(map[int64]struct{}, len(originalInsertLogs)*2)
|
|
for _, fb := range originalInsertLogs {
|
|
existingFields[fb.GetFieldID()] = struct{}{}
|
|
for _, childID := range fb.GetChildFields() {
|
|
existingFields[childID] = struct{}{}
|
|
}
|
|
}
|
|
newInsertLogsList := storage.SortFieldBinlogs(newInsertLogs)
|
|
dedupedNew := newInsertLogsList[:0]
|
|
for _, fb := range newInsertLogsList {
|
|
if _, ok := existingFields[fb.GetFieldID()]; ok {
|
|
log.Warn("backfill crash-replay: output field already in segment binlogs, skipping duplicate",
|
|
zap.Int64("fieldID", fb.GetFieldID()),
|
|
zap.Int64("segmentID", segment.GetSegmentID()))
|
|
continue
|
|
}
|
|
childDup := false
|
|
for _, childID := range fb.GetChildFields() {
|
|
if _, ok := existingFields[childID]; ok {
|
|
childDup = true
|
|
break
|
|
}
|
|
}
|
|
if childDup {
|
|
log.Warn("backfill crash-replay: output field already in segment binlogs, skipping duplicate",
|
|
zap.Int64("fieldID", fb.GetFieldID()),
|
|
zap.Int64("segmentID", segment.GetSegmentID()))
|
|
continue
|
|
}
|
|
dedupedNew = append(dedupedNew, fb)
|
|
}
|
|
mergedInsertLogs := make([]*datapb.FieldBinlog, 0, len(originalInsertLogs)+len(dedupedNew))
|
|
mergedInsertLogs = append(mergedInsertLogs, originalInsertLogs...)
|
|
mergedInsertLogs = append(mergedInsertLogs, dedupedNew...)
|
|
|
|
return mergedInsertLogs, newBm25Logs, nil
|
|
}
|
|
|
|
func (t *backfillCompactionTask) runBm25Function(ctx context.Context, functionRunner function.FunctionRunner) (*datapb.CompactionPlanResult, error) {
|
|
// 1. set up function schema — duplicate checks from checkFunctionRunner so this path cannot panic
|
|
// if schema/runner state diverges or call sites change.
|
|
functionSchema := functionRunner.GetSchema()
|
|
inputFieldIDs := functionSchema.GetInputFieldIds()
|
|
if len(inputFieldIDs) != 1 {
|
|
return nil, errors.Newf("bm25 backfill: expected exactly one input field, got %d (planID=%d)", len(inputFieldIDs), t.plan.GetPlanID())
|
|
}
|
|
inputFieldID := inputFieldIDs[0]
|
|
outputFieldIDs := functionSchema.GetOutputFieldIds()
|
|
if len(outputFieldIDs) != 1 {
|
|
return nil, errors.Newf("bm25 backfill: expected exactly one output field, got %d (planID=%d)", len(outputFieldIDs), t.plan.GetPlanID())
|
|
}
|
|
outputFieldID := outputFieldIDs[0]
|
|
outputField := typeutil.GetField(t.plan.GetSchema(), outputFieldID)
|
|
|
|
// 2. get segment info
|
|
segment := t.plan.GetSegmentBinlogs()[0]
|
|
collectionID := segment.GetCollectionID()
|
|
partitionID := segment.GetPartitionID()
|
|
segmentID := segment.GetSegmentID()
|
|
|
|
log := log.Ctx(ctx).With(
|
|
zap.Int64("planID", t.plan.GetPlanID()),
|
|
zap.Int64("collectionID", collectionID),
|
|
zap.Int64("partitionID", partitionID),
|
|
zap.Int64("segmentID", segmentID),
|
|
zap.Int64("inputFieldID", inputFieldID),
|
|
zap.Int64("outputFieldID", outputFieldID),
|
|
)
|
|
|
|
// Track durations for each heavy operation
|
|
var readDuration, computeDuration, writeDuration, updateStatsDuration time.Duration
|
|
|
|
// 3. open binlog reader
|
|
reader, err := t.openBinlogReader(inputFieldID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer reader.Close()
|
|
|
|
// 4. set up writer
|
|
writerResult, err := t.setupWriter(outputField, outputFieldID, segment, collectionID, partitionID, segmentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Log effective storage version (may differ from cluster version for V2-origin segments).
|
|
log.Info("backfill writer setup",
|
|
zap.Int64("effectiveStorageVersion", writerResult.storageVersion),
|
|
zap.Int64("clusterStorageVersion", t.compactionParams.StorageVersion),
|
|
zap.Bool("segmentHasManifest", segment.GetManifest() != ""),
|
|
zap.Int("inputFieldBinlogCount", len(segment.GetFieldBinlogs())),
|
|
zap.Strings("v2WritePaths", writerResult.paths),
|
|
)
|
|
writerClosed := false
|
|
defer func() {
|
|
if !writerClosed {
|
|
writerResult.writer.Close()
|
|
}
|
|
}()
|
|
|
|
// 5. batch loop: read → compute → write → accumulate stats
|
|
_, span := otel.Tracer(typeutil.DataNodeRole).Start(ctx, "BackfillCompact.batchProcess")
|
|
stats := storage.NewBM25Stats()
|
|
var totalRows int64
|
|
var totalSparseMemorySize int
|
|
|
|
for {
|
|
// read one batch
|
|
readStart := time.Now()
|
|
record, err := reader.Next()
|
|
if err != nil {
|
|
if err == sio.EOF {
|
|
readDuration += time.Since(readStart)
|
|
break
|
|
}
|
|
span.End()
|
|
return nil, err
|
|
}
|
|
readDuration += time.Since(readStart)
|
|
|
|
// extract input strings from this batch
|
|
col := record.Column(inputFieldID)
|
|
if col == nil {
|
|
record.Release()
|
|
span.End()
|
|
return nil, merr.WrapErrServiceInternal(fmt.Sprintf("input field %d not found in record", inputFieldID))
|
|
}
|
|
recordStr, ok := col.(*array.String)
|
|
if !ok {
|
|
record.Release()
|
|
span.End()
|
|
return nil, merr.WrapErrServiceInternal(fmt.Sprintf("input field %d data type must be varchar or text for bm25 function backfill, got %T", inputFieldID, col))
|
|
}
|
|
batchStrs := make([]string, record.Len())
|
|
for i := 0; i < record.Len(); i++ {
|
|
batchStrs[i] = recordStr.Value(i)
|
|
}
|
|
record.Release()
|
|
|
|
// compute BM25 for this batch
|
|
computeStart := time.Now()
|
|
insertData, batchMemSize, err := t.processBatch(functionRunner, batchStrs, outputFieldID)
|
|
computeDuration += time.Since(computeStart)
|
|
if err != nil {
|
|
span.End()
|
|
return nil, err
|
|
}
|
|
|
|
// accumulate stats from this batch
|
|
outputFieldData := insertData.Data[outputFieldID].(*storage.SparseFloatVectorFieldData)
|
|
stats.AppendBytes(outputFieldData.GetContents()...)
|
|
|
|
// write this batch
|
|
writeStart := time.Now()
|
|
if err := t.writeBatch(writerResult.writer, writerResult.arrowSchema, insertData, writerResult.outputSchema); err != nil {
|
|
span.End()
|
|
return nil, err
|
|
}
|
|
writeDuration += time.Since(writeStart)
|
|
|
|
totalRows += int64(len(batchStrs))
|
|
totalSparseMemorySize += batchMemSize
|
|
}
|
|
span.End()
|
|
|
|
// 6. write stats file (must happen before Close for V3 — stats are committed atomically)
|
|
_, span2 := otel.Tracer(typeutil.DataNodeRole).Start(ctx, "BackfillCompact.updateStats")
|
|
startTime := time.Now()
|
|
bytes, bm25LogPathForResult, bm25LogID, err := t.updateStats(stats, collectionID, partitionID, segmentID, outputFieldID, writerResult)
|
|
updateStatsDuration = time.Since(startTime)
|
|
span2.End()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
log.Info("backfill bm25 stats written",
|
|
zap.String("bm25LogPath", bm25LogPathForResult),
|
|
zap.Int64("bm25LogID", bm25LogID),
|
|
zap.Int("bm25StatsBytes", len(bytes)),
|
|
zap.Int64("storageVersion", writerResult.storageVersion),
|
|
zap.Int("v3StatsCount", len(writerResult.v3Stats)),
|
|
)
|
|
|
|
// Close writer — for V3, this commits new column groups to the existing manifest
|
|
// (version N → N+1). BM25 stats are added separately via AddStatsToManifest.
|
|
if err := writerResult.writer.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
writerClosed = true
|
|
// For V2: capture on-disk compressed file sizes written by CloseAndTell.
|
|
if v2w, ok := writerResult.writer.(*v2WriterWrapper); ok {
|
|
writerResult.fileSizes = v2w.fileSizes
|
|
log.Info("backfill V2 insert binlogs written",
|
|
zap.Strings("logPaths", writerResult.paths),
|
|
zap.Int64s("logIDs", writerResult.logIDs),
|
|
zap.Int64s("fileSizeBytes", v2w.fileSizes),
|
|
zap.Int64("totalRows", totalRows),
|
|
)
|
|
}
|
|
// Read the manifest produced by Close(); "" for V2, real path for V3.
|
|
manifestPath := writerResult.writer.Manifest()
|
|
if manifestPath != "" {
|
|
log.Info("backfill V3 writer closed, manifest produced", zap.String("manifestPath", manifestPath))
|
|
}
|
|
|
|
// For V3: register BM25 stats in manifest (version N+1 → N+2).
|
|
// Channel-level scheduler exclusion guarantees no concurrent manifest modification.
|
|
if writerResult.storageVersion == storage.StorageV3 && len(writerResult.v3Stats) > 0 {
|
|
newManifest, err := packed.AddStatsToManifest(
|
|
manifestPath, t.compactionParams.StorageConfig, writerResult.v3Stats)
|
|
if err != nil {
|
|
return nil, merr.WrapErrServiceInternal("failed to add BM25 stats to V3 manifest", err.Error())
|
|
}
|
|
log.Info("backfill V3 bm25 stats added to manifest",
|
|
zap.String("oldManifest", manifestPath),
|
|
zap.String("newManifest", newManifest),
|
|
)
|
|
manifestPath = newManifest
|
|
}
|
|
|
|
// 7. build merged logs
|
|
var mergedInsertLogs, mergedBm25Logs []*datapb.FieldBinlog
|
|
if writerResult.storageVersion == storage.StorageV3 {
|
|
mergedInsertLogs, mergedBm25Logs, err = t.buildMergedLogsV3(segment, writerResult, totalSparseMemorySize, totalRows, bytes, bm25LogPathForResult, bm25LogID, outputFieldID)
|
|
} else {
|
|
mergedInsertLogs, mergedBm25Logs, err = t.buildMergedLogsV2(segment, writerResult, totalSparseMemorySize, totalRows, bytes, bm25LogPathForResult, bm25LogID, outputFieldID)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 8. manifest path is already set from Close() and optionally updated by AddStatsToManifest above.
|
|
|
|
// 9. return compaction result
|
|
// For V3: BM25 stats are embedded in the manifest (committed atomically with
|
|
// column groups), so Bm25Logs should be nil — PackSegmentLoadInfo skips
|
|
// Bm25Logs when ManifestPath is set, relying on StatsResolver to read from manifest.
|
|
resultBm25Logs := mergedBm25Logs
|
|
if writerResult.storageVersion == storage.StorageV3 {
|
|
resultBm25Logs = nil
|
|
}
|
|
|
|
ret := &datapb.CompactionPlanResult{
|
|
PlanID: t.plan.GetPlanID(),
|
|
State: datapb.CompactionTaskState_completed,
|
|
Segments: []*datapb.CompactionSegment{
|
|
{
|
|
SegmentID: segmentID,
|
|
NumOfRows: totalRows,
|
|
InsertLogs: mergedInsertLogs,
|
|
Bm25Logs: resultBm25Logs,
|
|
Field2StatslogPaths: segment.GetField2StatslogPaths(),
|
|
Deltalogs: segment.GetDeltalogs(),
|
|
Channel: segment.GetInsertChannel(),
|
|
StorageVersion: writerResult.storageVersion,
|
|
Manifest: manifestPath,
|
|
},
|
|
},
|
|
Type: t.plan.GetType(),
|
|
}
|
|
log.Info("backfill compaction completed",
|
|
zap.Int64("numOfRows", totalRows),
|
|
zap.Int("mergedInsertLogsCount", len(mergedInsertLogs)),
|
|
zap.Int("mergedBm25LogsCount", len(mergedBm25Logs)),
|
|
zap.Int("resultBm25LogsCount", len(resultBm25Logs)),
|
|
zap.String("manifestPath", manifestPath),
|
|
zap.Int64("effectiveStorageVersion", writerResult.storageVersion),
|
|
zap.Int32("collectionSchemaVersion", t.plan.GetSchema().GetVersion()),
|
|
zap.Duration("readDuration", readDuration),
|
|
zap.Duration("computeDuration", computeDuration),
|
|
zap.Duration("writeDuration", writeDuration),
|
|
zap.Duration("updateStatsDuration", updateStatsDuration),
|
|
)
|
|
return ret, nil
|
|
}
|
|
|
|
func (t *backfillCompactionTask) Complete() {
|
|
if t.done != nil {
|
|
select {
|
|
case t.done <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (t *backfillCompactionTask) Stop() {
|
|
if t.cancel != nil {
|
|
t.cancel()
|
|
}
|
|
if t.done != nil {
|
|
<-t.done
|
|
}
|
|
}
|
|
|
|
func (t *backfillCompactionTask) GetPlanID() typeutil.UniqueID {
|
|
return t.plan.GetPlanID()
|
|
}
|
|
|
|
func (t *backfillCompactionTask) GetCollection() typeutil.UniqueID {
|
|
// Get collection ID from the first segment binlog
|
|
if len(t.plan.GetSegmentBinlogs()) > 0 {
|
|
return t.plan.GetSegmentBinlogs()[0].GetCollectionID()
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (t *backfillCompactionTask) GetChannelName() string {
|
|
return t.plan.GetChannel()
|
|
}
|
|
|
|
func (t *backfillCompactionTask) GetCompactionType() datapb.CompactionType {
|
|
return t.plan.GetType()
|
|
}
|
|
|
|
func (t *backfillCompactionTask) GetSlotUsage() int64 {
|
|
return t.plan.GetSlotUsage()
|
|
}
|
|
|
|
func (t *backfillCompactionTask) GetStorageConfig() *indexpb.StorageConfig {
|
|
return t.compactionParams.StorageConfig
|
|
}
|
|
|
|
var _ Compactor = (*backfillCompactionTask)(nil)
|
|
|
|
func NewBackfillCompactionTask(ctx context.Context, cm storage.ChunkManager, plan *datapb.CompactionPlan, compactionParams compaction.Params) *backfillCompactionTask {
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
return &backfillCompactionTask{
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
plan: plan,
|
|
compactionParams: compactionParams,
|
|
done: make(chan struct{}, 1),
|
|
logIDAlloc: allocator.NewLocalAllocator(plan.GetPreAllocatedLogIDs().GetBegin(), plan.GetPreAllocatedLogIDs().GetEnd()),
|
|
chunkManager: cm,
|
|
}
|
|
}
|