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.
877 lines
25 KiB
Go
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
|
|
}
|
|
}
|