Files
seaweedfs/weed/storage/erasure_coding/ecbalancer/snapshot_test.go
T
Chris Lu bea1357d38 ec: skip physically near-full disks when placing EC shards (#10167)
EC placement scored destinations purely by free EC shard slots (derived from
maxVolumeCount) and shard counts, blind to real disk fullness — the same defect
as volume balancing. A disk that is physically full but still shows free EC slots
kept being chosen, and EC shard bytes are captured by statfs free space yet not
by any slot accounting, so the slot math is exactly the metric that can't see EC
fullness.

Treat a disk at/above 90% physical usage as having zero free EC slots at
snapshot-build time, so every existing freeSlots>0 placement predicate excludes
it. Applied in all three snapshot builders (shell countFreeShardSlots, the shared
ecbalancer FromActiveTopology, and the worker ec_balance buildBalancerTopology)
via the shared balancer.DiskTooFullAfter gate. Servers not reporting disk bytes
fall back to slot-only behavior. ec.rebuild recovery is left ungated so shard
recovery can still complete onto fuller disks.
2026-06-30 20:01:55 -07:00

227 lines
7.7 KiB
Go

package ecbalancer
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
)
// TestFromActiveTopology verifies the encode/repair-side snapshot constructor maps
// nodes, per-disk EC shard counts, per-volume shard bits, and free slots from an
// ActiveTopology. Shard accounting is asserted exactly; free slots are asserted to
// be positive (their exact value depends on effective-capacity internals).
func TestFromActiveTopology(t *testing.T) {
const vid uint32 = 7
at := topology.NewActiveTopology(10)
// Node A holds one EC shard (id 3) of volume 7 on disk 0; node B is empty.
nodeA := &master_pb.DataNodeInfo{
Id: "10.0.0.1:8080",
DiskInfos: map[string]*master_pb.DiskInfo{
"hdd": {
DiskId: 0,
MaxVolumeCount: 100,
VolumeCount: 1,
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{{
Id: vid,
Collection: "c1",
EcIndexBits: uint32(1) << 3,
DiskId: 0,
}},
},
},
}
nodeB := &master_pb.DataNodeInfo{
Id: "10.0.0.2:8080",
DiskInfos: map[string]*master_pb.DiskInfo{
"hdd": {DiskId: 0, MaxVolumeCount: 100, VolumeCount: 0},
},
}
if err := at.UpdateTopology(&master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{{
Id: "rack1",
DataNodeInfos: []*master_pb.DataNodeInfo{nodeA, nodeB},
}},
}},
}); err != nil {
t.Fatalf("UpdateTopology: %v", err)
}
topo := FromActiveTopology(at, 0)
if got := len(topo.nodes); got != 2 {
t.Fatalf("node count = %d, want 2", got)
}
a := topo.nodes["10.0.0.1:8080"]
if a == nil {
t.Fatal("node A missing from snapshot")
}
if a.rack != "dc1:rack1" {
t.Errorf("node A rack = %q, want dc1:rack1", a.rack)
}
diskA := a.disks[0]
if diskA == nil {
t.Fatal("node A disk 0 missing")
}
if diskA.shardCount != 1 {
t.Errorf("node A disk 0 shardCount = %d, want 1", diskA.shardCount)
}
vs := a.shards[volKey{collection: "c1", vid: vid}]
if vs == nil {
t.Fatal("volume 7 shards not recorded on node A")
}
if !vs.shardBits.Has(erasure_coding.ShardId(3)) {
t.Errorf("node A volume 7 shardBits %b missing shard 3", vs.shardBits)
}
if vs.shardBits.Count() != 1 {
t.Errorf("node A volume 7 shard count = %d, want 1", vs.shardBits.Count())
}
b := topo.nodes["10.0.0.2:8080"]
if b == nil {
t.Fatal("node B missing from snapshot")
}
if b.rack != "dc1:rack1" {
t.Errorf("node B rack = %q, want dc1:rack1", b.rack)
}
if diskB := b.disks[0]; diskB == nil || diskB.shardCount != 0 {
t.Errorf("node B disk 0 shardCount = %v, want 0", diskB)
}
if len(b.shards) != 0 {
t.Errorf("node B should hold no volume shards, got %d", len(b.shards))
}
// Free slots should be positive on both near-empty disks.
if a.freeSlots <= 0 || b.freeSlots <= 0 {
t.Errorf("free slots not positive: A=%d B=%d", a.freeSlots, b.freeSlots)
}
}
// A physically near-full disk must contribute zero free EC slots to the snapshot,
// even though its slot math (MaxVolumeCount - VolumeCount) says it has room, so
// the planner never places shards onto it. Mirrors the volume-balance #10160 gate.
func TestFromActiveTopologySkipsPhysicallyFullDisk(t *testing.T) {
const gb = uint64(1) << 30
at := topology.NewActiveTopology(10)
// Both disks look slot-empty; only their physical byte fullness differs.
full := &master_pb.DataNodeInfo{
Id: "10.0.0.1:8080",
DiskInfos: map[string]*master_pb.DiskInfo{
"hdd": {DiskId: 0, MaxVolumeCount: 100, VolumeCount: 0, DiskTotalBytes: 1000 * gb, DiskFreeBytes: 40 * gb}, // 96% used
},
}
empty := &master_pb.DataNodeInfo{
Id: "10.0.0.2:8080",
DiskInfos: map[string]*master_pb.DiskInfo{
"hdd": {DiskId: 0, MaxVolumeCount: 100, VolumeCount: 0, DiskTotalBytes: 1000 * gb, DiskFreeBytes: 900 * gb}, // 10% used
},
}
if err := at.UpdateTopology(&master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{{Id: "rack1", DataNodeInfos: []*master_pb.DataNodeInfo{full, empty}}},
}},
}); err != nil {
t.Fatalf("UpdateTopology: %v", err)
}
topo := FromActiveTopology(at, 0)
fullNode := topo.nodes["10.0.0.1:8080"]
if fullNode == nil {
t.Fatal("full node missing")
}
if fullNode.freeSlots != 0 {
t.Errorf("physically full node freeSlots = %d, want 0", fullNode.freeSlots)
}
if d := fullNode.disks[0]; d == nil || d.freeSlots != 0 {
t.Errorf("physically full disk freeSlots = %v, want 0", d)
}
emptyNode := topo.nodes["10.0.0.2:8080"]
if emptyNode == nil || emptyNode.freeSlots <= 0 {
t.Errorf("physically empty node should keep free slots, got %v", emptyNode)
}
}
// TestFromActiveTopologyGroupsByAddressHost verifies the snapshot derives a node's
// machine from its address, not its (possibly opaque) id: two volume servers with
// distinct ids but the same host must land on one machine so EC placement spreads
// shards across boxes even when explicit node ids are configured.
func TestFromActiveTopologyGroupsByAddressHost(t *testing.T) {
at := topology.NewActiveTopology(10)
mk := func(id, addr string) *master_pb.DataNodeInfo {
return &master_pb.DataNodeInfo{
Id: id,
Address: addr,
DiskInfos: map[string]*master_pb.DiskInfo{"hdd": {DiskId: 0, MaxVolumeCount: 100, VolumeCount: 0}},
}
}
// vs-a and vs-b are two servers on the same physical host; vs-c is another box.
nodes := []*master_pb.DataNodeInfo{
mk("vs-a", "10.0.0.9:8080"),
mk("vs-b", "10.0.0.9:8081"),
mk("vs-c", "10.0.0.10:8080"),
}
if err := at.UpdateTopology(&master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{{Id: "rack1", DataNodeInfos: nodes}},
}},
}); err != nil {
t.Fatalf("UpdateTopology: %v", err)
}
topo := FromActiveTopology(at, 0)
if topo.nodes["vs-a"].host != topo.nodes["vs-b"].host {
t.Errorf("vs-a host %q != vs-b host %q; same-machine servers not grouped",
topo.nodes["vs-a"].host, topo.nodes["vs-b"].host)
}
if topo.nodes["vs-a"].host == topo.nodes["vs-c"].host {
t.Errorf("vs-a and vs-c share host %q but are different machines", topo.nodes["vs-a"].host)
}
if got := topo.nodes["vs-a"].host; got != "10.0.0.9" {
t.Errorf("vs-a host = %q, want 10.0.0.9", got)
}
}
// TestEcShardSlotsOnDiskRoundsUp covers the mixed-ratio (targetDataShards <
// existingDataShards) conversion: an existing shard's fractional footprint must
// round up so it is never floored to zero, which would overstate free capacity.
// OSS always uses the standard ratio at runtime, but ecShardSlotsOnDisk takes the
// target data-shard count as a parameter, so the fractional path is exercised
// directly here; the enterprise build reaches it with real per-volume ratios.
func TestEcShardSlotsOnDiskRoundsUp(t *testing.T) {
// A single shard (id 3) of a standard 10-data-shard volume on disk 0.
disk := &topology.DiskInfo{
DiskID: 0,
DiskInfo: &master_pb.DiskInfo{
DiskId: 0,
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{{
Id: 7,
Collection: "c1",
EcIndexBits: uint32(1) << 3,
DiskId: 0,
}},
},
}
// Against a 2-data-shard target the shard occupies 2/10 of a slot, which must
// round up to 1 rather than floor to 0.
if got := ecShardSlotsOnDisk(disk, 2); got != 1 {
t.Errorf("ecShardSlotsOnDisk(target=2) = %d, want 1 (rounded up from 0.2)", got)
}
// Identity case: target equals the existing data-shard count, so the shard
// consumes exactly its whole-number footprint.
if got := ecShardSlotsOnDisk(disk, erasure_coding.DataShardsCount); got != 1 {
t.Errorf("ecShardSlotsOnDisk(target=%d) = %d, want 1", erasure_coding.DataShardsCount, got)
}
}