Files
seaweedfs/weed/topology/data_node.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

508 lines
15 KiB
Go

package topology
import (
"fmt"
"sync/atomic"
"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/storage"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
"github.com/seaweedfs/seaweedfs/weed/util"
)
type DataNode struct {
NodeImpl
Ip string
Port int
GrpcPort int
PublicUrl string
LastSeen int64 // unix time in seconds
Counter int // in race condition, the previous dataNode was not dead
IsTerminating bool
// maintenanceMode mirrors the volume server's own flag, reported over the
// heartbeat. A server in maintenance is being drained: the master places
// no new volumes on it and assigns no writes to the volumes it holds. Read
// on the assign and volume-growth paths without the node lock.
maintenanceMode atomic.Bool
// lookupDigest covers the volumes reachable through this node in the volume
// layouts, for comparison against what its disks actually hold.
lookupDigest atomic.Uint64
// duplicateVolumeIds records that the node last reported one volume id more
// than once, which the master cannot represent.
duplicateVolumeIds atomic.Bool
// diskMetas holds each physical disk's tags, type, and capacity from the
// heartbeat DiskTags, including disks with no volumes or EC shards.
diskMetas map[uint32]diskMeta
}
type diskMeta struct {
tags []string
diskType types.DiskType
maxVolumeCount int64
}
func NewDataNode(id string) *DataNode {
dn := &DataNode{}
dn.id = NodeId(id)
dn.nodeType = "DataNode"
dn.diskUsages = newDiskUsages()
dn.children = make(map[NodeId]Node)
dn.capacityReservations = newCapacityReservations()
dn.NodeImpl.value = dn
return dn
}
func (dn *DataNode) String() string {
dn.RLock()
defer dn.RUnlock()
return fmt.Sprintf("Node:%s, Ip:%s, Port:%d, PublicUrl:%s", dn.NodeImpl.String(), dn.Ip, dn.Port, dn.PublicUrl)
}
// InMaintenanceMode reports whether the volume server asked to be left alone
// for writes; see Topology.SetDataNodeMaintenanceMode for what that changes.
func (dn *DataNode) InMaintenanceMode() bool {
return dn.maintenanceMode.Load()
}
// SetMaintenanceMode records the flag and reports whether it changed. Prefer
// Topology.SetDataNodeMaintenanceMode, which also updates the writable lists.
func (dn *DataNode) SetMaintenanceMode(on bool) (changed bool) {
return dn.maintenanceMode.Swap(on) != on
}
func (dn *DataNode) AddOrUpdateVolume(v storage.VolumeInfo) (isNew, isChangedRO, tierTransition bool) {
dn.Lock()
defer dn.Unlock()
return dn.doAddOrUpdateVolume(v)
}
// SetVolumeReadOnly records the read-only flag a volume server reported for
// one of its volumes, ahead of the heartbeat that will repeat it.
func (dn *DataNode) SetVolumeReadOnly(vid needle.VolumeId, readOnly bool) {
dn.Lock()
defer dn.Unlock()
for _, c := range dn.children {
if c.(*Disk).SetVolumeReadOnly(vid, readOnly) {
return
}
}
}
func (dn *DataNode) getOrCreateDisk(diskType string) *Disk {
c, found := dn.children[NodeId(diskType)]
if !found {
c = NewDisk(diskType)
dn.doLinkChildNode(c)
}
disk := c.(*Disk)
return disk
}
func (dn *DataNode) doAddOrUpdateVolume(v storage.VolumeInfo) (isNew, isChanged, tierTransition bool) {
disk := dn.getOrCreateDisk(v.DiskType)
return disk.AddOrUpdateVolume(v)
}
// AddProvisionalVolume records a volume the master registered on its own,
// ahead of any server report naming it. See Disk.AddProvisionalVolume.
func (dn *DataNode) AddProvisionalVolume(v storage.VolumeInfo) (isNew, isChanged, tierTransition bool) {
dn.Lock()
defer dn.Unlock()
disk := dn.getOrCreateDisk(v.DiskType)
return disk.AddProvisionalVolume(v)
}
// UpdateVolumes detects new/deleted/changed volumes on a volume server
// used in master to notify master clients of these changes.
//
// changedVolumes covers every replica the disk already held whose
// classification the new report altered in a way clients must learn about:
// the ReadOnly flag flipped, or IsRemote() flipped on tier transition. The
// latter is what lets the wdclient refresh DataInRemote after the digest
// mismatch recovery path resends a full Volumes list -- that path is the
// only way a re-tiered replica reaches the master without a separate
// ChangedVolumes heartbeat.
func (dn *DataNode) UpdateVolumes(actualVolumes []storage.VolumeInfo) (newVolumes, deletedVolumes, changedVolumes []storage.VolumeInfo) {
reported := newReportedVolumes(len(actualVolumes))
for _, v := range actualVolumes {
reported.add(v.Id, v.DiskType)
}
// A volume id mounted on two disks of one server -- a stale twin re-attached
// after a disk repair -- is reported twice, but the master keys volumes by
// id alone and keeps only the last copy. Its digest can then never equal the
// server's however often the list is resent, so record it and let the
// heartbeat fall back to the full list for this node.
dn.duplicateVolumeIds.Store(reported.duplicated)
dn.Lock()
defer dn.Unlock()
keptCount := 0
for _, c := range dn.children {
disk := c.(*Disk)
for _, v := range disk.RemoveVolumesNotIn(reported) {
glog.V(0).Infoln("Deleting volume id:", v.Id)
deletedVolumes = append(deletedVolumes, v)
deltaDiskUsage := &DiskUsageCounts{}
deltaDiskUsage.volumeCount = -1
if v.IsRemote() {
deltaDiskUsage.remoteVolumeCount = -1
}
if !v.ReadOnly {
deltaDiskUsage.activeVolumeCount = -1
}
disk.UpAdjustDiskUsageDelta(types.ToDiskType(v.DiskType), deltaDiskUsage)
}
keptCount += disk.VolumeCount()
}
// Everything still on the node is also in this heartbeat, so the remainder
// is what the node is about to gain. A steady-state heartbeat gains nothing
// and must not allocate here; a reconnecting server gains all of them.
if addedCount := reported.count() - keptCount; addedCount > 0 {
newVolumes = make([]storage.VolumeInfo, 0, addedCount)
}
for _, v := range actualVolumes {
isNew, isChanged, tierTransition := dn.doAddOrUpdateVolume(v)
if isNew {
newVolumes = append(newVolumes, v)
}
if isChanged || tierTransition {
changedVolumes = append(changedVolumes, v)
}
}
return
}
func (dn *DataNode) DeltaUpdateVolumes(newVolumes, deletedVolumes []storage.VolumeInfo) {
dn.Lock()
defer dn.Unlock()
for _, v := range deletedVolumes {
disk := dn.getOrCreateDisk(v.DiskType)
_, err := disk.GetVolumesById(v.Id)
if err != nil {
continue
}
disk.DeleteVolumeById(v.Id)
deltaDiskUsage := &DiskUsageCounts{}
deltaDiskUsage.volumeCount = -1
if v.IsRemote() {
deltaDiskUsage.remoteVolumeCount = -1
}
if !v.ReadOnly {
deltaDiskUsage.activeVolumeCount = -1
}
disk.UpAdjustDiskUsageDelta(types.ToDiskType(v.DiskType), deltaDiskUsage)
}
for _, v := range newVolumes {
dn.doAddOrUpdateVolume(v)
}
return
}
func (dn *DataNode) AdjustMaxVolumeCounts(maxVolumeCounts map[string]uint32) {
for diskType, maxVolumeCount := range maxVolumeCounts {
if maxVolumeCount == 0 {
// the volume server may have set the max to zero
continue
}
dt := types.ToDiskType(diskType)
currentDiskUsage := dn.diskUsages.getOrCreateDisk(dt)
currentDiskUsageMaxVolumeCount := atomic.LoadInt64(&currentDiskUsage.maxVolumeCount)
if currentDiskUsageMaxVolumeCount == int64(maxVolumeCount) {
continue
}
disk := dn.getOrCreateDisk(dt.String())
disk.UpAdjustDiskUsageDelta(dt, &DiskUsageCounts{
maxVolumeCount: int64(maxVolumeCount) - currentDiskUsageMaxVolumeCount,
})
}
}
// AdjustDiskUsageBytes records the physical filesystem capacity a volume server
// reports per disk type, applied as a delta so it flows through the same
// aggregation as the volume counts. Mirrors AdjustMaxVolumeCounts; entries with a
// zero total are treated as "not reported" and skipped.
func (dn *DataNode) AdjustDiskUsageBytes(diskTotalBytes, diskFreeBytes map[string]uint64) {
for diskType, totalBytes := range diskTotalBytes {
// Unlike maxVolumeCount, a 0 here is not "unset" but "not reported": let it
// flow through so a later heartbeat that drops physical-capacity reporting
// (e.g. statfs starts failing) clears the stale bytes and the gate falls
// back to slot-only instead of trusting outdated capacity.
dt := types.ToDiskType(diskType)
currentDiskUsage := dn.diskUsages.getOrCreateDisk(dt)
currentTotal := atomic.LoadInt64(&currentDiskUsage.diskTotalBytes)
currentFree := atomic.LoadInt64(&currentDiskUsage.diskFreeBytes)
newTotal := int64(totalBytes)
newFree := int64(diskFreeBytes[diskType])
if currentTotal == newTotal && currentFree == newFree {
continue
}
disk := dn.getOrCreateDisk(dt.String())
disk.UpAdjustDiskUsageDelta(dt, &DiskUsageCounts{
diskTotalBytes: newTotal - currentTotal,
diskFreeBytes: newFree - currentFree,
})
}
}
// AppendVolumeIds appends the ids of this node's volumes to all, and repeats
// the remote-tier ones on remote, without copying the volume records to read
// them.
func (dn *DataNode) AppendVolumeIds(all, remote, readOnly, readOnlyCanDelete []uint32) ([]uint32, []uint32, []uint32, []uint32) {
dn.RLock()
defer dn.RUnlock()
for _, c := range dn.children {
all, remote, readOnly, readOnlyCanDelete = c.(*Disk).AppendVolumeIds(all, remote, readOnly, readOnlyCanDelete)
}
return all, remote, readOnly, readOnlyCanDelete
}
func (dn *DataNode) GetVolumes() (ret []storage.VolumeInfo) {
dn.RLock()
defer dn.RUnlock()
total := 0
for _, c := range dn.children {
total += c.(*Disk).VolumeCount()
}
ret = make([]storage.VolumeInfo, 0, total)
for _, c := range dn.children {
ret = c.(*Disk).AppendVolumes(ret)
}
return ret
}
// HasDuplicateVolumeIds reports whether the node's last full report named one
// volume id more than once. While it does, the node's digest is not meaningful.
func (dn *DataNode) HasDuplicateVolumeIds() bool {
return dn.duplicateVolumeIds.Load()
}
// VolumeDigest summarises every volume the master believes this node holds. A
// volume server that reports a different digest has drifted from the master and
// needs to resend its volume list.
func (dn *DataNode) VolumeDigest() uint64 {
dn.RLock()
defer dn.RUnlock()
var digest uint64
for _, c := range dn.children {
digest ^= c.(*Disk).VolumeDigest()
}
return digest
}
func (dn *DataNode) GetVolumesById(id needle.VolumeId) (vInfo storage.VolumeInfo, err error) {
dn.RLock()
defer dn.RUnlock()
found := false
for _, c := range dn.children {
disk := c.(*Disk)
vInfo, err = disk.GetVolumesById(id)
if err == nil {
found = true
break
}
}
if found {
return vInfo, nil
} else {
return storage.VolumeInfo{}, fmt.Errorf("volumeInfo not found")
}
}
func (dn *DataNode) GetDataCenter() *DataCenter {
rack := dn.Parent()
if rack == nil {
return nil
}
dcNode := rack.Parent()
if dcNode == nil {
return nil
}
dcValue := dcNode.GetValue()
return dcValue.(*DataCenter)
}
func (dn *DataNode) GetDataCenterId() string {
if dc := dn.GetDataCenter(); dc != nil {
return string(dc.Id())
}
return ""
}
func (dn *DataNode) GetRack() *Rack {
return dn.Parent().(*NodeImpl).value.(*Rack)
}
func (dn *DataNode) matchesLocation(option *VolumeGrowOption) bool {
if option.DataCenter != "" && dn.GetDataCenter().Id() != NodeId(option.DataCenter) {
return false
}
if option.Rack != "" && dn.GetRack().Id() != NodeId(option.Rack) {
return false
}
if option.DataNode != "" && dn.Id() != NodeId(option.DataNode) {
return false
}
return true
}
func (dn *DataNode) GetTopology() *Topology {
p := dn.Parent()
for p.Parent() != nil {
p = p.Parent()
}
t := p.(*Topology)
return t
}
func (dn *DataNode) MatchLocation(ip string, port int) bool {
return dn.Ip == ip && dn.Port == port
}
func (dn *DataNode) Url() string {
return util.JoinHostPort(dn.Ip, dn.Port)
}
func (dn *DataNode) ServerAddress() pb.ServerAddress {
return pb.NewServerAddress(dn.Ip, dn.Port, dn.GrpcPort)
}
type DataNodeInfo struct {
Url string `json:"Url"`
PublicUrl string `json:"PublicUrl"`
Volumes int64 `json:"Volumes"`
EcShards int64 `json:"EcShards"`
Max int64 `json:"Max"`
VolumeIds string `json:"VolumeIds"`
}
func (dn *DataNode) ToInfo() (info DataNodeInfo) {
info.Url = dn.Url()
info.PublicUrl = dn.PublicUrl
// aggregated volume info
var volumeCount, ecShardCount, maxVolumeCount int64
var volumeIds string
for _, diskUsage := range dn.diskUsages.usages {
volumeCount += diskUsage.volumeCount
ecShardCount += diskUsage.ecShardCount
maxVolumeCount += diskUsage.maxVolumeCount
}
for _, disk := range dn.Children() {
d := disk.(*Disk)
volumeIds += " " + d.GetVolumeIds()
}
info.Volumes = volumeCount
info.EcShards = ecShardCount
info.Max = maxVolumeCount
info.VolumeIds = volumeIds
return
}
func (dn *DataNode) ToDataNodeInfo(filter VolumeFilter) *master_pb.DataNodeInfo {
m := &master_pb.DataNodeInfo{
Id: string(dn.Id()),
// Start from disk usage counters so empty disks are still represented
// even when there are no volumes/EC shards on this data node yet.
DiskInfos: dn.diskUsages.ToDiskInfo(),
GrpcPort: uint32(dn.GrpcPort),
Address: dn.Url(), // ip:port for connecting to the volume server
}
if m.DiskInfos == nil {
m.DiskInfos = make(map[string]*master_pb.DiskInfo)
}
for diskType, diskInfo := range m.DiskInfos {
if diskInfo == nil {
m.DiskInfos[diskType] = &master_pb.DiskInfo{Type: diskType}
continue
}
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))
for diskID, meta := range dn.diskMetas {
metas[diskID] = meta
}
dn.RUnlock()
for _, diskInfo := range m.DiskInfos {
if diskInfo == nil {
continue
}
if meta, found := metas[diskInfo.DiskId]; found {
diskInfo.Tags = append([]string(nil), meta.tags...)
}
// Max per physical disk of this type, empty and unavailable (max 0) ones
// included. Emit only when some disk reports capacity, so an older server
// sending all zeros leaves the map nil and falls back.
diskType := types.ToDiskType(diskInfo.Type)
maxByDisk := make(map[uint32]int64)
anyCapacity := false
for diskID, meta := range metas {
if meta.diskType != diskType {
continue
}
if meta.maxVolumeCount > 0 {
anyCapacity = true
}
maxByDisk[diskID] = meta.maxVolumeCount
}
if anyCapacity {
diskInfo.MaxVolumeCountByDisk = maxByDisk
}
}
return m
}
func (dn *DataNode) UpdateDiskTags(tags []*master_pb.DiskTag) {
if len(tags) == 0 {
return
}
// DiskTags is the full list on each full heartbeat; rebuild fresh to drop
// removed disks.
metas := make(map[uint32]diskMeta, len(tags))
for _, tagInfo := range tags {
if tagInfo == nil {
continue
}
metas[tagInfo.DiskId] = diskMeta{
tags: append([]string(nil), tagInfo.Tags...),
diskType: types.ToDiskType(tagInfo.Type),
maxVolumeCount: tagInfo.MaxVolumeCount,
}
}
dn.Lock()
dn.diskMetas = metas
dn.Unlock()
}