package distribution import ( "fmt" "slices" ) // ShardMove represents a planned shard move type ShardMove struct { ShardID int SourceNode *TopologyNode DestNode *TopologyNode Reason string } // String returns a human-readable description of the move func (m ShardMove) String() string { return fmt.Sprintf("shard %d: %s -> %s (%s)", m.ShardID, m.SourceNode.NodeID, m.DestNode.NodeID, m.Reason) } // RebalancePlan contains the complete plan for rebalancing EC shards type RebalancePlan struct { Moves []ShardMove Distribution *ECDistribution Analysis *TopologyAnalysis // Statistics TotalMoves int MovesAcrossDC int MovesAcrossRack int MovesWithinRack int } // String returns a summary of the plan func (p *RebalancePlan) String() string { return fmt.Sprintf("RebalancePlan{moves:%d, acrossDC:%d, acrossRack:%d, withinRack:%d}", p.TotalMoves, p.MovesAcrossDC, p.MovesAcrossRack, p.MovesWithinRack) } // DetailedString returns a detailed multi-line summary func (p *RebalancePlan) DetailedString() string { s := fmt.Sprintf("Rebalance Plan:\n") s += fmt.Sprintf(" Total Moves: %d\n", p.TotalMoves) s += fmt.Sprintf(" Across DC: %d\n", p.MovesAcrossDC) s += fmt.Sprintf(" Across Rack: %d\n", p.MovesAcrossRack) s += fmt.Sprintf(" Within Rack: %d\n", p.MovesWithinRack) s += fmt.Sprintf("\nMoves:\n") for i, move := range p.Moves { s += fmt.Sprintf(" %d. %s\n", i+1, move.String()) } return s } // Rebalancer plans shard moves to achieve proportional distribution type Rebalancer struct { ecConfig ECConfig repConfig ReplicationConfig } // NewRebalancer creates a new rebalancer with the given configuration func NewRebalancer(ec ECConfig, rep ReplicationConfig) *Rebalancer { return &Rebalancer{ ecConfig: ec, repConfig: rep, } } // PlanRebalance creates a rebalancing plan based on current topology analysis func (r *Rebalancer) PlanRebalance(analysis *TopologyAnalysis) (*RebalancePlan, error) { dist := CalculateDistribution(r.ecConfig, r.repConfig) plan := &RebalancePlan{ Distribution: dist, Analysis: analysis, } // Step 1: Balance across data centers dcMoves := r.planDCMoves(analysis, dist) for _, move := range dcMoves { plan.Moves = append(plan.Moves, move) plan.MovesAcrossDC++ } // Update analysis after DC moves (for planning purposes) r.applyMovesToAnalysis(analysis, dcMoves) // Step 2: Balance across racks within each DC rackMoves := r.planRackMoves(analysis, dist) for _, move := range rackMoves { plan.Moves = append(plan.Moves, move) plan.MovesAcrossRack++ } // Update analysis after rack moves r.applyMovesToAnalysis(analysis, rackMoves) // Step 3: Balance across nodes within each rack nodeMoves := r.planNodeMoves(analysis, dist) for _, move := range nodeMoves { plan.Moves = append(plan.Moves, move) plan.MovesWithinRack++ } plan.TotalMoves = len(plan.Moves) return plan, nil } // planDCMoves plans moves to balance shards across data centers func (r *Rebalancer) planDCMoves(analysis *TopologyAnalysis, dist *ECDistribution) []ShardMove { var moves []ShardMove overDCs := CalculateDCExcess(analysis, dist) underDCs := CalculateUnderservedDCs(analysis, dist) underIdx := 0 for _, over := range overDCs { for over.Excess > 0 && underIdx < len(underDCs) { destDC := underDCs[underIdx] // Find a shard and source node shardID, srcNode := r.pickShardToMove(analysis, over.Nodes) if srcNode == nil { break } // Find destination node in target DC destNode := r.pickBestDestination(analysis, destDC, "", dist) if destNode == nil { underIdx++ continue } moves = append(moves, ShardMove{ ShardID: shardID, SourceNode: srcNode, DestNode: destNode, Reason: fmt.Sprintf("balance DC: %s -> %s", srcNode.DataCenter, destDC), }) over.Excess-- analysis.ShardsByDC[srcNode.DataCenter]-- analysis.ShardsByDC[destDC]++ // Check if destDC reached target if analysis.ShardsByDC[destDC] >= dist.TargetShardsPerDC { underIdx++ } } } return moves } // planRackMoves plans moves to balance shards across racks within each DC func (r *Rebalancer) planRackMoves(analysis *TopologyAnalysis, dist *ECDistribution) []ShardMove { var moves []ShardMove for dc := range analysis.DCToRacks { dcShards := analysis.ShardsByDC[dc] numRacks := len(analysis.DCToRacks[dc]) if numRacks == 0 { continue } targetPerRack := ceilDivide(dcShards, max(numRacks, dist.ReplicationConfig.MinRacksPerDC)) overRacks := CalculateRackExcess(analysis, dc, targetPerRack) underRacks := CalculateUnderservedRacks(analysis, dc, targetPerRack) underIdx := 0 for _, over := range overRacks { for over.Excess > 0 && underIdx < len(underRacks) { destRack := underRacks[underIdx] // Find shard and source node shardID, srcNode := r.pickShardToMove(analysis, over.Nodes) if srcNode == nil { break } // Find destination node in target rack destNode := r.pickBestDestination(analysis, dc, destRack, dist) if destNode == nil { underIdx++ continue } moves = append(moves, ShardMove{ ShardID: shardID, SourceNode: srcNode, DestNode: destNode, Reason: fmt.Sprintf("balance rack: %s -> %s", srcNode.Rack, destRack), }) over.Excess-- analysis.ShardsByRack[srcNode.Rack]-- analysis.ShardsByRack[destRack]++ if analysis.ShardsByRack[destRack] >= targetPerRack { underIdx++ } } } } return moves } // planNodeMoves plans moves to balance shards across nodes within each rack func (r *Rebalancer) planNodeMoves(analysis *TopologyAnalysis, dist *ECDistribution) []ShardMove { var moves []ShardMove for rack, nodes := range analysis.RackToNodes { if len(nodes) <= 1 { continue } rackShards := analysis.ShardsByRack[rack] targetPerNode := ceilDivide(rackShards, max(len(nodes), dist.ReplicationConfig.MinNodesPerRack)) // Find over and under nodes var overNodes []*TopologyNode var underNodes []*TopologyNode for _, node := range nodes { count := analysis.ShardsByNode[node.NodeID] if count > targetPerNode { overNodes = append(overNodes, node) } else if count < targetPerNode { underNodes = append(underNodes, node) } } // Sort by excess/deficit slices.SortFunc(overNodes, func(a, b *TopologyNode) int { return analysis.ShardsByNode[b.NodeID] - analysis.ShardsByNode[a.NodeID] }) underIdx := 0 for _, srcNode := range overNodes { excess := analysis.ShardsByNode[srcNode.NodeID] - targetPerNode for excess > 0 && underIdx < len(underNodes) { destNode := underNodes[underIdx] // Pick a shard from this node, preferring parity shards shards := analysis.NodeToShards[srcNode.NodeID] if len(shards) == 0 { break } // Find a parity shard first, fallback to data shard shardID := -1 shardIdx := -1 for i, s := range shards { if r.ecConfig.IsParityShard(s) { shardID = s shardIdx = i break } } if shardID == -1 { shardID = shards[0] shardIdx = 0 } moves = append(moves, ShardMove{ ShardID: shardID, SourceNode: srcNode, DestNode: destNode, Reason: fmt.Sprintf("balance node: %s -> %s", srcNode.NodeID, destNode.NodeID), }) excess-- analysis.ShardsByNode[srcNode.NodeID]-- analysis.ShardsByNode[destNode.NodeID]++ // Update shard lists - remove the specific shard we picked analysis.NodeToShards[srcNode.NodeID] = append( shards[:shardIdx], shards[shardIdx+1:]...) analysis.NodeToShards[destNode.NodeID] = append( analysis.NodeToShards[destNode.NodeID], shardID) if analysis.ShardsByNode[destNode.NodeID] >= targetPerNode { underIdx++ } } } } return moves } // pickShardToMove selects a shard and its node from the given nodes. // It prefers to move parity shards first, keeping data shards spread out // since data shards serve read requests while parity shards are only for reconstruction. func (r *Rebalancer) pickShardToMove(analysis *TopologyAnalysis, nodes []*TopologyNode) (int, *TopologyNode) { // Sort by shard count (most shards first) slices.SortFunc(nodes, func(a, b *TopologyNode) int { return analysis.ShardsByNode[b.NodeID] - analysis.ShardsByNode[a.NodeID] }) // First pass: try to find a parity shard to move (prefer moving parity) for _, node := range nodes { shards := analysis.NodeToShards[node.NodeID] for _, shardID := range shards { if r.ecConfig.IsParityShard(shardID) { return shardID, node } } } // Second pass: if no parity shards, move a data shard for _, node := range nodes { shards := analysis.NodeToShards[node.NodeID] if len(shards) > 0 { return shards[0], node } } return -1, nil } // pickBestDestination selects the best destination node func (r *Rebalancer) pickBestDestination(analysis *TopologyAnalysis, targetDC, targetRack string, dist *ECDistribution) *TopologyNode { var candidates []*TopologyNode // Collect candidates for _, node := range analysis.AllNodes { // Filter by DC if specified if targetDC != "" && node.DataCenter != targetDC { continue } // Filter by rack if specified if targetRack != "" && node.Rack != targetRack { continue } // Check capacity if node.FreeSlots <= 0 { continue } // Check max shards limit if analysis.ShardsByNode[node.NodeID] >= dist.MaxShardsPerNode { continue } candidates = append(candidates, node) } if len(candidates) == 0 { return nil } // Sort by: 1) fewer shards, 2) more free slots slices.SortFunc(candidates, func(a, b *TopologyNode) int { aShards := analysis.ShardsByNode[a.NodeID] bShards := analysis.ShardsByNode[b.NodeID] if aShards != bShards { return aShards - bShards } return b.FreeSlots - a.FreeSlots }) return candidates[0] } // applyMovesToAnalysis is a no-op placeholder for potential future use. // Note: All planners (planDCMoves, planRackMoves, planNodeMoves) update // their respective counts (ShardsByDC, ShardsByRack, ShardsByNode) and // shard lists (NodeToShards) inline during planning. This avoids duplicate // updates that would occur if we also updated counts here. func (r *Rebalancer) applyMovesToAnalysis(analysis *TopologyAnalysis, moves []ShardMove) { // Counts are already updated by the individual planners. // This function is kept for API compatibility and potential future use. }