mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-08 04:57:50 +01:00
* ecbalancer: drain a full server through any target that can take a shard The global phase paired the fullest server with the single emptiest one and stopped for the whole rack when that pair had no legal move. Where every volume already has a shard on each machine, no cross-machine move keeps the spread, so on hosts running one volume server per disk the first server's disk kept its pile while its siblings on the same machine stayed nearly empty: the phase never planned a move. Try sources fullest first and targets emptiest first and take the first pair with a legal move, so such a server drains onto its own machine's emptier servers. A same-machine move goes only to a server holding none of the volume, so one disk never carries two of its shards. Per-machine counts are indexed once per rack and pairs without a move are remembered until a move can change them, so an uncapped drain stays fast. Claude-Session: https://claude.ai/code/session_011UpvafcfUuC2mLxNAXLQJi * ec_balance: keep global moves ahead of within-rack moves under the detection cap Detection truncated the plan to the per-cycle job limit in planner order, which puts the global moves last. A long within-rack backlog pushed all of them past the limit, so even a planned global move never ran. Order dedup and cross-rack first, then the capped global phase, then within-rack. Claude-Session: https://claude.ai/code/session_011UpvafcfUuC2mLxNAXLQJi * ec_balance: keep each volume's moves in plan order when promoting global moves The admin scheduler runs only the first proposal per volume each cycle. A global move promoted ahead of its volume's within-rack moves can take a shard that one of those moves placed, so it ran while the shard was still on the original server and failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ecbalancer: order global targets by fullness after taking the shard The search stops at the first target that would end up fuller than the source, which only holds when targets are ordered by that same value. With unequal capacities an empty one-slot server sorted first and ended the search before a larger server that could take the shard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ecbalancer: free the source disk slot on a global move The global phase cleared the moved shard's bits but left the source disk's free slots and shard count unchanged, unlike every other phase. Use releaseShard so a later pick of that server sees the freed disk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ec_balance: test that a capped detection keeps a global move Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ecbalancer: test the same-machine move skips a sibling holding the volume Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
256 lines
9.1 KiB
Go
256 lines
9.1 KiB
Go
package ec_balance
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding/ecbalancer"
|
|
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
|
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
|
)
|
|
|
|
// The EC balance policy itself is tested in the shared ecbalancer package; these
|
|
// tests cover the worker adapter: building the planner topology from the master
|
|
// topology (filters, capacity) and the Detection entry point.
|
|
|
|
func ecTopo(node1Collection string) *master_pb.TopologyInfo {
|
|
node1 := &master_pb.DataNodeInfo{
|
|
Id: "node1",
|
|
DiskInfos: map[string]*master_pb.DiskInfo{
|
|
"": {Type: "", MaxVolumeCount: 100, EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
|
{Id: 100, Collection: node1Collection, DiskId: 0, EcIndexBits: 0x3FFF}, // 14 shards
|
|
}},
|
|
},
|
|
}
|
|
node2 := &master_pb.DataNodeInfo{
|
|
Id: "node2",
|
|
DiskInfos: map[string]*master_pb.DiskInfo{"": {Type: "", MaxVolumeCount: 100}},
|
|
}
|
|
return &master_pb.TopologyInfo{
|
|
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
|
Id: "dc1",
|
|
RackInfos: []*master_pb.RackInfo{
|
|
{Id: "rack1", DataNodeInfos: []*master_pb.DataNodeInfo{node1}},
|
|
{Id: "rack2", DataNodeInfos: []*master_pb.DataNodeInfo{node2}},
|
|
},
|
|
}},
|
|
}
|
|
}
|
|
|
|
func TestBuildBalancerTopology(t *testing.T) {
|
|
config := NewDefaultConfig()
|
|
topo, nodeCount, _ := buildBalancerTopology(ecTopo("col1"), config, nil)
|
|
if nodeCount != 2 {
|
|
t.Fatalf("nodeCount = %d, want 2", nodeCount)
|
|
}
|
|
moves := ecbalancer.Plan(topo, ecbalancer.Options{ImbalanceThreshold: 0.01})
|
|
if len(moves) == 0 {
|
|
t.Error("expected cross-rack moves for an all-on-one-rack volume")
|
|
}
|
|
}
|
|
|
|
// TestBuildBalancerTopologyGroupsByHost: two volume servers on host 10.0.0.1
|
|
// (different ports) plus three other hosts, a 10+4 volume concentrated on the
|
|
// 10.0.0.1 machine. Four machines is enough to spread within parity, so after
|
|
// planning the 10.0.0.1 machine must hold <=4 shards of the volume -- which only
|
|
// holds if its two ports are grouped into one machine (host wired into the build).
|
|
func TestBuildBalancerTopologyGroupsByHost(t *testing.T) {
|
|
mkNode := func(id string, bits uint32) *master_pb.DataNodeInfo {
|
|
di := &master_pb.DiskInfo{Type: "", MaxVolumeCount: 100}
|
|
if bits != 0 {
|
|
di.EcShardInfos = []*master_pb.VolumeEcShardInformationMessage{{Id: 100, Collection: "col1", DiskId: 0, EcIndexBits: bits}}
|
|
}
|
|
return &master_pb.DataNodeInfo{Id: id, DiskInfos: map[string]*master_pb.DiskInfo{"": di}}
|
|
}
|
|
topoInfo := &master_pb.TopologyInfo{
|
|
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
|
Id: "dc1",
|
|
RackInfos: []*master_pb.RackInfo{{Id: "rack1", DataNodeInfos: []*master_pb.DataNodeInfo{
|
|
mkNode("10.0.0.1:8080", 0x007F), // shards 0-6 on host 10.0.0.1
|
|
mkNode("10.0.0.1:8081", 0x3F80), // shards 7-13 on host 10.0.0.1
|
|
mkNode("10.0.0.2:8080", 0),
|
|
mkNode("10.0.0.3:8080", 0),
|
|
mkNode("10.0.0.4:8080", 0),
|
|
}}},
|
|
}},
|
|
}
|
|
|
|
topo, _, _ := buildBalancerTopology(topoInfo, NewDefaultConfig(), nil)
|
|
moves := ecbalancer.Plan(topo, ecbalancer.Options{ImbalanceThreshold: 0.01})
|
|
|
|
host := func(nodeID string) string { h, _, _ := net.SplitHostPort(nodeID); return h }
|
|
count := map[string]int{"10.0.0.1": 14}
|
|
for _, m := range moves {
|
|
if m.VolumeID != 100 {
|
|
continue
|
|
}
|
|
count[host(m.SourceNode)]--
|
|
if m.SourceNode != m.TargetNode { // non-dedup move
|
|
count[host(m.TargetNode)]++
|
|
}
|
|
}
|
|
if count["10.0.0.1"] > 4 {
|
|
t.Errorf("machine 10.0.0.1 holds %d shards of the volume after balancing, want <=4 (host grouping not applied)", count["10.0.0.1"])
|
|
}
|
|
}
|
|
|
|
func TestBuildBalancerTopologyCollectionFilter(t *testing.T) {
|
|
config := NewDefaultConfig()
|
|
config.CollectionFilter = "other" // does not match the volume's collection
|
|
allowed, err := wildcard.CompileCollectionMatcher(config.CollectionFilter)
|
|
if err != nil {
|
|
t.Fatalf("CompileCollectionMatcher: %v", err)
|
|
}
|
|
topo, nodeCount, _ := buildBalancerTopology(ecTopo("col1"), config, allowed)
|
|
if nodeCount != 2 {
|
|
t.Fatalf("nodeCount = %d, want 2", nodeCount)
|
|
}
|
|
if moves := ecbalancer.Plan(topo, ecbalancer.Options{ImbalanceThreshold: 0.01}); len(moves) != 0 {
|
|
t.Errorf("filtered-out collection should produce no moves, got %d", len(moves))
|
|
}
|
|
}
|
|
|
|
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}
|
|
|
|
if _, _, err := Detection(context.Background(), nil, clusterInfo, config, 0); err == nil {
|
|
t.Fatal("expected error for nil topology")
|
|
}
|
|
}
|
|
|
|
func TestMovePhasePriority(t *testing.T) {
|
|
cases := map[string]types.TaskPriority{
|
|
"dedup": types.TaskPriorityHigh,
|
|
"cross_rack": types.TaskPriorityMedium,
|
|
"within_rack": types.TaskPriorityLow,
|
|
"global": types.TaskPriorityLow,
|
|
}
|
|
for phase, want := range cases {
|
|
if got := movePhasePriority(phase); got != want {
|
|
t.Errorf("movePhasePriority(%q) = %v, want %v", phase, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// keep the erasure_coding import meaningful for future adapter tests
|
|
var _ = erasure_coding.DataShardsCount
|
|
|
|
// TestSortMovesByPhaseKeepsGlobalAheadOfWithinRack: a long within-rack backlog
|
|
// must not push the capped global moves past the detection cap.
|
|
func TestSortMovesByPhaseKeepsGlobalAheadOfWithinRack(t *testing.T) {
|
|
moves := []ecbalancer.Move{
|
|
{VolumeID: 1, Phase: "within_rack"},
|
|
{VolumeID: 2, Phase: "dedup"},
|
|
{VolumeID: 3, Phase: "within_rack"},
|
|
{VolumeID: 4, Phase: "global"},
|
|
{VolumeID: 5, Phase: "cross_rack"},
|
|
{VolumeID: 6, Phase: "global"},
|
|
}
|
|
sortMovesByPhase(moves)
|
|
var got []uint32
|
|
for _, m := range moves {
|
|
got = append(got, m.VolumeID)
|
|
}
|
|
if want := []uint32{2, 5, 4, 6, 1, 3}; !slices.Equal(got, want) {
|
|
t.Errorf("order = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
// TestSortMovesByPhaseKeepsAVolumesMovesInPlanOrder: a global move of a shard
|
|
// that a within-rack move placed must not run ahead of that move.
|
|
func TestSortMovesByPhaseKeepsAVolumesMovesInPlanOrder(t *testing.T) {
|
|
moves := []ecbalancer.Move{
|
|
{VolumeID: 1, ShardID: 3, SourceNode: "within 1", Phase: "within_rack"},
|
|
{VolumeID: 2, SourceNode: "within 2", Phase: "within_rack"},
|
|
{VolumeID: 1, ShardID: 3, SourceNode: "global 1", Phase: "global"},
|
|
{VolumeID: 3, SourceNode: "global 3", Phase: "global"},
|
|
{VolumeID: 2, Collection: "other", SourceNode: "global other 2", Phase: "global"},
|
|
}
|
|
sortMovesByPhase(moves)
|
|
var got []string
|
|
for _, m := range moves {
|
|
got = append(got, m.SourceNode)
|
|
}
|
|
if want := []string{"global 3", "global other 2", "within 1", "within 2", "global 1"}; !slices.Equal(got, want) {
|
|
t.Errorf("order = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// TestDetectionKeepsGlobalMovesUnderTheCap: the within-rack moves that come
|
|
// first in plan order must not push every global move past maxResults.
|
|
func TestDetectionKeepsGlobalMovesUnderTheCap(t *testing.T) {
|
|
disk := func(shards ...*master_pb.VolumeEcShardInformationMessage) map[string]*master_pb.DiskInfo {
|
|
return map[string]*master_pb.DiskInfo{"": {Type: "", MaxVolumeCount: 100, EcShardInfos: shards}}
|
|
}
|
|
// Volume 100 sits on one machine (within-rack moves); volumes 1-20 load one
|
|
// server (global moves).
|
|
var loaded []*master_pb.VolumeEcShardInformationMessage
|
|
for vid := uint32(1); vid <= 20; vid++ {
|
|
loaded = append(loaded, &master_pb.VolumeEcShardInformationMessage{Id: vid, Collection: "col1", EcIndexBits: 1})
|
|
}
|
|
topoInfo := &master_pb.TopologyInfo{
|
|
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
|
Id: "dc1",
|
|
RackInfos: []*master_pb.RackInfo{{Id: "rack1", DataNodeInfos: []*master_pb.DataNodeInfo{
|
|
{Id: "10.0.0.1:8080", DiskInfos: disk(&master_pb.VolumeEcShardInformationMessage{Id: 100, Collection: "col1", EcIndexBits: 0x3FFF})},
|
|
{Id: "10.0.0.2:8080", DiskInfos: disk(loaded...)},
|
|
{Id: "10.0.0.3:8080", DiskInfos: disk()},
|
|
{Id: "10.0.0.4:8080", DiskInfos: disk()},
|
|
}}},
|
|
}},
|
|
}
|
|
at := topology.NewActiveTopology(0)
|
|
if err := at.UpdateTopology(topoInfo); err != nil {
|
|
t.Fatalf("UpdateTopology: %v", err)
|
|
}
|
|
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
|
|
|
all, _, err := Detection(context.Background(), nil, clusterInfo, NewDefaultConfig(), 0)
|
|
if err != nil {
|
|
t.Fatalf("Detection: %v", err)
|
|
}
|
|
withinRack := 0
|
|
for _, r := range all {
|
|
if strings.Contains(r.Reason, " within_rack:") {
|
|
withinRack++
|
|
}
|
|
}
|
|
if withinRack < 2 {
|
|
t.Fatalf("fixture plans %d within-rack moves, want at least 2", withinRack)
|
|
}
|
|
|
|
results, hasMore, err := Detection(context.Background(), nil, clusterInfo, NewDefaultConfig(), 1)
|
|
if err != nil {
|
|
t.Fatalf("Detection: %v", err)
|
|
}
|
|
if len(results) != 1 || !hasMore {
|
|
t.Fatalf("got %d results (hasMore %v), want 1 and more", len(results), hasMore)
|
|
}
|
|
if !strings.Contains(results[0].Reason, " global:") {
|
|
t.Errorf("kept %q, want a global move", results[0].Reason)
|
|
}
|
|
}
|