mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-07 20:47:46 +01:00
* admin: show disk usage on the dashboard instead of the data size The dashboard's Total Size card summed the volume and EC shard sizes the volume servers report. That leaves out deleted needles waiting for vacuum, EC generations kept after a vacuum, and anything else on the disks, so on a cluster with a lot of EC vacuum activity it showed far less than the disks held. The card now shows the bytes in use on the volume servers' disks, the same statfs numbers the volume server list uses, with the disks' capacity, and its trend line follows that number. Servers that report no statfs numbers count their data size, as on the volume server list. Claude-Session: https://claude.ai/code/session_011UpvafcfUuC2mLxNAXLQJi * admin: compute the disk usage percentage in float64 current*100 overflows int64 past about 92 PB of used space, so the dashboard card showed a negative percentage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: size the volume server disk bar with calculatePercent64 int(host.DiskUsage) truncates byte counts on 32-bit builds, so the bar width was wrong for any disk over 2 GB there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: test the cluster disk totals across statfs and fallback servers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
317 lines
12 KiB
Go
317 lines
12 KiB
Go
package dash
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/wdclient"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
type volumeAccessTestMaster struct {
|
|
master_pb.UnimplementedSeaweedServer
|
|
topology *master_pb.TopologyInfo
|
|
}
|
|
|
|
type volumeAccessTestServer struct {
|
|
volume_server_pb.UnimplementedVolumeServerServer
|
|
rpcError error
|
|
calls chan any
|
|
}
|
|
|
|
func (s *volumeAccessTestMaster) KeepConnected(stream master_pb.Seaweed_KeepConnectedServer) error {
|
|
if _, err := stream.Recv(); err != nil {
|
|
return err
|
|
}
|
|
if err := stream.Send(&master_pb.KeepConnectedResponse{}); err != nil {
|
|
return err
|
|
}
|
|
<-stream.Context().Done()
|
|
return stream.Context().Err()
|
|
}
|
|
|
|
func (s *volumeAccessTestMaster) VolumeList(context.Context, *master_pb.VolumeListRequest) (*master_pb.VolumeListResponse, error) {
|
|
// Return unfiltered topology to exercise compatibility with older masters.
|
|
return &master_pb.VolumeListResponse{TopologyInfo: s.topology}, nil
|
|
}
|
|
|
|
func (s *volumeAccessTestServer) VolumeMarkReadonly(_ context.Context, req *volume_server_pb.VolumeMarkReadonlyRequest) (*volume_server_pb.VolumeMarkReadonlyResponse, error) {
|
|
s.calls <- req
|
|
return &volume_server_pb.VolumeMarkReadonlyResponse{}, s.rpcError
|
|
}
|
|
|
|
func (s *volumeAccessTestServer) VolumeMarkWritable(_ context.Context, req *volume_server_pb.VolumeMarkWritableRequest) (*volume_server_pb.VolumeMarkWritableResponse, error) {
|
|
s.calls <- req
|
|
return &volume_server_pb.VolumeMarkWritableResponse{}, s.rpcError
|
|
}
|
|
|
|
func volumeAccessTestTopology(nodes ...*master_pb.DataNodeInfo) *master_pb.TopologyInfo {
|
|
return &master_pb.TopologyInfo{DataCenterInfos: []*master_pb.DataCenterInfo{{
|
|
RackInfos: []*master_pb.RackInfo{{DataNodeInfos: nodes}},
|
|
}}}
|
|
}
|
|
|
|
func TestSetVolumeReadOnly(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
readOnly bool
|
|
volumeID uint32
|
|
server string
|
|
rpcError error
|
|
wantCall bool
|
|
}{
|
|
{name: "persist read-only", readOnly: true, volumeID: 7, server: "node-a", wantCall: true},
|
|
{name: "restore read-write", volumeID: 7, server: "node-a", wantCall: true},
|
|
{name: "propagate volume server error", volumeID: 7, server: "node-a", wantCall: true, rpcError: status.Error(codes.PermissionDenied, "server is in maintenance mode")},
|
|
{name: "reject unknown volume", readOnly: true, volumeID: 8, server: "node-a"},
|
|
{name: "reject unknown server", readOnly: true, volumeID: 7, server: "127.0.0.1:9999"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
port := listener.Addr().(*net.TCPAddr).Port
|
|
master := &volumeAccessTestMaster{
|
|
topology: volumeAccessTestTopology(&master_pb.DataNodeInfo{
|
|
Id: "node-a", Address: "127.0.0.1:8080", GrpcPort: uint32(port),
|
|
DiskInfos: map[string]*master_pb.DiskInfo{"hdd": {
|
|
VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 7}},
|
|
}},
|
|
}),
|
|
}
|
|
fake := &volumeAccessTestServer{
|
|
rpcError: tc.rpcError,
|
|
calls: make(chan any, 1),
|
|
}
|
|
grpcServer := grpc.NewServer()
|
|
master_pb.RegisterSeaweedServer(grpcServer, master)
|
|
volume_server_pb.RegisterVolumeServerServer(grpcServer, fake)
|
|
go grpcServer.Serve(listener)
|
|
t.Cleanup(grpcServer.Stop)
|
|
|
|
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
|
address := pb.NewServerAddress("127.0.0.1", 9333, port)
|
|
discovery := pb.NewServiceDiscoveryFromMap(map[string]pb.ServerAddress{"master": address})
|
|
masterClient := wdclient.NewMasterClient(dialOption, "", "admin", "", "", "", *discovery)
|
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
|
defer cancel()
|
|
go masterClient.KeepConnectedToMaster(ctx)
|
|
admin := &AdminServer{masterClient: masterClient, grpcDialOption: dialOption}
|
|
|
|
err = admin.SetVolumeReadOnly(ctx, tc.volumeID, tc.server, tc.readOnly)
|
|
if !tc.wantCall {
|
|
require.ErrorContains(t, err, "not found on server")
|
|
require.Empty(t, fake.calls, "an unknown replica must not receive a mutation")
|
|
return
|
|
}
|
|
if tc.rpcError != nil {
|
|
require.Equal(t, status.Code(tc.rpcError), status.Code(err))
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
select {
|
|
case call := <-fake.calls:
|
|
if tc.readOnly {
|
|
req, ok := call.(*volume_server_pb.VolumeMarkReadonlyRequest)
|
|
require.True(t, ok)
|
|
require.Equal(t, tc.volumeID, req.VolumeId)
|
|
require.True(t, req.Persist, "operator changes must survive a restart")
|
|
require.False(t, req.CanDelete)
|
|
} else {
|
|
req, ok := call.(*volume_server_pb.VolumeMarkWritableRequest)
|
|
require.True(t, ok)
|
|
require.Equal(t, tc.volumeID, req.VolumeId)
|
|
}
|
|
default:
|
|
t.Fatal("volume server did not receive an access mode change")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVolumeReplicaAddress(t *testing.T) {
|
|
topology := volumeAccessTestTopology(
|
|
&master_pb.DataNodeInfo{Id: "legacy:8080", DiskInfos: map[string]*master_pb.DiskInfo{
|
|
"hdd": {VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 7}}},
|
|
}},
|
|
&master_pb.DataNodeInfo{Id: "node-b", Address: "host-b:8081", GrpcPort: 18082, DiskInfos: map[string]*master_pb.DiskInfo{
|
|
"ssd": {VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 7}}, EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{{Id: 8}}},
|
|
}},
|
|
)
|
|
address, err := volumeReplicaAddress(topology, 7, "legacy:8080")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "legacy:18080", address.ToGrpcAddress())
|
|
address, err = volumeReplicaAddress(topology, 7, "node-b")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "host-b:18082", address.ToGrpcAddress())
|
|
_, err = volumeReplicaAddress(topology, 8, "node-b")
|
|
require.Error(t, err, "EC shards must not be treated as regular volumes")
|
|
_, err = volumeReplicaAddress(nil, 7, "node-b")
|
|
require.Error(t, err)
|
|
}
|
|
|
|
// newVolumeServersTestAdmin wires an AdminServer to a fake master that returns
|
|
// topology, so the cluster volume server views can be built without a real
|
|
// cluster.
|
|
func newVolumeServersTestAdmin(t *testing.T, topology *master_pb.TopologyInfo) *AdminServer {
|
|
t.Helper()
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
grpcServer := grpc.NewServer()
|
|
master_pb.RegisterSeaweedServer(grpcServer, &volumeAccessTestMaster{topology: topology})
|
|
go grpcServer.Serve(listener)
|
|
t.Cleanup(grpcServer.Stop)
|
|
|
|
// The master HTTP API only supplies PublicUrl, which disk accounting does
|
|
// not use; answer it locally so the test never leaves the machine.
|
|
httpServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"Topology":{"DataCenters":[]}}`))
|
|
}))
|
|
httpServer.Start()
|
|
t.Cleanup(httpServer.Close)
|
|
|
|
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
|
address := pb.NewServerAddress(
|
|
"127.0.0.1",
|
|
httpServer.Listener.Addr().(*net.TCPAddr).Port,
|
|
listener.Addr().(*net.TCPAddr).Port,
|
|
)
|
|
discovery := pb.NewServiceDiscoveryFromMap(map[string]pb.ServerAddress{"master": address})
|
|
masterClient := wdclient.NewMasterClient(dialOption, "", "admin", "", "", "", *discovery)
|
|
go masterClient.KeepConnectedToMaster(t.Context())
|
|
|
|
admin := &AdminServer{masterClient: masterClient, grpcDialOption: dialOption}
|
|
// Versions come from the VolumeServerStatus fan-out, which is unrelated to
|
|
// disk accounting. A fresh cache keeps the test from dialing volume servers.
|
|
admin.volumeVersionCache = map[string]string{"node-a": "4.51"}
|
|
admin.lastVersionFetch = time.Now()
|
|
return admin
|
|
}
|
|
|
|
// TestGetClusterVolumeServersDiskAccounting pins the volume server usage to the
|
|
// same measurement as the capacity it is shown against. Usage used to sum the
|
|
// logical volume sizes, which on a compressing filesystem exceeds the physical
|
|
// capacity; the admin UI then derived a negative available space and rendered
|
|
// it as "NaN undefined".
|
|
func TestGetClusterVolumeServersDiskAccounting(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
disk *master_pb.DiskInfo
|
|
wantCapacity int64
|
|
wantUsage int64
|
|
}{
|
|
{
|
|
name: "compressed filesystem reports statfs usage",
|
|
disk: &master_pb.DiskInfo{
|
|
MaxVolumeCount: 100,
|
|
DiskTotalBytes: 5_700_000_000_000,
|
|
DiskFreeBytes: 1_500_000_000_000,
|
|
VolumeInfos: []*master_pb.VolumeInformationMessage{
|
|
{Id: 1, Size: 3_000_000_000_000},
|
|
{Id: 2, Size: 3_020_000_000_000},
|
|
},
|
|
},
|
|
wantCapacity: 5_700_000_000_000,
|
|
wantUsage: 4_200_000_000_000,
|
|
},
|
|
{
|
|
name: "plain filesystem reports statfs usage",
|
|
disk: &master_pb.DiskInfo{
|
|
MaxVolumeCount: 100,
|
|
DiskTotalBytes: 1_000_000_000_000,
|
|
DiskFreeBytes: 400_000_000_000,
|
|
VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 1, Size: 590_000_000_000}},
|
|
},
|
|
wantCapacity: 1_000_000_000_000,
|
|
wantUsage: 600_000_000_000,
|
|
},
|
|
{
|
|
name: "ec shards are not added on top of statfs usage",
|
|
disk: &master_pb.DiskInfo{
|
|
MaxVolumeCount: 100,
|
|
DiskTotalBytes: 1_000_000_000_000,
|
|
DiskFreeBytes: 300_000_000_000,
|
|
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
|
{Id: 3, EcIndexBits: 0b11, ShardSizes: []int64{100, 200}},
|
|
},
|
|
},
|
|
wantCapacity: 1_000_000_000_000,
|
|
wantUsage: 700_000_000_000,
|
|
},
|
|
{
|
|
name: "without statfs numbers falls back to logical size",
|
|
disk: &master_pb.DiskInfo{
|
|
MaxVolumeCount: 10,
|
|
VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 4, Size: 30_000_000_000}},
|
|
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
|
{Id: 5, EcIndexBits: 0b1, ShardSizes: []int64{300}},
|
|
},
|
|
},
|
|
// 10 slots at the 30000 MiB default volume size limit.
|
|
wantCapacity: 10 * 30000 * 1024 * 1024,
|
|
wantUsage: 30_000_000_000 + 300,
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
admin := newVolumeServersTestAdmin(t, volumeAccessTestTopology(&master_pb.DataNodeInfo{
|
|
Id: "node-a",
|
|
Address: "127.0.0.1:8080",
|
|
GrpcPort: 18080,
|
|
DiskInfos: map[string]*master_pb.DiskInfo{"hdd": tc.disk},
|
|
}))
|
|
|
|
data, err := admin.GetClusterVolumeServers()
|
|
require.NoError(t, err)
|
|
require.Len(t, data.VolumeServers, 1)
|
|
server := data.VolumeServers[0]
|
|
require.Equal(t, tc.wantCapacity, server.DiskCapacity)
|
|
require.Equal(t, tc.wantUsage, server.DiskUsage)
|
|
require.LessOrEqual(t, server.DiskUsage, server.DiskCapacity,
|
|
"usage above capacity makes the advertised available space negative")
|
|
})
|
|
}
|
|
}
|
|
|
|
// The dashboard totals sum each server's disk usage and capacity, statfs or
|
|
// fallback, while TotalSize stays the logical data size.
|
|
func TestClusterTopologyDiskTotals(t *testing.T) {
|
|
admin := newVolumeServersTestAdmin(t, volumeAccessTestTopology(
|
|
&master_pb.DataNodeInfo{
|
|
Id: "node-a",
|
|
DiskInfos: map[string]*master_pb.DiskInfo{"hdd": {
|
|
MaxVolumeCount: 100,
|
|
DiskTotalBytes: 1_000_000_000_000,
|
|
DiskFreeBytes: 400_000_000_000,
|
|
VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 1, Size: 590_000_000_000}},
|
|
}},
|
|
},
|
|
&master_pb.DataNodeInfo{
|
|
Id: "node-b",
|
|
DiskInfos: map[string]*master_pb.DiskInfo{"hdd": {
|
|
MaxVolumeCount: 10,
|
|
VolumeInfos: []*master_pb.VolumeInformationMessage{
|
|
{Id: 2, Size: 30_000_000_000},
|
|
{Id: 3, Size: 5_000_000_000, RemoteStorageName: "s3"},
|
|
},
|
|
}},
|
|
},
|
|
))
|
|
|
|
topology, err := admin.GetClusterTopology()
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(600_000_000_000+30_000_000_000), topology.DiskUsage)
|
|
require.Equal(t, int64(1_000_000_000_000+10*30000*1024*1024), topology.DiskCapacity)
|
|
require.Equal(t, int64(590_000_000_000+30_000_000_000+5_000_000_000), topology.TotalSize)
|
|
}
|