Files
seaweedfs/weed/worker/tasks/vacuum/detection.go
T
ac319fe318 volume: never compact a volume tiered to remote storage (#11895)
* volume: never compact a volume tiered to remote storage

A tiered volume's data backend is the remote object, but compaction reads
the local .dat path. When tiering removed the local .dat, every vacuum of a
tiered volume with garbage fails with "no such file or directory", and the
master's sweep and the worker's vacuum task keep selecting it. When the
local .dat was kept, the compaction succeeds against the stale copy and the
commit reloads the volume to read the remote object (old layout) through the
compacted index, so live needles stop reading.

Mirror the Rust volume server (#11545):
- CompactByIndex and CompactByVolumeData refuse a volume with a remote file
  before creating .cpd/.cpx ("volume N is tiered to remote storage, cannot
  compact").
- CommitCompact refuses under the data-file locks when the volume became
  tiered during the copy, and removes .cpd/.cpx.
- Store.CheckCompactVolume reports no garbage for a tiered volume, so the
  master's vacuum and volume.vacuum do not select it.
- The worker's vacuum detection skips a volume with a remote copy, as balance
  and EC detection already do.

Tests (each fails on master): TestCompactRefusesTieredVolume,
TestCommitCompactRefusesVolumeTieredDuringCopy (on master the commit
succeeds and a surviving needle no longer reads),
TestPickPrimaryReplicaSkipsTieredVolumes.

* volume: skip tiered replicas in vacuum selection, keep their real ratio

Reporting a zero garbage ratio for a tiered volume hid it from
volume.tier.compact, whose preflight reads that ratio while the volume is
still remote, and did not exclude it from a vacuum with a zero threshold.
Report the real ratio again, as the Rust volume server does, and have the
master's vacuum check skip a replica whose volume info names remote
storage, whatever the threshold or request.

When CommitCompact refuses a volume tiered during the copy, also close the
temporary needle map and remove .cpldb: nothing else would, since no later
vacuum selects the volume.

The tiered-compaction test no longer depends on random needle sizes
reaching a 0.3 garbage ratio.

* volume tier: do not attach a copy of a .dat a compaction replaced

The upload copies the .dat, then records the remote file in the .vif and
switches the backend. A compaction commit in between swaps in a compacted
.dat and index; the upload then served the old-layout remote object through
the new index, so live needles stopped reading. CommitCompact's tiered
check cannot see this: the volume is not tiered until the switch.

AttachRemoteDatFile records the remote file and switches the backend under
the data-file locks, and refuses when the backend is no longer the file
that was copied. The upload then removes the unused remote copy.

* volume tier: keep a committed .vif entry when only the dir fsync fails

AttachRemoteDatFile rolled back the in-memory remote entry on any
SaveVolumeInfo error. A NotCrashDurableError comes after the rename, so
the .vif on disk already names the copy; with the entry gone, the upload
handler deleted that object, and a reload would serve from a missing
remote file. Keep the entry and switch to the copy as the .vif says,
and still return the error so the caller keeps the local .dat.

* vacuum worker: filter tiered replicas instead of skipping the volume

A partially tiered volume is the leftover of an interrupted
volume.tier.upload; skipping the whole volume left garbage on its local
replicas unreclaimed. Per-replica filtering matches the master's
batchVacuumVolumeCheck and is safe: a tiered replica refuses both
compaction and commit.

---------

Co-authored-by: Javier de la Torre <jatorre@lakehousebox.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
2026-10-21 20:16:02 -07:00

235 lines
7.1 KiB
Go

package vacuum
import (
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/util"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// Detection implements the detection logic for vacuum tasks
func Detection(metrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterInfo, config base.TaskConfig) ([]*types.TaskDetectionResult, error) {
if !config.IsEnabled() {
return nil, nil
}
vacuumConfig := config.(*Config)
minVolumeAge := time.Duration(vacuumConfig.MinVolumeAgeSeconds) * time.Second
type volumeBucket struct {
replicas []*types.VolumeHealthMetrics
}
buckets := make(map[uint32]*volumeBucket)
order := make([]uint32, 0)
for _, m := range metrics {
if m == nil {
continue
}
b, ok := buckets[m.VolumeID]
if !ok {
b = &volumeBucket{}
buckets[m.VolumeID] = b
order = append(order, m.VolumeID)
}
b.replicas = append(b.replicas, m)
}
var results []*types.TaskDetectionResult
skippedDueToGarbage := 0
skippedDueToAge := 0
debugCount := 0
for _, vid := range order {
replicas := buckets[vid].replicas
// Pick the replica with the highest garbage ratio that also satisfies
// the minimum-age gate as the "primary" used for priority + reason.
// Master built-in vacuum filters per-replica again inside
// batchVacuumVolumeCheck, so it's fine if only some replicas are
// above threshold here — the worker's checkVacuumEligibility will
// filter again at execute time.
primary := pickPrimaryReplica(replicas, vacuumConfig.GarbageThreshold, minVolumeAge)
if primary == nil {
recordSkipReasons(replicas, vacuumConfig.GarbageThreshold, minVolumeAge, &skippedDueToGarbage, &skippedDueToAge, &debugCount)
continue
}
priority := types.TaskPriorityNormal
if primary.GarbageRatio > 0.6 {
priority = types.TaskPriorityHigh
}
taskID := fmt.Sprintf("vacuum_vol_%d_%d", vid, time.Now().Unix())
result := &types.TaskDetectionResult{
TaskID: taskID,
TaskType: types.TaskTypeVacuum,
VolumeID: vid,
Server: primary.Server,
Collection: primary.Collection,
Priority: priority,
Reason: "Volume has excessive garbage requiring vacuum",
ScheduleAt: time.Now(),
}
// Check if ANY task already exists in ActiveTopology for this volume
if clusterInfo != nil && clusterInfo.ActiveTopology != nil {
if clusterInfo.ActiveTopology.HasAnyTask(vid) {
glog.V(2).Infof("VACUUM: Skipping volume %d, task already exists in ActiveTopology", vid)
continue
}
}
result.TypedParams = createVacuumTaskParams(result, replicas, vacuumConfig, clusterInfo)
if result.TypedParams != nil {
results = append(results, result)
}
}
if len(results) == 0 && len(metrics) > 0 {
totalVolumes := len(metrics)
glog.V(1).Infof("VACUUM: No tasks created for %d volume replicas across %d volumes. Threshold=%.2f%%, MinAge=%s. Skipped: %d (garbage<threshold), %d (age<minimum)",
totalVolumes, len(buckets), vacuumConfig.GarbageThreshold*100, minVolumeAge, skippedDueToGarbage, skippedDueToAge)
for i, metric := range metrics {
if i >= 3 {
break
}
glog.V(1).Infof("VACUUM: Volume %d on %s: garbage=%.2f%% (need ≥%.2f%%), age=%s (need ≥%s)",
metric.VolumeID, metric.Server, metric.GarbageRatio*100, vacuumConfig.GarbageThreshold*100,
metric.Age.Truncate(time.Minute), minVolumeAge.Truncate(time.Minute))
}
}
return results, nil
}
// pickPrimaryReplica returns the eligible replica with the highest garbage
// ratio. Returns nil if no replica satisfies both gates. Replicas tiered to
// remote storage are skipped: the volume server cannot compact them.
func pickPrimaryReplica(replicas []*types.VolumeHealthMetrics, garbageThreshold float64, minAge time.Duration) *types.VolumeHealthMetrics {
var best *types.VolumeHealthMetrics
for _, m := range replicas {
if m.HasRemoteCopy {
continue
}
if m.GarbageRatio < garbageThreshold {
continue
}
if m.Age < minAge {
continue
}
if best == nil || m.GarbageRatio > best.GarbageRatio {
best = m
}
}
return best
}
// recordSkipReasons tallies skip counters for debug logging when no
// replica of a volume qualified for vacuum.
func recordSkipReasons(replicas []*types.VolumeHealthMetrics, garbageThreshold float64, minAge time.Duration, garbageSkip, ageSkip, debugCount *int) {
for _, m := range replicas {
if *debugCount >= 5 {
return
}
if m.GarbageRatio < garbageThreshold {
*garbageSkip++
}
if m.Age < minAge {
*ageSkip++
}
*debugCount++
}
}
// createVacuumTaskParams creates typed parameters for a vacuum task whose
// Sources list contains every non-tiered replica of the volume. Worker-side
// performVacuum iterates over Sources and vacuums each replica.
func createVacuumTaskParams(task *types.TaskDetectionResult, replicas []*types.VolumeHealthMetrics, vacuumConfig *Config, clusterInfo *types.ClusterInfo) *worker_pb.TaskParams {
garbageThreshold := 0.3
verifyChecksum := true
batchSize := int32(1000)
workingDir := ""
if vacuumConfig != nil {
garbageThreshold = vacuumConfig.GarbageThreshold
}
if clusterInfo == nil || clusterInfo.ActiveTopology == nil {
glog.Errorf("Topology not available for vacuum task on volume %d, skipping", task.VolumeID)
return nil
}
sources := make([]*worker_pb.TaskSource, 0, len(replicas))
seen := make(map[string]struct{}, len(replicas))
for _, m := range replicas {
if m.HasRemoteCopy {
continue
}
address, err := util.ResolveServerAddress(m.Server, clusterInfo.ActiveTopology)
if err != nil {
glog.Warningf("Failed to resolve address for server %s for vacuum task on volume %d, dropping replica: %v", m.Server, task.VolumeID, err)
continue
}
if _, ok := seen[address]; ok {
continue
}
seen[address] = struct{}{}
sources = append(sources, &worker_pb.TaskSource{
Node: address,
VolumeId: task.VolumeID,
EstimatedSize: m.Size,
DataCenter: m.DataCenter,
Rack: m.Rack,
})
}
if len(sources) == 0 {
glog.Errorf("No resolvable replicas for vacuum task on volume %d, skipping", task.VolumeID)
return nil
}
// Use the primary replica (matches task.Server) for the canonical size
// recorded in TaskParams. Master built-in keeps a single volumeSizeLimit
// here so timeouts are stable across replicas.
canonical := primaryReplicaByServer(replicas, task.Server)
var canonicalSize uint64
if canonical != nil {
canonicalSize = canonical.Size
}
return &worker_pb.TaskParams{
TaskId: task.TaskID,
VolumeId: task.VolumeID,
Collection: task.Collection,
VolumeSize: canonicalSize,
Sources: sources,
TaskParams: &worker_pb.TaskParams_VacuumParams{
VacuumParams: &worker_pb.VacuumTaskParams{
GarbageThreshold: garbageThreshold,
ForceVacuum: false,
BatchSize: batchSize,
WorkingDir: workingDir,
VerifyChecksum: verifyChecksum,
},
},
}
}
func primaryReplicaByServer(replicas []*types.VolumeHealthMetrics, server string) *types.VolumeHealthMetrics {
for _, m := range replicas {
if m.Server == server {
return m
}
}
if len(replicas) > 0 {
return replicas[0]
}
return nil
}