mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-17 19:48:35 +02:00
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>
This commit is contained in:
10 files changed
+429
-5
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user