Files
seaweedfs/weed/worker/tasks/ec_balance/detection.go
T
3c6700c310 volume balance: stop planning moves onto disk types a server does not have (#11762)
* fix(topology): capacity queries no longer create empty disk-type entries

AvailableSpaceFor and CapacityFor called getOrCreateDisk, so asking a
node whether it had room for a disk type it lacks created a zeroed
DiskUsageCounts entry for that type. Volume growth walks every data
center, rack and data node, and ToDataNodeInfo publishes diskUsages, so
every node ended up advertising an empty disk (MaxVolumeCount 0,
DiskId 0) of every type ever grown: HDD-only servers showed an "ssd"
disk and SSD-only servers an "hdd" disk.

Read the counters through a non-creating getDisk; a missing entry
answers 0, the same numbers as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Et288CnR1mxDyQNdYNMBNq

* fix(admin): don't let an empty disk-type entry shadow a node's real disk

UpdateTopology keys disks by node and disk id. The master published an
empty entry (no capacity, nothing on it, DiskId 0) for every disk type a
capacity check had asked about, and it collided with the node's real
disk 0; map iteration order decided which survived. An SSD-only node
intermittently showed an empty HDD disk, and an HDD node lost its disk
-- and with it its volumes from the volume index, hence "Could not find
volume N on source server" in balance detection.

IsShadowedEmptyDiskEntry matches an empty entry whose disk id another
DiskInfo of the node, one with capacity or data, already uses.
UpdateTopology and CountTopologyResources skip it. A lone empty entry
is still an empty disk of unknown capacity and is kept, as
GetDisksWithEffectiveCapacity expects.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Et288CnR1mxDyQNdYNMBNq

* fix(balance): don't seed balance candidates from shadowed empty disk entries

detectForDiskType registered every node with a DiskInfos entry of the
type as a zero-volume candidate. An empty entry the master made up for
a type the node lacks made that node the least-loaded destination, and
its zero max switched the whole disk type to raw-count balancing. The
volume server then refused every move: "no space left hdd" / "no space
left ssd".

Skip entries IsShadowedEmptyDiskEntry matches when seeding candidates.
A server that holds volumes of the type but reports no max still enters
through the metrics and still triggers the raw-count fallback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Et288CnR1mxDyQNdYNMBNq

* fix(admin,balance): ignore made-up disk-type entries at any disk id

The made-up entry is published with DiskId 0, but it need not collide
with a real disk: when location 0 is unavailable (not reported) the
node's usable disk is location 1. IsShadowedEmptyDiskEntry required a
collision, so such an entry survived both UpdateTopology -- where the
unknown-capacity fallback offers it to planners -- and balance
detection, which planned moves onto it again.

Its disk id cannot tell it apart: a DiskInfo with no volumes, no EC
shards and no per-disk max is published with DiskId 0 whichever disk
it stands for. IsPhantomDiskTypeEntry (renamed, since nothing needs to
be shadowed now) matches an empty entry on any node that has a disk of
another type with capacity or data. A real disk looks the same only
while it offers no volume slot, when it can take no volume either. A
node whose entries are all empty keeps them as disks of unknown
capacity, as before.

Reported by review on #11762.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Et288CnR1mxDyQNdYNMBNq

* test(balance): assert where the real disks land in the empty-entry test

Check through GetNodeDisks that each server's real disk sits at the
subtest's disk id before planning. The id-1 case already landed on
physical disk 1 (SplitByPhysicalDisk folds all-zero record ids onto the
entry's DiskId), so this only makes the setup explicit; on the first
revision of the fix it shows the real disk at 1 beside the made-up
entry at 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Et288CnR1mxDyQNdYNMBNq

* fix(topology,balance,ec): don't publish or trust usage-only disk-type entries

ToDataNodeInfo started from the raw disk-usage counters, so a node that
was asked for capacity of a disk type it lacks kept advertising an empty
DiskInfo for that type even though no such disk was ever reported. Skip
usage entries that have no disk child and hold all-zero counters.

The same stale entries still misled planners that read DiskInfos
directly: the balance plugin's move precondition treated a phantom entry
as proof a target has that disk type, and the EC balancer counted it as
a matching disk. Filter them with IsPhantomDiskTypeEntry.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-10-16 23:41:26 -07:00

352 lines
12 KiB
Go

package ec_balance
import (
"context"
"fmt"
"slices"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding/ecbalancer"
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
storagetypes "github.com/seaweedfs/seaweedfs/weed/storage/types"
"github.com/seaweedfs/seaweedfs/weed/topology/balancer"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// Detection builds an EC balance topology snapshot from the cluster's active
// topology, runs the shared ecbalancer planner, and converts the planned moves
// into worker task proposals. The balancing policy lives in
// weed/storage/erasure_coding/ecbalancer, shared with the shell ec.balance
// command so the two cannot drift.
func Detection(
ctx context.Context,
metrics []*types.VolumeHealthMetrics,
clusterInfo *types.ClusterInfo,
config base.TaskConfig,
maxResults int,
) ([]*types.TaskDetectionResult, bool, error) {
if !config.IsEnabled() {
return nil, false, nil
}
ecConfig := config.(*Config)
if maxResults < 0 {
maxResults = 0
}
if clusterInfo == nil || clusterInfo.ActiveTopology == nil {
return nil, false, fmt.Errorf("active topology not available for EC balance detection")
}
topoInfo := clusterInfo.ActiveTopology.GetTopologyInfo()
if topoInfo == nil {
return nil, false, fmt.Errorf("topology info not available")
}
allowedCollections, err := wildcard.CompileCollectionMatcher(ecConfig.CollectionFilter)
if err != nil {
return nil, false, err
}
topo, nodeCount, volumeRatio := buildBalancerTopology(topoInfo, ecConfig, allowedCollections)
if nodeCount < ecConfig.MinServerCount {
glog.V(1).Infof("EC balance: only %d servers, need at least %d", nodeCount, ecConfig.MinServerCount)
return nil, false, nil
}
replicaPlacement := resolveReplicaPlacement(ecConfig, clusterInfo)
if ctx != nil {
if err := ctx.Err(); err != nil {
return nil, false, err
}
}
// Canonical disk type for placement/execution: "hdd" -> "" (HardDriveType),
// matching the topology's disk keys and the volume server's move RPCs.
normalizedDiskType := storagetypes.ToDiskType(ecConfig.DiskType).String()
moves := ecbalancer.Plan(topo, ecbalancer.Options{
DiskType: normalizedDiskType,
ImbalanceThreshold: ecConfig.ImbalanceThreshold,
ReplicaPlacement: replicaPlacement,
Ratio: func(collection string) (int, int) {
return resolveECRatio(clusterInfo, collection)
},
// Prefer each volume's own heartbeat-reported ratio over the collection
// default so a mixed-ratio collection is spread per volume; 0 defers to
// resolveECRatio (and is the always-0 OSS case).
VolumeRatio: volumeRatio,
// Move incrementally across detection cycles rather than draining a rack
// in one batch; the scheduler re-evaluates each cycle.
GlobalMaxMovesPerRack: 10,
// Balance heterogeneous-capacity racks by fractional fullness.
GlobalUtilizationBased: true,
RackTotalCapRaised: func(collection string, vid uint32, rackCap, evenCap int) {
glog.Warningf("EC volume %d (collection %q): rack-total-cap raised to %d shards per rack, above the even %d: the racks lack room for an even spread", vid, collection, rackCap, evenCap)
},
})
if len(moves) == 0 {
return nil, false, nil
}
hasMore := false
if maxResults > 0 && len(moves) > maxResults {
moves = moves[:maxResults]
hasMore = true
}
now := time.Now()
results := make([]*types.TaskDetectionResult, 0, len(moves))
for i, m := range moves {
taskID := fmt.Sprintf("ec_balance_%d_%d_%s_%s_%d_%d",
m.VolumeID, m.ShardID, m.SourceNode, m.TargetNode, now.UnixNano(), i)
results = append(results, &types.TaskDetectionResult{
TaskID: taskID,
TaskType: types.TaskTypeECBalance,
VolumeID: m.VolumeID,
Server: m.SourceNode,
Collection: m.Collection,
Priority: movePhasePriority(m.Phase),
Reason: fmt.Sprintf("EC shard %d.%d %s: %s → %s",
m.VolumeID, m.ShardID, m.Phase, m.SourceNode, m.TargetNode),
ScheduleAt: now,
TypedParams: &worker_pb.TaskParams{
TaskId: taskID,
VolumeId: m.VolumeID,
Collection: m.Collection,
Sources: []*worker_pb.TaskSource{{
Node: m.SourceNode,
DiskId: m.SourceDisk,
Rack: m.SourceRack,
ShardIds: []uint32{uint32(m.ShardID)},
}},
Targets: []*worker_pb.TaskTarget{{
Node: m.TargetNode,
DiskId: m.TargetDisk,
Rack: m.TargetRack,
ShardIds: []uint32{uint32(m.ShardID)},
}},
TaskParams: &worker_pb.TaskParams_EcBalanceParams{
EcBalanceParams: &worker_pb.EcBalanceTaskParams{
DiskType: normalizedDiskType,
DedupKeepNode: m.KeepNode,
TimeoutSeconds: 600,
IoBytePerSecond: ecConfig.IoBytePerSecond,
},
},
},
})
}
glog.V(1).Infof("EC balance detection: %d moves proposed", len(results))
return results, hasMore, nil
}
// buildBalancerTopology builds an ecbalancer.Topology from the master topology,
// applying the data-center, disk-type, and collection filters. Rack keys are
// dc:rack composites to avoid cross-DC name collisions. Per-disk free capacity
// is split evenly from the node total because the wire collapses same-type disks.
// Returns the topology, the number of eligible nodes (for MinServerCount), and a
// per-volume ratio lookup built from each shard's heartbeat (0,0 when unreported,
// e.g. always in OSS) which Plan prefers over the collection ratio for mixed-ratio
// clusters.
func buildBalancerTopology(topoInfo *master_pb.TopologyInfo, config *Config, allowedCollections *wildcard.CollectionMatcher) (*ecbalancer.Topology, int, func(collection string, vid uint32) (int, int)) {
topo := ecbalancer.NewTopology()
type volRatioKey struct {
collection string
vid uint32
}
volRatios := make(map[volRatioKey][2]int)
// Normalize the disk-type filter: "hdd" (and the default "") map to the
// HardDriveType, which the topology reports under the empty-string key. Keep a
// separate "filter requested" flag so a configured "hdd" still filters to HDD
// disks instead of being mistaken for "all disk types".
filterByDiskType := config.DiskType != ""
wantDiskType := storagetypes.ToDiskType(config.DiskType).String()
nodeCount := 0
for _, dc := range topoInfo.DataCenterInfos {
if config.DataCenterFilter != "" {
matchers := wildcard.CompileWildcardMatchers(config.DataCenterFilter)
if !wildcard.MatchesAnyWildcard(matchers, dc.Id) {
continue
}
}
for _, rack := range dc.RackInfos {
rackKey := dc.Id + ":" + rack.Id
for _, dn := range rack.DataNodeInfos {
freeSlots := 0
freeByType := make(map[string]int) // disk type -> free EC slots
diskTypeOf := make(map[uint32]string) // physical disk_id -> disk type
diskShardCount := make(map[uint32]int)
fullDiskTypes := make(map[string]bool) // physically near-full, ineligible as a target
hasMatchingDisk := false
for diskType, diskInfo := range dn.DiskInfos {
if filterByDiskType && diskType != wantDiskType {
continue
}
if topology.IsPhantomDiskTypeEntry(dn, diskType) {
continue
}
hasMatchingDisk = true
// Don't place EC shards on a physically near-full disk, even if slot
// math says it has room (an over-set maxVolumeCount hides real
// fullness). Statfs free bytes already include EC shard files.
if balancer.DiskTooFullAfter(diskInfo.DiskTotalBytes, diskInfo.DiskFreeBytes, 0, balancer.DefaultMaxDiskUsagePercent) {
fullDiskTypes[diskType] = true
}
fs := int(diskInfo.MaxVolumeCount-diskInfo.VolumeCount)*erasure_coding.DataShardsCount - countEcShards(diskInfo.EcShardInfos)
if fs > 0 && !fullDiskTypes[diskType] {
freeSlots += fs
freeByType[diskType] += fs
}
// Discover physical disks from regular volumes too, so an
// EC-empty disk is still a candidate destination.
for _, vi := range diskInfo.VolumeInfos {
if _, ok := diskTypeOf[vi.DiskId]; !ok {
diskTypeOf[vi.DiskId] = diskType
}
}
for _, eci := range diskInfo.EcShardInfos {
if _, ok := diskTypeOf[eci.DiskId]; !ok {
diskTypeOf[eci.DiskId] = diskType
}
// Disk occupancy counts ALL volumes' shards (capacity model),
// independent of the collection filter below.
diskShardCount[eci.DiskId] += erasure_coding.GetShardCount(eci)
}
}
if !hasMatchingDisk {
continue
}
node := topo.AddNode(dn.Id, dc.Id, rackKey, freeSlots)
// Group servers sharing a host so a volume's shards spread across
// machines, not just nodes (servers on one host are one fault domain).
node.SetHost(pb.NewServerAddressFromDataNode(dn).ToHost())
// Spread each disk type's free slots over that type's non-full
// disks, so a physically full disk type doesn't dilute the share
// the usable disks advertise (its own disks get 0 below) and other
// types don't inflate it. Ceil division keeps the remainder on the
// first disks instead of dropping it.
nonFullByType := make(map[string]int)
for _, dt := range diskTypeOf {
if !fullDiskTypes[dt] {
nonFullByType[dt]++
}
}
var diskIDs []uint32
for diskID := range diskTypeOf {
diskIDs = append(diskIDs, diskID)
}
slices.Sort(diskIDs)
remainingByType := make(map[string]int, len(freeByType))
for dt, free := range freeByType {
remainingByType[dt] = free
}
for _, diskID := range diskIDs {
diskType := diskTypeOf[diskID]
diskFree := 0
if count := nonFullByType[diskType]; !fullDiskTypes[diskType] && count > 0 {
diskFree = (remainingByType[diskType] + count - 1) / count
remainingByType[diskType] -= diskFree
nonFullByType[diskType]--
}
node.AddDisk(diskID, diskType, diskFree, diskShardCount[diskID])
}
// Add shards only for volumes whose collection passes the filter;
// those are the volumes the planner will balance.
for diskType, diskInfo := range dn.DiskInfos {
if filterByDiskType && diskType != wantDiskType {
continue
}
for _, eci := range diskInfo.EcShardInfos {
if !allowedCollections.Matches(eci.Collection) {
continue
}
node.AddShards(eci.Id, eci.Collection, eci.DiskId, erasure_coding.ShardBits(eci.EcIndexBits))
if d, p := ecbalancer.VolumeShardRatio(eci); d > 0 || p > 0 {
volRatios[volRatioKey{eci.Collection, eci.Id}] = [2]int{d, p}
}
}
}
nodeCount++
}
}
}
volumeRatio := func(collection string, vid uint32) (int, int) {
r := volRatios[volRatioKey{collection, vid}]
return r[0], r[1]
}
return topo, nodeCount, volumeRatio
}
// resolveECRatio returns the (dataShards, parityShards) for a collection from the
// admin EC config snapshot when present, else the local default. This keeps the
// enterprise-only custom-ratio plumbing out of the shared planner.
func resolveECRatio(_ *types.ClusterInfo, _ string) (int, int) {
// Custom EC ratios are an enterprise feature; OSS uses the standard scheme.
return normalizeECShardCounts(0, 0)
}
// resolveReplicaPlacement picks the EC shard replica placement constraint: an
// explicit config value wins; otherwise it falls back to the master's default
// replication (matching the shell ec.balance default). A missing, invalid, or
// zero-replication value yields nil, meaning even spread / no constraint.
func resolveReplicaPlacement(ecConfig *Config, clusterInfo *types.ClusterInfo) *super_block.ReplicaPlacement {
clusterDefault := ""
if clusterInfo != nil {
clusterDefault = clusterInfo.DefaultReplicaPlacement
}
return super_block.ResolveReplicaPlacement(ecConfig.ReplicaPlacement, clusterDefault)
}
func normalizeECShardCounts(dataShards, parityShards int) (int, int) {
if dataShards <= 0 {
dataShards = erasure_coding.DataShardsCount
}
if parityShards <= 0 {
parityShards = erasure_coding.ParityShardsCount
}
return dataShards, parityShards
}
func countEcShards(ecShardInfos []*master_pb.VolumeEcShardInformationMessage) int {
count := 0
for _, eci := range ecShardInfos {
count += erasure_coding.GetShardCount(eci)
}
return count
}
func movePhasePriority(phase string) types.TaskPriority {
switch phase {
case "dedup":
return types.TaskPriorityHigh
case "cross_rack":
return types.TaskPriorityMedium
default:
return types.TaskPriorityLow
}
}