mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-16 11:08:39 +02:00
* admin/topology: carry the volume server address on DiskInfo The planning DiskInfo exposed only the node id, which can be an opaque label rather than ip:port. Record the address too so callers can resolve the physical machine a disk sits on. * ec.balance: spread a volume's shards across machines, not just nodes Volume servers sharing a host are one fault domain, but the within-rack spread treated them as independent nodes, so one box could end up holding more shards of a volume than EC can afford to lose. Add a machine (host) tier between rack and node: the within-rack pass spreads each volume across machines, and the global load phase no longer re-concentrates a volume onto a machine it already sits on. Host defaults to the node id, so clusters with one server per host are unchanged. * ec placement: prefer machines holding fewer of a volume's shards EC allocation and repair picked the least-loaded node in a rack with no regard for which physical machine it sits on, so a volume's shards could pile onto several servers of one box. Rank candidate nodes by their machine's shard count first, then the node's own. The machine is derived from the volume server address carried on DiskInfo, falling back to the node id, matching how the balancer resolves it. * volume.balance: don't move a replica onto a machine already holding one isGoodMove only rejected a move onto the same data node, so two replicas could land on two volume servers of one box and a single machine failure would lose both. Reject a target whose host already holds another replica of the volume. Best-effort: balancing simply skips and tries the next target. * volume allocation: spread same-rack replicas across machines PickNodesByWeight filled the same-rack replica picks by weight alone, so replicas could co-locate on one box. Prefer candidates on not-yet-used hosts, falling back when too few distinct machines exist. Data-center and rack tiers have no host, so their ordering is unchanged. * ec.balance: harden machine spread against re-concentration and capped machines Two cases where the machine-aware spread could still leave a volume badly placed: - The global load phase could move a shard of a volume onto a machine that already held it, raising that machine's count and undoing the within-rack spread (a 4/4/3/3 layout could become 3/5/3/3, past parity for 10+4). Limit the load-only fallback to same-machine moves, which leave a machine's count unchanged; cross-machine concentration is no longer allowed for load alone. - The within-rack spread chose a destination machine by free slots alone, so if that machine's only nodes were already at the SameRackCount cap it skipped the move instead of trying another machine. Require a machine to have a node that can actually take the shard before selecting it. * reduce comments across the machine-affinity change Trim narration down to the non-obvious why; one terse line where a block was overkill. * ec.balance: gate machine spread on fault-tolerance feasibility Spreading a volume evenly across machines only helps when there are enough that each can stay within EC's parity tolerance (numMachines >= ceil(total/parity)). With fewer -- or wildly unequal -- machines it can't make a machine loss survivable anyway, and forcing it fights capacity: e.g. a cluster of 12 volume servers on one host and 2 on another would have half of every volume crammed onto the 2-server box. So spread across machines only when it's achievable; otherwise fall back to per-node spread and let capacity/global balancing decide. The global load phase applies the same test: it protects a volume's machine spread (no cross-machine move that raises a machine's count past the source's) only where that spread is achievable, so heterogeneous clusters still level by fullness. * ec.balance worker: group servers by host when planning The worker built its planner topology without recording each server's host, so automated ec.balance treated ports on one machine as independent nodes and could concentrate a volume's shards on one physical box. Set the host from the volume server address, matching the shell path. * volume.balance worker: don't move a replica onto a machine holding one The worker compared only node ids, and the replica map dropped the server address, so it could move replicas onto different ports of one machine. Carry the host on ReplicaLocation (from the server address) and reject a target whose host already holds another replica of the volume. Best-effort, matching the shell. * ec.balance: judge machine-spread feasibility by the rack's shards The within-rack and global feasibility checks compared the whole volume's shard count against a rack's machine count, so a rack holding only part of a volume after cross-rack spreading -- e.g. 7 of a 10+4 volume across 2 machines -- was wrongly judged infeasible and fell back to node spread, which could pile 6 shards onto one host, past parity. Gate on the rack's own shard count of the volume instead. * ec.balance: spread a volume's shards across machines by combined count EC recovers from any loss within parity regardless of shard type, so what bounds a machine's exposure is its total shards of the volume, not data and parity separately. Spreading the two independently let each type's remainder land on the same machine -- ceil(d/M)+ceil(p/M) can exceed ceil(total/M), e.g. a 5/3 split where 4/4 was achievable, past parity. Balance the combined count in one pass; disk-level data/parity anti-affinity stays in pickBestDiskOnNode. * ec.balance: don't let the imbalance threshold skip an over-parity machine The within-rack spread gated on relative skew ((max-min)/avg > threshold), so a worker threshold of 0.5 skipped an exactly-50%-skewed layout like 5/4/3 for a 10+4 volume, leaving 5 shards -- past parity -- on one machine. The even cap (ceil(shards/groups)) is the real bound and the move loop already sheds only what exceeds it, so drop the threshold gate from the within-rack phase (machine and node): a balanced rack stays a no-op while any over-cap machine is always fixed. * ec.balance: keep the imbalance threshold for the node fallback Dropping the threshold from the whole within-rack phase made the node fallback too eager: it runs only when machine fault tolerance is unachievable, so it is cosmetic load distribution that should defer to the global utilization phase. Without the gate it would, for a one-server-per-host 6/4 split at threshold 0.5, schedule a count move that worsens utilization balance. Restore the threshold there; machine spreading keeps bypassing it, since that bound is durability, not cosmetic skew.
461 lines
17 KiB
Go
461 lines
17 KiB
Go
package ecbalancer
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
|
|
)
|
|
|
|
// buildPlaceTopo makes a topology of racks x nodesPerRack, each node one disk with
|
|
// perDiskFree free EC shard slots.
|
|
func buildPlaceTopo(racks, nodesPerRack, perDiskFree int) *Topology {
|
|
topo := NewTopology()
|
|
for r := 0; r < racks; r++ {
|
|
rackKey := fmt.Sprintf("dc1:rack%d", r)
|
|
for n := 0; n < nodesPerRack; n++ {
|
|
id := fmt.Sprintf("10.0.%d.%d:8080", r, n)
|
|
node := topo.AddNode(id, "dc1", rackKey, perDiskFree)
|
|
node.AddDisk(0, "", perDiskFree, 0)
|
|
}
|
|
}
|
|
return topo
|
|
}
|
|
|
|
func allShards() []int {
|
|
out := make([]int, erasure_coding.TotalShardsCount)
|
|
for i := range out {
|
|
out[i] = i
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestPlaceStrictSpreadAndCaps places a fresh 10+4 volume and checks every shard
|
|
// lands on a distinct node and no rack exceeds the even per-type cap.
|
|
func TestPlaceStrictSpreadAndCaps(t *testing.T) {
|
|
const racks = 4
|
|
topo := buildPlaceTopo(racks, 4, 50)
|
|
|
|
res, err := topo.Place(1, "c1", allShards(), Constraints{}, PlaceStrict)
|
|
if err != nil {
|
|
t.Fatalf("Place: %v", err)
|
|
}
|
|
if len(res.Destinations) != erasure_coding.TotalShardsCount {
|
|
t.Fatalf("placed %d shards, want %d", len(res.Destinations), erasure_coding.TotalShardsCount)
|
|
}
|
|
|
|
usedNodes := map[string]bool{}
|
|
dataPerRack := map[string]int{}
|
|
parityPerRack := map[string]int{}
|
|
for sid, d := range res.Destinations {
|
|
if usedNodes[d.Node] {
|
|
t.Errorf("node %s reused for shard %d (expected distinct nodes with ample capacity)", d.Node, sid)
|
|
}
|
|
usedNodes[d.Node] = true
|
|
if sid < erasure_coding.DataShardsCount {
|
|
dataPerRack[d.Rack]++
|
|
} else {
|
|
parityPerRack[d.Rack]++
|
|
}
|
|
}
|
|
dataCap := ceilDivide(erasure_coding.DataShardsCount, racks)
|
|
parityCap := ceilDivide(erasure_coding.ParityShardsCount, racks)
|
|
for rk, n := range dataPerRack {
|
|
if n > dataCap {
|
|
t.Errorf("rack %s holds %d data shards, cap %d", rk, n, dataCap)
|
|
}
|
|
}
|
|
for rk, n := range parityPerRack {
|
|
if n > parityCap {
|
|
t.Errorf("rack %s holds %d parity shards, cap %d", rk, n, parityCap)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPlaceSpreadsAcrossMachines: one rack runs two physical machines (each with
|
|
// four volume servers). Placing a 10+4 volume must spread its shards across both
|
|
// machines so no single machine holds more than ceil(14/2)=7, otherwise losing one
|
|
// box would take out more shards than EC can recover even though they look spread
|
|
// across distinct nodes.
|
|
func TestPlaceSpreadsAcrossMachines(t *testing.T) {
|
|
topo := NewTopology()
|
|
host := map[string]string{}
|
|
add := func(id, h string) {
|
|
n := topo.AddNode(id, "dc1", "dc1:rack0", 50)
|
|
n.SetHost(h)
|
|
n.AddDisk(0, "", 50, 0)
|
|
host[id] = h
|
|
}
|
|
for i := 0; i < 4; i++ {
|
|
add(fmt.Sprintf("a%d", i), "10.0.0.1")
|
|
add(fmt.Sprintf("b%d", i), "10.0.0.2")
|
|
}
|
|
|
|
res, err := topo.Place(1, "c1", allShards(), Constraints{}, PlaceStrict)
|
|
if err != nil {
|
|
t.Fatalf("Place: %v", err)
|
|
}
|
|
perMachine := map[string]int{}
|
|
for _, d := range res.Destinations {
|
|
perMachine[host[d.Node]]++
|
|
}
|
|
if len(perMachine) < 2 {
|
|
t.Fatalf("shards not spread across machines: %v", perMachine)
|
|
}
|
|
maxPerMachine := ceilDivide(erasure_coding.TotalShardsCount, 2)
|
|
for h, c := range perMachine {
|
|
if c > maxPerMachine {
|
|
t.Errorf("machine %s holds %d shards, want <=%d (ceil(14/2))", h, c, maxPerMachine)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPlaceStrictFailsAndRollsBack: a single tiny disk cannot hold 14 shards, so
|
|
// strict Place fails and leaves the snapshot untouched.
|
|
func TestPlaceStrictFailsAndRollsBack(t *testing.T) {
|
|
topo := buildPlaceTopo(1, 1, 2) // one node, room for 2 shards
|
|
node := topo.nodes["10.0.0.0:8080"]
|
|
freeBefore := node.freeSlots
|
|
diskFreeBefore := node.disks[0].freeSlots
|
|
|
|
_, err := topo.Place(1, "c1", allShards(), Constraints{}, PlaceStrict)
|
|
if err == nil {
|
|
t.Fatal("expected Place to fail on insufficient capacity")
|
|
}
|
|
if info, ok := node.shards[volKey{collection: "c1", vid: 1}]; ok && info.shardBits.Count() != 0 {
|
|
t.Errorf("volume shard bits left on node after failed strict Place (rollback incomplete): %b", info.shardBits)
|
|
}
|
|
if node.freeSlots != freeBefore {
|
|
t.Errorf("node freeSlots = %d after rollback, want %d", node.freeSlots, freeBefore)
|
|
}
|
|
if node.disks[0].freeSlots != diskFreeBefore {
|
|
t.Errorf("disk freeSlots = %d after rollback, want %d", node.disks[0].freeSlots, diskFreeBefore)
|
|
}
|
|
}
|
|
|
|
// TestPlaceDurabilityFirstRelaxesRP: a ReplicaPlacement rack limit too tight for
|
|
// the shard count makes strict fail, while durability-first relaxes RP to place
|
|
// everything and reports the relaxation.
|
|
func TestPlaceDurabilityFirstRelaxesRP(t *testing.T) {
|
|
rp := &super_block.ReplicaPlacement{DiffRackCount: 3} // <=3 shards per rack
|
|
topo := buildPlaceTopo(2, 8, 50) // 2 racks: 2*3=6 < 14 under RP
|
|
|
|
if _, err := topo.Place(1, "c1", allShards(), Constraints{ReplicaPlacement: rp}, PlaceStrict); err == nil {
|
|
t.Fatal("strict Place should fail when RP rack limit cannot fit all shards")
|
|
}
|
|
|
|
topo = buildPlaceTopo(2, 8, 50)
|
|
res, err := topo.Place(1, "c1", allShards(), Constraints{ReplicaPlacement: rp}, PlaceDurabilityFirst)
|
|
if err != nil {
|
|
t.Fatalf("durability-first Place: %v", err)
|
|
}
|
|
if len(res.Destinations) != erasure_coding.TotalShardsCount {
|
|
t.Fatalf("placed %d shards, want %d", len(res.Destinations), erasure_coding.TotalShardsCount)
|
|
}
|
|
relaxedRP := false
|
|
for _, r := range res.Relaxed {
|
|
if r == "replica-placement" {
|
|
relaxedRP = true
|
|
}
|
|
}
|
|
if !relaxedRP {
|
|
t.Errorf("expected replica-placement relaxation, got %v", res.Relaxed)
|
|
}
|
|
}
|
|
|
|
// TestPlaceSameRackCountIsDirectPerNodeCap: the 3rd ReplicaPlacement digit
|
|
// (SameRackCount) caps shards per node directly (max == digit), matching the
|
|
// per-rack DiffRackCount cap rather than allowing digit+1 per node.
|
|
func TestPlaceSameRackCountIsDirectPerNodeCap(t *testing.T) {
|
|
rp := &super_block.ReplicaPlacement{SameRackCount: 2} // <=2 shards per node
|
|
|
|
// 5 single-node racks: 5 nodes * 2 = 10 < 14, so a strict 10+4 placement
|
|
// cannot satisfy the per-node cap and must fail. Under the old digit+1 reading
|
|
// the cap would be 3/node => 15 slots and this would have wrongly succeeded.
|
|
topo := buildPlaceTopo(5, 1, 50)
|
|
if _, err := topo.Place(1, "c1", allShards(), Constraints{ReplicaPlacement: rp}, PlaceStrict); err == nil {
|
|
t.Fatal("strict Place should fail: 5 nodes cannot hold 14 shards at <=2 per node")
|
|
}
|
|
|
|
// Durability-first relaxes the unsatisfiable per-node cap, still places every
|
|
// shard, and reports the relaxation so it isn't silently weakened.
|
|
topo = buildPlaceTopo(5, 1, 50)
|
|
res, err := topo.Place(1, "c1", allShards(), Constraints{ReplicaPlacement: rp}, PlaceDurabilityFirst)
|
|
if err != nil {
|
|
t.Fatalf("durability-first Place: %v", err)
|
|
}
|
|
if len(res.Destinations) != erasure_coding.TotalShardsCount {
|
|
t.Fatalf("placed %d shards, want %d", len(res.Destinations), erasure_coding.TotalShardsCount)
|
|
}
|
|
relaxedRP := false
|
|
for _, r := range res.Relaxed {
|
|
if r == "replica-placement" {
|
|
relaxedRP = true
|
|
}
|
|
}
|
|
if !relaxedRP {
|
|
t.Errorf("expected replica-placement relaxation, got %v", res.Relaxed)
|
|
}
|
|
}
|
|
|
|
// TestPlaceDiskTypeHardFilter: with DiskType set, shards land only on disks of
|
|
// that type, even though the snapshot also contains other-typed disks.
|
|
func TestPlaceDiskTypeHardFilter(t *testing.T) {
|
|
topo := NewTopology()
|
|
for r := 0; r < 4; r++ {
|
|
rackKey := fmt.Sprintf("dc1:rack%d", r)
|
|
ssd := topo.AddNode(fmt.Sprintf("ssd-%d:8080", r), "dc1", rackKey, 50)
|
|
ssd.AddDisk(0, "ssd", 50, 0)
|
|
hdd := topo.AddNode(fmt.Sprintf("hdd-%d:8080", r), "dc1", rackKey, 50)
|
|
hdd.AddDisk(0, "hdd", 50, 0)
|
|
}
|
|
|
|
res, err := topo.Place(1, "c1", allShards(), Constraints{DiskType: "ssd", DiskTypePolicy: DiskTypeRequire}, PlaceStrict)
|
|
if err != nil {
|
|
t.Fatalf("Place ssd: %v", err)
|
|
}
|
|
for sid, d := range res.Destinations {
|
|
node := topo.nodes[d.Node]
|
|
disk := node.disks[d.DiskID]
|
|
if disk == nil || disk.diskType != "ssd" {
|
|
t.Errorf("shard %d placed on non-ssd disk: node=%s diskID=%d", sid, d.Node, d.DiskID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPlaceDiskTypeUnavailableFails: a request for a disk type with no matching
|
|
// disks fails rather than silently placing on the wrong tier.
|
|
func TestPlaceDiskTypeUnavailableFails(t *testing.T) {
|
|
topo := NewTopology()
|
|
for r := 0; r < 4; r++ {
|
|
n := topo.AddNode(fmt.Sprintf("hdd-%d:8080", r), "dc1", fmt.Sprintf("dc1:rack%d", r), 50)
|
|
n.AddDisk(0, "hdd", 50, 0)
|
|
}
|
|
if _, err := topo.Place(1, "c1", allShards(), Constraints{DiskType: "ssd", DiskTypePolicy: DiskTypeRequire}, PlaceStrict); err == nil {
|
|
t.Fatal("expected Place to fail when no disks of the requested type exist")
|
|
}
|
|
}
|
|
|
|
// TestPlaceHDDRequestMatchesEmptyTypeDisks: a "hdd" request normalizes to
|
|
// HardDriveType ("") and must land on the HDD disk (reported as ""), never the SSD
|
|
// disk, even on nodes that have both.
|
|
func TestPlaceHDDRequestMatchesEmptyTypeDisks(t *testing.T) {
|
|
topo := NewTopology()
|
|
for r := 0; r < 6; r++ {
|
|
n := topo.AddNode(fmt.Sprintf("n%d:8080", r), "dc1", fmt.Sprintf("dc1:rack%d", r), 100)
|
|
n.AddDisk(0, "", 50, 0) // HDD (HardDriveType, reported as "")
|
|
n.AddDisk(1, "ssd", 50, 0) // SSD
|
|
}
|
|
|
|
res, err := topo.Place(1, "c1", allShards(), Constraints{DiskType: "hdd", DiskTypePolicy: DiskTypeRequire}, PlaceStrict)
|
|
if err != nil {
|
|
t.Fatalf("Place hdd: %v", err)
|
|
}
|
|
for sid, d := range res.Destinations {
|
|
if d.DiskID != 0 { // disk 0 is the HDD disk on every node
|
|
t.Errorf("shard %d placed on disk %d (expected HDD disk 0) on node %s", sid, d.DiskID, d.Node)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPlaceDurabilityCapRejectsSkewed: in a near-full cluster where only one disk
|
|
// has spare room, Place must not pile more than parityShards shards onto it (losing
|
|
// it would then lose more than EC can recover). It fails instead, so the caller
|
|
// leaves the volume unencoded rather than minting an unrecoverable layout.
|
|
func TestPlaceDurabilityCapRejectsSkewed(t *testing.T) {
|
|
topo := NewTopology()
|
|
// One spacious node plus four nearly-full ones, all in a single rack.
|
|
a := topo.AddNode("a:8080", "dc1", "dc1:rack0", 100)
|
|
a.AddDisk(0, "", 100, 0)
|
|
for i := 0; i < 4; i++ {
|
|
n := topo.AddNode(fmt.Sprintf("b%d:8080", i), "dc1", "dc1:rack0", 1)
|
|
n.AddDisk(0, "", 1, 0)
|
|
}
|
|
|
|
// 14 shards, parity 4: node a is capped at 4, the others hold 1 each -> at most
|
|
// 4+4=8 placeable without exceeding parityShards on a disk, so Place must fail.
|
|
// (Without the per-disk cap, a would greedily absorb 10 shards and "succeed".)
|
|
if _, err := topo.Place(1, "c1", allShards(), Constraints{}, PlaceDurabilityFirst); err == nil {
|
|
t.Fatal("expected Place to fail rather than pile >parityShards shards on one disk")
|
|
}
|
|
}
|
|
|
|
// TestReleaseVolumeShards: removes all of a volume's shards from the snapshot and
|
|
// credits the freed capacity at BOTH disk and node level (rack capacity sums node
|
|
// freeSlots), mirroring how FromActiveTopology accounts stale shards.
|
|
func TestReleaseVolumeShards(t *testing.T) {
|
|
topo := NewTopology()
|
|
// Node total 20 = two disks of 10. Two stale shards occupy one slot each, so the
|
|
// snapshot would show disk0/disk1 at 9 and node at 18 (as FromActiveTopology does).
|
|
n := topo.AddNode("n0:8080", "dc1", "dc1:rack0", 20)
|
|
n.AddDisk(0, "", 10, 0)
|
|
n.AddDisk(1, "", 10, 0)
|
|
vk := volKey{collection: "c1", vid: 1}
|
|
n.AddShards(1, "c1", 0, erasure_coding.ShardBits(uint32(1)<<3))
|
|
n.AddShards(1, "c1", 1, erasure_coding.ShardBits(uint32(1)<<7))
|
|
n.disks[0].freeSlots = 9
|
|
n.disks[1].freeSlots = 9
|
|
n.freeSlots = 18
|
|
|
|
topo.ReleaseVolumeShards("c1", 1)
|
|
|
|
if _, ok := n.shards[vk]; ok {
|
|
t.Error("volume shards should be gone after ReleaseVolumeShards")
|
|
}
|
|
if n.disks[0].freeSlots != 10 || n.disks[1].freeSlots != 10 {
|
|
t.Errorf("disk freeSlots not restored: disk0=%d, disk1=%d (want 10, 10)", n.disks[0].freeSlots, n.disks[1].freeSlots)
|
|
}
|
|
if n.freeSlots != 20 {
|
|
t.Errorf("node freeSlots = %d, want 20 (must be credited at node level too)", n.freeSlots)
|
|
}
|
|
}
|
|
|
|
// TestClearShardAccounting: dropping one disk's copy of a shard preserves a kept
|
|
// copy of the same shard on another disk of the same node, and credits no capacity.
|
|
func TestClearShardAccounting(t *testing.T) {
|
|
topo := NewTopology()
|
|
n := topo.AddNode("n0:8080", "dc1", "dc1:rack0", 50)
|
|
n.AddDisk(0, "", 50, 0)
|
|
n.AddDisk(1, "", 50, 0)
|
|
vk := volKey{collection: "c1", vid: 1}
|
|
// Shard 3 lives on disk 0 (keep) and disk 1 (duplicate to delete).
|
|
n.AddShards(1, "c1", 0, erasure_coding.ShardBits(uint32(1)<<3))
|
|
n.AddShards(1, "c1", 1, erasure_coding.ShardBits(uint32(1)<<3))
|
|
if got := n.shards[vk].shardBits.Count(); got != 1 {
|
|
t.Fatalf("union count = %d, want 1", got)
|
|
}
|
|
freeBefore := n.disks[1].freeSlots
|
|
|
|
clearShardAccounting(n, vk, 3, 1)
|
|
|
|
if !n.shards[vk].shardBits.Has(erasure_coding.ShardId(3)) {
|
|
t.Error("kept copy of shard 3 (disk 0) lost from the node-level union")
|
|
}
|
|
if n.shards[vk].diskShardBits[1].Has(erasure_coding.ShardId(3)) {
|
|
t.Error("disk-1 copy of shard 3 was not cleared")
|
|
}
|
|
if n.disks[1].freeSlots != freeBefore {
|
|
t.Errorf("freeSlots changed %d -> %d; clearShardAccounting must not credit capacity", freeBefore, n.disks[1].freeSlots)
|
|
}
|
|
}
|
|
|
|
// TestPlaceDiskTypePreferSpills: DiskTypePrefer fills the preferred type first and
|
|
// spills the remainder to other types, reporting SpilledToOtherDiskType. SSD is
|
|
// scarce (one tiny SSD per node) so the volume must spill to HDD, but there are
|
|
// enough disks to keep each within the parityShards durability cap.
|
|
func TestPlaceDiskTypePreferSpills(t *testing.T) {
|
|
topo := NewTopology()
|
|
for r := 0; r < 8; r++ {
|
|
n := topo.AddNode(fmt.Sprintf("n%d:8080", r), "dc1", fmt.Sprintf("dc1:rack%d", r), 100)
|
|
n.AddDisk(0, "ssd", 1, 0) // tiny SSD: 1 shard
|
|
n.AddDisk(1, "", 50, 0) // roomy HDD
|
|
}
|
|
|
|
res, err := topo.Place(1, "c1", allShards(), Constraints{DiskType: "ssd", DiskTypePolicy: DiskTypePrefer}, PlaceDurabilityFirst)
|
|
if err != nil {
|
|
t.Fatalf("Place: %v", err)
|
|
}
|
|
if len(res.Destinations) != erasure_coding.TotalShardsCount {
|
|
t.Fatalf("placed %d, want %d", len(res.Destinations), erasure_coding.TotalShardsCount)
|
|
}
|
|
ssd, hdd := 0, 0
|
|
for _, d := range res.Destinations {
|
|
if topo.nodes[d.Node].disks[d.DiskID].diskType == "ssd" {
|
|
ssd++
|
|
} else {
|
|
hdd++
|
|
}
|
|
}
|
|
if ssd == 0 || hdd == 0 {
|
|
t.Errorf("expected prefer-then-spill: some shards on SSD and some on HDD, got ssd=%d hdd=%d", ssd, hdd)
|
|
}
|
|
if !res.SpilledToOtherDiskType {
|
|
t.Error("expected SpilledToOtherDiskType when SSD cannot hold every shard")
|
|
}
|
|
}
|
|
|
|
// TestPlacePreferredTagsUseTaggedDisks: when tagged disks can hold the whole plan,
|
|
// every shard lands on a tagged disk and no spill is reported.
|
|
func TestPlacePreferredTagsUseTaggedDisks(t *testing.T) {
|
|
topo := NewTopology()
|
|
for r := 0; r < 4; r++ {
|
|
rackKey := fmt.Sprintf("dc1:rack%d", r)
|
|
fast := topo.AddNode(fmt.Sprintf("fast-%d:8080", r), "dc1", rackKey, 50)
|
|
fast.AddDisk(0, "", 50, 0)
|
|
fast.AddDiskTags(0, []string{"fast"})
|
|
topo.AddNode(fmt.Sprintf("slow-%d:8080", r), "dc1", rackKey, 50).AddDisk(0, "", 50, 0)
|
|
}
|
|
|
|
res, err := topo.Place(1, "c1", allShards(), Constraints{PreferredTags: []string{"fast"}}, PlaceStrict)
|
|
if err != nil {
|
|
t.Fatalf("Place: %v", err)
|
|
}
|
|
for sid, d := range res.Destinations {
|
|
if !diskHasAnyTag(topo.nodes[d.Node].disks[d.DiskID], []string{"fast"}) {
|
|
t.Errorf("shard %d placed on an untagged disk (node %s)", sid, d.Node)
|
|
}
|
|
}
|
|
if res.SpilledOutsidePreferredTags {
|
|
t.Error("did not expect tag spill when tagged disks suffice")
|
|
}
|
|
}
|
|
|
|
// TestPlacePreferredTagsSpillWhenInsufficient: when the tagged tier cannot hold the
|
|
// whole plan, Place falls back to all disks and reports SpilledOutsidePreferredTags.
|
|
func TestPlacePreferredTagsSpillWhenInsufficient(t *testing.T) {
|
|
topo := NewTopology()
|
|
for r := 0; r < 4; r++ {
|
|
n := topo.AddNode(fmt.Sprintf("n-%d:8080", r), "dc1", fmt.Sprintf("dc1:rack%d", r), 50)
|
|
if r == 0 {
|
|
n.AddDisk(0, "", 5, 0) // the only tagged disk, too small for 14 shards
|
|
n.AddDiskTags(0, []string{"fast"})
|
|
} else {
|
|
n.AddDisk(0, "", 50, 0)
|
|
}
|
|
}
|
|
|
|
res, err := topo.Place(1, "c1", allShards(), Constraints{PreferredTags: []string{"fast"}}, PlaceStrict)
|
|
if err != nil {
|
|
t.Fatalf("Place: %v", err)
|
|
}
|
|
if len(res.Destinations) != erasure_coding.TotalShardsCount {
|
|
t.Fatalf("placed %d, want %d", len(res.Destinations), erasure_coding.TotalShardsCount)
|
|
}
|
|
if !res.SpilledOutsidePreferredTags {
|
|
t.Error("expected SpilledOutsidePreferredTags when the single fast disk cannot hold the plan")
|
|
}
|
|
}
|
|
|
|
// TestPlaceStrictCapsCountEligibleRacks: with DiskTypeRequire, the even per-rack
|
|
// cap divides by racks that have a matching disk, not all racks, so SSDs in only
|
|
// some racks still place successfully.
|
|
func TestPlaceStrictCapsCountEligibleRacks(t *testing.T) {
|
|
topo := NewTopology()
|
|
// SSDs live in only 2 of 4 racks, with several SSD nodes per rack so 14 shards
|
|
// fit at <= parityShards per disk. The even per-rack cap must divide by the 2
|
|
// eligible racks (ceil(10/2)=5 data/rack), not all 4 (ceil(10/4)=3 -> infeasible).
|
|
for r := 0; r < 4; r++ {
|
|
rackKey := fmt.Sprintf("dc1:rack%d", r)
|
|
topo.AddNode(fmt.Sprintf("hdd-%d:8080", r), "dc1", rackKey, 50).AddDisk(0, "", 50, 0)
|
|
if r < 2 {
|
|
for n := 0; n < 4; n++ {
|
|
topo.AddNode(fmt.Sprintf("ssd-%d-%d:8080", r, n), "dc1", rackKey, 50).AddDisk(0, "ssd", 50, 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
res, err := topo.Place(1, "c1", allShards(), Constraints{DiskType: "ssd", DiskTypePolicy: DiskTypeRequire}, PlaceStrict)
|
|
if err != nil {
|
|
t.Fatalf("Place ssd in 2/4 racks: %v", err)
|
|
}
|
|
if len(res.Destinations) != erasure_coding.TotalShardsCount {
|
|
t.Fatalf("placed %d, want %d", len(res.Destinations), erasure_coding.TotalShardsCount)
|
|
}
|
|
for sid, d := range res.Destinations {
|
|
if disk := topo.nodes[d.Node].disks[d.DiskID]; disk == nil || disk.diskType != "ssd" {
|
|
t.Errorf("shard %d not on an SSD disk: node=%s disk=%d", sid, d.Node, d.DiskID)
|
|
}
|
|
}
|
|
}
|