Files
seaweedfs/weed/topology/data_node.go
T
Chris LuandGitHub 553bc5ab90 topology: digest the volumes a master believes each node holds (#10619)
* topology: digest the volumes a master believes each node holds

A volume server resends its whole volume list every heartbeat because that list
is the only way the master can notice a volume that vanished without a delta.
A digest gives the master the same guarantee without the list: the two ends
agree iff the master's copy is current.

VolumeInfo.ReportHash covers every field of VolumeInformationMessage, so a
change the hash misses is a change the master would never hear about. Both ends
run it over the same converted VolumeInfo, so they cannot drift apart.

Disk keeps the xor of its volumes' hashes, which is order-independent and its
own inverse, so add, update and remove each stay O(1) and the running value
needs no per-volume storage.

Nothing reads the digest yet; the heartbeat protocol change comes next.

* topology: test that a changed-volumes-only heartbeat reconciles

The digest is not a change detector -- in a live cluster some volumes always
have changed. It answers whether the master holds what the volume server holds
once the heartbeat's own changes are applied, so reporting three volumes out of
fifty has to reconcile while a volume lost without a delta must not.

* topology: digest the lookup index too, not just the disk maps

The reported digest answers whether the master holds what the volume server
holds. It cannot answer whether the master can serve those volumes: the disk
map and the lookup index are maintained separately, and a disconnect racing a
reconnect drops a volume from the index while leaving it on the node. The
server's report is identical either way, so a digest built from the disk maps
alone matches while the volume answers 'volume id not found'.

Track a second digest over volume ids on both sides of that split, so the
master can see its own indexes disagree without the volume server's help, and
without the O(volumes) scan the full heartbeat currently relies on.

* topology: exclude nodes reporting a duplicate volume id from the digest

A volume id can end up mounted on two disks of one server -- a stale twin
re-attached after a disk repair, which the store handles rather than rejects.
The server reports both copies with different disk ids, but the master keys
volumes by id alone within a disk type and keeps only the last one. Its digest
can then never equal the server's, and no amount of resending the full list
would fix it.

Detect it from the report itself, where deduplicating the ids already tells us
the count, and mark the node. A marked node has to keep sending full lists;
representing both copies is a separate question, and nesting the volume map by
disk id would cost more memory than the digest saves.

* topology: move the lookup digest with the entry, not the node passed in

Two volume servers can hold one address: GetOrCreateDataNode keys on the id a
server reports and refuses to merge a new id onto an address an older node
still claims, while the lookup list keys on address alone. Registering the
second server therefore displaces the first from the entry, and unregistering
through either removes whichever node the entry named.

Crediting the node handed to Set and Remove instead of the one actually
displaced or removed left the digest on the wrong node. A displaced node went
on reporting a consistent index while it could no longer serve the volume,
which is exactly the silent unavailability the digest exists to catch.

Set and Remove now return the node they displaced and removed, so ownership
can be transferred rather than assumed.
2026-08-07 12:42:16 -07:00

426 lines
12 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 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)
}
func (dn *DataNode) AddOrUpdateVolume(v storage.VolumeInfo) (isNew, isChangedRO bool) {
dn.Lock()
defer dn.Unlock()
return dn.doAddOrUpdateVolume(v)
}
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 bool) {
disk := dn.getOrCreateDisk(v.DiskType)
return disk.AddOrUpdateVolume(v)
}
// UpdateVolumes detects new/deleted/changed volumes on a volume server
// used in master to notify master clients of these changes.
func (dn *DataNode) UpdateVolumes(actualVolumes []storage.VolumeInfo) (newVolumes, deletedVolumes, changedVolumes []storage.VolumeInfo) {
actualVolumeIds := make(map[needle.VolumeId]struct{}, len(actualVolumes))
for _, v := range actualVolumes {
actualVolumeIds[v.Id] = struct{}{}
}
// 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(len(actualVolumeIds) < len(actualVolumes))
dn.Lock()
defer dn.Unlock()
keptCount := 0
for _, c := range dn.children {
disk := c.(*Disk)
for _, v := range disk.RemoveVolumesNotIn(actualVolumeIds) {
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 := len(actualVolumes) - keptCount; addedCount > 0 {
newVolumes = make([]storage.VolumeInfo, 0, addedCount)
}
for _, v := range actualVolumes {
isNew, isChanged := dn.doAddOrUpdateVolume(v)
if isNew {
newVolumes = append(newVolumes, v)
}
if isChanged {
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,
})
}
}
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) 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() *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
}
for _, c := range dn.Children() {
disk := c.(*Disk)
m.DiskInfos[string(disk.Id())] = disk.ToDiskInfo()
}
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()
}