mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-11 08:47:46 +02:00
* 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).
1461 lines
49 KiB
Go
1461 lines
49 KiB
Go
package balance
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
|
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
|
)
|
|
|
|
// serverSpec describes a server for the topology builder.
|
|
type serverSpec struct {
|
|
id string // e.g. "node-1"
|
|
diskType string // e.g. "ssd", "hdd"
|
|
diskID uint32
|
|
dc string
|
|
rack string
|
|
maxVolumes int64
|
|
diskTotalBytes uint64 // physical disk capacity (0 = not reported)
|
|
diskFreeBytes uint64
|
|
}
|
|
|
|
// buildTopology constructs an ActiveTopology from server specs and volume metrics.
|
|
func buildTopology(servers []serverSpec, metrics []*types.VolumeHealthMetrics) *topology.ActiveTopology {
|
|
at := topology.NewActiveTopology(0)
|
|
|
|
volumesByServer := make(map[string][]*master_pb.VolumeInformationMessage)
|
|
for _, m := range metrics {
|
|
volumesByServer[m.Server] = append(volumesByServer[m.Server], &master_pb.VolumeInformationMessage{
|
|
Id: m.VolumeID,
|
|
Size: m.Size,
|
|
Collection: m.Collection,
|
|
Version: 1,
|
|
})
|
|
}
|
|
|
|
// Group servers by dc → rack for topology construction
|
|
type rackKey struct{ dc, rack string }
|
|
rackNodes := make(map[rackKey][]*master_pb.DataNodeInfo)
|
|
|
|
for _, s := range servers {
|
|
maxVol := s.maxVolumes
|
|
if maxVol == 0 {
|
|
maxVol = 1000
|
|
}
|
|
node := &master_pb.DataNodeInfo{
|
|
Id: s.id,
|
|
Address: s.id + ":8080",
|
|
DiskInfos: map[string]*master_pb.DiskInfo{
|
|
s.diskType: {
|
|
Type: s.diskType,
|
|
DiskId: s.diskID,
|
|
VolumeInfos: volumesByServer[s.id],
|
|
VolumeCount: int64(len(volumesByServer[s.id])),
|
|
MaxVolumeCount: maxVol,
|
|
DiskTotalBytes: s.diskTotalBytes,
|
|
DiskFreeBytes: s.diskFreeBytes,
|
|
},
|
|
},
|
|
}
|
|
key := rackKey{s.dc, s.rack}
|
|
rackNodes[key] = append(rackNodes[key], node)
|
|
}
|
|
|
|
// Build DC → Rack tree
|
|
dcRacks := make(map[string][]*master_pb.RackInfo)
|
|
for key, nodes := range rackNodes {
|
|
dcRacks[key.dc] = append(dcRacks[key.dc], &master_pb.RackInfo{
|
|
Id: key.rack,
|
|
DataNodeInfos: nodes,
|
|
})
|
|
}
|
|
|
|
var dcInfos []*master_pb.DataCenterInfo
|
|
for dcID, racks := range dcRacks {
|
|
dcInfos = append(dcInfos, &master_pb.DataCenterInfo{
|
|
Id: dcID,
|
|
RackInfos: racks,
|
|
})
|
|
}
|
|
|
|
at.UpdateTopology(&master_pb.TopologyInfo{DataCenterInfos: dcInfos})
|
|
return at
|
|
}
|
|
|
|
// makeVolumes generates n VolumeHealthMetrics for a server starting at volumeIDBase.
|
|
func makeVolumes(server, diskType, dc, rack, collection string, volumeIDBase uint32, n int) []*types.VolumeHealthMetrics {
|
|
out := make([]*types.VolumeHealthMetrics, n)
|
|
for i := range out {
|
|
out[i] = &types.VolumeHealthMetrics{
|
|
VolumeID: volumeIDBase + uint32(i),
|
|
Server: server,
|
|
ServerAddress: server + ":8080",
|
|
DiskType: diskType,
|
|
Collection: collection,
|
|
Size: 1024,
|
|
DataCenter: dc,
|
|
Rack: rack,
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func defaultConf() *Config {
|
|
return &Config{
|
|
BaseConfig: base.BaseConfig{
|
|
Enabled: true,
|
|
ScanIntervalSeconds: 30,
|
|
MaxConcurrent: 1,
|
|
},
|
|
MinServerCount: 2,
|
|
ImbalanceThreshold: 0.2,
|
|
}
|
|
}
|
|
|
|
// assertNoDuplicateVolumes verifies every task moves a distinct volume.
|
|
func assertNoDuplicateVolumes(t *testing.T, tasks []*types.TaskDetectionResult) {
|
|
t.Helper()
|
|
seen := make(map[uint32]bool)
|
|
for i, task := range tasks {
|
|
if seen[task.VolumeID] {
|
|
t.Errorf("duplicate volume %d in task %d", task.VolumeID, i)
|
|
}
|
|
seen[task.VolumeID] = true
|
|
}
|
|
}
|
|
|
|
// computeEffectiveCounts returns per-server volume counts after applying all planned moves.
|
|
// servers seeds the map so that empty destination servers (no volumes in metrics) are tracked.
|
|
func computeEffectiveCounts(servers []serverSpec, metrics []*types.VolumeHealthMetrics, tasks []*types.TaskDetectionResult) map[string]int {
|
|
// Build address → server ID mapping from the topology spec
|
|
addrToServer := make(map[string]string, len(servers))
|
|
counts := make(map[string]int, len(servers))
|
|
for _, s := range servers {
|
|
counts[s.id] = 0
|
|
addrToServer[s.id+":8080"] = s.id
|
|
addrToServer[s.id] = s.id
|
|
}
|
|
for _, m := range metrics {
|
|
counts[m.Server]++
|
|
}
|
|
for _, task := range tasks {
|
|
counts[task.Server]-- // source loses one
|
|
if task.TypedParams != nil && len(task.TypedParams.Targets) > 0 {
|
|
addr := task.TypedParams.Targets[0].Node
|
|
if serverID, ok := addrToServer[addr]; ok {
|
|
counts[serverID]++
|
|
}
|
|
}
|
|
}
|
|
return counts
|
|
}
|
|
|
|
func createMockTopology(volumes ...*types.VolumeHealthMetrics) *topology.ActiveTopology {
|
|
at := topology.NewActiveTopology(0)
|
|
|
|
// Group volumes by server for easier topology construction
|
|
volumesByServer := make(map[string][]*master_pb.VolumeInformationMessage)
|
|
for _, v := range volumes {
|
|
if _, ok := volumesByServer[v.Server]; !ok {
|
|
volumesByServer[v.Server] = []*master_pb.VolumeInformationMessage{}
|
|
}
|
|
volumesByServer[v.Server] = append(volumesByServer[v.Server], &master_pb.VolumeInformationMessage{
|
|
Id: v.VolumeID,
|
|
Size: v.Size,
|
|
Collection: v.Collection,
|
|
ReplicaPlacement: 0,
|
|
Ttl: 0,
|
|
Version: 1,
|
|
})
|
|
}
|
|
|
|
topoInfo := &master_pb.TopologyInfo{
|
|
DataCenterInfos: []*master_pb.DataCenterInfo{
|
|
{
|
|
Id: "dc1",
|
|
RackInfos: []*master_pb.RackInfo{
|
|
{
|
|
Id: "rack1",
|
|
DataNodeInfos: []*master_pb.DataNodeInfo{
|
|
// SSD Nodes
|
|
{
|
|
Id: "ssd-server-1",
|
|
Address: "ssd-server-1:8080",
|
|
DiskInfos: map[string]*master_pb.DiskInfo{
|
|
"ssd": {
|
|
Type: "ssd",
|
|
DiskId: 1,
|
|
VolumeInfos: volumesByServer["ssd-server-1"],
|
|
MaxVolumeCount: 1000,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Id: "ssd-server-2",
|
|
Address: "ssd-server-2:8080",
|
|
DiskInfos: map[string]*master_pb.DiskInfo{
|
|
"ssd": {
|
|
Type: "ssd",
|
|
DiskId: 2,
|
|
VolumeInfos: volumesByServer["ssd-server-2"],
|
|
MaxVolumeCount: 1000,
|
|
},
|
|
},
|
|
},
|
|
// HDD Nodes
|
|
{
|
|
Id: "hdd-server-1",
|
|
Address: "hdd-server-1:8080",
|
|
DiskInfos: map[string]*master_pb.DiskInfo{
|
|
"hdd": {
|
|
Type: "hdd",
|
|
DiskId: 3, // Changed index to avoid conflict
|
|
VolumeInfos: volumesByServer["hdd-server-1"],
|
|
MaxVolumeCount: 1000,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Id: "hdd-server-2",
|
|
Address: "hdd-server-2:8080",
|
|
DiskInfos: map[string]*master_pb.DiskInfo{
|
|
"hdd": {
|
|
Type: "hdd",
|
|
DiskId: 4,
|
|
VolumeInfos: volumesByServer["hdd-server-2"],
|
|
MaxVolumeCount: 1000,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
at.UpdateTopology(topoInfo)
|
|
return at
|
|
}
|
|
|
|
func TestDetection_MixedDiskTypes(t *testing.T) {
|
|
// Setup metrics
|
|
// 2 SSD servers with 10 volumes each (Balanced)
|
|
// 2 HDD servers with 100 volumes each (Balanced)
|
|
|
|
metrics := []*types.VolumeHealthMetrics{}
|
|
|
|
// SSD Servers
|
|
for i := 0; i < 10; i++ {
|
|
metrics = append(metrics, &types.VolumeHealthMetrics{
|
|
VolumeID: uint32(i + 1),
|
|
Server: "ssd-server-1",
|
|
ServerAddress: "ssd-server-1:8080",
|
|
DiskType: "ssd",
|
|
Collection: "c1",
|
|
Size: 1024,
|
|
DataCenter: "dc1",
|
|
Rack: "rack1",
|
|
})
|
|
}
|
|
for i := 0; i < 10; i++ {
|
|
metrics = append(metrics, &types.VolumeHealthMetrics{
|
|
VolumeID: uint32(20 + i + 1),
|
|
Server: "ssd-server-2",
|
|
ServerAddress: "ssd-server-2:8080",
|
|
DiskType: "ssd",
|
|
Collection: "c1",
|
|
Size: 1024,
|
|
DataCenter: "dc1",
|
|
Rack: "rack1",
|
|
})
|
|
}
|
|
|
|
// HDD Servers
|
|
for i := 0; i < 100; i++ {
|
|
metrics = append(metrics, &types.VolumeHealthMetrics{
|
|
VolumeID: uint32(100 + i + 1),
|
|
Server: "hdd-server-1",
|
|
ServerAddress: "hdd-server-1:8080",
|
|
DiskType: "hdd",
|
|
Collection: "c1",
|
|
Size: 1024,
|
|
DataCenter: "dc1",
|
|
Rack: "rack1",
|
|
})
|
|
}
|
|
for i := 0; i < 100; i++ {
|
|
metrics = append(metrics, &types.VolumeHealthMetrics{
|
|
VolumeID: uint32(200 + i + 1),
|
|
Server: "hdd-server-2",
|
|
ServerAddress: "hdd-server-2:8080",
|
|
DiskType: "hdd",
|
|
Collection: "c1",
|
|
Size: 1024,
|
|
DataCenter: "dc1",
|
|
Rack: "rack1",
|
|
})
|
|
}
|
|
|
|
conf := &Config{
|
|
BaseConfig: base.BaseConfig{
|
|
Enabled: true,
|
|
ScanIntervalSeconds: 30,
|
|
MaxConcurrent: 1,
|
|
},
|
|
MinServerCount: 2,
|
|
ImbalanceThreshold: 0.2, // 20%
|
|
}
|
|
|
|
at := createMockTopology(metrics...)
|
|
clusterInfo := &types.ClusterInfo{
|
|
ActiveTopology: at,
|
|
}
|
|
|
|
tasks, _, err := Detection(metrics, clusterInfo, conf, 100)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
|
|
if len(tasks) != 0 {
|
|
t.Errorf("Expected 0 tasks for balanced mixed types, got %d", len(tasks))
|
|
for _, task := range tasks {
|
|
t.Logf("Computed Task: %+v", task.Reason)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDetection_ImbalancedDiskType(t *testing.T) {
|
|
// Setup metrics
|
|
// 2 SSD servers: One with 100, One with 10. Imbalance!
|
|
metrics := []*types.VolumeHealthMetrics{}
|
|
|
|
// Server 1 (Overloaded SSD)
|
|
for i := 0; i < 100; i++ {
|
|
metrics = append(metrics, &types.VolumeHealthMetrics{
|
|
VolumeID: uint32(i + 1),
|
|
Server: "ssd-server-1",
|
|
ServerAddress: "ssd-server-1:8080",
|
|
DiskType: "ssd",
|
|
Collection: "c1",
|
|
Size: 1024,
|
|
DataCenter: "dc1",
|
|
Rack: "rack1",
|
|
})
|
|
}
|
|
// Server 2 (Underloaded SSD)
|
|
for i := 0; i < 10; i++ {
|
|
metrics = append(metrics, &types.VolumeHealthMetrics{
|
|
VolumeID: uint32(100 + i + 1),
|
|
Server: "ssd-server-2",
|
|
ServerAddress: "ssd-server-2:8080",
|
|
DiskType: "ssd",
|
|
Collection: "c1",
|
|
Size: 1024,
|
|
DataCenter: "dc1",
|
|
Rack: "rack1",
|
|
})
|
|
}
|
|
|
|
conf := &Config{
|
|
BaseConfig: base.BaseConfig{
|
|
Enabled: true,
|
|
ScanIntervalSeconds: 30,
|
|
MaxConcurrent: 1,
|
|
},
|
|
MinServerCount: 2,
|
|
ImbalanceThreshold: 0.2,
|
|
}
|
|
|
|
at := createMockTopology(metrics...)
|
|
clusterInfo := &types.ClusterInfo{
|
|
ActiveTopology: at,
|
|
}
|
|
|
|
tasks, _, err := Detection(metrics, clusterInfo, conf, 100)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
|
|
if len(tasks) == 0 {
|
|
t.Error("Expected tasks for imbalanced SSD cluster, got 0")
|
|
}
|
|
|
|
// With 100 volumes on server-1 and 10 on server-2, avg=55, detection should
|
|
// propose multiple moves until imbalance drops below 20% threshold.
|
|
// All tasks should move volumes from ssd-server-1 to ssd-server-2.
|
|
if len(tasks) < 2 {
|
|
t.Errorf("Expected multiple balance tasks, got %d", len(tasks))
|
|
}
|
|
|
|
for i, task := range tasks {
|
|
if task.VolumeID == 0 {
|
|
t.Errorf("Task %d has invalid VolumeID", i)
|
|
}
|
|
if task.TypedParams.Sources[0].Node != "ssd-server-1:8080" {
|
|
t.Errorf("Task %d: expected source ssd-server-1:8080, got %s", i, task.TypedParams.Sources[0].Node)
|
|
}
|
|
if task.TypedParams.Targets[0].Node != "ssd-server-2:8080" {
|
|
t.Errorf("Task %d: expected target ssd-server-2:8080, got %s", i, task.TypedParams.Targets[0].Node)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Issue #10160 in the maintenance worker: an over-configured maxVolumeCount makes
|
|
// a physically full server look under-utilized by slot count, so the greedy
|
|
// least-utilized-destination pick would drain onto it. The physical-disk gate
|
|
// must steer moves to a genuinely empty disk and never target the full one.
|
|
func TestDetection_SkipsPhysicallyFullDestination(t *testing.T) {
|
|
const gb = uint64(1) << 30
|
|
servers := []serverSpec{
|
|
{id: "src", diskType: "hdd", dc: "dc1", rack: "rack1", maxVolumes: 10},
|
|
{id: "disk-full", diskType: "hdd", dc: "dc1", rack: "rack1", maxVolumes: 1000, diskTotalBytes: 1000 * gb, diskFreeBytes: 40 * gb}, // 96% used
|
|
{id: "disk-empty", diskType: "hdd", dc: "dc1", rack: "rack1", maxVolumes: 1000, diskTotalBytes: 1000 * gb, diskFreeBytes: 900 * gb}, // 10% used
|
|
}
|
|
metrics := makeVolumes("src", "hdd", "dc1", "rack1", "c1", 1, 8)
|
|
|
|
at := buildTopology(servers, metrics)
|
|
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
|
|
|
tasks, _, err := Detection(metrics, clusterInfo, defaultConf(), 100)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
if len(tasks) == 0 {
|
|
t.Fatal("expected balance tasks for the imbalanced cluster, got 0")
|
|
}
|
|
targetedEmpty := false
|
|
for i, task := range tasks {
|
|
tgt := task.TypedParams.Targets[0].Node
|
|
if tgt == "disk-full:8080" {
|
|
t.Errorf("task %d targeted the physically full server %s", i, tgt)
|
|
}
|
|
if tgt == "disk-empty:8080" {
|
|
targetedEmpty = true
|
|
}
|
|
}
|
|
if !targetedEmpty {
|
|
t.Error("expected at least one task to target the physically empty server")
|
|
}
|
|
}
|
|
|
|
// When every candidate destination is physically full, the worker must create no
|
|
// tasks rather than pile onto a full disk.
|
|
func TestDetection_NoDestinationWhenAllDisksFull(t *testing.T) {
|
|
const gb = uint64(1) << 30
|
|
servers := []serverSpec{
|
|
{id: "src", diskType: "hdd", dc: "dc1", rack: "rack1", maxVolumes: 10, diskTotalBytes: 1000 * gb, diskFreeBytes: 40 * gb},
|
|
{id: "disk-full", diskType: "hdd", dc: "dc1", rack: "rack1", maxVolumes: 1000, diskTotalBytes: 1000 * gb, diskFreeBytes: 40 * gb},
|
|
}
|
|
metrics := makeVolumes("src", "hdd", "dc1", "rack1", "c1", 1, 8)
|
|
|
|
at := buildTopology(servers, metrics)
|
|
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
|
|
|
tasks, _, err := Detection(metrics, clusterInfo, defaultConf(), 100)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
if len(tasks) != 0 {
|
|
t.Fatalf("expected no tasks when all destinations are physically full, got %d", len(tasks))
|
|
}
|
|
}
|
|
|
|
func TestDetection_SkipsRemoteTieredVolumes(t *testing.T) {
|
|
metrics := []*types.VolumeHealthMetrics{}
|
|
|
|
for i := 0; i < 100; i++ {
|
|
metrics = append(metrics, &types.VolumeHealthMetrics{
|
|
VolumeID: uint32(i + 1),
|
|
Server: "ssd-server-1",
|
|
ServerAddress: "ssd-server-1:8080",
|
|
DiskType: "ssd",
|
|
Collection: "c1",
|
|
Size: 1024,
|
|
DataCenter: "dc1",
|
|
Rack: "rack1",
|
|
HasRemoteCopy: true,
|
|
})
|
|
}
|
|
for i := 0; i < 10; i++ {
|
|
metrics = append(metrics, &types.VolumeHealthMetrics{
|
|
VolumeID: uint32(100 + i + 1),
|
|
Server: "ssd-server-2",
|
|
ServerAddress: "ssd-server-2:8080",
|
|
DiskType: "ssd",
|
|
Collection: "c1",
|
|
Size: 1024,
|
|
DataCenter: "dc1",
|
|
Rack: "rack1",
|
|
HasRemoteCopy: true,
|
|
})
|
|
}
|
|
|
|
at := createMockTopology(metrics...)
|
|
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
|
|
|
tasks, _, err := Detection(metrics, clusterInfo, defaultConf(), 100)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
if len(tasks) != 0 {
|
|
t.Errorf("expected 0 tasks for remote-tiered volumes, got %d", len(tasks))
|
|
}
|
|
}
|
|
|
|
func TestDetection_RespectsMaxResults(t *testing.T) {
|
|
// Setup: 2 SSD servers with big imbalance (100 vs 10)
|
|
metrics := []*types.VolumeHealthMetrics{}
|
|
|
|
for i := 0; i < 100; i++ {
|
|
metrics = append(metrics, &types.VolumeHealthMetrics{
|
|
VolumeID: uint32(i + 1),
|
|
Server: "ssd-server-1",
|
|
ServerAddress: "ssd-server-1:8080",
|
|
DiskType: "ssd",
|
|
Collection: "c1",
|
|
Size: 1024,
|
|
DataCenter: "dc1",
|
|
Rack: "rack1",
|
|
})
|
|
}
|
|
for i := 0; i < 10; i++ {
|
|
metrics = append(metrics, &types.VolumeHealthMetrics{
|
|
VolumeID: uint32(100 + i + 1),
|
|
Server: "ssd-server-2",
|
|
ServerAddress: "ssd-server-2:8080",
|
|
DiskType: "ssd",
|
|
Collection: "c1",
|
|
Size: 1024,
|
|
DataCenter: "dc1",
|
|
Rack: "rack1",
|
|
})
|
|
}
|
|
|
|
conf := &Config{
|
|
BaseConfig: base.BaseConfig{
|
|
Enabled: true,
|
|
ScanIntervalSeconds: 30,
|
|
MaxConcurrent: 1,
|
|
},
|
|
MinServerCount: 2,
|
|
ImbalanceThreshold: 0.2,
|
|
}
|
|
|
|
at := createMockTopology(metrics...)
|
|
clusterInfo := &types.ClusterInfo{
|
|
ActiveTopology: at,
|
|
}
|
|
|
|
// Request only 3 results — there are enough volumes to produce more,
|
|
// so truncated should be true.
|
|
tasks, truncated, err := Detection(metrics, clusterInfo, conf, 3)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
|
|
if len(tasks) != 3 {
|
|
t.Errorf("Expected exactly 3 tasks (maxResults=3), got %d", len(tasks))
|
|
}
|
|
if !truncated {
|
|
t.Errorf("Expected truncated=true when maxResults caps results")
|
|
}
|
|
|
|
// Verify truncated=false when detection finishes naturally (no cap)
|
|
at2 := createMockTopology(metrics...)
|
|
clusterInfo2 := &types.ClusterInfo{ActiveTopology: at2}
|
|
tasks2, truncated2, err := Detection(metrics, clusterInfo2, conf, 500)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
if truncated2 {
|
|
t.Errorf("Expected truncated=false when detection finishes naturally, got true (len=%d)", len(tasks2))
|
|
}
|
|
}
|
|
|
|
// --- Complicated scenario tests ---
|
|
|
|
// TestDetection_ThreeServers_ConvergesToBalance verifies that with 3 servers
|
|
// (60/30/10 volumes) the algorithm moves volumes from the heaviest server first,
|
|
// then re-evaluates, potentially shifting from the second-heaviest too.
|
|
func TestDetection_ThreeServers_ConvergesToBalance(t *testing.T) {
|
|
servers := []serverSpec{
|
|
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
|
|
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1"},
|
|
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc1", rack: "rack1"},
|
|
}
|
|
|
|
var metrics []*types.VolumeHealthMetrics
|
|
metrics = append(metrics, makeVolumes("node-a", "hdd", "dc1", "rack1", "c1", 1, 60)...)
|
|
metrics = append(metrics, makeVolumes("node-b", "hdd", "dc1", "rack1", "c1", 100, 30)...)
|
|
metrics = append(metrics, makeVolumes("node-c", "hdd", "dc1", "rack1", "c1", 200, 10)...)
|
|
|
|
at := buildTopology(servers, metrics)
|
|
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
|
|
|
tasks, _, err := Detection(metrics, clusterInfo, defaultConf(), 100)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
|
|
if len(tasks) < 2 {
|
|
t.Fatalf("Expected multiple tasks for 60/30/10 imbalance, got %d", len(tasks))
|
|
}
|
|
|
|
assertNoDuplicateVolumes(t, tasks)
|
|
|
|
// Verify convergence: effective counts should be within 20% imbalance.
|
|
effective := computeEffectiveCounts(servers, metrics, tasks)
|
|
total := 0
|
|
maxC, minC := 0, len(metrics)
|
|
for _, c := range effective {
|
|
total += c
|
|
if c > maxC {
|
|
maxC = c
|
|
}
|
|
if c < minC {
|
|
minC = c
|
|
}
|
|
}
|
|
avg := float64(total) / float64(len(effective))
|
|
imbalance := float64(maxC-minC) / avg
|
|
if imbalance > 0.2 {
|
|
t.Errorf("After %d moves, cluster still imbalanced: effective=%v, imbalance=%.1f%%",
|
|
len(tasks), effective, imbalance*100)
|
|
}
|
|
|
|
// All sources should be from the overloaded nodes, never node-c
|
|
for i, task := range tasks {
|
|
src := task.TypedParams.Sources[0].Node
|
|
if src == "node-c:8080" {
|
|
t.Errorf("Task %d: should not move FROM the underloaded server node-c", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDetection_SkipsPreExistingPendingTasks verifies that volumes with
|
|
// already-registered pending tasks in ActiveTopology are skipped.
|
|
func TestDetection_SkipsPreExistingPendingTasks(t *testing.T) {
|
|
servers := []serverSpec{
|
|
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
|
|
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1"},
|
|
}
|
|
|
|
// node-a has 20, node-b has 5
|
|
var metrics []*types.VolumeHealthMetrics
|
|
metrics = append(metrics, makeVolumes("node-a", "hdd", "dc1", "rack1", "c1", 1, 20)...)
|
|
metrics = append(metrics, makeVolumes("node-b", "hdd", "dc1", "rack1", "c1", 100, 5)...)
|
|
|
|
at := buildTopology(servers, metrics)
|
|
|
|
// Pre-register pending tasks for the first 15 volumes on node-a.
|
|
// This simulates a previous detection run that already planned moves.
|
|
for i := 0; i < 15; i++ {
|
|
volID := uint32(1 + i)
|
|
err := at.AddPendingTask(topology.TaskSpec{
|
|
TaskID: fmt.Sprintf("existing-%d", volID),
|
|
TaskType: topology.TaskTypeBalance,
|
|
VolumeID: volID,
|
|
VolumeSize: 1024,
|
|
Sources: []topology.TaskSourceSpec{{ServerID: "node-a", DiskID: 1}},
|
|
Destinations: []topology.TaskDestinationSpec{{ServerID: "node-b", DiskID: 2}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AddPendingTask failed: %v", err)
|
|
}
|
|
}
|
|
|
|
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
|
tasks, _, err := Detection(metrics, clusterInfo, defaultConf(), 100)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
|
|
// None of the results should reference a volume with an existing task (IDs 1-15).
|
|
for i, task := range tasks {
|
|
if task.VolumeID >= 1 && task.VolumeID <= 15 {
|
|
t.Errorf("Task %d: volume %d already has a pending task, should have been skipped",
|
|
i, task.VolumeID)
|
|
}
|
|
}
|
|
|
|
// With 15 pending A→B moves, effective counts are A=5, B=20.
|
|
// Detection sees B as overloaded and may plan moves from B (5 volumes).
|
|
// Should produce a reasonable number of tasks without over-scheduling.
|
|
if len(tasks) > 5 {
|
|
t.Errorf("Expected at most 5 new tasks, got %d", len(tasks))
|
|
}
|
|
if len(tasks) == 0 {
|
|
t.Errorf("Expected at least 1 new task since projected imbalance still exists")
|
|
}
|
|
|
|
assertNoDuplicateVolumes(t, tasks)
|
|
}
|
|
|
|
// TestDetection_NoDuplicateVolumesAcrossIterations verifies that the loop
|
|
// never selects the same volume twice, even under high maxResults.
|
|
func TestDetection_NoDuplicateVolumesAcrossIterations(t *testing.T) {
|
|
servers := []serverSpec{
|
|
{id: "node-a", diskType: "ssd", diskID: 1, dc: "dc1", rack: "rack1"},
|
|
{id: "node-b", diskType: "ssd", diskID: 2, dc: "dc1", rack: "rack1"},
|
|
}
|
|
|
|
var metrics []*types.VolumeHealthMetrics
|
|
metrics = append(metrics, makeVolumes("node-a", "ssd", "dc1", "rack1", "c1", 1, 50)...)
|
|
metrics = append(metrics, makeVolumes("node-b", "ssd", "dc1", "rack1", "c1", 100, 10)...)
|
|
|
|
at := buildTopology(servers, metrics)
|
|
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
|
|
|
tasks, _, err := Detection(metrics, clusterInfo, defaultConf(), 200)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
|
|
if len(tasks) <= 1 {
|
|
t.Fatalf("Expected multiple tasks to verify no-duplicate invariant across iterations, got %d", len(tasks))
|
|
}
|
|
|
|
assertNoDuplicateVolumes(t, tasks)
|
|
}
|
|
|
|
// TestDetection_ThreeServers_MaxServerShifts verifies that after enough moves
|
|
// from the top server, the algorithm detects a new max server and moves from it.
|
|
func TestDetection_ThreeServers_MaxServerShifts(t *testing.T) {
|
|
servers := []serverSpec{
|
|
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
|
|
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1"},
|
|
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc1", rack: "rack1"},
|
|
}
|
|
|
|
// node-a: 40, node-b: 38, node-c: 10. avg ≈ 29.3
|
|
// Initial imbalance = (40-10)/29.3 ≈ 1.02 → move from node-a.
|
|
// After a few moves from node-a, node-b becomes the new max and should be
|
|
// picked as the source.
|
|
var metrics []*types.VolumeHealthMetrics
|
|
metrics = append(metrics, makeVolumes("node-a", "hdd", "dc1", "rack1", "c1", 1, 40)...)
|
|
metrics = append(metrics, makeVolumes("node-b", "hdd", "dc1", "rack1", "c1", 100, 38)...)
|
|
metrics = append(metrics, makeVolumes("node-c", "hdd", "dc1", "rack1", "c1", 200, 10)...)
|
|
|
|
at := buildTopology(servers, metrics)
|
|
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
|
|
|
tasks, _, err := Detection(metrics, clusterInfo, defaultConf(), 100)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
|
|
if len(tasks) < 3 {
|
|
t.Fatalf("Expected several tasks for 40/38/10 imbalance, got %d", len(tasks))
|
|
}
|
|
|
|
// Collect source servers
|
|
sourceServers := make(map[string]int)
|
|
for _, task := range tasks {
|
|
sourceServers[task.Server]++
|
|
}
|
|
|
|
// Both node-a and node-b should appear as sources (max server shifts)
|
|
if sourceServers["node-a"] == 0 {
|
|
t.Error("Expected node-a to be a source for some moves")
|
|
}
|
|
if sourceServers["node-b"] == 0 {
|
|
t.Error("Expected node-b to be a source after node-a is drained enough")
|
|
}
|
|
if sourceServers["node-c"] > 0 {
|
|
t.Error("node-c (underloaded) should never be a source")
|
|
}
|
|
|
|
assertNoDuplicateVolumes(t, tasks)
|
|
}
|
|
|
|
// TestDetection_FourServers_DestinationSpreading verifies that with 4 servers
|
|
// (1 heavy, 3 light) the algorithm spreads moves across multiple destinations.
|
|
func TestDetection_FourServers_DestinationSpreading(t *testing.T) {
|
|
servers := []serverSpec{
|
|
{id: "node-a", diskType: "ssd", diskID: 1, dc: "dc1", rack: "rack1"},
|
|
{id: "node-b", diskType: "ssd", diskID: 2, dc: "dc1", rack: "rack2"},
|
|
{id: "node-c", diskType: "ssd", diskID: 3, dc: "dc1", rack: "rack3"},
|
|
{id: "node-d", diskType: "ssd", diskID: 4, dc: "dc1", rack: "rack4"},
|
|
}
|
|
|
|
// node-a: 80, b/c/d: 5 each. avg=23.75
|
|
var metrics []*types.VolumeHealthMetrics
|
|
metrics = append(metrics, makeVolumes("node-a", "ssd", "dc1", "rack1", "c1", 1, 80)...)
|
|
metrics = append(metrics, makeVolumes("node-b", "ssd", "dc1", "rack2", "c1", 100, 5)...)
|
|
metrics = append(metrics, makeVolumes("node-c", "ssd", "dc1", "rack3", "c1", 200, 5)...)
|
|
metrics = append(metrics, makeVolumes("node-d", "ssd", "dc1", "rack4", "c1", 300, 5)...)
|
|
|
|
at := buildTopology(servers, metrics)
|
|
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
|
|
|
tasks, _, err := Detection(metrics, clusterInfo, defaultConf(), 100)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
|
|
if len(tasks) < 5 {
|
|
t.Fatalf("Expected many tasks, got %d", len(tasks))
|
|
}
|
|
|
|
// Count destination servers
|
|
destServers := make(map[string]int)
|
|
for _, task := range tasks {
|
|
if task.TypedParams != nil && len(task.TypedParams.Targets) > 0 {
|
|
destServers[task.TypedParams.Targets[0].Node]++
|
|
}
|
|
}
|
|
|
|
// With 3 eligible destinations (b, c, d) and pending-task-aware scoring,
|
|
// moves should go to more than just one destination.
|
|
if len(destServers) < 2 {
|
|
t.Errorf("Expected moves to spread across destinations, but only got: %v", destServers)
|
|
}
|
|
|
|
assertNoDuplicateVolumes(t, tasks)
|
|
}
|
|
|
|
// TestDetection_ConvergenceVerification verifies that after all planned moves,
|
|
// the effective volume distribution is within the configured threshold.
|
|
func TestDetection_ConvergenceVerification(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
counts []int // volumes per server
|
|
threshold float64
|
|
}{
|
|
{"2-server-big-gap", []int{100, 10}, 0.2},
|
|
{"3-server-staircase", []int{90, 50, 10}, 0.2},
|
|
{"4-server-one-hot", []int{200, 20, 20, 20}, 0.2},
|
|
{"3-server-tight-threshold", []int{30, 20, 10}, 0.1},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var servers []serverSpec
|
|
var metrics []*types.VolumeHealthMetrics
|
|
volBase := uint32(1)
|
|
|
|
for i, count := range tt.counts {
|
|
id := fmt.Sprintf("node-%d", i)
|
|
servers = append(servers, serverSpec{
|
|
id: id, diskType: "hdd", diskID: uint32(i + 1),
|
|
dc: "dc1", rack: "rack1",
|
|
})
|
|
metrics = append(metrics, makeVolumes(id, "hdd", "dc1", "rack1", "c1", volBase, count)...)
|
|
volBase += uint32(count)
|
|
}
|
|
|
|
at := buildTopology(servers, metrics)
|
|
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
|
|
|
conf := defaultConf()
|
|
conf.ImbalanceThreshold = tt.threshold
|
|
|
|
tasks, _, err := Detection(metrics, clusterInfo, conf, 500)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
|
|
if len(tasks) == 0 {
|
|
t.Fatal("Expected balance tasks, got 0")
|
|
}
|
|
|
|
assertNoDuplicateVolumes(t, tasks)
|
|
|
|
// Verify convergence
|
|
effective := computeEffectiveCounts(servers, metrics, tasks)
|
|
total := 0
|
|
maxC, minC := 0, len(metrics)
|
|
for _, c := range effective {
|
|
total += c
|
|
if c > maxC {
|
|
maxC = c
|
|
}
|
|
if c < minC {
|
|
minC = c
|
|
}
|
|
}
|
|
avg := float64(total) / float64(len(effective))
|
|
imbalance := float64(maxC-minC) / avg
|
|
if imbalance > tt.threshold {
|
|
t.Errorf("After %d moves, still imbalanced: effective=%v, imbalance=%.1f%% (threshold=%.1f%%)",
|
|
len(tasks), effective, imbalance*100, tt.threshold*100)
|
|
}
|
|
t.Logf("%s: %d moves, effective=%v, imbalance=%.1f%%",
|
|
tt.name, len(tasks), effective, imbalance*100)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDetection_ExhaustedServerFallsThrough verifies that when the most
|
|
// overloaded server has all its volumes blocked by pre-existing tasks,
|
|
// the algorithm falls through to the next overloaded server instead of stopping.
|
|
func TestDetection_ExhaustedServerFallsThrough(t *testing.T) {
|
|
servers := []serverSpec{
|
|
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
|
|
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1"},
|
|
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc1", rack: "rack1"},
|
|
}
|
|
|
|
// node-a: 50 volumes, node-b: 40 volumes, node-c: 10 volumes
|
|
// avg = 33.3, imbalance = (50-10)/33.3 = 1.2 > 0.2
|
|
var metrics []*types.VolumeHealthMetrics
|
|
metrics = append(metrics, makeVolumes("node-a", "hdd", "dc1", "rack1", "c1", 1, 50)...)
|
|
metrics = append(metrics, makeVolumes("node-b", "hdd", "dc1", "rack1", "c1", 100, 40)...)
|
|
metrics = append(metrics, makeVolumes("node-c", "hdd", "dc1", "rack1", "c1", 200, 10)...)
|
|
|
|
at := buildTopology(servers, metrics)
|
|
|
|
// Block ALL of node-a's volumes with pre-existing tasks
|
|
for i := 0; i < 50; i++ {
|
|
volID := uint32(1 + i)
|
|
err := at.AddPendingTask(topology.TaskSpec{
|
|
TaskID: fmt.Sprintf("existing-%d", volID),
|
|
TaskType: topology.TaskTypeBalance,
|
|
VolumeID: volID,
|
|
VolumeSize: 1024,
|
|
Sources: []topology.TaskSourceSpec{{ServerID: "node-a", DiskID: 1}},
|
|
Destinations: []topology.TaskDestinationSpec{{ServerID: "node-c", DiskID: 3}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AddPendingTask failed: %v", err)
|
|
}
|
|
}
|
|
|
|
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
|
tasks, _, err := Detection(metrics, clusterInfo, defaultConf(), 100)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
|
|
// node-a is exhausted, but node-b (40 vols) vs node-c (10 vols) is still
|
|
// imbalanced. The algorithm should fall through and move from node-b.
|
|
if len(tasks) == 0 {
|
|
t.Fatal("Expected tasks from node-b after node-a was exhausted, got 0")
|
|
}
|
|
|
|
for i, task := range tasks {
|
|
if task.Server == "node-a" {
|
|
t.Errorf("Task %d: should not move FROM node-a (all volumes blocked)", i)
|
|
}
|
|
}
|
|
|
|
// Verify node-b is the source
|
|
hasNodeBSource := false
|
|
for _, task := range tasks {
|
|
if task.Server == "node-b" {
|
|
hasNodeBSource = true
|
|
break
|
|
}
|
|
}
|
|
if !hasNodeBSource {
|
|
t.Error("Expected node-b to be a source after node-a was exhausted")
|
|
}
|
|
|
|
assertNoDuplicateVolumes(t, tasks)
|
|
t.Logf("Created %d tasks from node-b after node-a exhausted", len(tasks))
|
|
}
|
|
|
|
// TestDetection_HeterogeneousCapacity verifies that the balancer uses
|
|
// utilization ratio (volumes/maxVolumes) rather than raw volume counts.
|
|
// A server with more volumes but proportionally lower utilization should
|
|
// NOT be picked as the source over a server with fewer volumes but higher
|
|
// utilization.
|
|
func TestDetection_HeterogeneousCapacity(t *testing.T) {
|
|
// Simulate a cluster like:
|
|
// server-1: 600 volumes, max 700 → utilization 85.7%
|
|
// server-2: 690 volumes, max 700 → utilization 98.6% ← most utilized
|
|
// server-3: 695 volumes, max 700 → utilization 99.3% ← most utilized
|
|
// server-4: 900 volumes, max 1260 → utilization 71.4% ← least utilized
|
|
//
|
|
// The old algorithm (raw counts) would pick server-4 as source (900 > 695).
|
|
// The correct behavior is to pick server-3 (or server-2) as source since
|
|
// they have the highest utilization ratio.
|
|
servers := []serverSpec{
|
|
{id: "server-1", diskType: "hdd", dc: "dc1", rack: "rack1", maxVolumes: 700},
|
|
{id: "server-2", diskType: "hdd", dc: "dc1", rack: "rack1", maxVolumes: 700},
|
|
{id: "server-3", diskType: "hdd", dc: "dc1", rack: "rack1", maxVolumes: 700},
|
|
{id: "server-4", diskType: "hdd", dc: "dc1", rack: "rack1", maxVolumes: 1260},
|
|
}
|
|
|
|
volCounts := map[string]int{
|
|
"server-1": 600,
|
|
"server-2": 690,
|
|
"server-3": 695,
|
|
"server-4": 900,
|
|
}
|
|
|
|
var metrics []*types.VolumeHealthMetrics
|
|
vid := uint32(1)
|
|
for _, server := range []string{"server-1", "server-2", "server-3", "server-4"} {
|
|
count := volCounts[server]
|
|
metrics = append(metrics, makeVolumes(server, "hdd", "dc1", "rack1", "", vid, count)...)
|
|
vid += uint32(count)
|
|
}
|
|
|
|
at := buildTopology(servers, metrics)
|
|
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
|
cfg := &Config{
|
|
BaseConfig: base.BaseConfig{Enabled: true},
|
|
ImbalanceThreshold: 0.20,
|
|
MinServerCount: 2,
|
|
}
|
|
|
|
tasks, _, err := Detection(metrics, clusterInfo, cfg, 5)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
if len(tasks) == 0 {
|
|
t.Fatal("Expected balance tasks but got none")
|
|
}
|
|
|
|
// The source of the first task should be the most utilized server
|
|
// (server-3 at 99.3% or server-2 at 98.6%), NOT server-4.
|
|
firstSource := tasks[0].Server
|
|
if firstSource == "server-4" {
|
|
t.Errorf("Balancer incorrectly picked server-4 (lowest utilization 71.4%%) as source; should pick server-3 (99.3%%) or server-2 (98.6%%)")
|
|
}
|
|
if firstSource != "server-3" && firstSource != "server-2" {
|
|
t.Errorf("Expected server-3 or server-2 as first source, got %s", firstSource)
|
|
}
|
|
t.Logf("First balance task: move from %s (correct: highest utilization)", firstSource)
|
|
}
|
|
|
|
// TestDetection_ZeroVolumeServerIncludedInBalance verifies that a server
|
|
// with zero volumes (seeded from topology with a matching disk type) is
|
|
// correctly included in the balance calculation and receives moves to
|
|
// equalize the distribution.
|
|
func TestDetection_ZeroVolumeServerIncludedInBalance(t *testing.T) {
|
|
// 4 servers total, but only 3 have volumes.
|
|
// node-d has a disk of the same type but zero volumes, so it appears in the
|
|
// topology and is seeded into serverVolumeCounts with count=0.
|
|
|
|
servers := []serverSpec{
|
|
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1", maxVolumes: 20},
|
|
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1", maxVolumes: 20},
|
|
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc1", rack: "rack1", maxVolumes: 20},
|
|
{id: "node-d", diskType: "hdd", diskID: 4, dc: "dc1", rack: "rack1", maxVolumes: 20},
|
|
}
|
|
|
|
// node-a: 8 volumes, node-b: 2, node-c: 1, node-d: 0
|
|
var metrics []*types.VolumeHealthMetrics
|
|
metrics = append(metrics, makeVolumes("node-a", "hdd", "dc1", "rack1", "", 1, 8)...)
|
|
metrics = append(metrics, makeVolumes("node-b", "hdd", "dc1", "rack1", "", 20, 2)...)
|
|
metrics = append(metrics, makeVolumes("node-c", "hdd", "dc1", "rack1", "", 30, 1)...)
|
|
// node-d has 0 volumes — no metrics
|
|
|
|
at := buildTopology(servers, metrics)
|
|
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
|
|
|
tasks, _, err := Detection(metrics, clusterInfo, defaultConf(), 100)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
|
|
if len(tasks) == 0 {
|
|
t.Fatal("Expected balance tasks for 8/2/1/0 distribution, got 0")
|
|
}
|
|
|
|
assertNoDuplicateVolumes(t, tasks)
|
|
|
|
// With 11 volumes across 4 servers, the best achievable is 3/3/3/2
|
|
// (imbalance=36.4%), which exceeds the 20% threshold. The algorithm should
|
|
// stop when max-min<=1 rather than oscillating endlessly.
|
|
effective := computeEffectiveCounts(servers, metrics, tasks)
|
|
total := 0
|
|
maxC, minC := 0, len(metrics)
|
|
for _, c := range effective {
|
|
total += c
|
|
if c > maxC {
|
|
maxC = c
|
|
}
|
|
if c < minC {
|
|
minC = c
|
|
}
|
|
}
|
|
|
|
// The diff between max and min should be at most 1 (as balanced as possible)
|
|
if maxC-minC > 1 {
|
|
t.Errorf("After %d moves, distribution not optimally balanced: effective=%v, max-min=%d (want ≤1)",
|
|
len(tasks), effective, maxC-minC)
|
|
}
|
|
|
|
// Count destinations — moves should spread, not pile onto one server
|
|
destCounts := make(map[string]int)
|
|
for _, task := range tasks {
|
|
if task.TypedParams != nil && len(task.TypedParams.Targets) > 0 {
|
|
destCounts[task.TypedParams.Targets[0].Node]++
|
|
}
|
|
}
|
|
|
|
// Moves should go to at least 2 different destinations
|
|
if len(destCounts) < 2 {
|
|
t.Errorf("Expected moves to spread across destinations, but got: %v", destCounts)
|
|
}
|
|
|
|
// Should need only ~5 moves for 8/2/1/0 → 3/3/3/2, not 8+ (oscillation)
|
|
if len(tasks) > 8 {
|
|
t.Errorf("Too many moves (%d) — likely oscillating; expected ≤8 for this distribution", len(tasks))
|
|
}
|
|
|
|
avg := float64(total) / float64(len(effective))
|
|
imbalance := float64(maxC-minC) / avg
|
|
t.Logf("Distribution 8/2/1/0 → %v after %d moves (imbalance=%.1f%%)",
|
|
effective, len(tasks), imbalance*100)
|
|
}
|
|
|
|
func TestDetection_DataCenterFilter(t *testing.T) {
|
|
servers := []serverSpec{
|
|
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
|
|
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1"},
|
|
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc2", rack: "rack1"},
|
|
}
|
|
|
|
var metrics []*types.VolumeHealthMetrics
|
|
metrics = append(metrics, makeVolumes("node-a", "hdd", "dc1", "rack1", "c1", 1, 50)...)
|
|
metrics = append(metrics, makeVolumes("node-b", "hdd", "dc1", "rack1", "c1", 100, 10)...)
|
|
// node-c is in dc2, should be excluded by filter
|
|
metrics = append(metrics, makeVolumes("node-c", "hdd", "dc2", "rack1", "c1", 200, 30)...)
|
|
|
|
// Only include metrics from dc1
|
|
dc1Metrics := make([]*types.VolumeHealthMetrics, 0)
|
|
for _, m := range metrics {
|
|
if m.DataCenter == "dc1" {
|
|
dc1Metrics = append(dc1Metrics, m)
|
|
}
|
|
}
|
|
|
|
at := buildTopology(servers, metrics)
|
|
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
|
|
|
conf := defaultConf()
|
|
conf.DataCenterFilter = "dc1"
|
|
|
|
tasks, _, err := Detection(dc1Metrics, clusterInfo, conf, 100)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
|
|
// Ensure detection produced tasks so the following checks are not vacuous.
|
|
if len(tasks) == 0 {
|
|
t.Fatal("Expected balance tasks for 50/10 imbalance within dc1, got 0")
|
|
}
|
|
|
|
// With DC filter, only node-a and node-b are considered in topology seeding.
|
|
// node-c should never appear as source or destination.
|
|
for _, task := range tasks {
|
|
if task.Server == "node-c" {
|
|
t.Errorf("node-c (dc2) should not be a source with dc1 filter")
|
|
}
|
|
if task.TypedParams != nil {
|
|
for _, tgt := range task.TypedParams.Targets {
|
|
if strings.Contains(tgt.Node, "node-c") {
|
|
t.Errorf("node-c (dc2) should not be a target with dc1 filter")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(tasks) > 0 {
|
|
t.Logf("Created %d tasks within dc1 scope", len(tasks))
|
|
}
|
|
}
|
|
|
|
func TestDetection_NodeFilter(t *testing.T) {
|
|
servers := []serverSpec{
|
|
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
|
|
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1"},
|
|
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc1", rack: "rack1"},
|
|
}
|
|
|
|
var metrics []*types.VolumeHealthMetrics
|
|
metrics = append(metrics, makeVolumes("node-a", "hdd", "dc1", "rack1", "c1", 1, 50)...)
|
|
metrics = append(metrics, makeVolumes("node-b", "hdd", "dc1", "rack1", "c1", 100, 10)...)
|
|
metrics = append(metrics, makeVolumes("node-c", "hdd", "dc1", "rack1", "c1", 200, 5)...)
|
|
|
|
// Only include metrics from node-a and node-b
|
|
filteredMetrics := make([]*types.VolumeHealthMetrics, 0)
|
|
for _, m := range metrics {
|
|
if m.Server == "node-a" || m.Server == "node-b" {
|
|
filteredMetrics = append(filteredMetrics, m)
|
|
}
|
|
}
|
|
|
|
at := buildTopology(servers, metrics)
|
|
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
|
|
|
conf := defaultConf()
|
|
conf.NodeFilter = "node-a,node-b"
|
|
|
|
tasks, _, err := Detection(filteredMetrics, clusterInfo, conf, 100)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
|
|
// Ensure detection produced tasks so the following checks are not vacuous.
|
|
if len(tasks) == 0 {
|
|
t.Fatal("Expected balance tasks for 50/10 imbalance within node-a,node-b scope, got 0")
|
|
}
|
|
|
|
for _, task := range tasks {
|
|
if task.Server == "node-c" {
|
|
t.Errorf("node-c should not be a source with node filter")
|
|
}
|
|
if task.TypedParams != nil {
|
|
for _, tgt := range task.TypedParams.Targets {
|
|
if strings.Contains(tgt.Node, "node-c") {
|
|
t.Errorf("node-c should not be a target with node filter")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
t.Logf("Created %d tasks within node-a,node-b scope", len(tasks))
|
|
}
|
|
|
|
// TestDetection_HeterogeneousMax_NoOvershootNoOscillation is a regression test
|
|
// for a volume.balance bug in the plugin worker: when servers have different
|
|
// MaxVolumeCount values and the cluster is near (but above) the imbalance
|
|
// threshold, the greedy max→min algorithm could schedule moves that FLIP
|
|
// which server is the most-utilized, producing oscillation across a single
|
|
// detection cycle and pushing destination servers above the cluster-ideal
|
|
// utilization.
|
|
//
|
|
// Setup:
|
|
//
|
|
// node-a: 11 volumes, max=20 → util=0.55
|
|
// node-b: 5 volumes, max=10 → util=0.50
|
|
// ideal = 16/30 ≈ 0.533
|
|
//
|
|
// One naive move a→b leaves a=10/20=0.50 and b=6/10=0.60 — which flips the
|
|
// imbalance and pushes b well above the cluster ideal. The next iteration
|
|
// (without a per-move guard) would plan the reverse move, and so on.
|
|
//
|
|
// Invariants that must hold after detection:
|
|
// 1. No destination's effective utilization exceeds the cluster-ideal ratio.
|
|
// 2. Tasks flow in at most one direction between any server pair (no
|
|
// oscillation within a single detection cycle).
|
|
// 3. The final imbalance (after applying all planned moves) is not strictly
|
|
// worse than the initial imbalance.
|
|
func TestDetection_HeterogeneousMax_NoOvershootNoOscillation(t *testing.T) {
|
|
servers := []serverSpec{
|
|
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1", maxVolumes: 20},
|
|
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1", maxVolumes: 10},
|
|
}
|
|
|
|
var metrics []*types.VolumeHealthMetrics
|
|
metrics = append(metrics, makeVolumes("node-a", "hdd", "dc1", "rack1", "c1", 1, 11)...)
|
|
metrics = append(metrics, makeVolumes("node-b", "hdd", "dc1", "rack1", "c1", 100, 5)...)
|
|
|
|
at := buildTopology(servers, metrics)
|
|
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
|
|
|
// Strict threshold makes detection eager to move, exposing the overshoot
|
|
// bug when naive greedy selection is used.
|
|
conf := defaultConf()
|
|
conf.ImbalanceThreshold = 0.05
|
|
|
|
tasks, _, err := Detection(metrics, clusterInfo, conf, 50)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
|
|
maxCap := map[string]float64{"node-a": 20, "node-b": 10}
|
|
const idealUtil = 16.0 / 30.0 // 0.5333...
|
|
|
|
// (1) No destination server (recipient of at least one move) should end up
|
|
// above the cluster-ideal utilization. Source servers may remain slightly
|
|
// above ideal when no further beneficial move is possible — that is fine.
|
|
effective := computeEffectiveCounts(servers, metrics, tasks)
|
|
destinations := make(map[string]bool)
|
|
for _, task := range tasks {
|
|
if task.TypedParams != nil && len(task.TypedParams.Targets) > 0 {
|
|
addr := task.TypedParams.Targets[0].Node
|
|
// address → server id mapping matches buildTopology: "<id>:8080"
|
|
for _, s := range servers {
|
|
if s.id+":8080" == addr || s.id == addr {
|
|
destinations[s.id] = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for server := range destinations {
|
|
util := float64(effective[server]) / maxCap[server]
|
|
if util > idealUtil+1e-9 {
|
|
t.Errorf("destination %s effective util %.3f exceeds cluster ideal %.3f (count=%d, cap=%.0f)",
|
|
server, util, idealUtil, effective[server], maxCap[server])
|
|
}
|
|
}
|
|
|
|
// (2) Tasks should never flow both directions between node-a and node-b.
|
|
aAsSource, bAsSource := 0, 0
|
|
for _, task := range tasks {
|
|
switch task.Server {
|
|
case "node-a":
|
|
aAsSource++
|
|
case "node-b":
|
|
bAsSource++
|
|
}
|
|
}
|
|
if aAsSource > 0 && bAsSource > 0 {
|
|
t.Errorf("detection oscillated: %d tasks with node-a as source and %d with node-b as source",
|
|
aAsSource, bAsSource)
|
|
}
|
|
|
|
// (3) Final imbalance must not be worse than initial imbalance.
|
|
initUtilA := 11.0 / 20.0 // 0.55
|
|
initUtilB := 5.0 / 10.0 // 0.50
|
|
initDiff := initUtilA - initUtilB
|
|
|
|
finalUtilA := float64(effective["node-a"]) / maxCap["node-a"]
|
|
finalUtilB := float64(effective["node-b"]) / maxCap["node-b"]
|
|
finalDiff := finalUtilA - finalUtilB
|
|
if finalDiff < 0 {
|
|
finalDiff = -finalDiff
|
|
}
|
|
if finalDiff > initDiff+1e-9 {
|
|
t.Errorf("detection made imbalance worse: initial diff %.3f, final diff %.3f (tasks=%d, effective=%v)",
|
|
initDiff, finalDiff, len(tasks), effective)
|
|
}
|
|
|
|
t.Logf("tasks=%d, effective=%v, final diff=%.3f (initial=%.3f)", len(tasks), effective, finalDiff, initDiff)
|
|
}
|
|
|
|
// TestDetection_RespectsClusterIdealUtilization verifies that in a 3-server
|
|
// cluster with heterogeneous MaxVolumeCount values, detection does not push
|
|
// any destination above its proportional fair share of the cluster-ideal
|
|
// utilization. Without a per-move guard, the greedy algorithm happily fills
|
|
// the most-underutilized disk well past the cluster ideal, mirroring the
|
|
// real-world "nodes get filled up to ~99% capacity" failure mode reported
|
|
// after the 4.17 upgrade.
|
|
//
|
|
// Setup:
|
|
//
|
|
// node-a: 20 volumes, max=40 (util 0.50)
|
|
// node-b: 10 volumes, max=20 (util 0.50)
|
|
// node-c: 1 volume, max=10 (util 0.10) <- small, underloaded
|
|
// ideal = 31/70 ≈ 0.443
|
|
//
|
|
// A count-only greedy balancer would drain a and b into c until c.util reaches
|
|
// ~0.5 (matching the heavier servers), pushing c's utilization above the
|
|
// cluster ideal. The correct behavior is to stop moves to c once its
|
|
// utilization reaches the cluster ideal.
|
|
func TestDetection_RespectsClusterIdealUtilization(t *testing.T) {
|
|
servers := []serverSpec{
|
|
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1", maxVolumes: 40},
|
|
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1", maxVolumes: 20},
|
|
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc1", rack: "rack1", maxVolumes: 10},
|
|
}
|
|
|
|
var metrics []*types.VolumeHealthMetrics
|
|
metrics = append(metrics, makeVolumes("node-a", "hdd", "dc1", "rack1", "c1", 1, 20)...)
|
|
metrics = append(metrics, makeVolumes("node-b", "hdd", "dc1", "rack1", "c1", 100, 10)...)
|
|
metrics = append(metrics, makeVolumes("node-c", "hdd", "dc1", "rack1", "c1", 200, 1)...)
|
|
|
|
at := buildTopology(servers, metrics)
|
|
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
|
|
|
tasks, _, err := Detection(metrics, clusterInfo, defaultConf(), 200)
|
|
if err != nil {
|
|
t.Fatalf("Detection failed: %v", err)
|
|
}
|
|
|
|
const idealUtil = 31.0 / 70.0 // 0.4428...
|
|
maxCap := map[string]float64{"node-a": 40, "node-b": 20, "node-c": 10}
|
|
|
|
effective := computeEffectiveCounts(servers, metrics, tasks)
|
|
for server, count := range effective {
|
|
util := float64(count) / maxCap[server]
|
|
// Allow one-volume slack to account for integer rounding on small caps:
|
|
// a server with cap=10 cannot land exactly on 0.443; the nearest counts
|
|
// are 4 (0.40) and 5 (0.50). The guard should stop at 4.
|
|
slack := 1.0 / maxCap[server]
|
|
if util > idealUtil+slack+1e-9 {
|
|
t.Errorf("server %s (cap=%.0f) effective util %.3f exceeds cluster ideal %.3f (+%.3f slack); count=%d",
|
|
server, maxCap[server], util, idealUtil, slack, count)
|
|
}
|
|
}
|
|
|
|
t.Logf("tasks=%d, effective=%v, ideal=%.3f", len(tasks), effective, idealUtil)
|
|
}
|
|
|
|
// TestResolveBalanceDestination_UsesEffectiveCapacity verifies that
|
|
// resolveBalanceDestination respects ActiveTopology's effective available
|
|
// capacity — i.e., the destination check factors in pending and assigned
|
|
// tasks already registered against the disk. Without this, the destination
|
|
// planner reads a stale VolumeCount from the topology snapshot and keeps
|
|
// approving the same disk even after many moves have been planned against
|
|
// it within a single detection cycle.
|
|
func TestResolveBalanceDestination_UsesEffectiveCapacity(t *testing.T) {
|
|
servers := []serverSpec{
|
|
{id: "src-node", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1", maxVolumes: 100},
|
|
{id: "dst-node", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1", maxVolumes: 10},
|
|
}
|
|
|
|
var metrics []*types.VolumeHealthMetrics
|
|
metrics = append(metrics, makeVolumes("src-node", "hdd", "dc1", "rack1", "c1", 1, 50)...)
|
|
// dst-node starts with 8 volumes → 2 slots free
|
|
metrics = append(metrics, makeVolumes("dst-node", "hdd", "dc1", "rack1", "c1", 1000, 8)...)
|
|
|
|
at := buildTopology(servers, metrics)
|
|
|
|
// Simulate two prior balance moves already planned in this detection cycle
|
|
// that target dst-node:2. Effective capacity should drop to 0.
|
|
for i := 0; i < 2; i++ {
|
|
err := at.AddPendingTask(topology.TaskSpec{
|
|
TaskID: fmt.Sprintf("pending-%d", i),
|
|
TaskType: topology.TaskTypeBalance,
|
|
VolumeID: uint32(9000 + i),
|
|
VolumeSize: 1024,
|
|
Sources: []topology.TaskSourceSpec{
|
|
{ServerID: "src-node", DiskID: 1},
|
|
},
|
|
Destinations: []topology.TaskDestinationSpec{
|
|
{ServerID: "dst-node", DiskID: 2},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("seeding pending task %d failed: %v", i, err)
|
|
}
|
|
}
|
|
|
|
candidate := &types.VolumeHealthMetrics{
|
|
VolumeID: 500,
|
|
Server: "src-node",
|
|
ServerAddress: "src-node:8080",
|
|
DiskType: "hdd",
|
|
Collection: "c1",
|
|
Size: 1024,
|
|
DataCenter: "dc1",
|
|
Rack: "rack1",
|
|
}
|
|
|
|
if _, err := resolveBalanceDestination(at, candidate, "dst-node"); err == nil {
|
|
t.Error("expected resolveBalanceDestination to fail because dst-node's disk is effectively full " +
|
|
"(VolumeCount=8, MaxVolumeCount=10, 2 pending destination tasks)")
|
|
}
|
|
|
|
// Sanity check: a server with no pending tasks targeting it should still
|
|
// resolve successfully, proving the helper itself is working.
|
|
servers2 := []serverSpec{
|
|
{id: "src-node", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1", maxVolumes: 100},
|
|
{id: "dst-node", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1", maxVolumes: 10},
|
|
}
|
|
metrics2 := append([]*types.VolumeHealthMetrics(nil),
|
|
makeVolumes("src-node", "hdd", "dc1", "rack1", "c1", 1, 50)...)
|
|
metrics2 = append(metrics2, makeVolumes("dst-node", "hdd", "dc1", "rack1", "c1", 1000, 8)...)
|
|
at2 := buildTopology(servers2, metrics2)
|
|
plan, err := resolveBalanceDestination(at2, candidate, "dst-node")
|
|
if err != nil {
|
|
t.Fatalf("expected resolveBalanceDestination to succeed on fresh topology: %v", err)
|
|
}
|
|
if plan.TargetNode != "dst-node" {
|
|
t.Errorf("unexpected target node: got %q want %q", plan.TargetNode, "dst-node")
|
|
}
|
|
}
|