Files
seaweedfs/weed/worker/tasks/balance/detection_test.go
Chris LuGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1c0e24f06a fix(balance): don't move remote-tiered volumes; don't fatal on missing .idx (#9335)
* fix(volume): don't fatal on missing .idx for remote-tiered volume

A .vif left behind without its .idx (orphaned by a crashed move, partial
copy, or hand-edit) would trip glog.Fatalf in checkIdxFile and take the
whole volume server down on boot, killing every healthy volume on it
too. For remote-tiered volumes treat it as a per-volume load error so
the server can come up and the operator can clean up the stray .vif.

Refs #9331.

* fix(balance): skip remote-tiered volumes in admin balance detection

The admin/worker balance detector had no equivalent of the shell-side
guard ("does not move volume in remote storage" in
command_volume_balance.go), so it scheduled moves on remote-tiered
volumes. The "move" copies .idx/.vif to the destination and then calls
Volume.Destroy on the source, which calls backendStorage.DeleteFile —
deleting the remote object the destination's new .vif now points at.

Populate HasRemoteCopy on the metrics emitted by both the admin
maintenance scanner and the worker's master poll, then drop those
volumes at the top of Detection.

Fixes #9331.

* Apply suggestion from @gemini-code-assist[bot]

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix(volume): keep remote data on volume-move-driven delete

The on-source delete after a volume move (admin/worker balance and
shell volume.move) ran Volume.Destroy with no way to opt out of the
remote-object cleanup. Volume.Destroy unconditionally calls
backendStorage.DeleteFile for remote-tiered volumes, so a successful
move would copy .idx/.vif to the destination and then nuke the cloud
object the destination's new .vif was already pointing at.

Add VolumeDeleteRequest.keep_remote_data and plumb it through
Store.DeleteVolume / DiskLocation.DeleteVolume / Volume.Destroy. The
balance task and shell volume.move set it to true; the post-tier-upload
cleanup of other replicas and the over-replication trim in
volume.fix.replication also set it to true since the remote object is
still referenced. Other real-delete callers keep the default. The
delete-before-receive path in VolumeCopy also sets it: the inbound copy
carries a .vif that may reference the same cloud object as the
existing volume.

Refs #9331.

* test(storage): in-process remote-tier integration tests

Cover the four operations the user is most likely to run against a
cloud-tiered volume — balance/move, vacuum, EC encode, EC decode — by
registering a local-disk-backed BackendStorage as the "remote" tier and
exercising the real Volume / DiskLocation / EC encoder code paths.

Locks in:
- Destroy(keepRemoteData=true) preserves the remote object (move case)
- Destroy(keepRemoteData=false) deletes it (real-delete case)
- Vacuum/compact on a remote-tier volume never deletes the remote object
- EC encode requires the local .dat (callers must download first)
- EC encode + rebuild round-trips after a tier-down

Tests run in-process and finish in under a second total — no cluster,
binary, or external storage required.

* fix(rust-volume): keep remote data on volume-move-driven delete

Mirror the Go fix in seaweed-volume: plumb keep_remote_data through
grpc volume_delete → Store.delete_volume → DiskLocation.delete_volume
→ Volume.destroy, and skip the s3-tier delete_file call when the flag
is set. The pre-receive cleanup in volume_copy passes true for the
same reason as the Go side: the inbound copy carries a .vif that may
reference the same cloud object as the existing volume.

The Rust loader already warns rather than fataling on a stray .vif
without an .idx (volume.rs load_index_inmemory / load_index_redb), so
no counterpart to the Go fatal-on-missing-idx fix is needed.

Refs #9331.

* fix(volume): preserve remote tier on IO-error eviction; fix EC test target

Two review nits:

- Store.MaybeAddVolumes' periodic cleanup pass deleted IO-errored
  volumes with keepRemoteData=false, so a transient local fault on a
  remote-tiered volume would also nuke the cloud object. Track the
  delete reason via a parallel slice and pass keepRemoteData=v.HasRemoteFile()
  for IO-error evictions; TTL-expired evictions still pass false.

- TestRemoteTier_ECEncodeDecode_AfterDownload deleted shards 0..3 but
  called them "parity" — by the klauspost/reedsolomon convention shards
  0..DataShardsCount-1 are data and DataShardsCount..TotalShardsCount-1
  are parity. Switch the loop to delete the parity range so the
  intent matches the indices.

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-06 15:19:43 -07:00

1397 lines
46 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
}
// 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,
},
},
}
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)
}
}
}
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")
}
}