diff --git a/weed/admin/topology/empty_disk_type_test.go b/weed/admin/topology/empty_disk_type_test.go new file mode 100644 index 000000000..37e3a9fdd --- /dev/null +++ b/weed/admin/topology/empty_disk_type_test.go @@ -0,0 +1,174 @@ +package topology + +import ( + "fmt" + "testing" + + "github.com/seaweedfs/seaweedfs/weed/pb/master_pb" +) + +// A master that answered a capacity question for a disk type a node lacks +// published an empty DiskInfo of that type for the node: no capacity, no +// volumes, DiskId 0 -- the same id as the node's real first disk. Disks are +// keyed by node and disk id here, so whichever DiskInfo the map iteration +// visited last won disk 0: an SSD-only node sometimes looked like an empty +// HDD disk (and a balance destination for HDD volumes), and an HDD node +// sometimes lost its volumes behind an empty "ssd" disk. +func TestUpdateTopologyIgnoresEmptyDiskTypeEntries(t *testing.T) { + topo := &master_pb.TopologyInfo{ + Id: "phantom_disk_types", + DataCenterInfos: []*master_pb.DataCenterInfo{{ + Id: "dc1", + RackInfos: []*master_pb.RackInfo{{ + Id: "rack1", + DataNodeInfos: []*master_pb.DataNodeInfo{ + { + Id: "ssd-node", + Address: "ssd-node:8080", + DiskInfos: map[string]*master_pb.DiskInfo{ + "ssd": {Type: "ssd", MaxVolumeCount: 200, VolumeCount: 1, + VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 7, DiskType: "ssd"}}}, + "": {Type: ""}, // the empty entry the master made up + }, + }, + { + Id: "hdd-node", + Address: "hdd-node:8080", + DiskInfos: map[string]*master_pb.DiskInfo{ + "": {Type: "", MaxVolumeCount: 100, VolumeCount: 1, + VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 3}}}, + "ssd": {Type: "ssd"}, + }, + }, + { + // An empty disk of unknown capacity with nothing else on the + // node is a disk, not a leftover: it must stay. + Id: "new-node", + Address: "new-node:8080", + DiskInfos: map[string]*master_pb.DiskInfo{"": {Type: ""}}, + }, + }, + }}, + }}, + } + + at := NewActiveTopology(0) + // Map iteration order is random; repeat so a shadowed disk would show. + for i := 0; i < 64; i++ { + if err := at.UpdateTopology(topo); err != nil { + t.Fatalf("UpdateTopology: %v", err) + } + for node, want := range map[string]string{"ssd-node": "ssd", "hdd-node": "", "new-node": ""} { + disks := at.GetNodeDisks(node) + if len(disks) != 1 || disks[0].DiskType != want || (node != "new-node" && disks[0].DiskInfo.MaxVolumeCount == 0) { + var got []string + for _, d := range disks { + got = append(got, "\""+d.DiskType+"\"") + } + t.Fatalf("pass %d: %s disks %v, want exactly its real %q disk", i, node, got, want) + } + } + } + if _, _, disks := CountTopologyResources(topo); disks != 3 { + t.Errorf("CountTopologyResources disks = %d, want 3 (a made-up empty entry is not a disk)", disks) + } +} + +// The volume index is built from the per-disk map, so a shadowed disk 0 also +// hid its volumes: planners failed to find a volume on the server that holds +// it ("Could not find volume N on source server"). +func TestUpdateTopologyKeepsVolumesBehindEmptyDiskTypeEntries(t *testing.T) { + topo := &master_pb.TopologyInfo{ + Id: "phantom_disk_types_index", + DataCenterInfos: []*master_pb.DataCenterInfo{{ + Id: "dc1", + RackInfos: []*master_pb.RackInfo{{ + Id: "rack1", + DataNodeInfos: []*master_pb.DataNodeInfo{{ + Id: "hdd-node", + Address: "hdd-node:8080", + DiskInfos: map[string]*master_pb.DiskInfo{ + "": {Type: "", MaxVolumeCount: 100, VolumeCount: 1, + VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 3, Collection: "c1"}}}, + "ssd": {Type: "ssd"}, + }, + }}, + }}, + }}, + } + + at := NewActiveTopology(0) + for i := 0; i < 64; i++ { + if err := at.UpdateTopology(topo); err != nil { + t.Fatalf("UpdateTopology: %v", err) + } + locations := at.GetVolumeLocations(3, "c1") + if len(locations) != 1 || locations[0].ServerID != "hdd-node" { + t.Fatalf("pass %d: volume 3 locations %+v, want it on hdd-node", i, locations) + } + } +} + +// The made-up entry always carries DiskId 0, so it need not collide with a +// real disk: here each node's only usable disk is location 1 (location 0 is +// unavailable, so the volume server does not report it). The entry must still +// not count as a disk, nor reach planners as an empty disk of unknown +// capacity on a node that has no disk of its type. +func TestUpdateTopologyIgnoresEmptyDiskTypeEntriesAtAnyDiskId(t *testing.T) { + topo := &master_pb.TopologyInfo{ + Id: "phantom_disk_types_no_collision", + DataCenterInfos: []*master_pb.DataCenterInfo{{ + Id: "dc1", + RackInfos: []*master_pb.RackInfo{{ + Id: "rack1", + DataNodeInfos: []*master_pb.DataNodeInfo{ + { + Id: "ssd-node", + Address: "ssd-node:8080", + DiskInfos: map[string]*master_pb.DiskInfo{ + "ssd": {Type: "ssd", DiskId: 1, MaxVolumeCount: 200, VolumeCount: 1, + MaxVolumeCountByDisk: map[uint32]int64{1: 200}, + VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 7, DiskType: "ssd", DiskId: 1}}}, + "": {Type: ""}, + }, + }, + { + Id: "hdd-node", + Address: "hdd-node:8080", + DiskInfos: map[string]*master_pb.DiskInfo{ + "": {Type: "", DiskId: 1, MaxVolumeCount: 100, VolumeCount: 1, + MaxVolumeCountByDisk: map[uint32]int64{1: 100}, + VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 3, DiskId: 1}}}, + "ssd": {Type: "ssd"}, + }, + }, + }, + }}, + }}, + } + + at := NewActiveTopology(0) + for i := 0; i < 16; i++ { + if err := at.UpdateTopology(topo); err != nil { + t.Fatalf("UpdateTopology: %v", err) + } + for node, want := range map[string]string{"ssd-node": "ssd", "hdd-node": ""} { + disks := at.GetNodeDisks(node) + if len(disks) != 1 || disks[0].DiskType != want || disks[0].DiskID != 1 { + var got []string + for _, d := range disks { + got = append(got, fmt.Sprintf("%q@%d", d.DiskType, d.DiskID)) + } + t.Fatalf("pass %d: %s disks %v, want only its real %q disk 1", i, node, got, want) + } + } + } + for _, disk := range at.GetDisksWithEffectiveCapacity(TaskTypeBalance, "", 1) { + if disk.DiskID == 0 { + t.Errorf("planners offered %s disk 0 (%q), which does not exist", disk.NodeID, disk.DiskType) + } + } + if _, _, disks := CountTopologyResources(topo); disks != 2 { + t.Errorf("CountTopologyResources disks = %d, want 2", disks) + } +} diff --git a/weed/admin/topology/topology_management.go b/weed/admin/topology/topology_management.go index adc512b82..624bd403a 100644 --- a/weed/admin/topology/topology_management.go +++ b/weed/admin/topology/topology_management.go @@ -9,6 +9,36 @@ import ( "github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding" ) +// isEmptyDiskTypeEntry reports a DiskInfo with no capacity and nothing on it. +func isEmptyDiskTypeEntry(diskInfo *master_pb.DiskInfo) bool { + return diskInfo != nil && + diskInfo.MaxVolumeCount <= 0 && + diskInfo.VolumeCount == 0 && + diskInfo.RemoteVolumeCount == 0 && + len(diskInfo.VolumeInfos) == 0 && + len(diskInfo.EcShardInfos) == 0 && + len(diskInfo.MaxVolumeCountByDisk) == 0 +} + +// IsPhantomDiskTypeEntry reports whether node's DiskInfo of diskType is an +// empty entry on a node that has a disk of another type with capacity or +// data. Masters used to publish such an entry for every disk type a capacity +// check had asked about; keyed by node and disk id it can replace or pose as +// a real disk and be offered to planners as an empty destination of a type +// the node lacks. A node whose entries are all empty keeps them, as disks of +// unknown capacity. +func IsPhantomDiskTypeEntry(node *master_pb.DataNodeInfo, diskType string) bool { + if node == nil || !isEmptyDiskTypeEntry(node.DiskInfos[diskType]) { + return false + } + for otherType, other := range node.DiskInfos { + if otherType != diskType && other != nil && !isEmptyDiskTypeEntry(other) { + return true + } + } + return false +} + // CountTopologyResources counts datacenters, nodes, and disks in topology info func CountTopologyResources(topologyInfo *master_pb.TopologyInfo) (dcCount, nodeCount, diskCount int) { if topologyInfo == nil { @@ -22,7 +52,10 @@ func CountTopologyResources(topologyInfo *master_pb.TopologyInfo) (dcCount, node // DiskInfos is keyed by disk type, so same-type physical disks // collapse into one entry. Count physical disks so the number // matches the per-disk activeDisk map. - for _, diskInfo := range node.DiskInfos { + for diskType, diskInfo := range node.DiskInfos { + if IsPhantomDiskTypeEntry(node, diskType) { + continue + } diskCount += len(diskInfo.SplitByPhysicalDisk()) } } @@ -82,6 +115,9 @@ func (at *ActiveTopology) UpdateTopology(topologyInfo *master_pb.TopologyInfo) e // One activeDisk per physical disk_id: the master keys // DiskInfos by disk type, so same-type disks must be split out. for diskType, diskInfo := range nodeInfo.DiskInfos { + if IsPhantomDiskTypeEntry(nodeInfo, diskType) { + continue + } perDiskInfos := diskInfo.SplitByPhysicalDisk() for _, perDisk := range perDiskInfos { disk := &activeDisk{ diff --git a/weed/topology/data_node.go b/weed/topology/data_node.go index ca110ab4b..731e60709 100644 --- a/weed/topology/data_node.go +++ b/weed/topology/data_node.go @@ -432,10 +432,22 @@ func (dn *DataNode) ToDataNodeInfo(filter VolumeFilter) *master_pb.DataNodeInfo diskInfo.Type = diskType } + reported := make(map[string]bool) for _, c := range dn.Children() { disk := c.(*Disk) + reported[string(disk.Id())] = true m.DiskInfos[string(disk.Id())] = disk.ToDiskInfo(filter) } + // A zeroed usage counter with no disk child was only ever created by a + // capacity query, not by anything the server reported: drop it so the + // node does not advertise a disk type it does not have. + for diskType := range m.DiskInfos { + if !reported[diskType] { + if u := dn.diskUsages.getDisk(types.ToDiskType(diskType)); u != nil && u.snapshot() == (DiskUsageCounts{}) { + delete(m.DiskInfos, diskType) + } + } + } dn.RLock() metas := make(map[uint32]diskMeta, len(dn.diskMetas)) diff --git a/weed/topology/data_node_phantom_disk_type_test.go b/weed/topology/data_node_phantom_disk_type_test.go new file mode 100644 index 000000000..ed82e1d9b --- /dev/null +++ b/weed/topology/data_node_phantom_disk_type_test.go @@ -0,0 +1,76 @@ +package topology + +import ( + "testing" + + "github.com/seaweedfs/seaweedfs/weed/sequence" + "github.com/seaweedfs/seaweedfs/weed/storage/types" +) + +// Asking whether a node has room for a disk type it does not have must not +// make the node report that disk type. Volume growth walks every data center, +// rack and data node with AvailableSpaceFor; before the fix that walk left an +// empty DiskInfo (MaxVolumeCount 0) of the asked type on every node, so an +// HDD-only server advertised an "ssd" disk and an SSD-only one an "hdd" disk. +// Planners that read DiskInfos then took such a node for the emptiest +// destination of that type. +func TestCapacityQueriesDoNotCreateDiskTypes(t *testing.T) { + topo := NewTopology("weedfs", sequence.NewMemorySequencer(), 32*1024, 5, false) + dc := topo.GetOrCreateDataCenter("dc1") + rack := dc.GetOrCreateRack("rack1") + hddNode := rack.GetOrCreateDataNode("127.0.0.1", 34534, 0, "127.0.0.1", "", map[string]uint32{"": 10}) + hddNode.AdjustMaxVolumeCounts(map[string]uint32{"": 10}) + ssdNode := rack.GetOrCreateDataNode("127.0.0.2", 34534, 0, "127.0.0.2", "", map[string]uint32{"ssd": 4}) + ssdNode.AdjustMaxVolumeCounts(map[string]uint32{"ssd": 4}) + + hdd := &VolumeGrowOption{DiskType: types.HardDriveType} + ssd := &VolumeGrowOption{DiskType: types.SsdType} + nvme := &VolumeGrowOption{DiskType: types.ToDiskType("nvme")} + + // What growing a volume of each type does: reserve a slot by walking the + // tree, plus the assign path's capacity checks on the topology root. + for _, option := range []*VolumeGrowOption{hdd, ssd, nvme} { + _, _ = topo.ReserveOneVolume(0, option) + _ = topo.AvailableSpaceFor(option) + _ = topo.CapacityFor(option) + for _, dn := range []*DataNode{hddNode, ssdNode} { + _ = dn.AvailableSpaceForReservation(option) + _ = dn.CapacityFor(option) + } + } + + if got := hddNode.AvailableSpaceFor(ssd); got != 0 { + t.Errorf("hdd node AvailableSpaceFor(ssd) = %d, want 0", got) + } + if got := topo.CapacityFor(nvme); got != 0 { + t.Errorf("topology CapacityFor(nvme) = %d, want 0: no server has that disk type", got) + } + if got := topo.AvailableSpaceFor(hdd); got != 10 { + t.Errorf("topology AvailableSpaceFor(hdd) = %d, want 10", got) + } + if got := topo.AvailableSpaceFor(ssd); got != 4 { + t.Errorf("topology AvailableSpaceFor(ssd) = %d, want 4", got) + } + + check := func(name string, dn *DataNode, want string) { + t.Helper() + info := dn.ToDataNodeInfo(VolumeFilter{}) + if len(info.DiskInfos) != 1 { + keys := make([]string, 0, len(info.DiskInfos)) + for k := range info.DiskInfos { + keys = append(keys, "\""+k+"\"") + } + t.Errorf("%s reports disk types %v, want only %q", name, keys, want) + } + if di := info.DiskInfos[want]; di == nil || di.MaxVolumeCount == 0 { + t.Errorf("%s lost its real %q disk: %+v", name, want, di) + } + } + check("hdd node", hddNode, "") + check("ssd node", ssdNode, "ssd") + + // A usage entry created by a pre-fix capacity walk has no disk child: the + // node stops advertising it without waiting for a restart. + hddNode.diskUsages.getOrCreateDisk(types.SsdType) + check("hdd node", hddNode, "") +} diff --git a/weed/topology/disk.go b/weed/topology/disk.go index b6df59667..483c2f367 100644 --- a/weed/topology/disk.go +++ b/weed/topology/disk.go @@ -203,6 +203,15 @@ func (du *DiskUsages) getOrCreateDisk(diskType types.DiskType) *DiskUsageCounts return t } +// getDisk returns the counters for diskType, or nil when none were ever +// recorded. Read paths use it instead of getOrCreateDisk so a capacity +// question cannot create an entry that ToDiskInfo would publish as a disk. +func (du *DiskUsages) getDisk(diskType types.DiskType) *DiskUsageCounts { + du.RLock() + defer du.RUnlock() + return du.usages[diskType] +} + func (d *Disk) String() string { d.RLock() defer d.RUnlock() diff --git a/weed/topology/node.go b/weed/topology/node.go index b8b51eb48..98e212ba0 100644 --- a/weed/topology/node.go +++ b/weed/topology/node.go @@ -322,7 +322,10 @@ func (n *NodeImpl) AvailableSpaceFor(option *VolumeGrowOption) int64 { if n.inMaintenanceMode() { return 0 } - t := n.getOrCreateDisk(option.DiskType) + t := n.diskUsages.getDisk(option.DiskType) + if t == nil { + return 0 + } freeVolumeSlotCount := atomic.LoadInt64(&t.maxVolumeCount) + atomic.LoadInt64(&t.remoteVolumeCount) - atomic.LoadInt64(&t.volumeCount) freeVolumeSlotCount -= erasure_coding.VolumeSlots(atomic.LoadInt64(&t.ecShardCount)) return freeVolumeSlotCount @@ -331,7 +334,10 @@ func (n *NodeImpl) AvailableSpaceFor(option *VolumeGrowOption) int64 { // CapacityFor is the total registered volume slots for the option's disk type; // zero means no volume server has reported capacity for it yet. func (n *NodeImpl) CapacityFor(option *VolumeGrowOption) int64 { - t := n.getOrCreateDisk(option.DiskType) + t := n.diskUsages.getDisk(option.DiskType) + if t == nil { + return 0 + } return atomic.LoadInt64(&t.maxVolumeCount) + atomic.LoadInt64(&t.remoteVolumeCount) } diff --git a/weed/worker/tasks/balance/detection.go b/weed/worker/tasks/balance/detection.go index 03268f047..dd75dbc77 100644 --- a/weed/worker/tasks/balance/detection.go +++ b/weed/worker/tasks/balance/detection.go @@ -113,7 +113,9 @@ func detectForDiskType(diskType string, diskMetrics []*types.VolumeHealthMetrics continue } for diskTypeName, diskInfo := range node.DiskInfos { - if diskTypeName == diskType { + // An empty entry on a node whose real disks are of + // another type is not a disk of this type. + if diskTypeName == diskType && !topology.IsPhantomDiskTypeEntry(node, diskTypeName) { serverVolumeCounts[node.Id] = 0 serverMaxVolumes[node.Id] += diskInfo.MaxVolumeCount serverDiskTotalBytes[node.Id] += diskInfo.DiskTotalBytes diff --git a/weed/worker/tasks/balance/detection_empty_disk_type_test.go b/weed/worker/tasks/balance/detection_empty_disk_type_test.go new file mode 100644 index 000000000..72b236d18 --- /dev/null +++ b/weed/worker/tasks/balance/detection_empty_disk_type_test.go @@ -0,0 +1,102 @@ +package balance + +import ( + "fmt" + "strings" + "testing" + + "github.com/seaweedfs/seaweedfs/weed/pb/master_pb" + "github.com/seaweedfs/seaweedfs/weed/worker/tasks/base" + "github.com/seaweedfs/seaweedfs/weed/worker/types" +) + +// A master that answered a capacity question for a disk type a node lacks +// published an empty DiskInfo of that type for the node (MaxVolumeCount 0, no +// volumes). The planner registered every such node as a candidate with zero +// volumes, and one zero max switched the whole disk type to raw-count +// balancing, so the empty node became the destination of every move -- which +// the volume server then refused ("no space left hdd" / "no space left ssd"). +// +// The entry always has DiskId 0. With real disks at id 1 (location 0 +// unavailable) it collides with nothing and must be ignored all the same. +func TestDetection_IgnoresEmptyDiskTypeEntries(t *testing.T) { + for _, realDiskID := range []uint32{0, 1} { + t.Run(fmt.Sprintf("real disks at id %d", realDiskID), func(t *testing.T) { + // Raw counts and utilization disagree on purpose: by raw count hdd-3 is + // the most loaded, by utilization hdd-2 is. + servers := []serverSpec{ + {id: "hdd-1", diskType: "hdd", diskID: realDiskID, dc: "dc1", rack: "rack1", maxVolumes: 700}, + {id: "hdd-2", diskType: "hdd", diskID: realDiskID, dc: "dc1", rack: "rack1", maxVolumes: 700}, + {id: "hdd-3", diskType: "hdd", diskID: realDiskID, dc: "dc1", rack: "rack1", maxVolumes: 1260}, + {id: "ssd-1", diskType: "ssd", diskID: realDiskID, dc: "dc1", rack: "rack2", maxVolumes: 200}, + } + counts := []struct { + server, diskType string + n int + }{{"hdd-1", "hdd", 600}, {"hdd-2", "hdd", 695}, {"hdd-3", "hdd", 900}, {"ssd-1", "ssd", 22}} + + var metrics []*types.VolumeHealthMetrics + vid := uint32(1) + for _, c := range counts { + metrics = append(metrics, makeVolumes(c.server, c.diskType, "dc1", "rack1", "", vid, c.n)...) + vid += uint32(c.n) + } + + at := buildTopology(servers, metrics) + // Add what the master published: an empty entry of the other disk + // type on every node, DiskId 0. + topo := at.GetTopologyInfo() + for _, dc := range topo.DataCenterInfos { + for _, rack := range dc.RackInfos { + for _, node := range rack.DataNodeInfos { + other := "ssd" + if strings.HasPrefix(node.Id, "ssd-") { + other = "hdd" + } + node.DiskInfos[other] = &master_pb.DiskInfo{Type: other} + } + } + } + if err := at.UpdateTopology(topo); err != nil { + t.Fatalf("UpdateTopology: %v", err) + } + for _, s := range servers { + disks := at.GetNodeDisks(s.id) + if len(disks) != 1 || disks[0].DiskType != s.diskType || disks[0].DiskID != realDiskID { + var got []string + for _, d := range disks { + got = append(got, fmt.Sprintf("%q@%d", d.DiskType, d.DiskID)) + } + t.Fatalf("%s disks %v, want only its real %q disk %d", s.id, got, s.diskType, realDiskID) + } + } + + cfg := &Config{BaseConfig: base.BaseConfig{Enabled: true}, ImbalanceThreshold: 0.20, MinServerCount: 2} + tasks, _, err := Detection(metrics, &types.ClusterInfo{ActiveTopology: at}, cfg, 10) + if err != nil { + t.Fatalf("Detection failed: %v", err) + } + if len(tasks) == 0 { + t.Fatal("expected HDD balance tasks, got none") + } + for i, task := range tasks { + if task.TypedParams == nil || len(task.TypedParams.Targets) == 0 { + t.Fatalf("task %d (volume %d) has no target", i, task.VolumeID) + } + target := task.TypedParams.Targets[0].Node + if task.Server == "ssd-1" { + t.Errorf("task %d moves SSD volume %d from ssd-1 to %s: ssd-1 is the only server with an SSD disk", i, task.VolumeID, target) + } + if strings.HasPrefix(target, "ssd-1") { + t.Errorf("task %d moves HDD volume %d from %s onto ssd-1, which has no HDD disk", i, task.VolumeID, task.Server) + } + if task.Server == "hdd-3" { + t.Errorf("task %d picks hdd-3 (lowest utilization, 71%%) as source: balancing fell back to raw volume counts", i) + } + } + if tasks[0].Server != "hdd-2" { + t.Errorf("first source = %s, want hdd-2 (highest utilization, 99%%)", tasks[0].Server) + } + }) + } +} diff --git a/weed/worker/tasks/balance/plugin_handler.go b/weed/worker/tasks/balance/plugin_handler.go index 89df412e8..51519e5ce 100644 --- a/weed/worker/tasks/balance/plugin_handler.go +++ b/weed/worker/tasks/balance/plugin_handler.go @@ -505,7 +505,7 @@ func emitVolumeBalanceDetectionDecisionTrace( for _, rack := range dc.RackInfos { for _, node := range rack.DataNodeInfos { for diskTypeName := range node.DiskInfos { - if diskTypeName == diskType { + if diskTypeName == diskType && !topology.IsPhantomDiskTypeEntry(node, diskTypeName) { serverVolumeCounts[node.Id] = 0 } } @@ -853,6 +853,9 @@ func checkMoveDiskType(topologyInfo *master_pb.TopologyInfo, volumeID uint32, so if isTarget { targetFound = true for diskType := range node.DiskInfos { + if topology.IsPhantomDiskTypeEntry(node, diskType) { + continue + } targetDiskTypes[string(types.ToDiskType(diskType))] = true } } diff --git a/weed/worker/tasks/ec_balance/detection.go b/weed/worker/tasks/ec_balance/detection.go index 253efb858..b74f2bf28 100644 --- a/weed/worker/tasks/ec_balance/detection.go +++ b/weed/worker/tasks/ec_balance/detection.go @@ -6,6 +6,7 @@ import ( "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" @@ -197,6 +198,9 @@ func buildBalancerTopology(topoInfo *master_pb.TopologyInfo, config *Config, all 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