mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-14 10:30:45 +02:00
* fix(topology): keep physical disk 0 distinct in SplitByPhysicalDisk
DiskId 0 doubles as the first physical disk (Locations[0]) and the
protobuf "unset" default. SplitByPhysicalDisk folded every DiskId-0
record onto the aggregate DiskId whenever that was non-zero, so on a
multi-disk node the first disk's volumes merged into whichever disk
held volumes[0]: the node reported one fewer disk, the sibling showed
~2x volumes, and per-disk max was smeared across the survivors. This
surfaced as cluster.status and volume.list undercounting disks.
Only treat 0 as unset when no record carries a non-zero DiskId; with a
mix, 0 is a real disk and keeps its own entry.
* fix(admin): resolve physical disk 0 in active-topology indexes
rebuildIndexes re-derived each volume/EC record's physical disk id with
the same "DiskId 0 means unset" heuristic SplitByPhysicalDisk used, so
the two agreed only by sharing the bug. Now that SplitByPhysicalDisk
keeps disk 0 distinct, the duplicated heuristic would fold disk-0 records
onto a sibling while at.disks kept them on disk 0; GetVolumeLocations and
GetECShardLocations then matched no record and silently dropped every
volume and EC shard on the first disk, starving balance and EC tasks.
Build the indexes from the same SplitByPhysicalDisk reconstruction that
builds at.disks, so the keys always resolve. One source of truth instead
of a parallel normalize.
* fix(ec): allow physical disk 0 as preferred EC shard target
pickBestDiskOnNode gated its result on bestDiskId != 0, but 0 is both a
valid physical disk and the uint32 zero value, so a best-scoring disk 0
was discarded and the non-matching fallback returned instead. Gate on
bestScore.
* test(admin): cover EC-shard index resolution for physical disk 0
rebuildIndexes builds ecShardIndex the same way as volumeIndex; pin the EC
path too so a shard on disk 0 keeps resolving via GetECShardLocations.
* proto: per-disk type/capacity in DiskTag, DiskInfo.physical_disks
DiskTag gains type + max_volume_count so the heartbeat can describe every
physical disk, including ones holding no volumes or EC shards. DiskInfo
gains physical_disks so the master can hand the full per-type disk set to
per-physical-disk consumers.
* feat(volume): report each physical disk's type and capacity
CollectHeartbeat fills DiskTag.type and the per-disk effective max for
every location, so the master can account for disks that hold no volumes
or EC shards yet. Rust heartbeat mirrors it.
* feat(master): surface empty disks in the per-physical-disk view
The master records each disk's type and max from DiskTags and lists them
on DiskInfo.physical_disks per type, including disks with no volumes or
EC shards. SplitByPhysicalDisk enumerates that full set and gives each
disk its exact max, so cluster.status, volume.list and the admin
topology count and can target empty disks. Without physical_disks the
even-split fallback is unchanged.
* fix(master): clamp per-disk free at zero for over-allocated disks
In the exact-max path FreeVolumeCount could go negative when a disk holds
more volumes than its max; a negative would reduce the node's summed free
and block placement on healthy disks. Clamp at 0.
* fix(master): rebuild disk tags fresh each heartbeat
DiskTags is the full authoritative per-disk list every heartbeat, so
rebuild dn.diskTags from scratch like dn.diskBackends; merging left stale
entries for removed disks.
* fix(master): keep zero-capacity disks in physical_disks
A disk reporting max 0 (an unavailable disk) is a valid physical disk,
not a signal to drop it. List every disk of the type, but only emit
physical_disks when the node reports real per-disk capacity, so an older
server sending all zeros still falls back to the aggregate split.
* test(volume): cover disk-space-low per-disk max in heartbeat
Assert DiskTag.max_volume_count follows the used-slots override when a
location is low on space, matching the per-type max_volume_counts.
* chore: trim comments on the empty-disk change
Drop narration; keep only the non-obvious why (disk-0 sentinel, exact-max
free clamp, EC slots not subtracted, all-zeros fallback).
* refactor(master): merge per-disk tags and capacity into one map
diskTags and diskBackends were parallel maps keyed by the same DiskId and
filled together from DiskTags. Fold them into one diskMetas map of
{tags, type, max}.
* refactor(proto): per-disk max as a map keyed by disk id
physical_disks was a repeated {disk_id, max_volume_count} whose fields
duplicated DiskInfo's own disk_id/max_volume_count. A map<uint32,int64>
keyed by disk id expresses "max per disk" directly, drops the extra
PhysicalDiskInfo message, and the consumer reads it as the disk set.
* docs(proto): note DiskInfo.disk_id's two meanings
Identity on a per-physical-disk DiskInfo (from SplitByPhysicalDisk),
representative fallback on the type-keyed aggregate.
339 lines
9.3 KiB
Go
339 lines
9.3 KiB
Go
package master_pb
|
|
|
|
import (
|
|
"sort"
|
|
"testing"
|
|
)
|
|
|
|
// MaxVolumeCountByDisk surfaces empty disks and gives each its exact max.
|
|
func TestDiskInfoSplitByPhysicalDisk_includesEmptyDiskFromPhysicalDisks(t *testing.T) {
|
|
d := &DiskInfo{
|
|
Type: "hdd",
|
|
MaxVolumeCount: 1000, // per-type aggregate
|
|
VolumeInfos: []*VolumeInformationMessage{
|
|
{Id: 10, DiskId: 0},
|
|
{Id: 11, DiskId: 1},
|
|
},
|
|
MaxVolumeCountByDisk: map[uint32]int64{
|
|
0: 350,
|
|
1: 350,
|
|
2: 300, // empty disk, no volumes/shards
|
|
},
|
|
}
|
|
|
|
got := d.SplitByPhysicalDisk()
|
|
if len(got) != 3 {
|
|
t.Fatalf("want 3 physical disks including the empty one, got %d", len(got))
|
|
}
|
|
byID := map[uint32]*DiskInfo{}
|
|
for _, di := range got {
|
|
byID[di.DiskId] = di
|
|
}
|
|
|
|
empty, ok := byID[2]
|
|
if !ok {
|
|
t.Fatalf("empty disk 2 not surfaced; got %v", byID)
|
|
}
|
|
if empty.VolumeCount != 0 {
|
|
t.Errorf("empty disk: want 0 volumes, got %d", empty.VolumeCount)
|
|
}
|
|
if empty.FreeVolumeCount != 300 {
|
|
t.Errorf("empty disk free: want its full max 300, got %d", empty.FreeVolumeCount)
|
|
}
|
|
|
|
// Exact per-disk max, not the even split (which would give ~334/333/333).
|
|
if byID[0].MaxVolumeCount != 350 || byID[1].MaxVolumeCount != 350 || byID[2].MaxVolumeCount != 300 {
|
|
t.Errorf("want exact per-disk max 350/350/300, got %d/%d/%d",
|
|
byID[0].MaxVolumeCount, byID[1].MaxVolumeCount, byID[2].MaxVolumeCount)
|
|
}
|
|
if byID[0].FreeVolumeCount != 349 {
|
|
t.Errorf("disk 0 free: want 349 (350-1 volume), got %d", byID[0].FreeVolumeCount)
|
|
}
|
|
}
|
|
|
|
// An over-allocated disk reports zero free, not negative.
|
|
func TestDiskInfoSplitByPhysicalDisk_clampsNegativeFreeOnOverAllocation(t *testing.T) {
|
|
d := &DiskInfo{
|
|
Type: "hdd",
|
|
VolumeInfos: []*VolumeInformationMessage{
|
|
{Id: 1, DiskId: 0},
|
|
{Id: 2, DiskId: 0},
|
|
{Id: 3, DiskId: 0},
|
|
},
|
|
MaxVolumeCountByDisk: map[uint32]int64{
|
|
0: 2, // 3 volumes on a max-2 disk
|
|
},
|
|
}
|
|
|
|
got := d.SplitByPhysicalDisk()
|
|
if len(got) != 1 {
|
|
t.Fatalf("want 1 disk, got %d", len(got))
|
|
}
|
|
if got[0].FreeVolumeCount != 0 {
|
|
t.Errorf("over-allocated disk free: want clamped 0, got %d", got[0].FreeVolumeCount)
|
|
}
|
|
}
|
|
|
|
func TestDiskInfoSplitByPhysicalDisk_collapsesOnSingleDisk(t *testing.T) {
|
|
d := &DiskInfo{
|
|
Type: "hdd",
|
|
MaxVolumeCount: 10,
|
|
VolumeInfos: []*VolumeInformationMessage{
|
|
{Id: 1, DiskId: 0},
|
|
{Id: 2, DiskId: 0},
|
|
},
|
|
DiskId: 0,
|
|
}
|
|
|
|
got := d.SplitByPhysicalDisk()
|
|
if len(got) != 1 {
|
|
t.Fatalf("want 1 split disk, got %d", len(got))
|
|
}
|
|
if got[0] != d {
|
|
t.Errorf("single-disk input should be returned unchanged; got a copy")
|
|
}
|
|
}
|
|
|
|
func TestDiskInfoSplitByPhysicalDisk_splitsByVolumeDiskId(t *testing.T) {
|
|
d := &DiskInfo{
|
|
Type: "hdd",
|
|
MaxVolumeCount: 60,
|
|
FreeVolumeCount: 30,
|
|
ActiveVolumeCount: 12,
|
|
VolumeInfos: []*VolumeInformationMessage{
|
|
{Id: 10, DiskId: 0},
|
|
{Id: 11, DiskId: 0},
|
|
{Id: 20, DiskId: 1},
|
|
{Id: 21, DiskId: 2},
|
|
{Id: 22, DiskId: 2},
|
|
{Id: 23, DiskId: 2},
|
|
},
|
|
}
|
|
|
|
got := d.SplitByPhysicalDisk()
|
|
if len(got) != 3 {
|
|
t.Fatalf("want 3 split disks, got %d", len(got))
|
|
}
|
|
|
|
byID := map[uint32]*DiskInfo{}
|
|
for _, di := range got {
|
|
byID[di.DiskId] = di
|
|
}
|
|
for _, want := range []uint32{0, 1, 2} {
|
|
if _, ok := byID[want]; !ok {
|
|
t.Errorf("missing DiskId=%d in split result", want)
|
|
}
|
|
}
|
|
|
|
if byID[0].VolumeCount != 2 {
|
|
t.Errorf("disk 0: want 2 volumes, got %d", byID[0].VolumeCount)
|
|
}
|
|
if byID[1].VolumeCount != 1 {
|
|
t.Errorf("disk 1: want 1 volume, got %d", byID[1].VolumeCount)
|
|
}
|
|
if byID[2].VolumeCount != 3 {
|
|
t.Errorf("disk 2: want 3 volumes, got %d", byID[2].VolumeCount)
|
|
}
|
|
|
|
// Capacity is split evenly across the reconstructed disks. With 3 disks
|
|
// and a max of 60, every reconstructed disk gets 20.
|
|
for id, di := range byID {
|
|
if di.MaxVolumeCount != 20 {
|
|
t.Errorf("disk %d: want MaxVolumeCount=20 (60/3), got %d", id, di.MaxVolumeCount)
|
|
}
|
|
}
|
|
|
|
// Disk type is preserved on every reconstructed entry so writers that
|
|
// label by type still see "hdd".
|
|
for id, di := range byID {
|
|
if di.Type != "hdd" {
|
|
t.Errorf("disk %d: want Type=hdd, got %q", id, di.Type)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDiskInfoSplitByPhysicalDisk_preservesAggregateCapacityWithRemainder
|
|
// pins the invariant that the sum of reconstructed counters equals the
|
|
// original aggregate, even when the count does not divide evenly.
|
|
func TestDiskInfoSplitByPhysicalDisk_preservesAggregateCapacityWithRemainder(t *testing.T) {
|
|
d := &DiskInfo{
|
|
Type: "hdd",
|
|
MaxVolumeCount: 10,
|
|
FreeVolumeCount: 7,
|
|
VolumeInfos: []*VolumeInformationMessage{
|
|
{Id: 1, DiskId: 0},
|
|
{Id: 2, DiskId: 1},
|
|
{Id: 3, DiskId: 2},
|
|
},
|
|
}
|
|
|
|
got := d.SplitByPhysicalDisk()
|
|
if len(got) != 3 {
|
|
t.Fatalf("want 3 disks, got %d", len(got))
|
|
}
|
|
|
|
var sumMax, sumFree int64
|
|
for _, di := range got {
|
|
sumMax += di.MaxVolumeCount
|
|
sumFree += di.FreeVolumeCount
|
|
}
|
|
if sumMax != 10 {
|
|
t.Errorf("sum of MaxVolumeCount = %d, want 10 (lossless split)", sumMax)
|
|
}
|
|
if sumFree != 7 {
|
|
t.Errorf("sum of FreeVolumeCount = %d, want 7 (lossless split)", sumFree)
|
|
}
|
|
}
|
|
|
|
// TestDiskInfoSplitByPhysicalDisk_countsActiveAndRemoteExactly verifies
|
|
// the per-disk ActiveVolumeCount and RemoteVolumeCount are derived from
|
|
// the actual VolumeInfos rather than an even split of the node totals.
|
|
func TestDiskInfoSplitByPhysicalDisk_countsActiveAndRemoteExactly(t *testing.T) {
|
|
d := &DiskInfo{
|
|
Type: "hdd",
|
|
VolumeInfos: []*VolumeInformationMessage{
|
|
{Id: 1, DiskId: 0, ReadOnly: false},
|
|
{Id: 2, DiskId: 0, ReadOnly: true},
|
|
{Id: 3, DiskId: 1, ReadOnly: false, RemoteStorageName: "s3"},
|
|
{Id: 4, DiskId: 2, ReadOnly: false},
|
|
{Id: 5, DiskId: 2, ReadOnly: false, RemoteStorageName: "s3"},
|
|
},
|
|
}
|
|
|
|
got := d.SplitByPhysicalDisk()
|
|
byID := map[uint32]*DiskInfo{}
|
|
for _, di := range got {
|
|
byID[di.DiskId] = di
|
|
}
|
|
|
|
cases := []struct {
|
|
id uint32
|
|
wantActive int64
|
|
wantRemote int64
|
|
}{
|
|
{0, 1, 0},
|
|
{1, 1, 1},
|
|
{2, 2, 1},
|
|
}
|
|
for _, c := range cases {
|
|
di := byID[c.id]
|
|
if di == nil {
|
|
t.Errorf("missing disk %d", c.id)
|
|
continue
|
|
}
|
|
if di.ActiveVolumeCount != c.wantActive {
|
|
t.Errorf("disk %d: ActiveVolumeCount = %d, want %d", c.id, di.ActiveVolumeCount, c.wantActive)
|
|
}
|
|
if di.RemoteVolumeCount != c.wantRemote {
|
|
t.Errorf("disk %d: RemoteVolumeCount = %d, want %d", c.id, di.RemoteVolumeCount, c.wantRemote)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDiskInfoSplitByPhysicalDisk_splitsByEcShardDiskId(t *testing.T) {
|
|
d := &DiskInfo{
|
|
Type: "hdd",
|
|
EcShardInfos: []*VolumeEcShardInformationMessage{
|
|
{Id: 100, DiskId: 4},
|
|
{Id: 101, DiskId: 4},
|
|
{Id: 102, DiskId: 5},
|
|
},
|
|
}
|
|
|
|
got := d.SplitByPhysicalDisk()
|
|
|
|
ids := make([]int, 0, len(got))
|
|
for _, di := range got {
|
|
ids = append(ids, int(di.DiskId))
|
|
}
|
|
sort.Ints(ids)
|
|
if len(ids) != 2 || ids[0] != 4 || ids[1] != 5 {
|
|
t.Fatalf("want split disk ids [4,5], got %v", ids)
|
|
}
|
|
}
|
|
|
|
func TestDiskInfoSplitByPhysicalDisk_normalizesZeroToOuterDiskId(t *testing.T) {
|
|
// Older payloads / fixtures omit the per-record DiskId. The outer DiskId
|
|
// is the authoritative fallback.
|
|
d := &DiskInfo{
|
|
Type: "hdd",
|
|
DiskId: 7,
|
|
VolumeInfos: []*VolumeInformationMessage{
|
|
{Id: 1, DiskId: 0}, // normalize to 7
|
|
{Id: 2, DiskId: 0}, // normalize to 7
|
|
},
|
|
}
|
|
got := d.SplitByPhysicalDisk()
|
|
if len(got) != 1 {
|
|
t.Fatalf("want 1 disk, got %d", len(got))
|
|
}
|
|
if got[0].DiskId != 7 {
|
|
t.Errorf("want DiskId=7 after normalization, got %d", got[0].DiskId)
|
|
}
|
|
}
|
|
|
|
// TestDiskInfoSplitByPhysicalDisk_keepsRealDiskZeroWhenMixed reproduces a
|
|
// multi-disk node where physical disk 0 (Locations[0]) holds volumes and the
|
|
// aggregate DiskId is seeded from a non-zero sibling (volumes[0] landed on
|
|
// disk 6). DiskId 0 must stay its own physical disk, not get folded onto disk
|
|
// 6 — folding drops a disk from the count and doubles the sibling's volumes.
|
|
func TestDiskInfoSplitByPhysicalDisk_keepsRealDiskZeroWhenMixed(t *testing.T) {
|
|
d := &DiskInfo{
|
|
Type: "hdd",
|
|
MaxVolumeCount: 30,
|
|
DiskId: 6, // seeded from volumes[0].DiskId in ToDiskInfo
|
|
VolumeInfos: []*VolumeInformationMessage{
|
|
{Id: 60, DiskId: 6},
|
|
{Id: 61, DiskId: 6},
|
|
{Id: 1, DiskId: 0}, // real disk 0, must not be remapped to 6
|
|
{Id: 2, DiskId: 0},
|
|
{Id: 30, DiskId: 3},
|
|
},
|
|
}
|
|
|
|
got := d.SplitByPhysicalDisk()
|
|
byID := map[uint32]*DiskInfo{}
|
|
for _, di := range got {
|
|
byID[di.DiskId] = di
|
|
}
|
|
|
|
if len(got) != 3 {
|
|
t.Fatalf("want 3 physical disks (0, 3, 6), got %d: %v", len(got), byID)
|
|
}
|
|
if _, ok := byID[0]; !ok {
|
|
t.Fatalf("physical disk 0 was dropped; got disks %v", byID)
|
|
}
|
|
if byID[0].VolumeCount != 2 {
|
|
t.Errorf("disk 0: want 2 volumes, got %d", byID[0].VolumeCount)
|
|
}
|
|
if byID[6].VolumeCount != 2 {
|
|
t.Errorf("disk 6: want 2 volumes (not merged with disk 0), got %d", byID[6].VolumeCount)
|
|
}
|
|
if byID[3].VolumeCount != 1 {
|
|
t.Errorf("disk 3: want 1 volume, got %d", byID[3].VolumeCount)
|
|
}
|
|
|
|
var sumMax int64
|
|
for _, di := range got {
|
|
sumMax += di.MaxVolumeCount
|
|
}
|
|
if sumMax != 30 {
|
|
t.Errorf("sum of MaxVolumeCount = %d, want 30 (lossless split)", sumMax)
|
|
}
|
|
}
|
|
|
|
func TestDiskInfoSplitByPhysicalDisk_nilSafe(t *testing.T) {
|
|
var d *DiskInfo
|
|
if got := d.SplitByPhysicalDisk(); got != nil {
|
|
t.Errorf("nil receiver should return nil slice, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestDiskInfoSplitByPhysicalDisk_emptyDiskReturnsSelf(t *testing.T) {
|
|
d := &DiskInfo{Type: "hdd", MaxVolumeCount: 10, DiskId: 3}
|
|
got := d.SplitByPhysicalDisk()
|
|
if len(got) != 1 || got[0] != d {
|
|
t.Errorf("empty disk should be passed through unchanged; got %v", got)
|
|
}
|
|
}
|