Files
seaweedfs/weed/topology/collection_statistics_test.go
T
Chris Lu a2ff9cca27 master: let VolumeList ask for the volumes it wants (#10674)
* master: let VolumeList ask for the volumes it wants

The request carried nothing, so every caller was answered with the whole
cluster. A dashboard opening one volume's page, or a capacity probe adding up
one bucket, was served all 800k of them and threw away the rest -- and the
master built every one of those messages first.

The topology, its disks and their counters are still reported in full: a caller
reading free space or replica placement needs the cluster whichever volumes it
asked about. Only what is listed under a disk is selected, ec shards included.

An empty collection and a zero volume id take everything, the way volume.list
already reads its own -collectionPattern and -volumeId, so a caller that
forgets to narrow is answered too much rather than answered wrongly. That
leaves the default collection unnameable, since it is the one the empty string
names, so it gets a field of its own.

An older client sends none of it and is answered exactly as before.

* admin: ask the master for the volume the page is showing

A volume's detail page was pulling every volume in the cluster to find one and
its replicas, and discarding the rest.

* admin: ask the master for the ec volume the page is showing

Same as the volume detail page: one volume's shards were found by pulling every
ec shard in the cluster.

* s3: ask the master for the bucket's own collection

The SOSAPI capacity probe summed one collection's volumes out of a listing of
every volume in the cluster. Cluster capacity still comes out the same: it is
read from the disk counters, which a filtered listing reports in full.

* topology: read the disk usage counters atomically

They are written with atomic.AddInt64 from heartbeats but were read plainly by
the two listings and by FreeSpace, and the map they sit in was iterated without
the lock its neighbour takes. Under -race a listing concurrent with a heartbeat
trips on both.
2026-08-09 21:59:42 -07:00

310 lines
12 KiB
Go

package topology
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
)
// referenceCollectionStatistics is what callers computed for themselves from a
// full topology listing, kept here so the summary can be held to producing the
// same numbers. Quota enforcement reads these.
func referenceCollectionStatistics(t *master_pb.TopologyInfo) map[string]*CollectionStatistics {
type volumeKey struct {
collection string
volumeId uint32
}
out := map[string]*CollectionStatistics{}
seen := map[volumeKey]bool{}
ecCounts := map[volumeKey]*ecFileCounts{}
statsFor := func(c string) *CollectionStatistics {
s, ok := out[c]
if !ok {
s = &CollectionStatistics{Collection: c}
out[c] = s
}
return s
}
for _, dc := range t.DataCenterInfos {
for _, r := range dc.RackInfos {
for _, dn := range r.DataNodeInfos {
for _, diskInfo := range dn.DiskInfos {
for _, vi := range diskInfo.VolumeInfos {
s := statsFor(vi.Collection)
s.PhysicalSize += vi.Size
key := volumeKey{vi.Collection, vi.Id}
if seen[key] {
continue
}
seen[key] = true
s.Size += vi.Size
s.FileCount += vi.FileCount
s.DeleteCount += vi.DeleteCount
s.DeletedByteCount += vi.DeletedByteCount
s.VolumeCount++
}
for _, esi := range diskInfo.EcShardInfos {
s := statsFor(esi.Collection)
s.PhysicalSize += uint64(erasure_coding.EcShardsTotalSize(esi))
s.Size += uint64(erasure_coding.EcShardsDataSize(esi, 0))
key := volumeKey{esi.Collection, esi.Id}
agg, ok := ecCounts[key]
if !ok {
agg = &ecFileCounts{collection: esi.Collection}
ecCounts[key] = agg
s.VolumeCount++
}
if esi.FileCount > agg.fileCount {
agg.fileCount = esi.FileCount
}
agg.deleteCount += esi.DeleteCount
}
}
}
}
}
for _, agg := range ecCounts {
if s := out[agg.collection]; s != nil {
s.FileCount += agg.fileCount
s.DeleteCount += agg.deleteCount
}
}
return out
}
func statsTopology(t *testing.T) *Topology {
t.Helper()
topo := NewTopology("stats", nil, 32*1024*1024*1024, 5, false)
rack := topo.GetOrCreateDataCenter("dc1").GetOrCreateRack("rack1")
nodes := make([]*DataNode, 3)
for i := range nodes {
nodes[i] = rack.GetOrCreateDataNode(
"10.0.0."+string(rune('1'+i)), 8080, 18080, "", "", map[string]uint32{"": 1000, "ssd": 1000})
}
// Two collections, replicated volumes, one volume on a second disk type.
// Replicas agree here; disagreement is covered on its own, where the
// listing this is compared against is itself order dependent.
report := func(dn *DataNode, msgs ...*master_pb.VolumeInformationMessage) {
topo.SyncDataNodeRegistration(msgs, dn)
}
vol := func(id uint32, collection string, size uint64, diskType string) *master_pb.VolumeInformationMessage {
return &master_pb.VolumeInformationMessage{
Id: id, Collection: collection, Size: size, FileCount: uint64(id) * 10,
DeleteCount: uint64(id), DeletedByteCount: uint64(id) * 100,
Version: 3, ReplicaPlacement: 1, DiskType: diskType,
}
}
report(nodes[0], vol(1, "bucket-a", 1000, ""), vol(2, "bucket-a", 2000, ""), vol(5, "bucket-b", 700, "ssd"))
report(nodes[1], vol(1, "bucket-a", 1000, ""), vol(3, "bucket-b", 3000, ""))
report(nodes[2], vol(2, "bucket-a", 2000, ""), vol(3, "bucket-b", 3000, ""))
// Ec shards for a third collection, spread over two nodes, with the file
// count reported differently by each holder.
topo.SyncDataNodeEcShards([]*master_pb.VolumeEcShardInformationMessage{
{Id: 9, Collection: "bucket-c", EcIndexBits: 0x1f, ShardSizes: []int64{10, 20, 30, 40, 50}, FileCount: 40, DeleteCount: 2},
}, nodes[0])
topo.SyncDataNodeEcShards([]*master_pb.VolumeEcShardInformationMessage{
{Id: 9, Collection: "bucket-c", EcIndexBits: 0x3e0, ShardSizes: []int64{60, 70, 80, 90, 100}, FileCount: 44, DeleteCount: 3},
}, nodes[1])
return topo
}
// The summary replaces callers adding up a full topology listing, so it has to
// produce what that produced: these numbers enforce bucket quotas.
func TestCollectionStatisticsMatchesAFullListing(t *testing.T) {
topo := statsTopology(t)
want := referenceCollectionStatistics(topo.ToTopologyInfo(VolumeFilter{}))
got := map[string]*CollectionStatistics{}
for _, s := range topo.CollectionStatistics() {
got[s.Collection] = s
}
if len(got) != len(want) {
t.Fatalf("summarised %d collections, the listing had %d: %v vs %v", len(got), len(want), got, want)
}
for name, expected := range want {
actual, found := got[name]
if !found {
t.Errorf("collection %s is missing from the summary", name)
continue
}
if *actual != *expected {
t.Errorf("collection %s:\n summary %+v\n listing %+v", name, *actual, *expected)
}
}
}
// Replicas disagree while a write is landing or a heartbeat is late. The full
// listing walked the topology in map order and took whichever replica it
// reached first, so its answer was not stable; the summary takes the largest.
func TestCollectionStatisticsPicksTheLargestReplica(t *testing.T) {
topo := NewTopology("stats", nil, 32*1024*1024*1024, 5, false)
rack := topo.GetOrCreateDataCenter("dc1").GetOrCreateRack("rack1")
small := rack.GetOrCreateDataNode("10.0.0.1", 8080, 18080, "", "a", map[string]uint32{"": 100})
large := rack.GetOrCreateDataNode("10.0.0.2", 8080, 18080, "", "b", map[string]uint32{"": 100})
behind := &master_pb.VolumeInformationMessage{
Id: 1, Collection: "bucket-a", Size: 1000, FileCount: 10, Version: 3, ReplicaPlacement: 1,
}
ahead := &master_pb.VolumeInformationMessage{
Id: 1, Collection: "bucket-a", Size: 4000, FileCount: 40, Version: 3, ReplicaPlacement: 1,
}
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{behind}, small)
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{ahead}, large)
for i := 0; i < 8; i++ {
stats := topo.CollectionStatistics()
if len(stats) != 1 {
t.Fatalf("expected one collection, got %d", len(stats))
}
if stats[0].Size != 4000 || stats[0].FileCount != 40 {
t.Fatalf("run %d reported size %d files %d, want the larger replica's 4000 and 40",
i, stats[0].Size, stats[0].FileCount)
}
if stats[0].PhysicalSize != 5000 {
t.Fatalf("run %d reported physical size %d, want both replicas summed", i, stats[0].PhysicalSize)
}
}
}
func statsByCollection(topo *Topology) map[string]*CollectionStatistics {
out := map[string]*CollectionStatistics{}
for _, s := range topo.CollectionStatistics() {
out[s.Collection] = s
}
return out
}
// The .ecx and .ecj travel with the shards, so a holder still loading them
// reports zero and must not pin the volume's count down.
func TestCollectionStatisticsTakesTheLargestEcFileCount(t *testing.T) {
topo := NewTopology("stats", nil, 32*1024*1024*1024, 5, false)
rack := topo.GetOrCreateDataCenter("dc1").GetOrCreateRack("rack1")
loading := rack.GetOrCreateDataNode("10.0.0.1", 8080, 18080, "", "a", map[string]uint32{"": 100})
loaded := rack.GetOrCreateDataNode("10.0.0.2", 8080, 18080, "", "b", map[string]uint32{"": 100})
topo.SyncDataNodeEcShards([]*master_pb.VolumeEcShardInformationMessage{
{Id: 11, Collection: "bucket-b", EcIndexBits: 0x7f, ShardSizes: []int64{1, 1, 1, 1, 1, 1, 1}, FileCount: 0},
}, loading)
topo.SyncDataNodeEcShards([]*master_pb.VolumeEcShardInformationMessage{
{Id: 11, Collection: "bucket-b", EcIndexBits: 0x3f80, ShardSizes: []int64{1, 1, 1, 1, 1, 1, 1}, FileCount: 6},
}, loaded)
stats := statsByCollection(topo)["bucket-b"]
if stats == nil {
t.Fatal("expected bucket-b to be reported")
}
if stats.FileCount != 6 {
t.Errorf("file count %d, want the largest any holder reported (6)", stats.FileCount)
}
if stats.VolumeCount != 1 {
t.Errorf("volume count %d, want one volume however many holders report shards", stats.VolumeCount)
}
// 14 shards of 1 byte each; only the 10 data shards count as logical.
if stats.PhysicalSize != 14 {
t.Errorf("physical size %d, want every shard counted (14)", stats.PhysicalSize)
}
if stats.Size != 10 {
t.Errorf("size %d, want the data shards only (10)", stats.Size)
}
}
// A collection with both kinds has to have them added together.
func TestCollectionStatisticsCountsRegularAndEcTogether(t *testing.T) {
topo := NewTopology("stats", nil, 32*1024*1024*1024, 5, false)
rack := topo.GetOrCreateDataCenter("dc1").GetOrCreateRack("rack1")
dn := rack.GetOrCreateDataNode("10.0.0.1", 8080, 18080, "", "a", map[string]uint32{"": 100})
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{
{Id: 1, Collection: "mixed", Size: 500, FileCount: 5, DeleteCount: 1, DeletedByteCount: 50, Version: 3},
}, dn)
topo.SyncDataNodeEcShards([]*master_pb.VolumeEcShardInformationMessage{
{Id: 2, Collection: "mixed", EcIndexBits: 0x3fff,
ShardSizes: []int64{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}, FileCount: 7, DeleteCount: 2},
}, dn)
stats := statsByCollection(topo)["mixed"]
if stats == nil {
t.Fatal("expected the mixed collection to be reported")
}
if stats.VolumeCount != 2 {
t.Errorf("volume count %d, want the regular and the ec volume (2)", stats.VolumeCount)
}
if stats.FileCount != 12 {
t.Errorf("file count %d, want 5 regular plus 7 ec", stats.FileCount)
}
if stats.DeleteCount != 3 {
t.Errorf("delete count %d, want 1 regular plus 2 ec", stats.DeleteCount)
}
if stats.Size != 510 {
t.Errorf("size %d, want 500 regular plus 10 data shards", stats.Size)
}
if stats.PhysicalSize != 514 {
t.Errorf("physical size %d, want 500 regular plus all 14 shards", stats.PhysicalSize)
}
}
// Quotas are enforced on size less deletions, so the replica holding the most
// live data is the one to count. The replica with the biggest raw size can be
// the one that has deleted the most, and counting that one would report a
// bucket smaller than it is and leave it writable over its quota.
func TestCollectionStatisticsPicksTheReplicaHoldingTheMostLiveData(t *testing.T) {
topo := NewTopology("stats", nil, 32*1024*1024*1024, 5, false)
rack := topo.GetOrCreateDataCenter("dc1").GetOrCreateRack("rack1")
bigMostlyDeleted := rack.GetOrCreateDataNode("10.0.0.1", 8080, 18080, "", "a", map[string]uint32{"": 100})
smallerButLive := rack.GetOrCreateDataNode("10.0.0.2", 8080, 18080, "", "b", map[string]uint32{"": 100})
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{{
Id: 1, Collection: "bucket-a", Size: 1000, DeletedByteCount: 900,
Version: 3, ReplicaPlacement: 1,
}}, bigMostlyDeleted)
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{{
Id: 1, Collection: "bucket-a", Size: 900, DeletedByteCount: 0,
Version: 3, ReplicaPlacement: 1,
}}, smallerButLive)
stats := statsByCollection(topo)["bucket-a"]
if stats == nil {
t.Fatal("expected bucket-a to be reported")
}
live := stats.Size - stats.DeletedByteCount
if live != 900 {
t.Errorf("reported %d bytes live (size %d less %d deleted), want the 900 one replica holds",
live, stats.Size, stats.DeletedByteCount)
}
if stats.PhysicalSize != 1900 {
t.Errorf("physical size %d, want both replicas summed", stats.PhysicalSize)
}
}
// Live usage is read as the collection's size less its deletions, so a volume
// reporting more deleted bytes than it holds must not cancel live bytes
// belonging to other volumes in the same bucket.
func TestCollectionStatisticsDeletionsNeverExceedTheVolume(t *testing.T) {
topo := NewTopology("stats", nil, 32*1024*1024*1024, 5, false)
dn := topo.GetOrCreateDataCenter("dc1").GetOrCreateRack("rack1").
GetOrCreateDataNode("10.0.0.1", 8080, 18080, "", "a", map[string]uint32{"": 100})
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{
// More deleted bytes than the volume holds, which a compaction can
// leave behind, and a healthy volume beside it.
{Id: 1, Collection: "bucket-a", Size: 100, DeletedByteCount: 500, Version: 3},
{Id: 2, Collection: "bucket-a", Size: 1000, DeletedByteCount: 0, Version: 3},
}, dn)
stats := statsByCollection(topo)["bucket-a"]
if stats == nil {
t.Fatal("expected bucket-a to be reported")
}
if stats.DeletedByteCount > stats.Size {
t.Errorf("deletions %d exceed size %d, so live usage reads as zero", stats.DeletedByteCount, stats.Size)
}
if live := stats.Size - stats.DeletedByteCount; live != 1000 {
t.Errorf("reported %d bytes live, want the 1000 the second volume holds", live)
}
}