mirror of
https://github.com/milvus-io/milvus.git
synced 2026-07-21 02:05:41 +00:00
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>
2012 lines
72 KiB
Go
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)
|
|
}
|