mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-23 14:30:46 +02:00
* 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>
235 lines
7.1 KiB
Go
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
|
|
}
|