Files
milvus/internal/datacoord/task_refresh_external_collection.go
T
e2787d3981 enhance: standardize error handling on merr + Sys/Input classification (#50221)
issue: #47420

## What this PR does

Project-wide migration of raw `fmt.Errorf` / `errors.New` in function
bodies onto
the `merr` framework, plus the Sys-vs-Input error classification and the
machinery it drives (retriability, fine-grained metrics, segcore
unification),
plus the convention docs and a linter that keeps it from regressing.

Scope: storage, proxy, coordinators (root/data/query), query node, data
node,
`pkg/util` & `internal/util`, expression parser, message queue,
streaming, and
misc packages. Bare raw-error usages went from ~3000 to a ~340 allowlist
(package-level sentinels / build-tag / test sites).

---

## How to review this PR

It is large but the vast majority is mechanical. Changes fall into three
tiers;
spend review budget on Part 2 and Part 3.

### Part 1 — Mechanical standardization (low risk, verify by rule)

Each converted call follows one of a small fixed set of rules. To
review, check
that each site obeys the matching rule rather than reading every line:

| Pattern | Rule |
|---|---|
| `fmt.Errorf("...")` originating a new error | →
`merr.WrapErrXxxMsg("...")` with a code matching the failure's meaning |
| Adding context to an existing typed error | → `merr.Wrap(err, "...")`
/ `merr.Wrapf(...)` — **preserves** the inner code (never `WrapErr*Err`,
which overwrites it) |
| Errors inside the streaming subsystem | → `status.New*` factories
(StreamingError), **not** merr — this is the component-internal dialect
(see `docs/dev/error_handling_guide.md`) |
| Low-level / control-flow signal caught by `errors.Is` | → kept as a
package-level `errors.New` sentinel (lowercase, same-package) |

Conventions are documented in `docs/dev/error_handling_guide.md`
(how-to) and
`docs/dev/error_sentinel_convention.md` (rules + audit). A
`gocritic`/`ruleguard`
rule (`rawmerrerror`, in `rules.go`) enforces "no raw `return
errors.New/fmt.Errorf`"
under `make verifiers`.

### Part 2 — Behavior changes (review these closely)

These are the sites where the wire contract or runtime behavior changes,
not just
the source text. Listed by category; representative locations given,
full set in
the diff.

**A. gRPC wire-code shifts: `UnexpectedError(1)/Code 65535` → typed
code.**
Where a handler previously returned a raw error (collapsed to
`Code=65535` on the
wire), it now returns a typed merr, so the client sees a real code. The
most
common shift is to `IllegalArgument(5)/Code 1100` (ParameterInvalid).
Touch
points include datanode task handlers (CreateTask/Query/Drop), proxy
Upsert,
querynode GetMetrics, datacoord CreateIndex, httpserver query-response
builder,
and typeutil schema validation. One code refinement: an index-param
validation
moved `1100` → `1101` (ParameterMissing). **Client/SDK assertions and
any code
that switched on `Code=65535` for these paths must be re-checked** (the
go_client
e2e assertions were already aligned in this PR).

**B. Prometheus `status` label contract change (externally visible).**
The proxy metric's coarse `fail` / `rejected` values are split into
`fail_input` / `fail_system` and `rejected_user` / `rejected_system` (in
`requestutil.ParseMetricLabel`; auth/privilege rejections count as
`rejected_user`), so dashboards can attribute a failure to caller vs
operator.
**Dashboards/alerts querying `status="fail"` must migrate to
`status=~"fail_.*"`, and `status="rejected"` to
`status=~"rejected_.*"`.** The
in-repo Grafana dashboard is already migrated; external dashboards built
on the
old values silently go empty after upgrade. This is the one change that
requires an ops-side migration.

**C. Retriability semantics.**
- C1: `merr.Status(err)` now forces `Retriable=false` when the error is
an
`InputError` — a malformed request can never succeed on blind retry, so
clients
never get the self-contradictory "your input is wrong but you may
retry".
- C2: `retry.Do` short-circuits an `InputError` (non-retriable) — **but
only when
  the caller did not pass a `RetryErr` predicate**. The check is an
`if c.isRetryErr != nil { ... } else if InputError { ... }` *mutually
exclusive*
branch (`pkg/util/retry/retry.go`): an explicit `RetryErr` takes
precedence and
bypasses the InputError abort. `retry.Handle` deliberately does **not**
apply
the InputError abort (its callers signal abort via `shouldRetry=false`).
Four
flusher startup callsites that must retry through transient "not ready"
errors
  were given explicit `RetryErr` escape hatches.

**D. segcore (C++→Go) error classification.**
A single shared Go-side table (`pkg/util/merr/segcore.go`) maps each
segcore code
to a merr sentinel + InputError/signal category, replacing scattered
hand-written
`if errorCode == ...` switches in the cgo wrappers. **Wire `Code` values
change
for every segcore pass-through error, not just the remapped ones.**
Named
sentinels remap (C++ `2003` → merr `2001`, `2033` → `2002`,
Folly/Knowhere codes
likewise); **all remaining pass-through codes (`2004`–`2043`, previously
surfaced to clients as raw C++ enum values) now serialize as `2000`**
(`ErrSegcore`), with the original C++ code preserved in the `Reason`
text
(`segcoreCode=...`); unknown/future codes collapse to `2000` as well
(pinned by
the `wire_code_projection` test). Transient segcore classes (object
storage /
file IO / OOM / mmap / FieldNotLoaded — 11 codes) now report
`Retriable=true`.
**Any client switching on raw segcore codes in the `2004`–`2043` range
must be
re-checked**; the in-Reason code remains available for diagnostics.
Signal
codes (PretendFinished / FollyCancel) are recognized centrally.
`errors.Is`-based
control flow on these (e.g. scheduler skip/retry) is preserved.

**E. InputError classification (25 sentinels + dynamic marks).**
25 sentinels in `errors.go` carry `WithErrorType(InputError)` (the
Collection /
ResourceGroup / Database families, `ErrIndexDuplicate`,
`ErrParameterInvalid`,
`ErrPrivilegeNotAuthenticated`, `ErrImportFailed`, `ErrQueryPlan`, ...),
plus dynamic
marks for the 8 segcore input codes (ExprInvalid, DimNotMatch,
MetricTypeInvalid, FieldIDInvalid, ...) and
`WrapErrAsInputError`. The widest blast radius is `ErrParameterInvalid`
(1100):
~2335 `WrapErrParameterInvalid*` callsites now classify as input /
non-retriable. Because of C1/C2 this changes retriability for
any path that returns these. **The audit to confirm no transient path
was
mis-marked is the single most important review item** (see Part 3). One
reverse
correction: storage field-stats parsing moved from `ErrParameterInvalid`
(input)
to `ErrDataIntegrity` — a corrupted stored stat is data corruption, not
user
input.

### Part 3 — Known risks & traps (called out proactively)

1. **`merr.Wrap` vs `WrapErr*Err` (code-masking).** `WrapErr*Err` builds
a
`wrappedMilvusError{sentinel: ErrServiceInternal}` whose `code()`
returns the
*outer* sentinel — it overwrites the inner typed code and hides the
`errors.Is`
chain. This is intentional (use it to *deliberately* downgrade), but it
was a
recurring conversion defect; the rule "add context with `merr.Wrap`,
downgrade
with `WrapErr*Err`" is enforced by convention and reviewed across the
diff.
2. **InputError × `retry.Do` blast radius.** Marking a sentinel
`InputError` makes
any `retry.Do(...)` without a `RetryErr` predicate stop retrying it.
Reviewers
should sanity-check that no transient use of the 19 newly-marked
sentinels
(especially `ErrParameterInvalid`) sits inside a retry loop that needed
to keep
   spinning. The known flusher cases were handled (see C2).
3. **The ~340 raw-error allowlist.** What remains as bare `errors.New`
is, by
design: package-level sentinels (caught by `errors.Is`), `//go:build
test`
sites, and out-of-band trees (`cmd/`, `tests/`, codegen, walimpls). The
linter
only bans the *direct-return* form; assignment-then-return escapes and
the full
no-exceptions ban are deferred to an AST-based linter (Tier 2,
documented).
4. **segcore C++ second step deferred.** This PR unifies classification
on the Go
side; splitting the dual-semantic C++ codes at the source is a
follow-up.

---

## Validation

- `make verifiers`: Go side clean (gofmt + static-check across modules,
including
  the new `rawmerrerror` rule with a 0-hit baseline repo-wide).
- `make test-go`: passing; the one real regression introduced (a
datanode
`invalid_task_type` assertion shifting `1` → `5` from a ParameterInvalid
  conversion) was fixed in-tree.
- go_client e2e CreateIndex assertions aligned to the new merr messages.

---------

Signed-off-by: zhenshan.cao <zhenshan.cao@zilliz.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 15:04:51 -07:00

837 lines
30 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 datacoord
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
"github.com/milvus-io/milvus-proto/go-api/v3/commonpb"
"github.com/milvus-io/milvus/internal/datacoord/allocator"
"github.com/milvus-io/milvus/internal/datacoord/session"
globalTask "github.com/milvus-io/milvus/internal/datacoord/task"
"github.com/milvus-io/milvus/internal/metastore"
"github.com/milvus-io/milvus/internal/util/segmentutil"
"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/indexpb"
"github.com/milvus-io/milvus/pkg/v3/taskcommon"
"github.com/milvus-io/milvus/pkg/v3/util/merr"
"github.com/milvus-io/milvus/pkg/v3/util/paramtable"
)
// refreshExternalCollectionTask wraps ExternalCollectionRefreshTask for scheduling.
// This is used by the global task scheduler to dispatch refresh tasks to DataNodes.
type refreshExternalCollectionTask struct {
*datapb.ExternalCollectionRefreshTask
times *taskcommon.Times
refreshMeta *externalCollectionRefreshMeta
mt *meta
allocator allocator.Allocator
// processFinishedJob is the per-job entry point on the refresh checker.
// The task calls it synchronously after transitioning to a terminal state
// so the finished-callback (schema update + WAL broadcast) fires before
// the task method returns and progress polls observe a consistent state.
// The checker still runs the same logic on its periodic tick as a safety
// net for missed events. Set by the manager during task wrapping; nil in
// unit tests.
processFinishedJob func(jobID int64)
}
var _ globalTask.Task = (*refreshExternalCollectionTask)(nil)
func newRefreshExternalCollectionTask(
t *datapb.ExternalCollectionRefreshTask,
refreshMeta *externalCollectionRefreshMeta,
mt *meta,
alloc allocator.Allocator,
) *refreshExternalCollectionTask {
return &refreshExternalCollectionTask{
ExternalCollectionRefreshTask: t,
times: taskcommon.NewTimes(),
refreshMeta: refreshMeta,
mt: mt,
allocator: alloc,
}
}
func (t *refreshExternalCollectionTask) GetTaskID() int64 {
return t.TaskId
}
func (t *refreshExternalCollectionTask) GetTaskType() taskcommon.Type {
return taskcommon.RefreshExternalCollection
}
func (t *refreshExternalCollectionTask) GetTaskState() taskcommon.State {
// taskcommon.State is a type alias of indexpb.JobState, so this is type-safe.
return t.GetState()
}
func (t *refreshExternalCollectionTask) GetTaskSlot() int64 {
// External collection tasks are lightweight, use 1 slot
return 1
}
func (t *refreshExternalCollectionTask) SetTaskTime(timeType taskcommon.TimeType, time time.Time) {
t.times.SetTaskTime(timeType, time)
}
func (t *refreshExternalCollectionTask) GetTaskTime(timeType taskcommon.TimeType) time.Time {
return timeType.GetTaskTime(t.times)
}
func (t *refreshExternalCollectionTask) GetTaskVersion() int64 {
return t.GetVersion()
}
// validateSource checks if this task's external source matches the current collection source
// Returns error if task has been superseded
func (t *refreshExternalCollectionTask) validateSource() error {
if t.mt == nil {
// Skip validation if mt is not provided (e.g., during inspector reload)
return nil
}
// Validate against job-level snapshot to isolate in-flight tasks from schema changes.
job := t.refreshMeta.GetJob(t.GetJobId())
if job == nil {
return merr.WrapErrServiceInternalMsg("job %d not found", t.GetJobId())
}
currentSource := job.GetExternalSource()
currentSpec := job.GetExternalSpec()
taskSource := t.GetExternalSource()
taskSpec := t.GetExternalSpec()
if currentSource != taskSource || currentSpec != taskSpec {
return merr.WrapErrServiceInternalMsg(
"task source mismatch: task source=%s/%s, job source=%s/%s (task belongs to a different refresh job)",
taskSource, taskSpec, currentSource, currentSpec,
)
}
return nil
}
func (t *refreshExternalCollectionTask) SetState(state indexpb.JobState, failReason string) {
t.State = state
t.FailReason = failReason
}
func (t *refreshExternalCollectionTask) UpdateStateWithMeta(state indexpb.JobState, failReason string) error {
if err := t.refreshMeta.UpdateTaskState(t.GetTaskId(), state, failReason); err != nil {
log.Warn("update refresh task state failed",
zap.Int64("taskID", t.GetTaskId()),
zap.String("state", state.String()),
zap.String("failReason", failReason),
zap.Error(err))
return err
}
t.SetState(state, failReason)
// When the task reaches a terminal state, synchronously drive per-job
// processing on the checker. processJob is the single aggregation point
// — it re-reads tasks, transitions job state, and fires the finish
// callback + schema update + WAL broadcast before this method returns.
// This guarantees that callers polling GetRefreshExternalCollectionProgress
// observe a consistent state: when the job appears Finished, the schema
// update has already been applied. The checker's periodic tick runs the
// same logic as a safety net for missed events (e.g., DataCoord restart).
if state == indexpb.JobState_JobStateFinished || state == indexpb.JobState_JobStateFailed {
if t.processFinishedJob != nil {
t.processFinishedJob(t.GetJobId())
}
}
return nil
}
func (t *refreshExternalCollectionTask) UpdateProgressWithMeta(progress int64) error {
if err := t.refreshMeta.UpdateTaskProgress(t.GetTaskId(), progress); err != nil {
log.Warn("update refresh task progress failed",
zap.Int64("taskID", t.GetTaskId()),
zap.Int64("progress", progress),
zap.Error(err))
return err
}
t.Progress = progress
return nil
}
func (t *refreshExternalCollectionTask) UpdateResultWithMeta(
state indexpb.JobState,
failReason string,
keptSegments []int64,
updatedSegments []*datapb.SegmentInfo,
) error {
if err := t.refreshMeta.UpdateTaskResult(t.GetTaskId(), state, failReason, keptSegments, updatedSegments); err != nil {
log.Warn("update refresh task result failed",
zap.Int64("taskID", t.GetTaskId()),
zap.String("state", state.String()),
zap.String("failReason", failReason),
zap.Error(err))
return err
}
t.SetState(state, failReason)
t.KeptSegments = append([]int64(nil), keptSegments...)
t.UpdatedSegments = cloneProtoSegments(updatedSegments)
if state == indexpb.JobState_JobStateFinished || state == indexpb.JobState_JobStateFailed {
if t.processFinishedJob != nil {
t.processFinishedJob(t.GetJobId())
}
}
return nil
}
func applyExternalCollectionSegmentUpdate(
ctx context.Context,
mt *meta,
collectionID int64,
keptSegmentIDs []int64,
updatedSegments []*datapb.SegmentInfo,
logFields ...zap.Field,
) error {
if mt == nil {
return merr.WrapErrServiceInternalMsg("meta is nil, cannot update segments")
}
fields := append(logFields, zap.Int64("collectionID", collectionID))
log := log.Ctx(ctx).With(fields...)
log.Info("processing external collection update response",
zap.Int("keptSegments", len(keptSegmentIDs)),
zap.Int("updatedSegments", len(updatedSegments)))
keptSegmentMap := make(map[int64]bool)
for _, segID := range keptSegmentIDs {
segment := mt.segments.GetSegment(segID)
if segment == nil {
return merr.WrapErrServiceInternalMsg("kept segment %d not found", segID)
}
if segment.GetCollectionID() != collectionID {
return merr.WrapErrServiceInternalMsg("collection mismatch for kept segment %d: existing %d, want %d",
segID, segment.GetCollectionID(), collectionID)
}
if segment.GetState() == commonpb.SegmentState_Dropped {
return merr.WrapErrServiceInternalMsg("cannot keep dropped segment %d", segID)
}
keptSegmentMap[segID] = true
}
upsertSegmentMap := make(map[int64]*datapb.SegmentInfo)
validUpdatedSegments := make([]*datapb.SegmentInfo, 0, len(updatedSegments))
for _, seg := range updatedSegments {
if seg == nil {
continue
}
if err := validateExternalRefreshUpdatedSegment(seg, collectionID); err != nil {
return err
}
if keptSegmentMap[seg.GetID()] {
return merr.WrapErrServiceInternalMsg("segment %d cannot be both kept and updated", seg.GetID())
}
if _, ok := upsertSegmentMap[seg.GetID()]; ok {
return merr.WrapErrServiceInternalMsg("duplicate updated segment %d", seg.GetID())
}
upsertSegmentMap[seg.GetID()] = seg
validUpdatedSegments = append(validUpdatedSegments, seg)
}
// Safety validation: count current active segments and segments to be dropped
currentSegments := mt.SelectSegments(ctx, CollectionFilter(collectionID))
activeSegmentCount := 0
segmentsToDrop := make([]int64, 0)
existingSegmentMap := make(map[int64]*SegmentInfo)
finalSegmentCount := 0
for _, seg := range currentSegments {
existingSegmentMap[seg.GetID()] = seg
if seg.GetState() != commonpb.SegmentState_Dropped {
activeSegmentCount++
if !keptSegmentMap[seg.GetID()] && upsertSegmentMap[seg.GetID()] == nil {
segmentsToDrop = append(segmentsToDrop, seg.GetID())
} else {
finalSegmentCount++
}
}
}
for _, incoming := range upsertSegmentMap {
existing := existingSegmentMap[incoming.GetID()]
if existing == nil {
existing = mt.segments.GetSegment(incoming.GetID())
}
if existing != nil {
if err := validateExternalRefreshPatch(existing, incoming, collectionID); err != nil {
return err
}
continue
}
if err := validateExternalRefreshNewSegment(incoming); err != nil {
return err
}
finalSegmentCount++
}
log.Info("segment update safety check",
zap.Int("currentActiveSegments", activeSegmentCount),
zap.Int("segmentsToDrop", len(segmentsToDrop)),
zap.Int("keptSegments", len(keptSegmentMap)),
zap.Int("upsertSegments", len(upsertSegmentMap)),
zap.Int("finalSegmentCount", finalSegmentCount))
// Safety check: reject if dropping all segments without adding new ones
// This prevents accidental data loss from malformed worker responses
if activeSegmentCount > 0 && finalSegmentCount == 0 {
log.Error("safety check failed: refusing to drop all segments without replacement",
zap.Int("activeSegmentCount", activeSegmentCount),
zap.Int("keptSegments", len(keptSegmentMap)),
zap.Int("updatedSegments", len(upsertSegmentMap)))
return merr.WrapErrServiceInternalMsg("safety check failed: refusing to drop all %d segments without replacement (keptSegments=%d, updatedSegments=%d)",
activeSegmentCount, len(keptSegmentMap), len(upsertSegmentMap))
}
// Safety check: warn if dropping more than configured ratio of segments
if activeSegmentCount > 0 && len(segmentsToDrop) > 0 {
dropRatio := float64(len(segmentsToDrop)) / float64(activeSegmentCount)
threshold := paramtable.Get().DataCoordCfg.ExternalCollectionDropRatioWarn.GetAsFloat()
if threshold <= 0 {
threshold = 0.9
}
if dropRatio > threshold {
log.Warn("high segment drop ratio detected",
zap.Float64("dropRatio", dropRatio),
zap.Float64("threshold", threshold),
zap.Int64s("segmentsToDrop", segmentsToDrop),
zap.Int("activeSegmentCount", activeSegmentCount))
}
}
collInfo := mt.GetCollection(collectionID)
if collInfo == nil {
return merr.WrapErrServiceInternalMsg("collection %d not found in meta", collectionID)
}
// External collections are single-shard, single-partition (enforced at creation).
// Assert exactly-one here to catch any invariant violation from data corruption or legacy data.
if len(collInfo.VChannelNames) != 1 {
return merr.WrapErrServiceInternalMsg("external collection %d expected exactly 1 VChannel, got %d", collectionID, len(collInfo.VChannelNames))
}
if len(collInfo.Partitions) != 1 {
return merr.WrapErrServiceInternalMsg("external collection %d expected exactly 1 partition, got %d", collectionID, len(collInfo.Partitions))
}
insertChannel := collInfo.VChannelNames[0]
partitionID := collInfo.Partitions[0]
normalizedUpdatedSegments := make([]*datapb.SegmentInfo, 0, len(validUpdatedSegments))
normalizedUpsertSegmentMap := make(map[int64]*datapb.SegmentInfo, len(upsertSegmentMap))
for _, seg := range validUpdatedSegments {
normalized := normalizeExternalRefreshUpdatedSegment(seg, collectionID, partitionID, insertChannel)
normalizedUpdatedSegments = append(normalizedUpdatedSegments, normalized)
normalizedUpsertSegmentMap[normalized.GetID()] = normalized
}
upsertSegmentMap = normalizedUpsertSegmentMap
// Build update operators
var operators []UpdateOperator
var patchErr error
validationOperator := func(modPack *updateSegmentPack) bool {
for _, incoming := range upsertSegmentMap {
existing := modPack.meta.segments.GetSegment(incoming.GetID())
if existing != nil {
if err := validateExternalRefreshPatch(existing, incoming, collectionID); err != nil {
patchErr = err
log.Warn("invalid external refresh segment patch",
zap.Int64("segmentID", incoming.GetID()),
zap.Error(err))
return false
}
}
}
return true
}
operators = append(operators, validationOperator)
// Operator 1: Drop segments not in kept list
dropOperator := func(modPack *updateSegmentPack) bool {
if patchErr != nil {
return false
}
currentSegments := modPack.meta.segments.GetSegments()
for _, seg := range currentSegments {
// Skip segments not in this collection
if seg.GetCollectionID() != collectionID {
continue
}
// Skip segments that are already dropped
if seg.GetState() == commonpb.SegmentState_Dropped {
continue
}
// Drop segment if not kept or upserted by this refresh response.
if !keptSegmentMap[seg.GetID()] && upsertSegmentMap[seg.GetID()] == nil {
segment := modPack.Get(seg.GetID())
if segment != nil {
updateSegStateAndPrepareMetrics(segment, commonpb.SegmentState_Dropped, modPack.metricMutation)
segment.DroppedAt = uint64(time.Now().UnixNano())
modPack.segments[seg.GetID()] = segment
log.Info("marking segment as dropped",
zap.Int64("segmentID", seg.GetID()),
zap.Int64("numRows", seg.GetNumOfRows()))
}
}
}
return true
}
operators = append(operators, dropOperator)
// Operator 2: Add new segments or patch existing active segments.
for _, seg := range normalizedUpdatedSegments {
incoming := seg
upsertOperator := func(modPack *updateSegmentPack) bool {
if patchErr != nil {
return false
}
existing := modPack.Get(incoming.GetID())
if existing != nil {
if err := validateExternalRefreshPatch(existing, incoming, collectionID); err != nil {
patchErr = err
log.Warn("invalid external refresh segment patch",
zap.Int64("segmentID", incoming.GetID()),
zap.Error(err))
return false
}
patched := applyExternalRefreshPatch(existing, incoming)
modPack.segments[incoming.GetID()] = patched
modPack.increments[incoming.GetID()] = metastore.BinlogsIncrement{
Segment: patched.SegmentInfo,
}
log.Info("patching existing segment",
zap.Int64("segmentID", incoming.GetID()),
zap.Int64("numRows", incoming.GetNumOfRows()),
zap.String("manifestPath", incoming.GetManifestPath()))
return true
}
segInfo := NewSegmentInfo(incoming)
modPack.segments[incoming.GetID()] = segInfo
modPack.increments[incoming.GetID()] = metastore.BinlogsIncrement{
Segment: incoming,
}
modPack.metricMutation.addNewSeg(
commonpb.SegmentState_Flushed,
incoming.GetLevel(),
incoming.GetIsSorted(),
incoming.GetStorageVersion(),
segmentMetricFormatLabel(segInfo),
incoming.GetNumOfRows(),
)
log.Info("adding new segment",
zap.Int64("segmentID", incoming.GetID()),
zap.Int64("numRows", incoming.GetNumOfRows()))
return true
}
operators = append(operators, upsertOperator)
}
// Execute all operators atomically
if err := mt.UpdateSegmentsInfo(ctx, operators...); err != nil {
log.Warn("failed to update segments atomically", zap.Error(err))
return err
}
if patchErr != nil {
return patchErr
}
log.Info("external collection segments updated successfully",
zap.Int("updatedSegments", len(updatedSegments)),
zap.Int("keptSegments", len(keptSegmentIDs)))
return nil
}
func validateExternalRefreshUpdatedSegment(incoming *datapb.SegmentInfo, collectionID int64) error {
if incoming.GetCollectionID() != 0 && incoming.GetCollectionID() != collectionID {
return merr.WrapErrServiceInternalMsg("collection mismatch for segment %d: got %d, want %d",
incoming.GetID(), incoming.GetCollectionID(), collectionID)
}
if incoming.GetManifestPath() == "" {
return merr.WrapErrServiceInternalMsg("updated segment %d has empty manifest path", incoming.GetID())
}
if len(incoming.GetBinlogs()) == 0 {
return merr.WrapErrServiceInternalMsg("updated segment %d has empty fake binlogs", incoming.GetID())
}
return nil
}
func normalizeExternalRefreshUpdatedSegment(
incoming *datapb.SegmentInfo,
collectionID int64,
partitionID int64,
insertChannel string,
) *datapb.SegmentInfo {
normalized := proto.Clone(incoming).(*datapb.SegmentInfo)
normalized.CollectionID = collectionID
normalized.State = commonpb.SegmentState_Flushed
if normalized.InsertChannel == "" {
normalized.InsertChannel = insertChannel
}
if normalized.PartitionID == 0 {
normalized.PartitionID = partitionID
}
return normalized
}
func validateExternalRefreshNewSegment(incoming *datapb.SegmentInfo) error {
return validateExternalRefreshBinlogRowCount(incoming, incoming.GetNumOfRows())
}
func validateExternalRefreshPatch(oldSeg *SegmentInfo, incoming *datapb.SegmentInfo, collectionID int64) error {
if oldSeg == nil {
return merr.WrapErrServiceInternalMsg("existing segment is nil")
}
if oldSeg.GetCollectionID() != collectionID {
return merr.WrapErrServiceInternalMsg("collection mismatch for segment %d: existing %d, want %d",
oldSeg.GetID(), oldSeg.GetCollectionID(), collectionID)
}
if oldSeg.GetState() == commonpb.SegmentState_Dropped {
return merr.WrapErrServiceInternalMsg("cannot patch dropped segment %d", oldSeg.GetID())
}
if incoming.GetCollectionID() != 0 && incoming.GetCollectionID() != collectionID {
return merr.WrapErrServiceInternalMsg("collection mismatch for segment %d: got %d, want %d",
incoming.GetID(), incoming.GetCollectionID(), collectionID)
}
if incoming.GetNumOfRows() != oldSeg.GetNumOfRows() {
return merr.WrapErrServiceInternalMsg("row count changed for segment %d: got %d, want %d",
incoming.GetID(), incoming.GetNumOfRows(), oldSeg.GetNumOfRows())
}
if incoming.GetStorageVersion() != 0 && incoming.GetStorageVersion() != oldSeg.GetStorageVersion() {
return merr.WrapErrServiceInternalMsg("storage version changed for segment %d: got %d, want %d",
incoming.GetID(), incoming.GetStorageVersion(), oldSeg.GetStorageVersion())
}
if incoming.GetSchemaVersion() < oldSeg.GetSchemaVersion() {
return merr.WrapErrServiceInternalMsg("schema version rollback for segment %d: got %d, want >= %d",
incoming.GetID(), incoming.GetSchemaVersion(), oldSeg.GetSchemaVersion())
}
if incoming.GetManifestPath() == "" {
return merr.WrapErrServiceInternalMsg("patched segment %d has empty manifest path", incoming.GetID())
}
if len(incoming.GetBinlogs()) == 0 {
return merr.WrapErrServiceInternalMsg("patched segment %d has empty fake binlogs", incoming.GetID())
}
if err := validateExternalRefreshBinlogRowCount(incoming, oldSeg.GetNumOfRows()); err != nil {
return err
}
return nil
}
func validateExternalRefreshBinlogRowCount(segment *datapb.SegmentInfo, expectedRows int64) error {
binlogRows := segmentutil.CalcRowCountFromBinLog(segment)
if binlogRows == -1 {
return merr.WrapErrServiceInternalMsg("invalid binlog row count for segment %d", segment.GetID())
}
if expectedRows > 0 && binlogRows != expectedRows {
return merr.WrapErrServiceInternalMsg("binlog row count mismatch for segment %d: got %d, want %d",
segment.GetID(), binlogRows, expectedRows)
}
if binlogRows > 0 && binlogRows != segment.GetNumOfRows() {
return merr.WrapErrServiceInternalMsg("binlog row count mismatch for segment %d: got %d, segment rows %d",
segment.GetID(), binlogRows, segment.GetNumOfRows())
}
return nil
}
func applyExternalRefreshPatch(oldSeg *SegmentInfo, incoming *datapb.SegmentInfo) *SegmentInfo {
cloned := oldSeg.Clone()
cloned.ManifestPath = incoming.GetManifestPath()
cloned.SchemaVersion = incoming.GetSchemaVersion()
cloned.Binlogs = incoming.GetBinlogs()
if incoming.GetStorageVersion() != 0 {
cloned.StorageVersion = incoming.GetStorageVersion()
}
return cloned
}
// SetJobInfo processes a complete job-level response and updates segment information atomically.
func (t *refreshExternalCollectionTask) SetJobInfo(ctx context.Context, resp *datapb.RefreshExternalCollectionTaskResponse) error {
return applyExternalCollectionSegmentUpdate(
ctx,
t.mt,
t.GetCollectionId(),
resp.GetKeptSegments(),
resp.GetUpdatedSegments(),
zap.Int64("taskID", t.GetTaskId()),
)
}
func (t *refreshExternalCollectionTask) CreateTaskOnWorker(nodeID int64, cluster session.Cluster) {
timeout := paramtable.Get().DataCoordCfg.RequestTimeoutSeconds.GetAsDuration(time.Second)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
log := log.Ctx(ctx).With(
zap.Int64("taskID", t.GetTaskId()),
zap.Int64("collectionID", t.GetCollectionId()),
zap.Int64("nodeID", nodeID),
)
var err error
defer func() {
if err != nil {
log.Warn("failed to create refresh task on worker", zap.Error(err))
if updateErr := t.UpdateStateWithMeta(indexpb.JobState_JobStateFailed, err.Error()); updateErr != nil {
log.Warn("failed to persist Failed state after create error", zap.Error(updateErr))
}
}
}()
log.Info("creating refresh task on worker")
if t.mt == nil {
err = merr.WrapErrServiceInternalMsg("meta is nil, cannot create task on worker")
return
}
// Persist task version and nodeID before dispatching to worker
if err = t.refreshMeta.UpdateTaskVersion(t.GetTaskId(), nodeID); err != nil {
log.Warn("failed to update task version", zap.Error(err))
return
}
// Re-read task from meta to sync in-memory state (nodeID and version)
updatedTask := t.refreshMeta.GetTask(t.GetTaskId())
if updatedTask == nil {
err = merr.WrapErrServiceInternalMsg("task %d not found after version update", t.GetTaskId())
return
}
t.ExternalCollectionRefreshTask = updatedTask
// Get current segments for the collection
segments := t.mt.SelectSegments(ctx, CollectionFilter(t.GetCollectionId()))
currentSegments := make([]*datapb.SegmentInfo, 0, len(segments))
for _, seg := range segments {
currentSegments = append(currentSegments, seg.SegmentInfo)
}
log.Info("collected current segments", zap.Int("segmentCount", len(currentSegments)))
// Pre-allocate segment IDs for data mapping
preAllocCount := paramtable.Get().DataCoordCfg.ExternalCollectionPreAllocSegments.GetAsInt64()
idBegin, idEnd, err := t.allocator.AllocN(preAllocCount)
if err != nil {
log.Warn("failed to batch allocate segment IDs", zap.Error(err))
return
}
idRange := &datapb.IDRange{
Begin: idBegin,
End: idEnd,
}
log.Info("Pre-allocated segment IDs for external task",
zap.Int64("idBegin", idBegin),
zap.Int64("idEnd", idEnd),
zap.Int64("count", idEnd-idBegin))
// Use the current collection schema as this task's snapshot. There is no
// job/task-level schema-version gate for the current additive-only refresh
// scope: if AddField races after this request is built, the task may finish
// with the older schema and skip the new field, and a later refresh will
// self-heal it through missing-column detection. Drop, rename, or type
// changes must reintroduce stronger schema coordination, such as a gate or
// lock, before they are supported.
collInfo := t.mt.GetCollection(t.GetCollectionId())
if collInfo == nil {
err = merr.WrapErrServiceInternalMsg("collection %d not found in meta", t.GetCollectionId())
return
}
if len(collInfo.Partitions) != 1 {
err = merr.WrapErrServiceInternalMsg("external collection %d expected exactly 1 partition, got %d", t.GetCollectionId(), len(collInfo.Partitions))
return
}
partitionID := collInfo.Partitions[0]
req := &datapb.RefreshExternalCollectionTaskRequest{
CollectionID: t.GetCollectionId(),
PartitionID: partitionID,
TaskID: t.GetTaskId(),
CurrentSegments: currentSegments,
ExternalSource: t.GetExternalSource(),
ExternalSpec: t.GetExternalSpec(),
StorageConfig: createStorageConfig(),
Schema: collInfo.Schema,
PreAllocatedSegmentIds: idRange,
NumSegmentsExpected: preAllocCount,
ExploreManifestPath: t.GetExploreManifestPath(),
FileIndexBegin: t.GetFileIndexBegin(),
FileIndexEnd: t.GetFileIndexEnd(),
}
// Submit task to worker via unified task system
err = cluster.CreateRefreshExternalCollectionTask(nodeID, req)
if err != nil {
log.Warn("failed to create refresh task on worker", zap.Error(err))
return
}
// Mark task as in progress - QueryTaskOnWorker will check completion
if err = t.UpdateStateWithMeta(indexpb.JobState_JobStateInProgress, ""); err != nil {
log.Warn("failed to update task state to InProgress", zap.Error(err))
return
}
log.Info("refresh task submitted successfully")
}
func (t *refreshExternalCollectionTask) QueryTaskOnWorker(cluster session.Cluster) {
timeout := paramtable.Get().DataCoordCfg.RequestTimeoutSeconds.GetAsDuration(time.Second)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
log := log.Ctx(ctx).With(
zap.Int64("taskID", t.GetTaskId()),
zap.Int64("collectionID", t.GetCollectionId()),
zap.Int64("nodeID", t.GetNodeId()),
)
// Check if job has been canceled/superseded before querying worker
job := t.refreshMeta.GetJob(t.GetJobId())
if job == nil {
log.Info("job not found, task has been canceled")
// Best-effort cleanup: try to drop task on worker if it was assigned
if t.GetNodeId() != 0 {
_ = cluster.DropRefreshExternalCollectionTask(t.GetNodeId(), t.GetTaskId())
}
if err := t.UpdateStateWithMeta(indexpb.JobState_JobStateFailed, "job canceled"); err != nil {
log.Warn("failed to persist Failed state after job cancellation", zap.Error(err))
}
return
}
if job.GetState() == indexpb.JobState_JobStateFailed {
log.Info("job has been marked as failed, canceling task",
zap.String("jobFailReason", job.GetFailReason()))
// Best-effort cleanup: try to drop task on worker if it was assigned
if t.GetNodeId() != 0 {
_ = cluster.DropRefreshExternalCollectionTask(t.GetNodeId(), t.GetTaskId())
}
if err := t.UpdateStateWithMeta(indexpb.JobState_JobStateFailed, "job canceled: "+job.GetFailReason()); err != nil {
log.Warn("failed to persist Failed state after job cancellation", zap.Error(err))
}
return
}
// Query task status from worker
resp, err := cluster.QueryRefreshExternalCollectionTask(t.GetNodeId(), t.GetTaskId())
if err != nil {
log.Warn("query refresh task result failed", zap.Error(err))
// If query fails, mark task as failed
if updateErr := t.UpdateStateWithMeta(indexpb.JobState_JobStateFailed, fmt.Sprintf("query task failed: %v", err)); updateErr != nil {
log.Warn("failed to persist Failed state after query error", zap.Error(updateErr))
}
return
}
state := resp.GetState()
failReason := resp.GetFailReason()
log.Info("queried refresh task status",
zap.String("state", state.String()),
zap.String("failReason", failReason))
// Handle different task states
switch state {
case indexpb.JobState_JobStateFinished:
// Validate source before processing - check if task has been superseded
if err := t.validateSource(); err != nil {
log.Warn("task validation failed, task has been superseded", zap.Error(err))
t.UpdateStateWithMeta(indexpb.JobState_JobStateFailed, err.Error())
return
}
// Persist the task result. Segment metadata is applied once at the
// job level after all sibling tasks have finished, so a single task
// cannot drop segments produced by another task of the same job.
if err := t.UpdateResultWithMeta(
state,
"",
resp.GetKeptSegments(),
resp.GetUpdatedSegments(),
); err != nil {
log.Warn("failed to update task state to Finished", zap.Error(err))
return
}
log.Info("refresh task completed successfully")
case indexpb.JobState_JobStateFailed:
// Task failed
if err := t.UpdateStateWithMeta(state, failReason); err != nil {
log.Warn("failed to update task state to Failed", zap.Error(err))
return
}
log.Warn("refresh task failed", zap.String("reason", failReason))
case indexpb.JobState_JobStateInProgress, indexpb.JobState_JobStateNone, indexpb.JobState_JobStateInit:
// Task still in progress or not yet picked up by scheduler, no action needed
log.Info("refresh task still in progress",
zap.String("state", state.String()))
case indexpb.JobState_JobStateRetry:
// Task needs retry - mark as failed
log.Warn("refresh task in unexpected state, marking as failed",
zap.String("state", state.String()))
if err := t.UpdateStateWithMeta(indexpb.JobState_JobStateFailed, fmt.Sprintf("task in unexpected state: %s", state.String())); err != nil {
log.Warn("failed to persist Failed state for retry branch", zap.Error(err))
}
default:
log.Warn("refresh task in unknown state",
zap.String("state", state.String()))
}
}
func (t *refreshExternalCollectionTask) DropTaskOnWorker(cluster session.Cluster) {
timeout := paramtable.Get().DataCoordCfg.RequestTimeoutSeconds.GetAsDuration(time.Second)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
log := log.Ctx(ctx).With(
zap.Int64("taskID", t.GetTaskId()),
zap.Int64("collectionID", t.GetCollectionId()),
zap.Int64("nodeID", t.GetNodeId()),
)
// Drop task on worker to cancel execution and clean up resources
err := cluster.DropRefreshExternalCollectionTask(t.GetNodeId(), t.GetTaskId())
if err != nil {
log.Warn("failed to drop refresh task on worker", zap.Error(err))
return
}
log.Info("refresh task dropped successfully")
}