Files
seaweedfs/weed/pb/master_pb/master_helper.go
T
Chris Lu 77bf2a3ab0 volume.balance: gate on real physical disk usage (fixes #10160) (#10162)
* shell: add volume.balance -byDiskUsage to balance by actual data

The default balancer ranks servers by slot density, dividing used volumes by
MaxVolumeCount. When MaxVolumeCount is configured higher than the disk can hold,
a physically near-full server looks nearly empty and gets picked as the move
target, so balancing drains less-full servers onto an already-full one.

-byDiskUsage ranks servers by the actual data they hold (sum of volume sizes)
instead, so the fullest-by-data server is treated as full and balancing drains
it. It assumes comparable disk sizes per disk type and still respects each
server's free volume slots. Default behavior is unchanged.

* plumb physical disk usage into topology, gate volume.balance on it

Volume servers now report each disk's filesystem total/free bytes in the
heartbeat, and the master stores them in DiskInfo. volume.balance uses them to
skip any move target whose disk is already near full (-maxDiskUsagePercent,
default 90), so an over-configured maxVolumeCount can no longer make a
physically full server look empty and get drained onto. The gate judges each
server against its own disk, so heterogeneous disk sizes are fine; servers that
do not report bytes fall back to slot-only behavior.

Rust seaweed-volume mirrors the heartbeat reporting.

* admin: report real physical disk capacity when volume servers provide it

The dashboard estimated server capacity as maxVolumeCount * volumeSizeLimit,
which overstates it when maxVolumeCount is set higher than the disk holds.
Prefer the filesystem capacity now reported per disk, falling back to the
estimate for servers that do not report it.

* worker: gate automatic balance on physical disk fullness too

The maintenance balance worker selects the least slot-utilized server as the
move destination, so an over-configured maxVolumeCount makes a physically full
server look empty and get drained onto — the same defect as the shell command.
Now that DiskInfo carries real disk bytes, skip any destination whose disk is
at/above 90% used (per server, against its own disk); a full server can still be
a source. When every candidate destination is full, create no tasks. Servers
that do not report disk bytes are not gated.

* balance: share the physical-disk-fullness gate between shell and worker

The shell volume.balance command and the maintenance balance worker each grew
their own copy of the disk-fullness gate (targetDiskTooFull / destinationDiskTooFull)
and a maxDiskUsagePercent=90 constant. Pull both into weed/topology/balancer
(DiskTooFullAfter + DefaultMaxDiskUsagePercent) so the policy has one home and the
two balancers can't drift.

* balance: harden the physical-disk gate

Guard against a nil DiskInfo in the byte/slot lookups. Let a zero disk-capacity
report clear previously stored bytes (0 means "not reported" for bytes, unlike
maxVolumeCount), so a server that stops reporting falls back to slot-only instead
of trusting stale capacity. In the worker, charge each planned move's bytes to
its destination within a detection cycle so the gate sees a target fill up rather
than only its heartbeat-time free space. Note the per-location capacity summing
assumes one location per filesystem (the used ratio the gate relies on stays
correct regardless; absolute capacity can over-report).
2026-06-30 19:31:12 -07:00

152 lines
4.6 KiB
Go

package master_pb
import "sort"
func (v *VolumeLocation) IsEmptyUrl() bool {
return v.Url == "" || v.Url == ":0"
}
// SplitByPhysicalDisk returns one DiskInfo per physical disk. The wire format
// keys DataNodeInfo.DiskInfos by disk type, collapsing same-type disks into one
// entry, so this rebuilds the per-disk view. MaxVolumeCountByDisk, when present,
// is the authoritative set (empty disks included) and gives each disk its exact
// max; otherwise the set is the DiskIds seen on records with aggregate Max/Free
// split evenly.
func (d *DiskInfo) SplitByPhysicalDisk() []*DiskInfo {
if d == nil {
return nil
}
// DiskId 0 is overloaded: it is both the first physical disk (Locations[0])
// and the protobuf default for "unset". Only treat 0 as unset when every
// record reports 0 (the legacy case where the volume server didn't populate
// DiskId). If any record carries a non-zero DiskId, the reporting is real and
// a 0 means physical disk 0 — keep it distinct instead of folding it onto
// d.DiskId, which would merge two physical disks and drop disk 0 from view.
hasNonZero := false
for _, vi := range d.VolumeInfos {
if vi.DiskId != 0 {
hasNonZero = true
break
}
}
if !hasNonZero {
for _, eci := range d.EcShardInfos {
if eci.DiskId != 0 {
hasNonZero = true
break
}
}
}
normalize := func(id uint32) uint32 {
if id == 0 && !hasNonZero && d.DiskId != 0 {
return d.DiskId
}
return id
}
perDiskVolumes := make(map[uint32][]*VolumeInformationMessage)
for _, vi := range d.VolumeInfos {
id := normalize(vi.DiskId)
perDiskVolumes[id] = append(perDiskVolumes[id], vi)
}
perDiskShards := make(map[uint32][]*VolumeEcShardInformationMessage)
for _, eci := range d.EcShardInfos {
id := normalize(eci.DiskId)
perDiskShards[id] = append(perDiskShards[id], eci)
}
diskIDs := make(map[uint32]struct{})
for id := range perDiskVolumes {
diskIDs[id] = struct{}{}
}
for id := range perDiskShards {
diskIDs[id] = struct{}{}
}
// MaxVolumeCountByDisk (when present) is the authoritative set with each
// disk's exact max.
exactMax := len(d.MaxVolumeCountByDisk) > 0
for diskID := range d.MaxVolumeCountByDisk {
diskIDs[diskID] = struct{}{}
}
if len(diskIDs) == 0 {
diskIDs[d.DiskId] = struct{}{}
}
// A lone disk equal to the aggregate needs no reconstruction; exactMax disks
// always reconstruct so they report their own max.
if !exactMax && len(diskIDs) == 1 {
for diskID := range diskIDs {
if diskID == d.DiskId {
return []*DiskInfo{d}
}
}
}
// Sort disk IDs so the remainder distribution is deterministic and the
// reconstructed slice is in DiskId order, which is what downstream
// renderers expect.
ids := make([]uint32, 0, len(diskIDs))
for id := range diskIDs {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
count := int64(len(ids))
// share returns total / count, plus one extra for the first
// (total % count) entries so the sum of shares equals total. Without
// the remainder distribution, splitting 10 across 3 disks would yield
// 3+3+3 = 9 and under-report aggregate capacity.
share := func(total int64, idx int) int64 {
base := total / count
if int64(idx) < total%count {
return base + 1
}
return base
}
result := make([]*DiskInfo, 0, len(ids))
for i, diskID := range ids {
var activeCount, remoteCount int64
for _, vi := range perDiskVolumes[diskID] {
if !vi.ReadOnly {
activeCount++
}
if vi.RemoteStorageName != "" {
remoteCount++
}
}
volumeCount := int64(len(perDiskVolumes[diskID]))
maxVolumeCount := share(d.MaxVolumeCount, i)
freeVolumeCount := share(d.FreeVolumeCount, i)
if exactMax {
maxVolumeCount = d.MaxVolumeCountByDisk[diskID]
// EC slots aren't subtracted (needs the configurable ratio, outside
// this package); planners subtract them from EcShardInfos. Clamp so an
// over-allocated disk can't report negative free.
freeVolumeCount = maxVolumeCount - (volumeCount - remoteCount)
if freeVolumeCount < 0 {
freeVolumeCount = 0
}
}
result = append(result, &DiskInfo{
Type: d.Type,
MaxVolumeCount: maxVolumeCount,
VolumeCount: volumeCount,
FreeVolumeCount: freeVolumeCount,
ActiveVolumeCount: activeCount,
RemoteVolumeCount: remoteCount,
VolumeInfos: perDiskVolumes[diskID],
EcShardInfos: perDiskShards[diskID],
DiskId: diskID,
Tags: append([]string(nil), d.Tags...),
DiskTotalBytes: uint64(share(int64(d.DiskTotalBytes), i)),
DiskFreeBytes: uint64(share(int64(d.DiskFreeBytes), i)),
})
}
return result
}