mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
* fix(balance): guard against destination overshoot and oscillation Plugin-worker volume_balance detection re-selects maxServer/minServer each iteration based on utilization ratio. With heterogeneous MaxVolumeCount values, a single greedy move can flip which server is most-utilized, causing A->B, B->A oscillation within one detection cycle and pushing destinations past the cluster ideal. Mirror the shell balancer's per-move guard (weed/shell/command_volume_balance.go:440): before scheduling a move, verify that the destination's post-move utilization would not strictly exceed the source's post-move utilization. If it would, no single move can improve balance, so stop. Add regression tests that cover: - TestDetection_HeterogeneousMax_NoOvershootNoOscillation: 2 servers with different caps just above threshold; detection must not oscillate or make the imbalance worse. - TestDetection_RespectsClusterIdealUtilization: 3-server heterogeneous layout; destinations must not overshoot cluster ideal. * fix(balance): use effective capacity when resolving destination disk resolveBalanceDestination read VolumeCount directly from the topology snapshot, which is not updated when AddPendingTask registers a move within the current detection cycle. This meant multiple moves planned in a single cycle all saw the same static count and could target the same disk past its effective capacity. Switch to ActiveTopology.GetNodeDisks + GetEffectiveAvailableCapacity so that destination planning accounts for all pending and assigned tasks affecting the disk — consistent with how the detection loop already tracks effectiveCounts at the server level. Add a unit test that seeds two pending balance tasks against a destination disk with 2 free slots and asserts resolveBalanceDestination rejects a third planned move. * fix(ec_balance): capacity-weighted guard in Phase 4 global rebalance detectGlobalImbalance picked min/max nodes by raw shard count and compared them against a simple (unweighted) rack-wide average. With heterogeneous MaxVolumeCount across nodes in the same rack, this lets the greedy algorithm move shards from a large, barely-used node to a small, nearly-full node just because the small node has fewer shards in absolute terms — strictly worsening imbalance by utilization and potentially overfilling the small node. Snapshot each node's total shard capacity (current shards plus free slots) at loop start and add a per-move convergence guard: reject any move where the destination's post-move utilization would strictly exceed the source's post-move utilization. Mirrors the fix in weed/worker/tasks/balance/detection.go. Regression test TestDetectGlobalImbalance_HeterogeneousCapacity covers a rack with node1 (cap 100, 10 shards → 10% util) and node2 (cap 5, 3 shards → 60% util). Before the fix, Phase 4 moves 2 shards from node1 to node2, filling node2 to 100% util. After the fix, the guard blocks both moves. * fix(ec_balance): utilization-based max/min in Phase 4 rebalance Phase 4's global rebalancer picked source and destination nodes by raw shard count, and compared against a simple raw-count average. With heterogeneous MaxVolumeCount across nodes in a rack, this got the direction wrong: a large-capacity node holding many shards in absolute terms but only a small fraction of its capacity would be picked as the "overloaded" source, while a small-capacity node nearly at its slot limit (but holding fewer absolute shards) would be picked as the "underloaded" destination. The previous fix added a strict-improvement guard that prevented the bad move but left balance untouched — the rack stayed in an uneven state. Switch to utilization-based selection and a utilization-based pre-check: - Pick max/min by (count / capacity), where capacity is the node's current allowed shards plus remaining free slots (snapshotted once per rack and held constant for the duration of the loop). - Replace the raw-count imbalance gate (exceedsImbalanceThreshold) with a new exceedsUtilImbalanceThreshold helper that compares fractional fullness. The raw-count gate is still used by Phase 2 and Phase 3, where the per-rack / per-volume semantics differ. - Drop the raw-count guards (maxCount <= avgShards || minCount+1 > avgShards and maxCount-minCount <= 1) now that the per-move strict-improvement check handles termination correctly for both homogeneous and heterogeneous capacity. Also fix a latent bug in the inner shard-selection loop: it was not updating shardBits between iterations, so every iteration picked the same lowest-set bit and emitted duplicate move requests for the same physical shard. Update maxNode and minNode's shardBits immediately after appending a move, mirroring what applyMovesToTopology does between phases. Update TestDetectGlobalImbalance_HeterogeneousCapacity to assert: - Moves flow from the higher-util node2 to the lower-util node1 (direction check), and - Each (volumeID, shardID) pair appears at most once in the move list (duplicate-shard guard). * fix(ec_balance): keep source freeSlots in sync after planned shard moves All three phase loops that plan EC shard moves (detectCrossRackImbalance, detectWithinRackImbalance, detectGlobalImbalance) decrement the destination node's freeSlots but leave the source node's freeSlots stale. Over the course of a detection run that processes many volumes or iterates within a rack, the source's reported freeSlots drifts below its actual value. In Phase 4 specifically, the per-move strict-improvement guard prevents the source from becoming a destination candidate, so the stale value never affects decisions. In Phases 2 and 3 it can: a node that sheds shards for one volume's rebalance is eligible as a destination for another volume in the same run, and the destination selection uses node.freeSlots <= 0 as a hard skip (findDestNodeInUnderloadedRack / findLeastLoadedNodeInRack). A tightly-provisioned node could be skipped as a destination even after it has freed slots. Increment maxNode.freeSlots / node.freeSlots symmetrically at each scheduled move so freeSlots remains an accurate running view of available slot capacity throughout a detection run.
588 lines
17 KiB
Go
588 lines
17 KiB
Go
package ec_balance
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
|
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
|
)
|
|
|
|
func TestShardBitCount(t *testing.T) {
|
|
tests := []struct {
|
|
bits uint32
|
|
expected int
|
|
}{
|
|
{0, 0},
|
|
{1, 1},
|
|
{0b111, 3},
|
|
{0x3FFF, 14}, // all 14 shards
|
|
{0b10101010, 4},
|
|
}
|
|
for _, tt := range tests {
|
|
got := shardBitCount(tt.bits)
|
|
if got != tt.expected {
|
|
t.Errorf("shardBitCount(%b) = %d, want %d", tt.bits, got, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCeilDivide(t *testing.T) {
|
|
tests := []struct {
|
|
a, b int
|
|
expected int
|
|
}{
|
|
{14, 3, 5},
|
|
{14, 7, 2},
|
|
{10, 3, 4},
|
|
{0, 5, 0},
|
|
{5, 0, 0},
|
|
}
|
|
for _, tt := range tests {
|
|
got := ceilDivide(tt.a, tt.b)
|
|
if got != tt.expected {
|
|
t.Errorf("ceilDivide(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDetectDuplicateShards(t *testing.T) {
|
|
nodes := map[string]*ecNodeInfo{
|
|
"node1": {
|
|
nodeID: "node1", address: "node1:8080", rack: "dc1:rack1", freeSlots: 5,
|
|
ecShards: map[uint32]*ecVolumeInfo{
|
|
100: {collection: "col1", shardBits: 0b11}, // shard 0, 1
|
|
},
|
|
},
|
|
"node2": {
|
|
nodeID: "node2", address: "node2:8080", rack: "dc1:rack2", freeSlots: 10,
|
|
ecShards: map[uint32]*ecVolumeInfo{
|
|
100: {collection: "col1", shardBits: 0b01}, // shard 0 (duplicate)
|
|
},
|
|
},
|
|
}
|
|
|
|
moves := detectDuplicateShards(100, "col1", nodes, "")
|
|
|
|
if len(moves) != 1 {
|
|
t.Fatalf("expected 1 dedup move, got %d", len(moves))
|
|
}
|
|
|
|
move := moves[0]
|
|
if move.phase != "dedup" {
|
|
t.Errorf("expected phase 'dedup', got %q", move.phase)
|
|
}
|
|
if move.shardID != 0 {
|
|
t.Errorf("expected shard 0 to be deduplicated, got %d", move.shardID)
|
|
}
|
|
// node1 has fewer free slots, so the duplicate on node1 should be removed (keeper is node2)
|
|
if move.source.nodeID != "node1" {
|
|
t.Errorf("expected source node1 (fewer free slots), got %s", move.source.nodeID)
|
|
}
|
|
// Dedup moves set target=source so isDedupPhase recognizes unmount+delete only
|
|
if move.target.nodeID != "node1" {
|
|
t.Errorf("expected target node1 (same as source for dedup), got %s", move.target.nodeID)
|
|
}
|
|
}
|
|
|
|
func TestDetectCrossRackImbalance(t *testing.T) {
|
|
// 14 shards all on rack1, 2 racks available — large imbalance
|
|
nodes := map[string]*ecNodeInfo{
|
|
"node1": {
|
|
nodeID: "node1", address: "node1:8080", rack: "dc1:rack1", freeSlots: 0,
|
|
ecShards: map[uint32]*ecVolumeInfo{
|
|
100: {collection: "col1", shardBits: 0x3FFF}, // all 14 shards
|
|
},
|
|
},
|
|
"node2": {
|
|
nodeID: "node2", address: "node2:8080", rack: "dc1:rack2", freeSlots: 20,
|
|
ecShards: map[uint32]*ecVolumeInfo{},
|
|
},
|
|
}
|
|
racks := map[string]*ecRackInfo{
|
|
"dc1:rack1": {
|
|
nodes: map[string]*ecNodeInfo{"node1": nodes["node1"]},
|
|
freeSlots: 0,
|
|
},
|
|
"dc1:rack2": {
|
|
nodes: map[string]*ecNodeInfo{"node2": nodes["node2"]},
|
|
freeSlots: 20,
|
|
},
|
|
}
|
|
|
|
// Use very low threshold so this triggers
|
|
moves := detectCrossRackImbalance(100, "col1", nodes, racks, "", 0.01)
|
|
|
|
// With 14 shards across 2 racks, max per rack = 7
|
|
// rack1 has 14 -> excess = 7, should move 7 to rack2
|
|
if len(moves) != 7 {
|
|
t.Fatalf("expected 7 cross-rack moves, got %d", len(moves))
|
|
}
|
|
for _, move := range moves {
|
|
if move.phase != "cross_rack" {
|
|
t.Errorf("expected phase 'cross_rack', got %q", move.phase)
|
|
}
|
|
if move.source.rack != "dc1:rack1" {
|
|
t.Errorf("expected source dc1:rack1, got %s", move.source.rack)
|
|
}
|
|
if move.target.rack != "dc1:rack2" {
|
|
t.Errorf("expected target dc1:rack2, got %s", move.target.rack)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDetectCrossRackImbalanceBelowThreshold(t *testing.T) {
|
|
// Slight imbalance: rack1 has 8, rack2 has 6 — imbalance = 2/7 ≈ 0.29
|
|
nodes := map[string]*ecNodeInfo{
|
|
"node1": {
|
|
nodeID: "node1", address: "node1:8080", rack: "dc1:rack1", freeSlots: 10,
|
|
ecShards: map[uint32]*ecVolumeInfo{
|
|
100: {collection: "col1", shardBits: 0xFF}, // 8 shards
|
|
},
|
|
},
|
|
"node2": {
|
|
nodeID: "node2", address: "node2:8080", rack: "dc1:rack2", freeSlots: 10,
|
|
ecShards: map[uint32]*ecVolumeInfo{
|
|
100: {collection: "col1", shardBits: 0x3F00}, // 6 shards
|
|
},
|
|
},
|
|
}
|
|
racks := map[string]*ecRackInfo{
|
|
"dc1:rack1": {
|
|
nodes: map[string]*ecNodeInfo{"node1": nodes["node1"]},
|
|
freeSlots: 10,
|
|
},
|
|
"dc1:rack2": {
|
|
nodes: map[string]*ecNodeInfo{"node2": nodes["node2"]},
|
|
freeSlots: 10,
|
|
},
|
|
}
|
|
|
|
// High threshold should skip this
|
|
moves := detectCrossRackImbalance(100, "col1", nodes, racks, "", 0.5)
|
|
if len(moves) != 0 {
|
|
t.Fatalf("expected 0 moves below threshold, got %d", len(moves))
|
|
}
|
|
}
|
|
|
|
func TestDetectWithinRackImbalance(t *testing.T) {
|
|
// rack1 has 2 nodes: node1 has 10 shards, node2 has 0 shards
|
|
nodes := map[string]*ecNodeInfo{
|
|
"node1": {
|
|
nodeID: "node1", address: "node1:8080", rack: "dc1:rack1", freeSlots: 5,
|
|
ecShards: map[uint32]*ecVolumeInfo{
|
|
100: {collection: "col1", shardBits: 0b1111111111}, // shards 0-9
|
|
},
|
|
},
|
|
"node2": {
|
|
nodeID: "node2", address: "node2:8080", rack: "dc1:rack1", freeSlots: 20,
|
|
ecShards: map[uint32]*ecVolumeInfo{},
|
|
},
|
|
}
|
|
racks := map[string]*ecRackInfo{
|
|
"dc1:rack1": {
|
|
nodes: map[string]*ecNodeInfo{"node1": nodes["node1"], "node2": nodes["node2"]},
|
|
freeSlots: 25,
|
|
},
|
|
}
|
|
|
|
moves := detectWithinRackImbalance(100, "col1", nodes, racks, "", 0.01)
|
|
|
|
// 10 shards on 2 nodes, max per node = 5
|
|
// node1 has 10 -> excess = 5, should move 5 to node2
|
|
if len(moves) != 5 {
|
|
t.Fatalf("expected 5 within-rack moves, got %d", len(moves))
|
|
}
|
|
for _, move := range moves {
|
|
if move.phase != "within_rack" {
|
|
t.Errorf("expected phase 'within_rack', got %q", move.phase)
|
|
}
|
|
if move.source.nodeID != "node1" {
|
|
t.Errorf("expected source node1, got %s", move.source.nodeID)
|
|
}
|
|
if move.target.nodeID != "node2" {
|
|
t.Errorf("expected target node2, got %s", move.target.nodeID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDetectGlobalImbalance(t *testing.T) {
|
|
// node1 has 20 total shards, node2 has 2 total shards (same rack)
|
|
nodes := map[string]*ecNodeInfo{
|
|
"node1": {
|
|
nodeID: "node1", address: "node1:8080", rack: "dc1:rack1", freeSlots: 5,
|
|
ecShards: map[uint32]*ecVolumeInfo{
|
|
100: {collection: "col1", shardBits: 0x3FFF}, // 14 shards
|
|
200: {collection: "col1", shardBits: 0b111111}, // 6 shards
|
|
},
|
|
},
|
|
"node2": {
|
|
nodeID: "node2", address: "node2:8080", rack: "dc1:rack1", freeSlots: 30,
|
|
ecShards: map[uint32]*ecVolumeInfo{
|
|
300: {collection: "col1", shardBits: 0b11}, // 2 shards
|
|
},
|
|
},
|
|
}
|
|
racks := map[string]*ecRackInfo{
|
|
"dc1:rack1": {
|
|
nodes: map[string]*ecNodeInfo{"node1": nodes["node1"], "node2": nodes["node2"]},
|
|
freeSlots: 35,
|
|
},
|
|
}
|
|
|
|
config := NewDefaultConfig()
|
|
config.ImbalanceThreshold = 0.01 // low threshold to ensure moves happen
|
|
moves := detectGlobalImbalance(nodes, racks, config, nil)
|
|
|
|
// Total = 22 shards, avg = 11. node1 has 20, node2 has 2.
|
|
// Should move shards until balanced (max 10 iterations)
|
|
if len(moves) == 0 {
|
|
t.Fatal("expected global balance moves, got 0")
|
|
}
|
|
for _, move := range moves {
|
|
if move.phase != "global" {
|
|
t.Errorf("expected phase 'global', got %q", move.phase)
|
|
}
|
|
if move.source.nodeID != "node1" {
|
|
t.Errorf("expected moves from node1, got %s", move.source.nodeID)
|
|
}
|
|
if move.target.nodeID != "node2" {
|
|
t.Errorf("expected moves to node2, got %s", move.target.nodeID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDetectGlobalImbalance_HeterogeneousCapacity is a regression test for
|
|
// the Phase 4 rebalancer on heterogeneous racks. node1 holds more shards in
|
|
// absolute terms but has much higher capacity, so it is actually the LESS
|
|
// utilized node; node2 holds fewer shards but is nearly full. The greedy
|
|
// algorithm must pick the most-utilized node as the source and move shards
|
|
// in the direction that reduces fractional fullness, NOT in the direction
|
|
// that would equalize raw counts (which here would overfill node2).
|
|
//
|
|
// Scenario:
|
|
//
|
|
// node1: 10 shards, freeSlots=90 → capacity 100, util 10%
|
|
// node2: 3 shards, freeSlots=2 → capacity 5, util 60%
|
|
//
|
|
// Correct behavior: move shards FROM node2 TO node1 (draining the
|
|
// most-utilized node), until no further improvement is possible. Also
|
|
// verifies that moves are de-duplicated — the inner loop must update
|
|
// shardBits between iterations so each proposed move refers to a distinct
|
|
// physical shard.
|
|
func TestDetectGlobalImbalance_HeterogeneousCapacity(t *testing.T) {
|
|
nodes := map[string]*ecNodeInfo{
|
|
"node1": {
|
|
nodeID: "node1", address: "node1:8080", rack: "dc1:rack1", freeSlots: 90,
|
|
ecShards: map[uint32]*ecVolumeInfo{
|
|
100: {collection: "col1", shardBits: 0x3FF}, // 10 shards
|
|
},
|
|
},
|
|
"node2": {
|
|
nodeID: "node2", address: "node2:8080", rack: "dc1:rack1", freeSlots: 2,
|
|
ecShards: map[uint32]*ecVolumeInfo{
|
|
200: {collection: "col1", shardBits: 0b111}, // 3 shards
|
|
},
|
|
},
|
|
}
|
|
racks := map[string]*ecRackInfo{
|
|
"dc1:rack1": {
|
|
nodes: map[string]*ecNodeInfo{"node1": nodes["node1"], "node2": nodes["node2"]},
|
|
freeSlots: 92,
|
|
},
|
|
}
|
|
|
|
config := NewDefaultConfig()
|
|
config.ImbalanceThreshold = 0.01
|
|
moves := detectGlobalImbalance(nodes, racks, config, nil)
|
|
|
|
if len(moves) == 0 {
|
|
t.Fatal("expected moves from high-util node2 to low-util node1, got 0")
|
|
}
|
|
|
|
// Every move must drain the higher-util node (node2) and target the
|
|
// lower-util node (node1). A raw-count-based greedy algorithm would
|
|
// pick the opposite direction — that is the bug this test guards.
|
|
for _, move := range moves {
|
|
if move.source.nodeID != "node2" {
|
|
t.Errorf("expected source node2 (util 0.60), got %s", move.source.nodeID)
|
|
}
|
|
if move.target.nodeID != "node1" {
|
|
t.Errorf("expected target node1 (util 0.10), got %s", move.target.nodeID)
|
|
}
|
|
}
|
|
|
|
// Verify no duplicate (volumeID, shardID) pairs — the inner loop must
|
|
// update shardBits between iterations so each move refers to a distinct
|
|
// physical shard.
|
|
seen := make(map[[2]int]bool, len(moves))
|
|
for _, move := range moves {
|
|
key := [2]int{int(move.volumeID), move.shardID}
|
|
if seen[key] {
|
|
t.Errorf("duplicate move for volume %d shard %d", move.volumeID, move.shardID)
|
|
}
|
|
seen[key] = true
|
|
}
|
|
}
|
|
|
|
func TestDetectGlobalImbalanceSkipsFullNodes(t *testing.T) {
|
|
// node2 has 0 free slots — should not be chosen as destination
|
|
nodes := map[string]*ecNodeInfo{
|
|
"node1": {
|
|
nodeID: "node1", address: "node1:8080", rack: "dc1:rack1", freeSlots: 10,
|
|
ecShards: map[uint32]*ecVolumeInfo{
|
|
100: {collection: "col1", shardBits: 0x3FFF}, // 14 shards
|
|
},
|
|
},
|
|
"node2": {
|
|
nodeID: "node2", address: "node2:8080", rack: "dc1:rack1", freeSlots: 0,
|
|
ecShards: map[uint32]*ecVolumeInfo{
|
|
200: {collection: "col1", shardBits: 0b11}, // 2 shards
|
|
},
|
|
},
|
|
}
|
|
racks := map[string]*ecRackInfo{
|
|
"dc1:rack1": {
|
|
nodes: map[string]*ecNodeInfo{"node1": nodes["node1"], "node2": nodes["node2"]},
|
|
freeSlots: 10,
|
|
},
|
|
}
|
|
|
|
config := NewDefaultConfig()
|
|
config.ImbalanceThreshold = 0.01
|
|
moves := detectGlobalImbalance(nodes, racks, config, nil)
|
|
|
|
// node2 has no free slots so no moves should be proposed
|
|
if len(moves) != 0 {
|
|
t.Fatalf("expected 0 moves (node2 full), got %d", len(moves))
|
|
}
|
|
}
|
|
|
|
func TestBuildECTopology(t *testing.T) {
|
|
topoInfo := &master_pb.TopologyInfo{
|
|
DataCenterInfos: []*master_pb.DataCenterInfo{
|
|
{
|
|
Id: "dc1",
|
|
RackInfos: []*master_pb.RackInfo{
|
|
{
|
|
Id: "rack1",
|
|
DataNodeInfos: []*master_pb.DataNodeInfo{
|
|
{
|
|
Id: "server1:8080",
|
|
DiskInfos: map[string]*master_pb.DiskInfo{
|
|
"": {
|
|
MaxVolumeCount: 100,
|
|
VolumeCount: 50,
|
|
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
|
{
|
|
Id: 1,
|
|
Collection: "test",
|
|
EcIndexBits: 0x3FFF, // all 14 shards
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
config := NewDefaultConfig()
|
|
nodes, racks := buildECTopology(topoInfo, config)
|
|
|
|
if len(nodes) != 1 {
|
|
t.Fatalf("expected 1 node, got %d", len(nodes))
|
|
}
|
|
if len(racks) != 1 {
|
|
t.Fatalf("expected 1 rack, got %d", len(racks))
|
|
}
|
|
|
|
node := nodes["server1:8080"]
|
|
if node == nil {
|
|
t.Fatal("expected node server1:8080")
|
|
}
|
|
if node.dc != "dc1" {
|
|
t.Errorf("expected dc=dc1, got %s", node.dc)
|
|
}
|
|
// Rack key should be dc:rack composite
|
|
if node.rack != "dc1:rack1" {
|
|
t.Errorf("expected rack=dc1:rack1, got %s", node.rack)
|
|
}
|
|
|
|
ecInfo, ok := node.ecShards[1]
|
|
if !ok {
|
|
t.Fatal("expected EC shard info for volume 1")
|
|
}
|
|
if ecInfo.collection != "test" {
|
|
t.Errorf("expected collection=test, got %s", ecInfo.collection)
|
|
}
|
|
if shardBitCount(ecInfo.shardBits) != 14 {
|
|
t.Errorf("expected 14 shards, got %d", shardBitCount(ecInfo.shardBits))
|
|
}
|
|
}
|
|
|
|
func TestBuildECTopologyCrossDCRackNames(t *testing.T) {
|
|
// Two DCs with identically-named racks should produce distinct rack keys
|
|
topoInfo := &master_pb.TopologyInfo{
|
|
DataCenterInfos: []*master_pb.DataCenterInfo{
|
|
{
|
|
Id: "dc1",
|
|
RackInfos: []*master_pb.RackInfo{{
|
|
Id: "rack1",
|
|
DataNodeInfos: []*master_pb.DataNodeInfo{{
|
|
Id: "node-dc1:8080",
|
|
DiskInfos: map[string]*master_pb.DiskInfo{
|
|
"": {MaxVolumeCount: 10, VolumeCount: 0},
|
|
},
|
|
}},
|
|
}},
|
|
},
|
|
{
|
|
Id: "dc2",
|
|
RackInfos: []*master_pb.RackInfo{{
|
|
Id: "rack1",
|
|
DataNodeInfos: []*master_pb.DataNodeInfo{{
|
|
Id: "node-dc2:8080",
|
|
DiskInfos: map[string]*master_pb.DiskInfo{
|
|
"": {MaxVolumeCount: 10, VolumeCount: 0},
|
|
},
|
|
}},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
|
|
config := NewDefaultConfig()
|
|
_, racks := buildECTopology(topoInfo, config)
|
|
|
|
if len(racks) != 2 {
|
|
t.Fatalf("expected 2 distinct racks, got %d", len(racks))
|
|
}
|
|
if _, ok := racks["dc1:rack1"]; !ok {
|
|
t.Error("expected dc1:rack1 rack key")
|
|
}
|
|
if _, ok := racks["dc2:rack1"]; !ok {
|
|
t.Error("expected dc2:rack1 rack key")
|
|
}
|
|
}
|
|
|
|
func TestCollectECCollections(t *testing.T) {
|
|
nodes := map[string]*ecNodeInfo{
|
|
"node1": {
|
|
ecShards: map[uint32]*ecVolumeInfo{
|
|
100: {collection: "col1"},
|
|
200: {collection: "col2"},
|
|
},
|
|
},
|
|
"node2": {
|
|
ecShards: map[uint32]*ecVolumeInfo{
|
|
100: {collection: "col1"},
|
|
300: {collection: "col2"},
|
|
},
|
|
},
|
|
}
|
|
|
|
config := NewDefaultConfig()
|
|
collections := collectECCollections(nodes, config)
|
|
|
|
if len(collections) != 2 {
|
|
t.Fatalf("expected 2 collections, got %d", len(collections))
|
|
}
|
|
if len(collections["col1"]) != 1 {
|
|
t.Errorf("expected 1 volume in col1, got %d", len(collections["col1"]))
|
|
}
|
|
if len(collections["col2"]) != 2 {
|
|
t.Errorf("expected 2 volumes in col2, got %d", len(collections["col2"]))
|
|
}
|
|
}
|
|
|
|
func TestCollectECCollectionsWithFilter(t *testing.T) {
|
|
nodes := map[string]*ecNodeInfo{
|
|
"node1": {
|
|
ecShards: map[uint32]*ecVolumeInfo{
|
|
100: {collection: "col1"},
|
|
200: {collection: "col2"},
|
|
},
|
|
},
|
|
}
|
|
|
|
config := NewDefaultConfig()
|
|
config.CollectionFilter = "col1"
|
|
collections := collectECCollections(nodes, config)
|
|
|
|
if len(collections) != 1 {
|
|
t.Fatalf("expected 1 collection, got %d", len(collections))
|
|
}
|
|
if _, ok := collections["col1"]; !ok {
|
|
t.Error("expected col1 to be present")
|
|
}
|
|
}
|
|
|
|
func TestDetectionDisabled(t *testing.T) {
|
|
config := NewDefaultConfig()
|
|
config.Enabled = false
|
|
|
|
results, hasMore, err := Detection(context.Background(), nil, nil, config, 0)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if hasMore {
|
|
t.Error("expected hasMore=false")
|
|
}
|
|
if len(results) != 0 {
|
|
t.Errorf("expected 0 results, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestDetectionNilTopology(t *testing.T) {
|
|
config := NewDefaultConfig()
|
|
clusterInfo := &types.ClusterInfo{ActiveTopology: nil}
|
|
|
|
_, _, err := Detection(context.Background(), nil, clusterInfo, config, 0)
|
|
if err == nil {
|
|
t.Fatal("expected error for nil topology")
|
|
}
|
|
}
|
|
|
|
func TestMovePhasePriority(t *testing.T) {
|
|
if movePhasePriority("dedup") != types.TaskPriorityHigh {
|
|
t.Error("dedup should be high priority")
|
|
}
|
|
if movePhasePriority("cross_rack") != types.TaskPriorityMedium {
|
|
t.Error("cross_rack should be medium priority")
|
|
}
|
|
if movePhasePriority("within_rack") != types.TaskPriorityLow {
|
|
t.Error("within_rack should be low priority")
|
|
}
|
|
if movePhasePriority("global") != types.TaskPriorityLow {
|
|
t.Error("global should be low priority")
|
|
}
|
|
}
|
|
|
|
func TestExceedsImbalanceThreshold(t *testing.T) {
|
|
// 14 vs 0 across 2 groups: imbalance = 14/7 = 2.0 > any reasonable threshold
|
|
counts := map[string]int{"a": 14, "b": 0}
|
|
if !exceedsImbalanceThreshold(counts, 14, 2, 0.2) {
|
|
t.Error("expected imbalance to exceed 0.2 threshold")
|
|
}
|
|
|
|
// Only one group has shards but numGroups=2: min is 0 from absent group
|
|
counts2 := map[string]int{"a": 14}
|
|
if !exceedsImbalanceThreshold(counts2, 14, 2, 0.2) {
|
|
t.Error("expected imbalance with absent group to exceed 0.2 threshold")
|
|
}
|
|
|
|
// 7 vs 7: perfectly balanced
|
|
counts3 := map[string]int{"a": 7, "b": 7}
|
|
if exceedsImbalanceThreshold(counts3, 14, 2, 0.01) {
|
|
t.Error("expected balanced distribution to not exceed threshold")
|
|
}
|
|
}
|
|
|
|
// helper to avoid unused import
|
|
var _ = erasure_coding.DataShardsCount
|