Files
seaweedfs/weed/worker/tasks/ec_balance/detection.go
T
Chris Lu 2fd60cfbc3 fix(balance): guard against destination overshoot and oscillation (#9090)
* 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.
2026-04-15 12:47:59 -07:00

877 lines
25 KiB
Go

package ec_balance
import (
"context"
"fmt"
"math"
"sort"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// ecNodeInfo represents a volume server with EC shard information for detection
type ecNodeInfo struct {
nodeID string
address string
dc string
rack string // dc:rack composite key
freeSlots int
// volumeID -> shardBits (bitmask of shard IDs present on this node)
ecShards map[uint32]*ecVolumeInfo
}
type ecVolumeInfo struct {
collection string
shardBits uint32 // bitmask
diskID uint32
}
// ecRackInfo represents a rack with EC node information
type ecRackInfo struct {
nodes map[string]*ecNodeInfo
freeSlots int
}
// shardMove represents a proposed EC shard move
type shardMove struct {
volumeID uint32
shardID int
collection string
source *ecNodeInfo
sourceDisk uint32
target *ecNodeInfo
targetDisk uint32
phase string // "dedup", "cross_rack", "within_rack", "global"
}
// Detection implements the multi-phase EC shard balance detection algorithm.
// It analyzes EC shard distribution and proposes moves to achieve even distribution.
func Detection(
ctx context.Context,
metrics []*types.VolumeHealthMetrics,
clusterInfo *types.ClusterInfo,
config base.TaskConfig,
maxResults int,
) ([]*types.TaskDetectionResult, bool, error) {
if !config.IsEnabled() {
return nil, false, nil
}
ecConfig := config.(*Config)
if maxResults < 0 {
maxResults = 0
}
if clusterInfo == nil || clusterInfo.ActiveTopology == nil {
return nil, false, fmt.Errorf("active topology not available for EC balance detection")
}
topoInfo := clusterInfo.ActiveTopology.GetTopologyInfo()
if topoInfo == nil {
return nil, false, fmt.Errorf("topology info not available")
}
// Build EC topology view
nodes, racks := buildECTopology(topoInfo, ecConfig)
if len(nodes) < ecConfig.MinServerCount {
glog.V(1).Infof("EC balance: only %d servers, need at least %d", len(nodes), ecConfig.MinServerCount)
return nil, false, nil
}
// Collect all EC volumes grouped by collection
collections := collectECCollections(nodes, ecConfig)
if len(collections) == 0 {
glog.V(1).Infof("EC balance: no EC volumes found matching filters")
return nil, false, nil
}
threshold := ecConfig.ImbalanceThreshold
var allMoves []*shardMove
// Build set of allowed collections for global phase filtering
allowedVids := make(map[uint32]bool)
for _, volumeIDs := range collections {
for _, vid := range volumeIDs {
allowedVids[vid] = true
}
}
for collection, volumeIDs := range collections {
if ctx != nil {
if err := ctx.Err(); err != nil {
return nil, false, err
}
}
// Phase 1: Detect duplicate shards (always run, duplicates are errors not imbalance)
for _, vid := range volumeIDs {
moves := detectDuplicateShards(vid, collection, nodes, ecConfig.DiskType)
applyMovesToTopology(moves)
allMoves = append(allMoves, moves...)
}
// Phase 2: Balance shards across racks (operates on updated topology from phase 1)
for _, vid := range volumeIDs {
moves := detectCrossRackImbalance(vid, collection, nodes, racks, ecConfig.DiskType, threshold)
applyMovesToTopology(moves)
allMoves = append(allMoves, moves...)
}
// Phase 3: Balance shards within racks (operates on updated topology from phases 1-2)
for _, vid := range volumeIDs {
moves := detectWithinRackImbalance(vid, collection, nodes, racks, ecConfig.DiskType, threshold)
applyMovesToTopology(moves)
allMoves = append(allMoves, moves...)
}
}
// Phase 4: Global node balance across racks (only for volumes in allowed collections)
globalMoves := detectGlobalImbalance(nodes, racks, ecConfig, allowedVids)
allMoves = append(allMoves, globalMoves...)
// Cap results
hasMore := false
if maxResults > 0 && len(allMoves) > maxResults {
allMoves = allMoves[:maxResults]
hasMore = true
}
// Convert moves to TaskDetectionResults
now := time.Now()
results := make([]*types.TaskDetectionResult, 0, len(allMoves))
for i, move := range allMoves {
// Include loop index and source/target in TaskID for uniqueness
taskID := fmt.Sprintf("ec_balance_%d_%d_%s_%s_%d_%d",
move.volumeID, move.shardID,
move.source.nodeID, move.target.nodeID,
now.UnixNano(), i)
result := &types.TaskDetectionResult{
TaskID: taskID,
TaskType: types.TaskTypeECBalance,
VolumeID: move.volumeID,
Server: move.source.nodeID,
Collection: move.collection,
Priority: movePhasePriority(move.phase),
Reason: fmt.Sprintf("EC shard %d.%d %s: %s → %s (%s)",
move.volumeID, move.shardID, move.phase,
move.source.nodeID, move.target.nodeID, move.phase),
ScheduleAt: now,
TypedParams: &worker_pb.TaskParams{
TaskId: taskID,
VolumeId: move.volumeID,
Collection: move.collection,
Sources: []*worker_pb.TaskSource{{
Node: move.source.address,
DiskId: move.sourceDisk,
Rack: move.source.rack,
ShardIds: []uint32{uint32(move.shardID)},
}},
Targets: []*worker_pb.TaskTarget{{
Node: move.target.address,
DiskId: move.targetDisk,
Rack: move.target.rack,
ShardIds: []uint32{uint32(move.shardID)},
}},
TaskParams: &worker_pb.TaskParams_EcBalanceParams{
EcBalanceParams: &worker_pb.EcBalanceTaskParams{
DiskType: ecConfig.DiskType,
TimeoutSeconds: 600,
},
},
},
}
results = append(results, result)
}
glog.V(1).Infof("EC balance detection: %d moves proposed across %d collections",
len(results), len(collections))
return results, hasMore, nil
}
// buildECTopology constructs EC node and rack structures from topology info.
// Rack keys are dc:rack composites to avoid cross-DC name collisions.
// Only racks with eligible nodes (matching disk type, having EC shards or capacity) are included.
func buildECTopology(topoInfo *master_pb.TopologyInfo, config *Config) (map[string]*ecNodeInfo, map[string]*ecRackInfo) {
nodes := make(map[string]*ecNodeInfo)
racks := make(map[string]*ecRackInfo)
for _, dc := range topoInfo.DataCenterInfos {
if config.DataCenterFilter != "" {
matchers := wildcard.CompileWildcardMatchers(config.DataCenterFilter)
if !wildcard.MatchesAnyWildcard(matchers, dc.Id) {
continue
}
}
for _, rack := range dc.RackInfos {
// Use dc:rack composite key to avoid cross-DC name collisions
rackKey := dc.Id + ":" + rack.Id
for _, dn := range rack.DataNodeInfos {
node := &ecNodeInfo{
nodeID: dn.Id,
address: dn.Id,
dc: dc.Id,
rack: rackKey,
ecShards: make(map[uint32]*ecVolumeInfo),
}
hasMatchingDisk := false
for diskType, diskInfo := range dn.DiskInfos {
if config.DiskType != "" && diskType != config.DiskType {
continue
}
hasMatchingDisk = true
freeSlots := int(diskInfo.MaxVolumeCount-diskInfo.VolumeCount)*erasure_coding.DataShardsCount - countEcShards(diskInfo.EcShardInfos)
if freeSlots > 0 {
node.freeSlots += freeSlots
}
for _, ecShardInfo := range diskInfo.EcShardInfos {
vid := ecShardInfo.Id
existing, ok := node.ecShards[vid]
if !ok {
existing = &ecVolumeInfo{
collection: ecShardInfo.Collection,
diskID: ecShardInfo.DiskId,
}
node.ecShards[vid] = existing
}
existing.shardBits |= ecShardInfo.EcIndexBits
}
}
if !hasMatchingDisk {
continue
}
nodes[dn.Id] = node
// Only create rack entry when we have an eligible node
if _, ok := racks[rackKey]; !ok {
racks[rackKey] = &ecRackInfo{nodes: make(map[string]*ecNodeInfo)}
}
racks[rackKey].nodes[dn.Id] = node
racks[rackKey].freeSlots += node.freeSlots
}
}
}
return nodes, racks
}
// collectECCollections groups EC volume IDs by collection, applying filters
func collectECCollections(nodes map[string]*ecNodeInfo, config *Config) map[string][]uint32 {
allowedCollections := wildcard.CompileWildcardMatchers(config.CollectionFilter)
// Collect unique volume IDs per collection
collectionVids := make(map[string]map[uint32]bool)
for _, node := range nodes {
for vid, info := range node.ecShards {
if len(allowedCollections) > 0 && !wildcard.MatchesAnyWildcard(allowedCollections, info.collection) {
continue
}
if _, ok := collectionVids[info.collection]; !ok {
collectionVids[info.collection] = make(map[uint32]bool)
}
collectionVids[info.collection][vid] = true
}
}
// Convert to sorted slices
result := make(map[string][]uint32, len(collectionVids))
for collection, vids := range collectionVids {
vidSlice := make([]uint32, 0, len(vids))
for vid := range vids {
vidSlice = append(vidSlice, vid)
}
sort.Slice(vidSlice, func(i, j int) bool { return vidSlice[i] < vidSlice[j] })
result[collection] = vidSlice
}
return result
}
// detectDuplicateShards finds shards that exist on multiple nodes.
// Duplicates are always returned regardless of threshold since they are data errors.
func detectDuplicateShards(vid uint32, collection string, nodes map[string]*ecNodeInfo, diskType string) []*shardMove {
// Build shard -> list of nodes mapping
shardLocations := make(map[int][]*ecNodeInfo)
for _, node := range nodes {
info, ok := node.ecShards[vid]
if !ok {
continue
}
for shardID := 0; shardID < erasure_coding.MaxShardCount; shardID++ {
if info.shardBits&(1<<uint(shardID)) != 0 {
shardLocations[shardID] = append(shardLocations[shardID], node)
}
}
}
var moves []*shardMove
for shardID, locs := range shardLocations {
if len(locs) <= 1 {
continue
}
// Keep the copy on the node with most free slots (ascending sort, keep last)
sort.Slice(locs, func(i, j int) bool { return locs[i].freeSlots < locs[j].freeSlots })
// Propose deletion of all other copies (skip the keeper at the end).
// Set target=source so isDedupPhase() recognizes this as unmount+delete only.
for _, node := range locs[:len(locs)-1] {
moves = append(moves, &shardMove{
volumeID: vid,
shardID: shardID,
collection: collection,
source: node,
sourceDisk: ecShardDiskID(node, vid),
target: node,
targetDisk: ecShardDiskID(node, vid),
phase: "dedup",
})
}
}
return moves
}
// detectCrossRackImbalance detects shards that should be moved across racks for even distribution.
// Returns nil if imbalance is below the threshold.
func detectCrossRackImbalance(vid uint32, collection string, nodes map[string]*ecNodeInfo, racks map[string]*ecRackInfo, diskType string, threshold float64) []*shardMove {
numRacks := len(racks)
if numRacks <= 1 {
return nil
}
// Count shards per rack for this volume
rackShardCount := make(map[string]int)
rackShardNodes := make(map[string][]*ecNodeInfo)
totalShards := 0
for _, node := range nodes {
info, ok := node.ecShards[vid]
if !ok {
continue
}
count := shardBitCount(info.shardBits)
if count > 0 {
rackShardCount[node.rack] += count
rackShardNodes[node.rack] = append(rackShardNodes[node.rack], node)
totalShards += count
}
}
if totalShards == 0 {
return nil
}
// Check if imbalance exceeds threshold
if !exceedsImbalanceThreshold(rackShardCount, totalShards, numRacks, threshold) {
return nil
}
maxPerRack := ceilDivide(totalShards, numRacks)
var moves []*shardMove
// Find over-loaded racks and move excess shards to under-loaded racks
for rackID, count := range rackShardCount {
if count <= maxPerRack {
continue
}
excess := count - maxPerRack
movedFromRack := 0
// Find shards to move from this rack
for _, node := range rackShardNodes[rackID] {
if movedFromRack >= excess {
break
}
info := node.ecShards[vid]
for shardID := 0; shardID < erasure_coding.TotalShardsCount; shardID++ {
if movedFromRack >= excess {
break
}
if info.shardBits&(1<<uint(shardID)) == 0 {
continue
}
// Find destination: rack with fewest shards of this volume
destNode := findDestNodeInUnderloadedRack(vid, racks, rackShardCount, maxPerRack, rackID, nodes)
if destNode == nil {
continue
}
moves = append(moves, &shardMove{
volumeID: vid,
shardID: shardID,
collection: collection,
source: node,
sourceDisk: ecShardDiskID(node, vid),
target: destNode,
targetDisk: ecShardDiskID(destNode, vid),
phase: "cross_rack",
})
movedFromRack++
// Reserve capacity on destination so it isn't picked again,
// and release one slot on the source so later volumes in this
// same detection run see its true available capacity.
rackShardCount[destNode.rack]++
rackShardCount[rackID]--
node.freeSlots++
destNode.freeSlots--
}
}
}
return moves
}
// detectWithinRackImbalance detects shards that should be moved within racks for even node distribution.
// Returns nil if imbalance is below the threshold.
func detectWithinRackImbalance(vid uint32, collection string, nodes map[string]*ecNodeInfo, racks map[string]*ecRackInfo, diskType string, threshold float64) []*shardMove {
var moves []*shardMove
for _, rack := range racks {
if len(rack.nodes) <= 1 {
continue
}
// Count shards per node in this rack for this volume
nodeShardCount := make(map[string]int)
totalInRack := 0
for nodeID, node := range rack.nodes {
info, ok := node.ecShards[vid]
if !ok {
continue
}
count := shardBitCount(info.shardBits)
nodeShardCount[nodeID] = count
totalInRack += count
}
if totalInRack == 0 {
continue
}
// Check if imbalance exceeds threshold
if !exceedsImbalanceThreshold(nodeShardCount, totalInRack, len(rack.nodes), threshold) {
continue
}
maxPerNode := ceilDivide(totalInRack, len(rack.nodes))
// Find over-loaded nodes and move excess
for nodeID, count := range nodeShardCount {
if count <= maxPerNode {
continue
}
excess := count - maxPerNode
node := rack.nodes[nodeID]
info := node.ecShards[vid]
moved := 0
for shardID := 0; shardID < erasure_coding.TotalShardsCount; shardID++ {
if moved >= excess {
break
}
if info.shardBits&(1<<uint(shardID)) == 0 {
continue
}
// Find least-loaded node in same rack
destNode := findLeastLoadedNodeInRack(vid, rack, nodeID, nodeShardCount, maxPerNode)
if destNode == nil {
continue
}
moves = append(moves, &shardMove{
volumeID: vid,
shardID: shardID,
collection: collection,
source: node,
sourceDisk: ecShardDiskID(node, vid),
target: destNode,
targetDisk: 0,
phase: "within_rack",
})
moved++
nodeShardCount[nodeID]--
nodeShardCount[destNode.nodeID]++
node.freeSlots++
destNode.freeSlots--
}
}
}
return moves
}
// detectGlobalImbalance detects total shard count imbalance across nodes in each rack.
// Respects ImbalanceThreshold from config. Only considers volumes in allowedVids.
func detectGlobalImbalance(nodes map[string]*ecNodeInfo, racks map[string]*ecRackInfo, config *Config, allowedVids map[uint32]bool) []*shardMove {
var moves []*shardMove
for _, rack := range racks {
if len(rack.nodes) <= 1 {
continue
}
// Count total EC shards per node (only for allowed volumes)
nodeShardCounts := make(map[string]int)
totalShards := 0
for nodeID, node := range rack.nodes {
count := 0
for vid, info := range node.ecShards {
if len(allowedVids) > 0 && !allowedVids[vid] {
continue
}
count += shardBitCount(info.shardBits)
}
nodeShardCounts[nodeID] = count
totalShards += count
}
if totalShards == 0 {
continue
}
// Snapshot each node's total shard capacity (current shards from allowed
// volumes plus any remaining free slots). Capacity is fixed for the
// duration of this loop — moves conserve total shards across the rack,
// so the denominator does not change as nodeShardCounts shift.
nodeCapacity := make(map[string]int, len(rack.nodes))
for nodeID, count := range nodeShardCounts {
nodeCapacity[nodeID] = count + rack.nodes[nodeID].freeSlots
}
// Check if imbalance exceeds threshold using utilization ratios
// (count/capacity), not raw shard counts. Raw counts would say a
// cluster is imbalanced whenever a large-capacity node holds more
// shards than a small-capacity node, even when both are at the
// same fractional fullness.
if !exceedsUtilImbalanceThreshold(nodeShardCounts, nodeCapacity, config.ImbalanceThreshold) {
continue
}
// Iteratively move shards from most-utilized to least-utilized
for i := 0; i < 10; i++ { // cap iterations to avoid infinite loops
// Find min and max nodes by utilization ratio. Min must have free
// slots so it can receive a shard; max can be any node with shards
// (we move shards out of it). Utilization-based selection is
// critical on heterogeneous racks: a large-capacity node with many
// shards in absolute terms may still be the LEAST utilized, and
// moving shards into it from a small, nearly-full node is the
// correct direction even though raw counts would suggest otherwise.
var minNode, maxNode *ecNodeInfo
minUtil := math.Inf(1)
maxUtil := -1.0
var minCount, maxCount int
for nodeID, count := range nodeShardCounts {
node := rack.nodes[nodeID]
cap := nodeCapacity[nodeID]
if cap <= 0 {
continue
}
util := float64(count) / float64(cap)
if util < minUtil && node.freeSlots > 0 {
minUtil = util
minCount = count
minNode = node
}
if util > maxUtil {
maxUtil = util
maxCount = count
maxNode = rack.nodes[nodeID]
}
}
if maxNode == nil || minNode == nil || maxNode.nodeID == minNode.nodeID {
break
}
// Per-move convergence guard: reject any move where the
// destination's post-move utilization would strictly exceed the
// source's post-move utilization. This mirrors the guard in
// weed/worker/tasks/balance/detection.go and terminates the loop
// once no further beneficial move exists, preventing oscillation
// and overshoot on heterogeneous racks.
maxCap := nodeCapacity[maxNode.nodeID]
minCap := nodeCapacity[minNode.nodeID]
if maxCap <= 0 || minCap <= 0 {
break
}
newSrcUtil := float64(maxCount-1) / float64(maxCap)
newDstUtil := float64(minCount+1) / float64(minCap)
if newDstUtil > newSrcUtil {
break
}
// Pick a shard from maxNode that doesn't already exist on minNode
moved := false
for vid, info := range maxNode.ecShards {
if moved {
break
}
if len(allowedVids) > 0 && !allowedVids[vid] {
continue
}
// Check minNode doesn't have this volume's shards already (avoid same-volume overlap)
minInfo := minNode.ecShards[vid]
for shardID := 0; shardID < erasure_coding.TotalShardsCount; shardID++ {
if info.shardBits&(1<<uint(shardID)) == 0 {
continue
}
// Skip if destination already has this shard
if minInfo != nil && minInfo.shardBits&(1<<uint(shardID)) != 0 {
continue
}
moves = append(moves, &shardMove{
volumeID: vid,
shardID: shardID,
collection: info.collection,
source: maxNode,
sourceDisk: info.diskID,
target: minNode,
targetDisk: 0,
phase: "global",
})
// Update in-memory shard placement so the next iteration
// of this loop picks a different shard. Without this, the
// inner loop always finds the lowest-set bit and emits
// duplicate move requests for the same physical shard.
shardBit := uint32(1 << uint(shardID))
info.shardBits &^= shardBit
if minInfo == nil {
minInfo = &ecVolumeInfo{
collection: info.collection,
diskID: info.diskID,
}
minNode.ecShards[vid] = minInfo
}
minInfo.shardBits |= shardBit
nodeShardCounts[maxNode.nodeID]--
nodeShardCounts[minNode.nodeID]++
maxNode.freeSlots++
minNode.freeSlots--
moved = true
break
}
}
if !moved {
break
}
}
}
return moves
}
// findDestNodeInUnderloadedRack finds a node in a rack that has fewer than maxPerRack shards
func findDestNodeInUnderloadedRack(vid uint32, racks map[string]*ecRackInfo, rackShardCount map[string]int, maxPerRack int, excludeRack string, nodes map[string]*ecNodeInfo) *ecNodeInfo {
var bestNode *ecNodeInfo
bestFreeSlots := -1
for rackID, rack := range racks {
if rackID == excludeRack {
continue
}
if rackShardCount[rackID] >= maxPerRack {
continue
}
if rack.freeSlots <= 0 {
continue
}
for _, node := range rack.nodes {
if node.freeSlots <= 0 {
continue
}
if node.freeSlots > bestFreeSlots {
bestFreeSlots = node.freeSlots
bestNode = node
}
}
}
return bestNode
}
// findLeastLoadedNodeInRack finds the node with fewest shards in a rack
func findLeastLoadedNodeInRack(vid uint32, rack *ecRackInfo, excludeNode string, nodeShardCount map[string]int, maxPerNode int) *ecNodeInfo {
var bestNode *ecNodeInfo
bestCount := maxPerNode + 1
for nodeID, node := range rack.nodes {
if nodeID == excludeNode {
continue
}
if node.freeSlots <= 0 {
continue
}
count := nodeShardCount[nodeID]
if count >= maxPerNode {
continue
}
if count < bestCount {
bestCount = count
bestNode = node
}
}
return bestNode
}
// exceedsImbalanceThreshold checks if the distribution of counts exceeds the threshold.
// numGroups is the total number of groups (including those with 0 shards that aren't in the map).
// imbalanceRatio = (maxCount - minCount) / avgCount
func exceedsImbalanceThreshold(counts map[string]int, total int, numGroups int, threshold float64) bool {
if numGroups <= 1 || total == 0 {
return false
}
minCount := 0 // groups not in map have 0 shards
if len(counts) >= numGroups {
// All groups have entries; find actual min
minCount = total + 1
for _, count := range counts {
if count < minCount {
minCount = count
}
}
}
maxCount := -1
for _, count := range counts {
if count > maxCount {
maxCount = count
}
}
avg := float64(total) / float64(numGroups)
if avg == 0 {
return false
}
imbalanceRatio := float64(maxCount-minCount) / avg
return imbalanceRatio > threshold
}
// exceedsUtilImbalanceThreshold checks whether the per-node utilization ratio
// (shard count / shard slot capacity) is skewed beyond the given threshold.
// Unlike exceedsImbalanceThreshold, it compares fractional fullness rather
// than raw counts so that racks with heterogeneous MaxVolumeCount are
// evaluated correctly — a large-capacity node holding more shards than a
// small-capacity node is not considered imbalanced if both are at the same
// fractional fullness. Nodes with zero capacity are skipped.
func exceedsUtilImbalanceThreshold(counts map[string]int, capacities map[string]int, threshold float64) bool {
minUtil := math.Inf(1)
maxUtil := -1.0
seen := 0
for nodeID, count := range counts {
cap := capacities[nodeID]
if cap <= 0 {
continue
}
util := float64(count) / float64(cap)
if util < minUtil {
minUtil = util
}
if util > maxUtil {
maxUtil = util
}
seen++
}
if seen < 2 || maxUtil <= 0 {
return false
}
avg := (maxUtil + minUtil) / 2
if avg == 0 {
return false
}
return (maxUtil-minUtil)/avg > threshold
}
// applyMovesToTopology simulates planned moves on the in-memory topology
// so subsequent detection phases see updated shard placement.
func applyMovesToTopology(moves []*shardMove) {
for _, move := range moves {
shardBit := uint32(1 << uint(move.shardID))
// Remove shard from source
if srcInfo, ok := move.source.ecShards[move.volumeID]; ok {
srcInfo.shardBits &^= shardBit
}
// For non-dedup moves, add shard to target
if move.source.nodeID != move.target.nodeID {
dstInfo, ok := move.target.ecShards[move.volumeID]
if !ok {
dstInfo = &ecVolumeInfo{
collection: move.collection,
diskID: move.targetDisk,
}
move.target.ecShards[move.volumeID] = dstInfo
}
dstInfo.shardBits |= shardBit
}
}
}
// Helper functions
func countEcShards(ecShardInfos []*master_pb.VolumeEcShardInformationMessage) int {
count := 0
for _, eci := range ecShardInfos {
count += erasure_coding.GetShardCount(eci)
}
return count
}
func shardBitCount(bits uint32) int {
count := 0
for bits != 0 {
count += int(bits & 1)
bits >>= 1
}
return count
}
func ecShardDiskID(node *ecNodeInfo, vid uint32) uint32 {
if info, ok := node.ecShards[vid]; ok {
return info.diskID
}
return 0
}
func ceilDivide(a, b int) int {
if b == 0 {
return 0
}
return (a + b - 1) / b
}
func movePhasePriority(phase string) types.TaskPriority {
switch phase {
case "dedup":
return types.TaskPriorityHigh
case "cross_rack":
return types.TaskPriorityMedium
default:
return types.TaskPriorityLow
}
}