Files
milvus/internal/datacoord/garbage_collector.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

2012 lines
72 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"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/cockroachdb/errors"
"github.com/samber/lo"
"go.uber.org/atomic"
"go.uber.org/zap"
"github.com/milvus-io/milvus-proto/go-api/v3/commonpb"
"github.com/milvus-io/milvus/internal/datacoord/broker"
"github.com/milvus-io/milvus/internal/metastore/kv/binlog"
"github.com/milvus-io/milvus/internal/metastore/model"
"github.com/milvus-io/milvus/internal/storage"
"github.com/milvus-io/milvus/internal/storagev2/packed"
"github.com/milvus-io/milvus/pkg/v3/common"
"github.com/milvus-io/milvus/pkg/v3/log"
"github.com/milvus-io/milvus/pkg/v3/metrics"
"github.com/milvus-io/milvus/pkg/v3/proto/datapb"
"github.com/milvus-io/milvus/pkg/v3/util/conc"
"github.com/milvus-io/milvus/pkg/v3/util/funcutil"
"github.com/milvus-io/milvus/pkg/v3/util/hardware"
"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/typeutil"
)
// GcOption garbage collection options
type GcOption struct {
cli storage.ChunkManager // client
enabled bool // enable switch
checkInterval time.Duration // each interval
missingTolerance time.Duration // key missing in meta tolerance time
dropTolerance time.Duration // dropped segment related key tolerance time
scanInterval time.Duration // interval for scan residue for interupted log wrttien
broker broker.Broker
removeObjectPool *conc.Pool[struct{}]
}
// garbageCollector handles garbage files in object storage
// which could be dropped collection remanent or data node failure traces
type garbageCollector struct {
ctx context.Context
cancel context.CancelFunc
option GcOption
meta *meta
handler Handler
startOnce sync.Once
stopOnce sync.Once
wg sync.WaitGroup
cmdCh chan gcCmd
pauseUntil *gcPauseRecords
pausedCollection *typeutil.ConcurrentMap[int64, *gcPauseRecords]
controlChannels map[string]chan gcCmd
systemMetricsListener *hardware.SystemMetricsListener
}
type gcCmd struct {
cmdType datapb.GcCommand
duration time.Duration
collectionID int64
ticket string
done chan error
timeout <-chan struct{}
}
type gcPauseRecord struct {
ticket string
pauseUntil time.Time
}
type gcPauseRecords struct {
mut sync.RWMutex
maxLen int
records typeutil.Heap[gcPauseRecord]
}
func (gc *gcPauseRecords) PauseUntil() time.Time {
// nil protection
if gc == nil {
return time.Time{}
}
gc.mut.RLock()
defer gc.mut.RUnlock()
// no pause records, return zero value
if gc.records.Len() == 0 {
return time.Time{}
}
return gc.records.Peek().pauseUntil
}
func (gc *gcPauseRecords) Insert(ticket string, pauseUntil time.Time) error {
gc.mut.Lock()
defer gc.mut.Unlock()
// heap small enough, short path
if gc.records.Len() < gc.maxLen {
gc.records.Push(gcPauseRecord{
ticket: ticket,
pauseUntil: pauseUntil,
})
return nil
}
records := make([]gcPauseRecord, 0, gc.records.Len())
now := time.Now()
for gc.records.Len() > 0 {
record := gc.records.Pop()
if record.pauseUntil.After(now) {
records = append(records, record)
}
}
gc.records = typeutil.NewObjectArrayBasedMaximumHeap(records, func(r gcPauseRecord) int64 {
return r.pauseUntil.UnixNano()
})
if gc.records.Len() < gc.maxLen {
gc.records.Push(gcPauseRecord{
ticket: ticket,
pauseUntil: pauseUntil,
})
return nil
}
// too many pause records, refresh heap
return merr.WrapErrTooManyRequests(64, "too many pause records")
}
func (gc *gcPauseRecords) Delete(ticket string) {
gc.mut.Lock()
defer gc.mut.Unlock()
now := time.Now()
records := make([]gcPauseRecord, 0, gc.records.Len())
for gc.records.Len() > 0 {
record := gc.records.Pop()
if now.Before(record.pauseUntil) && record.ticket != ticket {
records = append(records, record)
}
}
gc.records = typeutil.NewObjectArrayBasedMaximumHeap(records, func(r gcPauseRecord) int64 {
return r.pauseUntil.UnixNano()
})
}
func (gc *gcPauseRecords) Len() int {
gc.mut.RLock()
defer gc.mut.RUnlock()
return gc.records.Len()
}
func NewGCPauseRecords() *gcPauseRecords {
return &gcPauseRecords{
records: typeutil.NewObjectArrayBasedMaximumHeap[gcPauseRecord, int64]([]gcPauseRecord{}, func(r gcPauseRecord) int64 {
return r.pauseUntil.UnixNano()
}),
maxLen: 64,
}
}
// newSystemMetricsListener creates a system metrics listener for garbage collector.
// used to slow down the garbage collector when cpu usage is high.
func newSystemMetricsListener(opt *GcOption) *hardware.SystemMetricsListener {
return &hardware.SystemMetricsListener{
Cooldown: 15 * time.Second,
Context: false,
Condition: func(metrics hardware.SystemMetrics, listener *hardware.SystemMetricsListener) bool { return true },
Callback: func(metrics hardware.SystemMetrics, listener *hardware.SystemMetricsListener) {
isSlowDown := listener.Context.(bool)
if metrics.UsedRatio() > paramtable.Get().DataCoordCfg.GCSlowDownCPUUsageThreshold.GetAsFloat() {
if !isSlowDown {
log.Info("garbage collector slow down...", zap.Float64("cpuUsage", metrics.UsedRatio()))
opt.removeObjectPool.Resize(1)
listener.Context = true
}
return
}
if isSlowDown {
log.Info("garbage collector slow down finished", zap.Float64("cpuUsage", metrics.UsedRatio()))
opt.removeObjectPool.Resize(paramtable.Get().DataCoordCfg.GCRemoveConcurrent.GetAsInt())
listener.Context = false
}
},
}
}
// newGarbageCollector create garbage collector with meta and option
func newGarbageCollector(meta *meta, handler Handler, opt GcOption) *garbageCollector {
log.Info("GC with option",
zap.Bool("enabled", opt.enabled),
zap.Duration("interval", opt.checkInterval),
zap.Duration("scanInterval", opt.scanInterval),
zap.Duration("missingTolerance", opt.missingTolerance),
zap.Duration("dropTolerance", opt.dropTolerance))
opt.removeObjectPool = conc.NewPool[struct{}](Params.DataCoordCfg.GCRemoveConcurrent.GetAsInt(), conc.WithExpiryDuration(time.Minute))
ctx, cancel := context.WithCancel(context.Background())
metaSignal := make(chan gcCmd)
orphanSignal := make(chan gcCmd)
lobSignal := make(chan gcCmd)
controlChannels := map[string]chan gcCmd{
"meta": metaSignal,
"orphan": orphanSignal,
"lob": lobSignal,
}
return &garbageCollector{
ctx: ctx,
cancel: cancel,
meta: meta,
handler: handler,
option: opt,
cmdCh: make(chan gcCmd),
systemMetricsListener: newSystemMetricsListener(&opt),
pauseUntil: NewGCPauseRecords(),
pausedCollection: typeutil.NewConcurrentMap[int64, *gcPauseRecords](),
controlChannels: controlChannels,
}
}
// start a goroutine and perform gc check every `checkInterval`
func (gc *garbageCollector) start() {
if gc.option.enabled {
if gc.option.cli == nil {
log.Warn("DataCoord gc enabled, but SSO client is not provided")
return
}
gc.startOnce.Do(func() {
gc.work(gc.ctx)
})
}
}
// GcStatus holds the current status of the garbage collector.
type GcStatus struct {
IsPaused bool
TimeRemaining time.Duration
}
// GetStatus returns the current status of the garbage collector.
func (gc *garbageCollector) GetStatus() GcStatus {
pauseUntil := gc.pauseUntil.PauseUntil()
now := time.Now()
if now.Before(pauseUntil) {
return GcStatus{
IsPaused: true,
TimeRemaining: pauseUntil.Sub(now),
}
}
return GcStatus{
IsPaused: false,
TimeRemaining: 0,
}
}
func (gc *garbageCollector) Pause(ctx context.Context, collectionID int64, ticket string, pauseDuration time.Duration) error {
if !gc.option.enabled {
log.Info("garbage collection not enabled")
return nil
}
done := make(chan error, 1)
select {
case gc.cmdCh <- gcCmd{
cmdType: datapb.GcCommand_Pause,
duration: pauseDuration,
collectionID: collectionID,
ticket: ticket,
done: done,
timeout: ctx.Done(),
}:
return <-done
case <-ctx.Done():
return ctx.Err()
}
}
func (gc *garbageCollector) Resume(ctx context.Context, collectionID int64, ticket string) error {
if !gc.option.enabled {
log.Warn("garbage collection not enabled, cannot resume")
return merr.WrapErrServiceUnavailable("garbage collection not enabled")
}
done := make(chan error)
select {
case gc.cmdCh <- gcCmd{
cmdType: datapb.GcCommand_Resume,
done: done,
collectionID: collectionID,
ticket: ticket,
timeout: ctx.Done(),
}:
<-done
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// work contains actual looping check logic
func (gc *garbageCollector) work(ctx context.Context) {
// TODO: fast cancel for gc when closing.
// Run gc tasks in parallel.
gc.wg.Add(4)
go func() {
defer gc.wg.Done()
gc.runRecycleTaskWithPauser(ctx, "meta", gc.option.checkInterval, func(ctx context.Context, signal <-chan gcCmd) {
gc.recycleDroppedSegments(ctx, signal)
gc.recycleChannelCPMeta(ctx, signal)
gc.recycleUnusedIndexes(ctx, signal)
gc.recycleUnusedSegIndexes(ctx, signal)
gc.recycleUnusedAnalyzeFiles(ctx, signal)
gc.recycleUnusedTextIndexFiles(ctx, signal)
gc.recycleUnusedJSONIndexFiles(ctx, signal)
gc.recycleUnusedJSONStatsFiles(ctx, signal)
gc.recycleSnapshots(ctx, signal)
})
}()
go func() {
defer gc.wg.Done()
gc.runRecycleTaskWithPauser(ctx, "orphan", gc.option.scanInterval, func(ctx context.Context, signal <-chan gcCmd) {
// orphan file not controlled by collection level pause for now
gc.recycleUnusedBinlogFiles(ctx)
gc.recycleUnusedIndexFilesV0(ctx)
gc.recycleUnusedIndexFilesV1(ctx)
})
}()
go func() {
defer gc.wg.Done()
// LOB (TEXT column) file GC runs on its own interval
lobCheckInterval := Params.DataCoordCfg.GCLOBCheckInterval.GetAsDuration(time.Second)
gc.runRecycleTaskWithPauser(ctx, "lob", lobCheckInterval, func(ctx context.Context, signal <-chan gcCmd) {
gc.recycleUnusedLOBFiles(ctx)
})
}()
go func() {
defer gc.wg.Done()
gc.startControlLoop(ctx)
}()
}
func (gc *garbageCollector) ackSignal(signal <-chan gcCmd) {
select {
case cmd := <-signal:
if cmd.done != nil {
close(cmd.done)
}
default:
}
}
// startControlLoop start a control loop for garbageCollector.
func (gc *garbageCollector) startControlLoop(_ context.Context) {
hardware.RegisterSystemMetricsListener(gc.systemMetricsListener)
defer hardware.UnregisterSystemMetricsListener(gc.systemMetricsListener)
for {
select {
case cmd := <-gc.cmdCh:
switch cmd.cmdType {
case datapb.GcCommand_Pause:
err := gc.pause(cmd)
cmd.done <- err
case datapb.GcCommand_Resume:
gc.resume(cmd)
}
close(cmd.done)
case <-gc.ctx.Done():
log.Warn("garbage collector control loop quit")
return
}
}
}
func (gc *garbageCollector) pause(cmd gcCmd) error {
log := log.With(
zap.Int64("collectionID", cmd.collectionID),
zap.String("ticket", cmd.ticket),
)
reqPauseUntil := time.Now().Add(cmd.duration)
log = log.With(
zap.Time("pauseUntil", reqPauseUntil),
zap.Duration("duration", cmd.duration),
)
var err error
if cmd.collectionID <= 0 { // legacy pause all
err = gc.pauseUntil.Insert(cmd.ticket, reqPauseUntil)
log.Info("global pause ticket recorded")
} else {
curr, has := gc.pausedCollection.Get(cmd.collectionID)
if !has {
curr = NewGCPauseRecords()
gc.pausedCollection.Insert(cmd.collectionID, curr)
}
err = curr.Insert(cmd.ticket, reqPauseUntil)
log.Info("collection new pause ticket recorded")
}
if err != nil {
return err
}
signalCh := gc.controlChannels["meta"]
// send signal to worker
// make sure worker ack the pause command before returning
signal := gcCmd{
done: make(chan error),
timeout: cmd.timeout,
}
select {
case signalCh <- signal:
<-signal.done
case <-cmd.timeout:
// timeout, resume the pause
gc.resume(cmd)
}
return nil
}
func (gc *garbageCollector) resume(cmd gcCmd) {
// reset to zero value
var afterResume time.Time
if cmd.collectionID <= 0 {
gc.pauseUntil.Delete(cmd.ticket)
afterResume = gc.pauseUntil.PauseUntil()
} else {
curr, has := gc.pausedCollection.Get(cmd.collectionID)
if has {
curr.Delete(cmd.ticket)
afterResume = curr.PauseUntil()
if curr.Len() == 0 || time.Now().After(afterResume) {
gc.pausedCollection.Remove(cmd.collectionID)
}
}
}
stillPaused := time.Now().Before(afterResume)
log.Info("garbage collection resumed", zap.Bool("stillPaused", stillPaused))
}
// runRecycleTaskWithPauser is a helper function to create a task with pauser
func (gc *garbageCollector) runRecycleTaskWithPauser(ctx context.Context, name string, interval time.Duration, task func(ctx context.Context, signal <-chan gcCmd)) {
logger := log.With(zap.String("gcType", name)).With(zap.Duration("interval", interval))
timer := time.NewTicker(interval)
defer timer.Stop()
// get signal channel, ok if nil, means no control
signal := gc.controlChannels[name]
for {
select {
case <-ctx.Done():
return
case cmd := <-signal:
// notify signal received
close(cmd.done)
case <-timer.C:
globalPauseUntil := gc.pauseUntil.PauseUntil()
if time.Now().Before(globalPauseUntil) {
logger.Info("garbage collector paused", zap.Time("until", globalPauseUntil))
continue
}
logger.Info("garbage collector recycle task start...")
start := time.Now()
task(ctx, signal)
logger.Info("garbage collector recycle task done", zap.Duration("timeCost", time.Since(start)))
}
}
}
func (gc *garbageCollector) collectionGCPaused(collectionID int64) bool {
collPauseUntil, has := gc.pausedCollection.Get(collectionID)
return has && time.Now().Before(collPauseUntil.PauseUntil())
}
// close stop the garbage collector.
func (gc *garbageCollector) close() {
gc.stopOnce.Do(func() {
gc.cancel()
gc.wg.Wait()
if gc.option.removeObjectPool != nil {
gc.option.removeObjectPool.Release()
}
})
}
// recycleUnusedBinlogFiles load meta file info and compares OSS keys
// if missing found, performs gc cleanup
func (gc *garbageCollector) recycleUnusedBinlogFiles(ctx context.Context) {
start := time.Now()
log := log.With(zap.String("gcName", "recycleUnusedBinlogFiles"), zap.Time("startAt", start))
log.Info("start recycleUnusedBinlogFiles...")
defer func() { log.Info("recycleUnusedBinlogFiles done", zap.Duration("timeCost", time.Since(start))) }()
type scanTask struct {
prefix string
checker func(objectInfo *storage.ChunkObjectInfo, segment *SegmentInfo) bool
label string
}
scanTasks := []scanTask{
{
prefix: path.Join(gc.option.cli.RootPath(), common.SegmentInsertLogPath),
checker: func(objectInfo *storage.ChunkObjectInfo, segment *SegmentInfo) bool {
return segment != nil
},
label: metrics.InsertFileLabel,
},
{
prefix: path.Join(gc.option.cli.RootPath(), common.SegmentStatslogPath),
checker: func(objectInfo *storage.ChunkObjectInfo, segment *SegmentInfo) bool {
logID, err := binlog.GetLogIDFromBingLogPath(objectInfo.FilePath)
if err != nil {
log.Warn("garbageCollector find dirty stats log", zap.String("filePath", objectInfo.FilePath), zap.Error(err))
return false
}
return segment != nil && segment.IsStatsLogExists(logID)
},
label: metrics.StatFileLabel,
},
{
prefix: path.Join(gc.option.cli.RootPath(), common.SegmentDeltaLogPath),
checker: func(objectInfo *storage.ChunkObjectInfo, segment *SegmentInfo) bool {
logID, err := binlog.GetLogIDFromBingLogPath(objectInfo.FilePath)
if err != nil {
log.Warn("garbageCollector find dirty dleta log", zap.String("filePath", objectInfo.FilePath), zap.Error(err))
return false
}
return segment != nil && segment.IsDeltaLogExists(logID)
},
label: metrics.DeleteFileLabel,
},
}
for _, task := range scanTasks {
gc.recycleUnusedBinLogWithChecker(ctx, task.prefix, task.label, task.checker)
}
metrics.GarbageCollectorRunCount.WithLabelValues(paramtable.GetStringNodeID()).Add(1)
}
// recycleUnusedBinLogWithChecker scans the prefix and checks the path with checker.
// GC the file if checker returns false.
func (gc *garbageCollector) recycleUnusedBinLogWithChecker(ctx context.Context, prefix string, label string, checker func(objectInfo *storage.ChunkObjectInfo, segment *SegmentInfo) bool) {
logger := log.With(zap.String("prefix", prefix))
logger.Info("garbageCollector recycleUnusedBinlogFiles start", zap.String("prefix", prefix))
lastFilePath := ""
total := 0
valid := 0
unexpectedFailure := atomic.NewInt32(0)
removed := atomic.NewInt32(0)
start := time.Now()
// isSnapshotProtected checks if a segment should be skipped from GC due to snapshot references.
// Returns true if the segment is protected (should NOT be deleted).
//
// Delegates to snapshotMeta.IsSegmentGCBlocked, which is O(1) and handles the
// fail-closed layering (unloaded RefIndex → coarse collection-level block, else
// point query on the pre-computed segmentReferencedByGC set). The per-call caching
// that used to be needed here is no longer necessary because the lookups are now
// direct set/map reads.
snapshotMeta := gc.meta.GetSnapshotMeta()
isSnapshotProtected := func(segmentID, collectionID int64) bool {
if snapshotMeta == nil {
return false
}
return snapshotMeta.IsSegmentGCBlocked(collectionID, segmentID)
}
futures := make([]*conc.Future[struct{}], 0)
err := gc.option.cli.WalkWithPrefix(ctx, prefix, true, func(chunkInfo *storage.ChunkObjectInfo) bool {
total++
lastFilePath = chunkInfo.FilePath
// Check file tolerance first to avoid unnecessary operation.
if time.Since(chunkInfo.ModifyTime) <= gc.option.missingTolerance {
logger.Info("garbageCollector recycleUnusedBinlogFiles skip file since it is not expired", zap.String("filePath", chunkInfo.FilePath), zap.Time("modifyTime", chunkInfo.ModifyTime))
return true
}
// Parse segmentID from file path.
// TODO: Does all files in the same segment have the same segmentID?
segmentID, err := storage.ParseSegmentIDByBinlog(gc.option.cli.RootPath(), chunkInfo.FilePath)
if err != nil {
// Try V3 path format: insert_log/{coll}/{part}/{seg}/...
// V3 orphan files are managed by loon (milvus-storage), skip them.
if v3SegID, parseErr := parseV3SegmentID(gc.option.cli.RootPath(), chunkInfo.FilePath); parseErr == nil {
v3Seg := gc.meta.GetSegment(ctx, v3SegID)
if v3Seg == nil || v3Seg.GetStorageVersion() == storage.StorageV3 {
// V3 segment file or orphan V3 file — skip, managed by loon
valid++
return true
}
}
unexpectedFailure.Inc()
logger.Warn("garbageCollector recycleUnusedBinlogFiles parse segment id error",
zap.String("filePath", chunkInfo.FilePath),
zap.Error(err))
return true
}
segment := gc.meta.GetSegment(ctx, segmentID)
// Skip V3 segments — orphan files managed by loon
if segment != nil && segment.GetStorageVersion() == storage.StorageV3 {
valid++
return true
}
if checker(chunkInfo, segment) {
valid++
logger.Info("garbageCollector recycleUnusedBinlogFiles skip file since it is valid", zap.String("filePath", chunkInfo.FilePath), zap.Int64("segmentID", segmentID))
return true
}
// Check if segment is referenced by any snapshot before deleting its binlog.
collectionID := int64(-1)
if segment != nil {
collectionID = segment.GetCollectionID()
}
if isSnapshotProtected(segmentID, collectionID) {
logger.Info("skip GC binlog files since segment is protected by snapshot",
zap.Int64("segmentID", segmentID))
valid++
return true
}
// ignore error since it could be cleaned up next time
file := chunkInfo.FilePath
future := gc.option.removeObjectPool.Submit(func() (struct{}, error) {
logger := logger.With(zap.String("file", file))
logger.Info("garbageCollector recycleUnusedBinlogFiles remove file...")
if err = gc.option.cli.Remove(ctx, file); err != nil {
log.Warn("garbageCollector recycleUnusedBinlogFiles remove file failed", zap.Error(err))
unexpectedFailure.Inc()
return struct{}{}, err
}
log.Info("garbageCollector recycleUnusedBinlogFiles remove file success")
removed.Inc()
return struct{}{}, nil
})
futures = append(futures, future)
return true
})
// Wait for all remove tasks done.
if err := conc.BlockOnAll(futures...); err != nil {
// error is logged, and can be ignored here.
logger.Warn("some task failure in remove object pool", zap.Error(err))
}
cost := time.Since(start)
logger.Info("garbageCollector recycleUnusedBinlogFiles done",
zap.Int("total", total),
zap.Int("valid", valid),
zap.Int("unexpectedFailure", int(unexpectedFailure.Load())),
zap.Int("removed", int(removed.Load())),
zap.String("lastFilePath", lastFilePath),
zap.Duration("cost", cost),
zap.Error(err))
metrics.GarbageCollectorFileScanDuration.
WithLabelValues(paramtable.GetStringNodeID(), label).
Observe(float64(cost.Milliseconds()))
}
func (gc *garbageCollector) checkDroppedSegmentGC(segment *SegmentInfo,
childSegment *SegmentInfo,
indexSet typeutil.UniqueSet,
cpTimestamp Timestamp,
) bool {
log := log.With(zap.Int64("segmentID", segment.ID))
if !gc.isExpire(segment.GetDroppedAt()) {
return false
}
isCompacted := childSegment != nil || segment.GetCompacted()
if isCompacted {
// For compact A, B -> C, don't GC A or B if C is not indexed,
// guarantee replacing A, B with C won't downgrade performance
// If the child is GC'ed first, then childSegment will be nil.
if childSegment != nil && !indexSet.Contain(childSegment.GetID()) {
log.WithRateGroup("GC_FAIL_COMPACT_TO_NOT_INDEXED", 1, 60).
RatedInfo(60, "skipping GC when compact target segment is not indexed",
zap.Int64("child segment ID", childSegment.GetID()))
return false
}
}
segInsertChannel := segment.GetInsertChannel()
// Ignore segments from potentially dropped collection. Check if collection is to be dropped by checking if channel is dropped.
// We do this because collection meta drop relies on all segment being GCed.
if gc.meta.catalog.ChannelExists(context.Background(), segInsertChannel) &&
segmentEffectiveDmlTs(segment.SegmentInfo) > cpTimestamp {
// segment gc shall only happen when channel cp is after segment dml cp.
log.WithRateGroup("GC_FAIL_CP_BEFORE", 1, 60).
RatedInfo(60, "dropped segment dml position after channel cp, skip meta gc",
zap.Uint64("dmlPosTs", segmentEffectiveDmlTs(segment.SegmentInfo)),
zap.Uint64("channelCpTs", cpTimestamp),
)
return false
}
return true
}
// recycleDroppedSegments scans all segments and remove those dropped segments from meta and oss.
func (gc *garbageCollector) recycleDroppedSegments(ctx context.Context, signal <-chan gcCmd) {
start := time.Now()
log := log.With(zap.String("gcName", "recycleDroppedSegments"), zap.Time("startAt", start))
log.Info("start clear dropped segments...")
defer func() { log.Info("clear dropped segments done", zap.Duration("timeCost", time.Since(start))) }()
all := gc.meta.SelectSegments(ctx)
drops := make(map[int64]*SegmentInfo, 0)
compactTo := make(map[int64]*SegmentInfo)
channels := typeutil.NewSet[string]()
for _, segment := range all {
if segment.GetState() == commonpb.SegmentState_Dropped {
drops[segment.GetID()] = segment
channels.Insert(segment.GetInsertChannel())
// continue
// A(indexed), B(indexed) -> C(no indexed), D(no indexed) -> E(no indexed), A, B can not be GC
}
for _, from := range segment.GetCompactionFrom() {
compactTo[from] = segment
}
}
droppedCompactTo := make(map[int64]*SegmentInfo)
for id := range drops {
if to, ok := compactTo[id]; ok {
droppedCompactTo[to.GetID()] = to
}
}
indexedSegments := FilterInIndexedSegments(ctx, gc.handler, gc.meta, false, lo.Values(droppedCompactTo)...)
if ctx.Err() != nil {
return
}
indexedSet := make(typeutil.UniqueSet)
for _, segment := range indexedSegments {
indexedSet.Insert(segment.GetID())
}
channelCPs := make(map[string]uint64)
for channel := range channels {
pos := gc.meta.GetChannelCheckpoint(channel)
channelCPs[channel] = pos.GetTimestamp()
}
// try to get loaded segments
loadedSegments := typeutil.NewSet[int64]()
segments, err := gc.handler.ListLoadedSegments(ctx)
if err != nil {
log.Warn("failed to get loaded segments", zap.Error(err))
return
}
for _, segmentID := range segments {
loadedSegments.Insert(segmentID)
}
log.Info("start to GC segments", zap.Int("drop_num", len(drops)))
for segmentID, segment := range drops {
if ctx.Err() != nil {
// process canceled, stop.
return
}
gc.ackSignal(signal)
if gc.collectionGCPaused(segment.GetCollectionID()) {
log.Info("skip GC segment since collection is paused", zap.Int64("segmentID", segmentID), zap.Int64("collectionID", segment.GetCollectionID()))
continue
}
log := log.With(zap.Int64("segmentID", segmentID))
segInsertChannel := segment.GetInsertChannel()
if loadedSegments.Contain(segmentID) {
log.Info("skip GC segment since it is loaded", zap.Int64("segmentID", segmentID))
continue
}
// Skip segments protected by snapshot references. IsSegmentGCBlocked is O(1)
// and embeds the "RefIndex not loaded → fail-closed" check, so we don't need
// a separate loaded-state probe.
if snapshotMeta := gc.meta.GetSnapshotMeta(); snapshotMeta != nil {
if snapshotMeta.IsSegmentGCBlocked(segment.GetCollectionID(), segmentID) {
log.Info("skip GC segment since it is protected by snapshot",
zap.Int64("collectionID", segment.GetCollectionID()),
zap.Int64("partitionID", segment.GetPartitionID()),
zap.String("channel", segInsertChannel),
zap.Int64("segmentID", segmentID))
continue
}
}
if !gc.checkDroppedSegmentGC(segment, compactTo[segment.GetID()], indexedSet, channelCPs[segInsertChannel]) {
continue
}
gc.recycleDroppedSegment(ctx, segmentID, segment)
}
}
// recycleDroppedSegment deletes a single dropped segment's object files,
// segment-index files, segment-index meta, and segment meta in that order.
//
// The ordering matters: files first so a meta-only retry after partial
// file deletion can still observe the leftover keys; segment-index meta
// next so segment meta deletion (the final marker) is the only step that
// commits the GC; if any step fails the later state is preserved for the
// next GC cycle to retry.
//
// This path can race with recycleUnusedSegIndexes on the same BuildID
// whenever a dropped segment's parent field index has also been marked
// IsDeleted — both paths call removeObjectFiles for the same index files
// and call indexMeta.RemoveSegmentIndex(buildID). The races are safe today
// only because two invariants hold elsewhere:
//
// 1. removeObjectFiles swallows merr.ErrIoKeyNotFound (see the loop in
// removeObjectFiles below), so a double file delete is a no-op for the
// loser.
// 2. indexMeta.RemoveSegmentIndex acquires a per-buildID keyLock and
// returns nil when segmentBuildInfo.Get(buildID) is !ok (see
// index_meta.go), so a double catalog delete is a no-op for the loser.
//
// Any future refactor on either side — tightening removeObjectFiles to
// surface NotFound, or batching RemoveSegmentIndex past the per-buildID
// keyLock — must preserve these invariants, otherwise dropped-segment GC
// will silently break under load.
func (gc *garbageCollector) recycleDroppedSegment(ctx context.Context, segmentID int64, segment *SegmentInfo) {
log := log.With(zap.Int64("segmentID", segmentID), zap.Int64("collectionID", segment.GetCollectionID()))
if ctx.Err() != nil {
return
}
segIndexes, indexFiles, indexSnapshotBlocked := gc.getDroppedSegmentIndexFiles(segmentID)
if indexSnapshotBlocked {
log.Info("skip GC segment since segment index is protected by snapshot",
zap.Int("segmentIndexes", len(segIndexes)))
return
}
cloned := segment.Clone()
if err := gc.removeDroppedSegmentFiles(ctx, cloned, indexFiles); err != nil {
log.Warn("GC segment remove files failed", zap.Error(err))
return
}
if ctx.Err() != nil {
return
}
if err := gc.removeDroppedSegmentIndexMeta(ctx, segIndexes); err != nil {
log.Warn("GC segment index meta failed, wait to retry", zap.Error(err))
return
}
if ctx.Err() != nil {
return
}
if err := gc.meta.DropSegment(ctx, cloned.GetID()); err != nil {
log.Warn("GC segment meta failed to drop segment", zap.Error(err))
return
}
log.Info("GC segment meta drop segment done", zap.Int("segmentIndexes", len(segIndexes)))
}
func (gc *garbageCollector) getDroppedSegmentIndexFiles(segmentID int64) ([]*model.SegmentIndex, map[string]struct{}, bool) {
segIndexes := gc.getAllSegmentIndexesForDroppedSegment(segmentID)
if len(segIndexes) == 0 {
return nil, nil, false
}
if snapshotMeta := gc.meta.GetSnapshotMeta(); snapshotMeta != nil {
for _, segIdx := range segIndexes {
if snapshotMeta.IsBuildIDGCBlocked(segIdx.CollectionID, segIdx.BuildID) {
return segIndexes, nil, true
}
}
}
indexFiles := make(map[string]struct{}, len(segIndexes))
for _, segIdx := range segIndexes {
for key := range gc.getAllIndexFilesOfIndex(segIdx) {
indexFiles[key] = struct{}{}
}
}
return segIndexes, indexFiles, false
}
// getAllSegmentIndexesForDroppedSegment wraps indexMeta.GetAllSegmentIndexes
// with a defensive nil guard. Production newMeta always wires indexMeta, but
// the guard is cheap and turns any unexpected nil into "no index records"
// instead of a panic during a GC sweep — keeping a single misbuilt gc
// instance from taking the whole datacoord down.
func (gc *garbageCollector) getAllSegmentIndexesForDroppedSegment(segmentID int64) []*model.SegmentIndex {
if gc.meta == nil || gc.meta.indexMeta == nil {
return nil
}
return gc.meta.indexMeta.GetAllSegmentIndexes(segmentID)
}
func (gc *garbageCollector) removeDroppedSegmentFiles(ctx context.Context, cloned *SegmentInfo, indexFiles map[string]struct{}) error {
log := log.With(zap.Int64("segmentID", cloned.GetID()))
// V3 segment data lives under the manifest base path. Segment index files still
// live under index file prefixes and must be deleted from recorded file keys.
if cloned.GetStorageVersion() == storage.StorageV3 {
basePath, _, err := packed.UnmarshalManifestPath(cloned.GetManifestPath())
if err != nil {
log.Warn("GC V3 segment failed to parse manifest path",
zap.String("manifestPath", cloned.GetManifestPath()),
zap.Error(err))
return err
}
log.Info("GC V3 segment start, removing basePath...",
zap.String("basePath", basePath),
zap.Int("indexFiles", len(indexFiles)))
if err := gc.option.cli.RemoveWithPrefix(ctx, basePath); err != nil {
log.Warn("GC V3 segment remove basePath failed",
zap.String("basePath", basePath),
zap.Error(err))
return err
}
if len(indexFiles) == 0 {
log.Info("GC V3 segment files done")
return nil
}
if err := gc.removeObjectFiles(ctx, indexFiles); err != nil {
log.Warn("GC V3 segment remove index files failed", zap.Error(err))
return err
}
log.Info("GC V3 segment files done")
return nil
}
binlog.DecompressBinLogs(cloned.SegmentInfo)
logs := getLogs(cloned)
for key := range getTextLogs(cloned) {
logs[key] = struct{}{}
}
for key := range getJSONKeyLogs(cloned, gc) {
logs[key] = struct{}{}
}
for key := range indexFiles {
logs[key] = struct{}{}
}
log.Info("GC segment start...", zap.Int("insert_logs", len(cloned.GetBinlogs())),
zap.Int("delta_logs", len(cloned.GetDeltalogs())),
zap.Int("stats_logs", len(cloned.GetStatslogs())),
zap.Int("bm25_logs", len(cloned.GetBm25Statslogs())),
zap.Int("text_logs", len(cloned.GetTextStatsLogs())),
zap.Int("json_key_logs", len(cloned.GetJsonKeyStats())),
zap.Int("index_files", len(indexFiles)))
if err := gc.removeObjectFiles(ctx, logs); err != nil {
log.Warn("GC segment remove logs failed", zap.Error(err))
return err
}
return nil
}
func (gc *garbageCollector) removeDroppedSegmentIndexMeta(ctx context.Context, segIndexes []*model.SegmentIndex) error {
if len(segIndexes) == 0 {
return nil
}
for _, segIdx := range segIndexes {
if err := gc.meta.indexMeta.RemoveSegmentIndex(ctx, segIdx.BuildID); err != nil {
return err
}
}
return nil
}
func (gc *garbageCollector) recycleChannelCPMeta(ctx context.Context, signal <-chan gcCmd) {
log := log.Ctx(ctx)
channelCPs, err := gc.meta.catalog.ListChannelCheckpoint(ctx)
if err != nil {
log.Warn("list channel cp fail during GC", zap.Error(err))
return
}
collectionID2GcStatus := make(map[int64]bool)
skippedCnt := 0
log.Info("start to GC channel cp", zap.Int("vchannelCPCnt", len(channelCPs)))
for vChannel := range channelCPs {
collectionID := funcutil.GetCollectionIDFromVChannel(vChannel)
if gc.collectionGCPaused(collectionID) {
continue
}
gc.ackSignal(signal)
// !!! Skip to GC if vChannel format is illegal, it will lead meta leak in this case
if collectionID == -1 {
skippedCnt++
log.Warn("parse collection id fail, skip to gc channel cp", zap.String("vchannel", vChannel))
continue
}
_, ok := collectionID2GcStatus[collectionID]
if !ok {
if ctx.Err() != nil {
// process canceled, stop.
return
}
timeoutCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
has, err := gc.option.broker.HasCollection(timeoutCtx, collectionID)
if err == nil && !has {
collectionID2GcStatus[collectionID] = gc.meta.catalog.GcConfirm(ctx, collectionID, -1)
} else {
// skip checkpoints GC of this cycle if describe collection fails or the collection state is available.
log.Debug("skip channel cp GC, the collection state is available",
zap.Int64("collectionID", collectionID),
zap.Bool("dropped", has), zap.Error(err))
collectionID2GcStatus[collectionID] = false
}
}
// Skip to GC if all segments meta of the corresponding collection are not removed
if gcConfirmed := collectionID2GcStatus[collectionID]; !gcConfirmed {
skippedCnt++
continue
}
err := gc.meta.DropChannelCheckpoint(vChannel)
if err != nil {
// Try to GC in the next gc cycle if drop channel cp meta fail.
log.Warn("failed to drop channelcp check point during gc", zap.String("vchannel", vChannel), zap.Error(err))
} else {
log.Info("GC channel cp", zap.String("vchannel", vChannel))
}
}
log.Info("GC channel cp done", zap.Int("skippedChannelCP", skippedCnt))
}
func (gc *garbageCollector) isExpire(dropts Timestamp) bool {
droptime := time.Unix(0, int64(dropts))
return time.Since(droptime) > gc.option.dropTolerance
}
// parseV3SegmentID attempts to parse segmentID from a V3 path format.
// V3 paths: {root}/insert_log/{coll}/{part}/{seg}/...
// Returns segmentID or error if path doesn't match.
func parseV3SegmentID(rootPath, filePath string) (int64, error) {
if !strings.HasPrefix(filePath, rootPath) {
return 0, merr.WrapErrServiceInternalMsg("path %q does not contain rootPath %q", filePath, rootPath)
}
p := strings.TrimPrefix(filePath[len(rootPath):], "/")
parts := strings.Split(p, "/")
// Minimum: insert_log/coll/part/seg/something
if len(parts) < 5 || parts[0] != common.SegmentInsertLogPath {
return 0, merr.WrapErrServiceInternalMsg("not a V3 insert_log path: %s", filePath)
}
return strconv.ParseInt(parts[3], 10, 64)
}
func getLogs(sinfo *SegmentInfo) map[string]struct{} {
logs := make(map[string]struct{})
for _, flog := range sinfo.GetBinlogs() {
for _, l := range flog.GetBinlogs() {
logs[l.GetLogPath()] = struct{}{}
}
}
for _, flog := range sinfo.GetStatslogs() {
for _, l := range flog.GetBinlogs() {
logs[l.GetLogPath()] = struct{}{}
}
}
for _, flog := range sinfo.GetDeltalogs() {
for _, l := range flog.GetBinlogs() {
logs[l.GetLogPath()] = struct{}{}
}
}
for _, flog := range sinfo.GetBm25Statslogs() {
for _, l := range flog.GetBinlogs() {
logs[l.GetLogPath()] = struct{}{}
}
}
return logs
}
func getTextLogs(sinfo *SegmentInfo) map[string]struct{} {
textLogs := make(map[string]struct{})
for _, flog := range sinfo.GetTextStatsLogs() {
for _, file := range flog.GetFiles() {
textLogs[file] = struct{}{}
}
}
return textLogs
}
func getJSONKeyLogs(sinfo *SegmentInfo, gc *garbageCollector) map[string]struct{} {
jsonkeyLogs := make(map[string]struct{})
for _, flog := range sinfo.GetJsonKeyStats() {
for _, file := range flog.GetFiles() {
prefix := fmt.Sprintf("%s/%s/%d/%d/%d/%d/%d/%d", gc.option.cli.RootPath(), common.JSONIndexPath,
flog.GetBuildID(), flog.GetVersion(), sinfo.GetCollectionID(), sinfo.GetPartitionID(), sinfo.GetID(), flog.GetFieldID())
file = path.Join(prefix, file)
jsonkeyLogs[file] = struct{}{}
}
}
return jsonkeyLogs
}
// removeObjectFiles remove file from oss storage, return error if any log failed to remove.
func (gc *garbageCollector) removeObjectFiles(ctx context.Context, filePaths map[string]struct{}) error {
futures := make([]*conc.Future[struct{}], 0)
for filePath := range filePaths {
filePath := filePath
future := gc.option.removeObjectPool.Submit(func() (struct{}, error) {
err := gc.option.cli.Remove(ctx, filePath)
// ignore the error Key Not Found
if err != nil {
if !errors.Is(err, merr.ErrIoKeyNotFound) {
return struct{}{}, err
}
log.Ctx(ctx).Info("remove log failed, key not found, may be removed at previous GC, ignore the error",
zap.String("path", filePath),
zap.Error(err))
}
return struct{}{}, nil
})
futures = append(futures, future)
}
return conc.BlockOnAll(futures...)
}
// recycleUnusedIndexes is used to delete those indexes that is deleted by collection.
func (gc *garbageCollector) recycleUnusedIndexes(ctx context.Context, signal <-chan gcCmd) {
start := time.Now()
log := log.Ctx(ctx).With(zap.String("gcName", "recycleUnusedIndexes"), zap.Time("startAt", start))
log.Info("start recycleUnusedIndexes...")
defer func() { log.Info("recycleUnusedIndexes done", zap.Duration("timeCost", time.Since(start))) }()
deletedIndexes := gc.meta.indexMeta.GetDeletedIndexes()
for _, index := range deletedIndexes {
if ctx.Err() != nil {
// process canceled.
return
}
if gc.collectionGCPaused(index.CollectionID) {
continue
}
gc.ackSignal(signal)
log := log.With(zap.Int64("collectionID", index.CollectionID), zap.Int64("fieldID", index.FieldID), zap.Int64("indexID", index.IndexID))
if err := gc.meta.indexMeta.RemoveIndex(ctx, index.CollectionID, index.IndexID); err != nil {
log.Warn("remove index on collection fail", zap.Error(err))
continue
}
log.Info("remove index on collection done")
}
}
// recycleUnusedSegIndexes remove the index of segment if index is deleted or segment itself is deleted.
func (gc *garbageCollector) recycleUnusedSegIndexes(ctx context.Context, signal <-chan gcCmd) {
start := time.Now()
log := log.Ctx(ctx).With(zap.String("gcName", "recycleUnusedSegIndexes"), zap.Time("startAt", start))
log.Info("start recycleUnusedSegIndexes...")
defer func() { log.Info("recycleUnusedSegIndexes done", zap.Duration("timeCost", time.Since(start))) }()
segIndexes := gc.meta.indexMeta.GetAllSegIndexes()
for _, segIdx := range segIndexes {
if ctx.Err() != nil {
// process canceled.
return
}
if gc.collectionGCPaused(segIdx.CollectionID) {
continue
}
gc.ackSignal(signal)
// 1. segment belongs to is deleted.
// 2. index is deleted.
if gc.meta.GetSegment(ctx, segIdx.SegmentID) == nil || !gc.meta.indexMeta.IsIndexExist(segIdx.CollectionID, segIdx.IndexID) {
indexFiles := gc.getAllIndexFilesOfIndex(segIdx)
log := log.With(zap.Int64("collectionID", segIdx.CollectionID),
zap.Int64("partitionID", segIdx.PartitionID),
zap.Int64("segmentID", segIdx.SegmentID),
zap.Int64("indexID", segIdx.IndexID),
zap.Int64("buildID", segIdx.BuildID),
zap.Int64("nodeID", segIdx.NodeID),
zap.Int("indexFiles", len(indexFiles)))
// Skip buildIDs protected by snapshot references. IsBuildIDGCBlocked is O(1)
// and embeds the "RefIndex not loaded → fail-closed" check.
if snapshotMeta := gc.meta.GetSnapshotMeta(); snapshotMeta != nil {
if snapshotMeta.IsBuildIDGCBlocked(segIdx.CollectionID, segIdx.BuildID) {
log.Info("skip GC segment index since buildID is protected by snapshot",
zap.Int64("collectionID", segIdx.CollectionID),
zap.Int64("buildID", segIdx.BuildID))
continue
}
}
log.Info("GC Segment Index file start...")
// Remove index files first.
if err := gc.removeObjectFiles(ctx, indexFiles); err != nil {
log.Warn("fail to remove index files for index", zap.Error(err))
continue
}
// Remove meta from index meta.
if err := gc.meta.indexMeta.RemoveSegmentIndex(ctx, segIdx.BuildID); err != nil {
log.Warn("delete index meta from etcd failed, wait to retry", zap.Error(err))
continue
}
log.Info("index meta recycle success")
}
}
}
// recycleUnusedIndexFilesV0 deletes orphan files under the legacy v0 index_files prefix.
// v0 paths are rooted by buildID, so the first-level directory can be parsed and
// checked against index meta directly.
func (gc *garbageCollector) recycleUnusedIndexFilesV0(ctx context.Context) {
start := time.Now()
log := log.Ctx(ctx).With(zap.String("gcName", "recycleUnusedIndexFilesV0"), zap.Time("startAt", start))
log.Info("start recycleUnusedIndexFilesV0...")
prefix := path.Join(gc.option.cli.RootPath(), common.SegmentIndexV0Path) + "/"
// Resolve snapshotMeta once. Both IsBuildIDGCBlocked paths below are O(1) so
// no caching of intermediate state is needed.
snapshotMeta := gc.meta.GetSnapshotMeta()
// list dir first
keyCount := 0
err := gc.option.cli.WalkWithPrefix(ctx, prefix, false, func(indexPathInfo *storage.ChunkObjectInfo) bool {
key := indexPathInfo.FilePath
keyCount++
logger := log.With(zap.String("prefix", prefix), zap.String("key", key))
// This recycler only walks index_files/ (v0). Its first path level is buildID;
// v1 collectionID directories live under index_v1/ and are handled below.
buildID, err := parseBuildIDFromFilePath(key)
if err != nil {
logger.Warn("garbageCollector recycleUnusedIndexFilesV0 parseIndexFileKey", zap.Error(err))
return true
}
logger = logger.With(zap.Int64("buildID", buildID))
logger.Info("garbageCollector will recycle index files")
canRecycle, segIdx := gc.meta.indexMeta.CheckCleanSegmentIndex(buildID)
if !canRecycle {
// Even if the index is marked as deleted, the index file will not be recycled, wait for the next gc,
// and delete all index files about the buildID at one time.
logger.Info("garbageCollector can not recycle index files")
return true
}
if segIdx == nil {
// buildID no longer exists in meta. Orphan buildID walk has no collection context,
// so IsBuildIDGCBlocked(-1, buildID) fail-closes on ANY unloaded RefIndex globally.
if snapshotMeta != nil && snapshotMeta.IsBuildIDGCBlocked(-1, buildID) {
logger.Info("skip GC index files since buildID is protected by snapshot",
zap.Int64("buildID", buildID))
return true
}
// buildID no longer exists in meta, remove all index files
logger.Info("garbageCollector recycleUnusedIndexFilesV0 find meta has not exist, remove index files")
err = gc.option.cli.RemoveWithPrefix(ctx, key)
if err != nil {
logger.Warn("garbageCollector recycleUnusedIndexFilesV0 remove index files failed", zap.Error(err))
return true
}
logger.Info("garbageCollector recycleUnusedIndexFilesV0 remove index files success")
return true
}
// Skip buildIDs protected by snapshot references. IsBuildIDGCBlocked is O(1)
// and embeds the "RefIndex not loaded → fail-closed" check.
if snapshotMeta != nil {
if snapshotMeta.IsBuildIDGCBlocked(segIdx.CollectionID, segIdx.BuildID) {
logger.Info("skip GC index files since buildID is protected by snapshot",
zap.Int64("collectionID", segIdx.CollectionID),
zap.Int64("buildID", segIdx.BuildID))
return true
}
}
filesMap := gc.getAllIndexFilesOfIndex(segIdx)
logger.Info("recycle index files", zap.Int("meta files num", len(filesMap)))
deletedFilesNum := atomic.NewInt32(0)
fileNum := 0
futures := make([]*conc.Future[struct{}], 0)
err = gc.option.cli.WalkWithPrefix(ctx, key, true, func(indexFile *storage.ChunkObjectInfo) bool {
fileNum++
file := indexFile.FilePath
if _, ok := filesMap[file]; !ok {
future := gc.option.removeObjectPool.Submit(func() (struct{}, error) {
logger := logger.With(zap.String("file", file))
logger.Info("garbageCollector recycleUnusedIndexFilesV0 remove file...")
if err := gc.option.cli.Remove(ctx, file); err != nil {
logger.Warn("garbageCollector recycleUnusedIndexFilesV0 remove file failed", zap.Error(err))
return struct{}{}, err
}
deletedFilesNum.Inc()
logger.Info("garbageCollector recycleUnusedIndexFilesV0 remove file success")
return struct{}{}, nil
})
futures = append(futures, future)
}
return true
})
// Wait for all remove tasks done.
if err := conc.BlockOnAll(futures...); err != nil {
// error is logged, and can be ignored here.
logger.Warn("some task failure in remove object pool", zap.Error(err))
}
logger = logger.With(zap.Int("deleteIndexFilesNum", int(deletedFilesNum.Load())), zap.Int("walkFileNum", fileNum))
if err != nil {
logger.Warn("index files recycle failed when walk with prefix", zap.Error(err))
return true
}
logger.Info("index files recycle done")
return true
})
log = log.With(zap.Duration("timeCost", time.Since(start)), zap.Int("keyCount", keyCount), zap.Error(err))
if err != nil {
log.Warn("garbageCollector recycleUnusedIndexFilesV0 failed", zap.Error(err))
return
}
log.Info("recycleUnusedIndexFilesV0 done")
}
// getAllIndexFilesOfIndex returns all expected index files using the path version
// recorded on the SegmentIndex: v0 builds index_files paths, v1 builds index_v1 paths.
func (gc *garbageCollector) getAllIndexFilesOfIndex(segmentIndex *model.SegmentIndex) map[string]struct{} {
builder := metautil.NewIndexPathBuilder(gc.option.cli.RootPath(),
segmentIndex.IndexStorePathVersion, segmentIndex.CollectionID,
segmentIndex.PartitionID, segmentIndex.SegmentID,
segmentIndex.BuildID, segmentIndex.IndexVersion)
filesMap := make(map[string]struct{})
for _, fileID := range segmentIndex.IndexFileKeys {
filesMap[builder.BuildFilePath(fileID)] = struct{}{}
}
return filesMap
}
// recycleUnusedIndexFilesV1 cleans index files for v1 format entries (collection-partitioned paths).
// v1 uses the separate index_v1 prefix and puts collectionID before buildID,
// so GC iterates deleted metadata entries instead of trying to parse buildID from a prefix walk.
func (gc *garbageCollector) recycleUnusedIndexFilesV1(ctx context.Context) {
log := log.Ctx(ctx).With(zap.String("gcName", "recycleUnusedIndexFilesV1"))
snapshotMeta := gc.meta.GetSnapshotMeta()
deletedIndexes := gc.meta.indexMeta.GetDeletedIndexesWithV1Path()
if len(deletedIndexes) == 0 {
return
}
log.Info("start recycleUnusedIndexFilesV1", zap.Int("deletedCount", len(deletedIndexes)))
futures := make([]*conc.Future[struct{}], 0, len(deletedIndexes))
for _, segIdx := range deletedIndexes {
segIdx := segIdx
if snapshotMeta != nil && snapshotMeta.IsBuildIDGCBlocked(segIdx.CollectionID, segIdx.BuildID) {
log.Info("skip GC v1 index files since buildID is protected by snapshot",
zap.Int64("collectionID", segIdx.CollectionID),
zap.Int64("buildID", segIdx.BuildID))
continue
}
future := gc.option.removeObjectPool.Submit(func() (struct{}, error) {
builder := metautil.NewIndexPathBuilder(gc.option.cli.RootPath(),
segIdx.IndexStorePathVersion, segIdx.CollectionID,
segIdx.PartitionID, segIdx.SegmentID,
segIdx.BuildID, segIdx.IndexVersion)
prefix := builder.BuildPrefix() + "/"
if err := gc.option.cli.RemoveWithPrefix(ctx, prefix); err != nil {
log.Warn("recycleUnusedIndexFilesV1 remove failed",
zap.Int64("collectionID", segIdx.CollectionID),
zap.Int64("partitionID", segIdx.PartitionID),
zap.Int64("segmentID", segIdx.SegmentID),
zap.Int64("buildID", segIdx.BuildID),
zap.Int64("indexID", segIdx.IndexID),
zap.Stringer("pathVersion", segIdx.IndexStorePathVersion),
zap.String("prefix", prefix),
zap.Error(err))
return struct{}{}, err
}
if err := gc.meta.indexMeta.RemoveSegmentIndex(ctx, segIdx.BuildID); err != nil {
log.Warn("recycleUnusedIndexFilesV1 remove segment index meta failed",
zap.Int64("collectionID", segIdx.CollectionID),
zap.Int64("partitionID", segIdx.PartitionID),
zap.Int64("segmentID", segIdx.SegmentID),
zap.Int64("buildID", segIdx.BuildID),
zap.Int64("indexID", segIdx.IndexID),
zap.Stringer("pathVersion", segIdx.IndexStorePathVersion),
zap.String("prefix", prefix),
zap.Error(err))
return struct{}{}, err
}
log.Info("recycleUnusedIndexFilesV1 removed index files and meta",
zap.Int64("collectionID", segIdx.CollectionID),
zap.Int64("partitionID", segIdx.PartitionID),
zap.Int64("segmentID", segIdx.SegmentID),
zap.Int64("buildID", segIdx.BuildID),
zap.Int64("indexID", segIdx.IndexID),
zap.Stringer("pathVersion", segIdx.IndexStorePathVersion),
zap.String("prefix", prefix))
return struct{}{}, nil
})
futures = append(futures, future)
}
if err := conc.BlockOnAll(futures...); err != nil {
log.Warn("some task failure in remove object pool", zap.Error(err))
}
}
// recycleUnusedAnalyzeFiles is used to delete those analyze stats files that no longer exist in the meta.
func (gc *garbageCollector) recycleUnusedAnalyzeFiles(ctx context.Context, signal <-chan gcCmd) {
log := log.Ctx(ctx)
log.Info("start recycleUnusedAnalyzeFiles")
startTs := time.Now()
prefix := path.Join(gc.option.cli.RootPath(), common.AnalyzeStatsPath) + "/"
// list dir first
keys := make([]string, 0)
err := gc.option.cli.WalkWithPrefix(ctx, prefix, false, func(chunkInfo *storage.ChunkObjectInfo) bool {
keys = append(keys, chunkInfo.FilePath)
return true
})
if err != nil {
log.Warn("garbageCollector recycleUnusedAnalyzeFiles list keys from chunk manager failed", zap.Error(err))
return
}
log.Info("recycleUnusedAnalyzeFiles, finish list object", zap.Duration("time spent", time.Since(startTs)), zap.Int("task ids", len(keys)))
for _, key := range keys {
if ctx.Err() != nil {
// process canceled
return
}
// collection gc pause not affect analyze file for now
gc.ackSignal(signal)
log.Debug("analyze keys", zap.String("key", key))
taskID, err := parseBuildIDFromFilePath(key)
if err != nil {
log.Warn("garbageCollector recycleUnusedAnalyzeFiles parseAnalyzeResult failed", zap.String("key", key), zap.Error(err))
continue
}
log.Info("garbageCollector will recycle analyze stats files", zap.Int64("taskID", taskID))
canRecycle, task := gc.meta.analyzeMeta.CheckCleanAnalyzeTask(taskID)
if !canRecycle {
// Even if the analysis task is marked as deleted, the analysis stats file will not be recycled, wait for the next gc,
// and delete all index files about the taskID at one time.
log.Info("garbageCollector no need to recycle analyze stats files", zap.Int64("taskID", taskID))
continue
}
if task == nil {
// taskID no longer exists in meta, remove all analysis files
log.Info("garbageCollector recycleUnusedAnalyzeFiles find meta has not exist, remove index files",
zap.Int64("taskID", taskID))
err = gc.option.cli.RemoveWithPrefix(ctx, key)
if err != nil {
log.Warn("garbageCollector recycleUnusedAnalyzeFiles remove analyze stats files failed",
zap.Int64("taskID", taskID), zap.String("prefix", key), zap.Error(err))
continue
}
log.Info("garbageCollector recycleUnusedAnalyzeFiles remove analyze stats files success",
zap.Int64("taskID", taskID), zap.String("prefix", key))
continue
}
log.Info("remove analyze stats files which version is less than current task",
zap.Int64("taskID", taskID), zap.Int64("current version", task.Version))
var i int64
for i = 0; i < task.Version; i++ {
if ctx.Err() != nil {
// process canceled.
return
}
removePrefix := prefix + fmt.Sprintf("%d/", task.Version)
if err := gc.option.cli.RemoveWithPrefix(ctx, removePrefix); err != nil {
log.Warn("garbageCollector recycleUnusedAnalyzeFiles remove files with prefix failed",
zap.Int64("taskID", taskID), zap.String("removePrefix", removePrefix))
continue
}
}
log.Info("analyze stats files recycle success", zap.Int64("taskID", taskID))
}
}
// recycleUnusedTextIndexFiles load meta file info and compares OSS keys
// if missing found, performs gc cleanup
func (gc *garbageCollector) recycleUnusedTextIndexFiles(ctx context.Context, signal <-chan gcCmd) {
start := time.Now()
log := log.Ctx(ctx).With(zap.String("gcName", "recycleUnusedTextIndexFiles"), zap.Time("startAt", start))
log.Info("start recycleUnusedTextIndexFiles...")
defer func() { log.Info("recycleUnusedTextIndexFiles done", zap.Duration("timeCost", time.Since(start))) }()
hasTextIndexSegments := gc.meta.SelectSegments(ctx, SegmentFilterFunc(func(info *SegmentInfo) bool {
return len(info.GetTextStatsLogs()) != 0
}))
fileNum := 0
deletedFilesNum := atomic.NewInt32(0)
snapshotMeta := gc.meta.GetSnapshotMeta()
for _, seg := range hasTextIndexSegments {
if ctx.Err() != nil {
// process canceled, stop.
return
}
if gc.collectionGCPaused(seg.GetCollectionID()) {
log.Info("skip GC segment since collection is paused", zap.Int64("segmentID", seg.GetID()), zap.Int64("collectionID", seg.GetCollectionID()))
continue
}
// Skip segments whose files are still referenced by snapshots. IsSegmentGCBlocked
// is O(1) and embeds the "RefIndex not loaded → fail-closed" check.
if snapshotMeta != nil && snapshotMeta.IsSegmentGCBlocked(seg.GetCollectionID(), seg.GetID()) {
log.Info("skip GC text index files since segment is protected by snapshot",
zap.Int64("segmentID", seg.GetID()),
zap.Int64("collectionID", seg.GetCollectionID()))
continue
}
gc.ackSignal(signal)
for _, fieldStats := range seg.GetTextStatsLogs() {
log := log.With(zap.Int64("segmentID", seg.GetID()), zap.Int64("fieldID", fieldStats.GetFieldID()))
// clear low version task
for i := int64(1); i < fieldStats.GetVersion(); i++ {
prefix := metautil.BuildTextIndexPrefix(gc.option.cli.RootPath(),
fieldStats.GetBuildID(), i, seg.GetCollectionID(), seg.GetPartitionID(), seg.GetID(), fieldStats.GetFieldID())
futures := make([]*conc.Future[struct{}], 0)
err := gc.option.cli.WalkWithPrefix(ctx, prefix, true, func(files *storage.ChunkObjectInfo) bool {
file := files.FilePath
future := gc.option.removeObjectPool.Submit(func() (struct{}, error) {
log := log.With(zap.String("file", file))
log.Info("garbageCollector recycleUnusedTextIndexFiles remove file...")
if err := gc.option.cli.Remove(ctx, file); err != nil {
log.Warn("garbageCollector recycleUnusedTextIndexFiles remove file failed", zap.Error(err))
return struct{}{}, err
}
deletedFilesNum.Inc()
log.Info("garbageCollector recycleUnusedTextIndexFiles remove file success")
return struct{}{}, nil
})
futures = append(futures, future)
return true
})
// Wait for all remove tasks done.
if err := conc.BlockOnAll(futures...); err != nil {
// error is logged, and can be ignored here.
log.Warn("some task failure in remove object pool", zap.Error(err))
}
log = log.With(zap.Int("deleteIndexFilesNum", int(deletedFilesNum.Load())), zap.Int("walkFileNum", fileNum))
if err != nil {
log.Warn("text index files recycle failed when walk with prefix", zap.Error(err))
return
}
}
}
}
log.Info("text index files recycle done")
metrics.GarbageCollectorRunCount.WithLabelValues(paramtable.GetStringNodeID()).Add(1)
}
// recycleUnusedJSONStatsFiles load meta file info and compares OSS keys
// if missing found, performs gc cleanup
func (gc *garbageCollector) recycleUnusedJSONStatsFiles(ctx context.Context, signal <-chan gcCmd) {
start := time.Now()
log := log.Ctx(ctx).With(zap.String("gcName", "recycleUnusedJSONStatsFiles"), zap.Time("startAt", start))
log.Info("start recycleUnusedJSONStatsFiles...")
defer func() { log.Info("recycleUnusedJSONStatsFiles done", zap.Duration("timeCost", time.Since(start))) }()
hasJSONStatsSegments := gc.meta.SelectSegments(ctx, SegmentFilterFunc(func(info *SegmentInfo) bool {
return len(info.GetJsonKeyStats()) != 0
}))
fileNum := 0
deletedFilesNum := atomic.NewInt32(0)
snapshotMeta := gc.meta.GetSnapshotMeta()
for _, seg := range hasJSONStatsSegments {
if ctx.Err() != nil {
// process canceled, stop.
return
}
if gc.collectionGCPaused(seg.GetCollectionID()) {
log.Info("skip GC segment since collection is paused", zap.Int64("segmentID", seg.GetID()), zap.Int64("collectionID", seg.GetCollectionID()))
continue
}
// Skip segments whose files are still referenced by snapshots.
if snapshotMeta != nil && snapshotMeta.IsSegmentGCBlocked(seg.GetCollectionID(), seg.GetID()) {
log.Info("skip GC JSON stats files since segment is protected by snapshot",
zap.Int64("segmentID", seg.GetID()),
zap.Int64("collectionID", seg.GetCollectionID()))
continue
}
gc.ackSignal(signal)
for _, fieldStats := range seg.GetJsonKeyStats() {
log := log.With(zap.Int64("segmentID", seg.GetID()), zap.Int64("fieldID", fieldStats.GetFieldID()))
// clear low version task
for i := int64(1); i < fieldStats.GetVersion(); i++ {
prefix := metautil.BuildJSONKeyStatsPrefix(gc.option.cli.RootPath(), fieldStats.GetJsonKeyStatsDataFormat(),
fieldStats.GetBuildID(), i, seg.GetCollectionID(), seg.GetPartitionID(), seg.GetID(), fieldStats.GetFieldID())
futures := make([]*conc.Future[struct{}], 0)
err := gc.option.cli.WalkWithPrefix(ctx, prefix, true, func(files *storage.ChunkObjectInfo) bool {
file := files.FilePath
future := gc.option.removeObjectPool.Submit(func() (struct{}, error) {
log := log.With(zap.String("file", file))
log.Info("garbageCollector recycleUnusedJSONStatsFiles remove file...")
if err := gc.option.cli.Remove(ctx, file); err != nil {
log.Warn("garbageCollector recycleUnusedJSONStatsFiles remove file failed", zap.Error(err))
return struct{}{}, err
}
deletedFilesNum.Inc()
log.Info("garbageCollector recycleUnusedJSONStatsFiles remove file success")
return struct{}{}, nil
})
futures = append(futures, future)
return true
})
// Wait for all remove tasks done.
if err := conc.BlockOnAll(futures...); err != nil {
// error is logged, and can be ignored here.
log.Warn("some task failure in remove object pool", zap.Error(err))
}
if err != nil {
log.Warn("json stats files recycle failed when walk with prefix", zap.Error(err))
return
}
}
// clear low data format version stats file
// for upgrade from old version to new version, we need to clear the old data format version stats file
for i := int64(1); i < fieldStats.GetJsonKeyStatsDataFormat(); i++ {
prefix := fmt.Sprintf("%s/%s/%d", gc.option.cli.RootPath(), common.JSONStatsPath, i)
futures := make([]*conc.Future[struct{}], 0)
err := gc.option.cli.WalkWithPrefix(ctx, prefix, true, func(files *storage.ChunkObjectInfo) bool {
file := files.FilePath
future := gc.option.removeObjectPool.Submit(func() (struct{}, error) {
log := log.With(zap.String("file", file))
log.Info("garbageCollector recycleUnusedJSONStatsFiles remove file...")
if err := gc.option.cli.Remove(ctx, file); err != nil {
log.Warn("garbageCollector recycleUnusedJSONStatsFiles remove file failed", zap.Error(err))
return struct{}{}, err
}
deletedFilesNum.Inc()
log.Info("garbageCollector recycleUnusedJSONStatsFiles remove file success")
return struct{}{}, nil
})
futures = append(futures, future)
return true
})
// Wait for all remove tasks done.
if err := conc.BlockOnAll(futures...); err != nil {
// error is logged, and can be ignored here.
log.Warn("some task failure in remove object pool", zap.Error(err))
}
if err != nil {
log.Warn("json stats lower data format files recycle failed when walk with prefix", zap.Error(err))
return
}
}
}
}
log.Info("json stats files recycle done",
zap.Int("deleteJSONStatsNum", int(deletedFilesNum.Load())),
zap.Int("walkFileNum", fileNum))
metrics.GarbageCollectorRunCount.WithLabelValues(paramtable.GetStringNodeID()).Add(1)
}
// recycleUnusedJSONIndexFiles load meta file info and compares OSS keys
func (gc *garbageCollector) recycleUnusedJSONIndexFiles(ctx context.Context, signal <-chan gcCmd) {
start := time.Now()
log := log.Ctx(ctx).With(zap.String("gcName", "recycleUnusedJSONIndexFiles"), zap.Time("startAt", start))
log.Info("start recycleUnusedJSONIndexFiles...")
defer func() { log.Info("recycleUnusedJSONIndexFiles done", zap.Duration("timeCost", time.Since(start))) }()
hasJSONIndexSegments := gc.meta.SelectSegments(ctx, SegmentFilterFunc(func(info *SegmentInfo) bool {
return len(info.GetJsonKeyStats()) != 0
}))
fileNum := 0
deletedFilesNum := atomic.NewInt32(0)
for _, seg := range hasJSONIndexSegments {
if ctx.Err() != nil {
// process canceled, stop.
return
}
if gc.collectionGCPaused(seg.GetCollectionID()) {
log.Info("skip GC segment since collection is paused", zap.Int64("segmentID", seg.GetID()), zap.Int64("collectionID", seg.GetCollectionID()))
continue
}
// Skip segments whose files are still referenced by snapshots.
if snapshotMeta := gc.meta.GetSnapshotMeta(); snapshotMeta != nil {
if snapshotMeta.IsSegmentGCBlocked(seg.GetCollectionID(), seg.GetID()) {
log.Info("skip GC JSON index files since segment is protected by snapshot",
zap.Int64("segmentID", seg.GetID()),
zap.Int64("collectionID", seg.GetCollectionID()))
continue
}
}
gc.ackSignal(signal)
for _, fieldStats := range seg.GetJsonKeyStats() {
log := log.With(zap.Int64("segmentID", seg.GetID()), zap.Int64("fieldID", fieldStats.GetFieldID()))
// clear low version task
for i := int64(1); i < fieldStats.GetVersion(); i++ {
prefix := fmt.Sprintf("%s/%s/%d/%d/%d/%d/%d/%d", gc.option.cli.RootPath(), common.JSONIndexPath,
fieldStats.GetBuildID(), i, seg.GetCollectionID(), seg.GetPartitionID(), seg.GetID(), fieldStats.GetFieldID())
futures := make([]*conc.Future[struct{}], 0)
err := gc.option.cli.WalkWithPrefix(ctx, prefix, true, func(files *storage.ChunkObjectInfo) bool {
file := files.FilePath
future := gc.option.removeObjectPool.Submit(func() (struct{}, error) {
log := log.With(zap.String("file", file))
log.Info("garbageCollector recycleUnusedJSONIndexFiles remove file...")
if err := gc.option.cli.Remove(ctx, file); err != nil {
log.Warn("garbageCollector recycleUnusedJSONIndexFiles remove file failed", zap.Error(err))
return struct{}{}, err
}
deletedFilesNum.Inc()
log.Info("garbageCollector recycleUnusedJSONIndexFiles remove file success")
return struct{}{}, nil
})
futures = append(futures, future)
return true
})
// Wait for all remove tasks done.
if err := conc.BlockOnAll(futures...); err != nil {
// error is logged, and can be ignored here.
log.Warn("some task failure in remove object pool", zap.Error(err))
}
if err != nil {
log.Warn("json index files recycle failed when walk with prefix", zap.Error(err))
return
}
}
}
}
log.Info("json index files recycle done", zap.Int("deleteJSONKeyIndexNum", int(deletedFilesNum.Load())), zap.Int("walkFileNum", fileNum))
metrics.GarbageCollectorRunCount.WithLabelValues(paramtable.GetStringNodeID()).Add(1)
}
// recycleSnapshots cleans up snapshot resources in three phases:
// 1. PENDING snapshots: Failed 2PC commits that exceeded timeout — clean S3 + catalog.
// 2. DELETING snapshots: DropSnapshot succeeded but S3 cleanup failed — retry S3 + catalog.
// 3. Orphan snapshots: Snapshots whose collection was dropped — clean expired pins, then drop.
//
// Process flow:
// 1. Get all PENDING snapshots from catalog that have exceeded timeout.
// 2. For each pending snapshot:
// a. Compute manifest directory and metadata file path from snapshot ID.
// b. Delete manifest directory using RemoveWithPrefix.
// c. Delete metadata file.
// d. Delete catalog (etcd) record.
//
// Failure handling:
// - For PENDING snapshots, if any S3 cleanup step fails (b/c), GC will NOT
// delete the catalog record. This keeps the snapshot eligible for retry in
// the next GC cycle, ensuring we do not lose the ability to clean up S3
// artifacts.
func (gc *garbageCollector) recycleSnapshots(ctx context.Context, signal <-chan gcCmd) {
start := time.Now()
log := log.Ctx(ctx).With(zap.String("gcName", "recycleSnapshots"), zap.Time("startAt", start))
log.Info("start recycleSnapshots...")
defer func() { log.Info("recycleSnapshots done", zap.Duration("timeCost", time.Since(start))) }()
snapshotMeta := gc.meta.GetSnapshotMeta()
if snapshotMeta == nil {
log.Warn("snapshotMeta is nil, skip recycleSnapshots")
return
}
// Get pending timeout from config
pendingTimeout := paramtable.Get().DataCoordCfg.SnapshotPendingTimeout.GetAsDuration(time.Minute)
// Get all pending snapshots that have exceeded timeout
pendingSnapshots, err := snapshotMeta.GetPendingSnapshots(ctx, pendingTimeout)
if err != nil {
log.Warn("failed to get pending snapshots", zap.Error(err))
return
}
if len(pendingSnapshots) > 0 {
log.Info("found pending snapshots to cleanup", zap.Int("count", len(pendingSnapshots)))
cleanedCount := 0
for _, snapshot := range pendingSnapshots {
snapshotLog := log.With(
zap.String("snapshotName", snapshot.GetName()),
zap.Int64("snapshotID", snapshot.GetId()),
zap.Int64("collectionID", snapshot.GetCollectionId()),
)
gc.ackSignal(signal)
// Compute paths from collection_id + snapshot_id
manifestDir, metadataPath := GetSnapshotPaths(
gc.option.cli.RootPath(),
snapshot.GetCollectionId(),
snapshot.GetId(),
)
snapshotLog.Info("cleaning up pending snapshot",
zap.String("manifestDir", manifestDir),
zap.String("metadataPath", metadataPath))
// Delete manifest directory using RemoveWithPrefix (no list needed)
// This removes all segment manifest files: manifests/{snapshot_id}/*.avro
if err := gc.option.cli.RemoveWithPrefix(ctx, manifestDir); err != nil {
snapshotLog.Warn("failed to remove pending snapshot manifest directory", zap.Error(err))
// Keep catalog record for retry in next GC cycle.
continue
}
// Delete metadata file
if err := gc.option.cli.Remove(ctx, metadataPath); err != nil {
snapshotLog.Warn("failed to remove pending snapshot metadata file", zap.Error(err))
// Keep catalog record for retry in next GC cycle.
continue
}
// Delete etcd record
if err := snapshotMeta.CleanupPendingSnapshot(ctx, snapshot); err != nil {
snapshotLog.Warn("failed to drop pending snapshot from catalog", zap.Error(err))
continue
}
snapshotLog.Info("successfully cleaned up pending snapshot")
cleanedCount++
}
log.Info("pending snapshots cleanup completed",
zap.Int("totalPending", len(pendingSnapshots)),
zap.Int("cleanedCount", cleanedCount))
}
// Clean up DELETING snapshots (two-phase delete cleanup)
// These are snapshots that were marked for deletion but S3 cleanup failed
deletingSnapshots, err := snapshotMeta.GetDeletingSnapshots(ctx)
if err != nil {
log.Warn("failed to get deleting snapshots", zap.Error(err))
} else if len(deletingSnapshots) > 0 {
log.Info("found deleting snapshots to cleanup", zap.Int("count", len(deletingSnapshots)))
deletingCleanedCount := 0
for _, snapshot := range deletingSnapshots {
snapshotLog := log.With(
zap.String("snapshotName", snapshot.GetName()),
zap.Int64("snapshotID", snapshot.GetId()),
zap.Int64("collectionID", snapshot.GetCollectionId()),
)
gc.ackSignal(signal)
// Compute paths from collection_id + snapshot_id
manifestDir, metadataPath := GetSnapshotPaths(
gc.option.cli.RootPath(),
snapshot.GetCollectionId(),
snapshot.GetId(),
)
snapshotLog.Info("cleaning up deleting snapshot",
zap.String("manifestDir", manifestDir),
zap.String("metadataPath", metadataPath))
// Delete manifest directory
if err := gc.option.cli.RemoveWithPrefix(ctx, manifestDir); err != nil {
snapshotLog.Warn("failed to remove deleting snapshot manifest directory", zap.Error(err))
// Continue with metadata and etcd cleanup even if S3 cleanup fails
}
// Delete metadata file
if err := gc.option.cli.Remove(ctx, metadataPath); err != nil {
snapshotLog.Warn("failed to remove deleting snapshot metadata file", zap.Error(err))
// Continue with etcd cleanup even if S3 cleanup fails
}
// Delete etcd record
if err := snapshotMeta.CleanupDeletingSnapshot(ctx, snapshot); err != nil {
snapshotLog.Warn("failed to drop deleting snapshot from catalog", zap.Error(err))
continue
}
snapshotLog.Info("successfully cleaned up deleting snapshot")
deletingCleanedCount++
}
log.Info("deleting snapshots cleanup completed",
zap.Int("totalDeleting", len(deletingSnapshots)),
zap.Int("cleanedCount", deletingCleanedCount))
}
// GC fallback: Two responsibilities per collection:
// 1. For EVERY collection with snapshot records, reap expired pin entries
// from SnapshotInfo to bound etcd storage growth. Orphan pins
// (crashed restores, swallowed Unpin errors) would otherwise accumulate
// forever since Pin/Unpin only touch their own entries.
// 2. For collections whose owning collection was DROPPED, cascade-delete
// the orphan snapshots. Handles the case where the drop-collection
// cascade callback failed to fully clean up.
activeCollectionIDs := snapshotMeta.GetActiveCollectionIDs()
if len(activeCollectionIDs) > 0 {
orphanCleanedCount := 0
for _, collectionID := range activeCollectionIDs {
gc.ackSignal(signal)
if ctx.Err() != nil {
log.Warn("context canceled, stop snapshot cleanup")
break
}
// Step 1: reap expired pins regardless of collection liveness.
for _, r := range snapshotMeta.cleanExpiredPinsForCollection(ctx, collectionID) {
setSnapshotActivePinsGauge(r.CollectionID, r.SnapshotName, r.ActivePins)
}
// Step 2: if the collection was dropped, cascade-delete orphan snapshots.
timeoutCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
has, err := gc.option.broker.HasCollection(timeoutCtx, collectionID)
cancel()
if err != nil {
log.Warn("failed to check collection existence for orphan snapshot cleanup",
zap.Int64("collectionID", collectionID),
zap.Error(err))
continue
}
if has {
// Collection still exists, not an orphan — expired pins already reaped above.
continue
}
log.Info("found orphan snapshots for dropped collection, cleaning up",
zap.Int64("collectionID", collectionID))
dropped, err := snapshotMeta.DropSnapshotsByCollection(ctx, collectionID)
for _, n := range dropped {
setSnapshotActivePinsGauge(collectionID, n, 0)
}
if err != nil {
log.Warn("failed to drop orphan snapshots for collection",
zap.Int64("collectionID", collectionID),
zap.Error(err))
continue
}
log.Info("successfully cleaned up orphan snapshots for dropped collection",
zap.Int64("collectionID", collectionID))
orphanCleanedCount++
}
if orphanCleanedCount > 0 {
log.Info("orphan snapshots cleanup completed",
zap.Int("totalOrphanCollections", len(activeCollectionIDs)),
zap.Int("cleanedCount", orphanCleanedCount))
}
}
metrics.GarbageCollectorRunCount.WithLabelValues(paramtable.GetStringNodeID()).Add(1)
}