feat: Phase 8 CP8-1/2/3/4 -- ops control plane, multi-replica, CSI snapshots, observability

CP8-1: HTTP REST API (create/delete/lookup/list/assign/servers), blockapi Go
client with multi-master failover, 5 shell commands, HTML dashboard at /block/.

CP8-2: RF=2/RF=3 multi-replica support -- ShipperGroup fan-out, distributed
sync, health scoring, segment-based scrub, gated promotion (heartbeat
freshness + WAL LSN + role checks), failover/rebuild for N>2 replicas.

CP8-3: CSI snapshot + expansion -- CreateSnapshot/DeleteSnapshot/ListSnapshots
RPCs, NodeExpandVolume with iSCSI rescan, snapshot ID helpers, 20 adversarial
tests covering concurrent ops, edge cases, and error injection.

CP8-4: Observability -- EngineMetrics atomic counters for flusher/group-commit/
WAL-shipper/scrub, 10 new Prometheus metrics, barrier_lag_lsn SLO gauge,
failover/promotion/rebuild counters, request ID correlation in master gRPC
logs, baseline regression framework with 7 hard-fail conditions.

Total: 63 files, ~11.2K LOC, 160+ new tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ping QiuandClaude Opus 4.6 committed 2026-03-06 00:05:17 -08:00
1 parent 8b2b5f6f66
commit 979a9b496c
63 files changed
+11207 -659

No files matched your search

+7 -4
View File
@@ -71,8 +71,9 @@ type MasterOptions struct {
raftBootstrap *bool
telemetryUrl *string
telemetryEnabled *bool
debug *bool
debugPort *int
debug *bool
debugPort *int
blockPromotionLSNTolerance *int
}
func init() {
@@ -104,6 +105,7 @@ func init() {
m.telemetryEnabled = cmdMaster.Flag.Bool("telemetry", false, "enable telemetry reporting")
m.debug = cmdMaster.Flag.Bool("debug", false, "serves runtime profiling data via pprof on the port specified by -debug.port")
m.debugPort = cmdMaster.Flag.Int("debug.port", 6060, "http port for debugging")
m.blockPromotionLSNTolerance = cmdMaster.Flag.Int("block.promotion.lsnTolerance", 100, "max WAL LSN lag for block volume replica promotion eligibility")
}
var cmdMaster = &Command{
@@ -410,7 +412,8 @@ func (m *MasterOptions) toMasterOption(whiteList []string) *weed_server.MasterOp
DisableHttp: *m.disableHttp,
MetricsAddress: *m.metricsAddress,
MetricsIntervalSec: *m.metricsIntervalSec,
TelemetryUrl: *m.telemetryUrl,
TelemetryEnabled: *m.telemetryEnabled,
TelemetryUrl: *m.telemetryUrl,
TelemetryEnabled: *m.telemetryEnabled,
BlockPromotionLSNTolerance: *m.blockPromotionLSNTolerance,
}
}
+3
View File
@@ -310,6 +310,9 @@ func (v VolumeServerOptions) startVolumeServer(volumeFolders, maxVolumeCounts, v
// Start block volume iSCSI service (disabled if block.dir is empty).
blockService := weed_server.StartBlockService(*v.blockListen, *v.blockDir, *v.blockIQNPrefix)
if blockService != nil {
volumeServer.SetBlockService(blockService)
}
grace.OnReload(volumeServer.LoadNewVolumes)
grace.OnReload(volumeServer.Reload)
+59
View File
@@ -63,6 +63,14 @@ service Seaweed {
}
rpc LookupBlockVolume (LookupBlockVolumeRequest) returns (LookupBlockVolumeResponse) {
}
rpc CreateBlockSnapshot (CreateBlockSnapshotRequest) returns (CreateBlockSnapshotResponse) {
}
rpc DeleteBlockSnapshot (DeleteBlockSnapshotRequest) returns (DeleteBlockSnapshotResponse) {
}
rpc ListBlockSnapshots (ListBlockSnapshotsRequest) returns (ListBlockSnapshotsResponse) {
}
rpc ExpandBlockVolume (ExpandBlockVolumeRequest) returns (ExpandBlockVolumeResponse) {
}
}
//////////////////////////////////////////////////
@@ -493,6 +501,10 @@ message BlockVolumeInfoMessage {
string disk_type = 9;
string replica_data_addr = 10;
string replica_ctrl_addr = 11;
double health_score = 12;
int64 scrub_errors = 13;
int64 last_scrub_time = 14;
bool replica_degraded = 15;
}
message BlockVolumeShortInfoMessage {
@@ -510,12 +522,19 @@ message BlockVolumeAssignment {
string replica_data_addr = 5;
string replica_ctrl_addr = 6;
string rebuild_addr = 7;
repeated ReplicaAddrMessage replica_addrs = 8;
}
message ReplicaAddrMessage {
string data_addr = 1;
string ctrl_addr = 2;
}
message CreateBlockVolumeRequest {
string name = 1;
uint64 size_bytes = 2;
string disk_type = 3;
uint32 replica_factor = 4;
}
message CreateBlockVolumeResponse {
string volume_id = 1;
@@ -524,6 +543,7 @@ message CreateBlockVolumeResponse {
string iqn = 4;
uint64 capacity_bytes = 5;
string replica_server = 6;
repeated string replica_servers = 7;
}
message DeleteBlockVolumeRequest {
@@ -541,4 +561,43 @@ message LookupBlockVolumeResponse {
string iqn = 3;
uint64 capacity_bytes = 4;
string replica_server = 5;
uint32 replica_factor = 6;
repeated string replica_servers = 7;
}
message CreateBlockSnapshotRequest {
string volume_name = 1;
uint32 snapshot_id = 2;
}
message CreateBlockSnapshotResponse {
uint32 snapshot_id = 1;
int64 created_at = 2; // Unix timestamp in seconds
uint64 size_bytes = 3;
}
message DeleteBlockSnapshotRequest {
string volume_name = 1;
uint32 snapshot_id = 2;
}
message DeleteBlockSnapshotResponse {
}
message ListBlockSnapshotsRequest {
string volume_name = 1;
}
message ListBlockSnapshotsResponse {
repeated BlockSnapshotInfo snapshots = 1;
}
message BlockSnapshotInfo {
uint32 snapshot_id = 1;
int64 created_at = 2; // Unix timestamp in seconds
uint64 volume_size_bytes = 3;
}
message ExpandBlockVolumeRequest {
string name = 1;
uint64 new_size_bytes = 2;
}
message ExpandBlockVolumeResponse {
uint64 capacity_bytes = 1;
}
File diff suppressed because it is too large. Load diff
+152
View File
@@ -46,6 +46,10 @@ const (
Seaweed_CreateBlockVolume_FullMethodName = "/master_pb.Seaweed/CreateBlockVolume"
Seaweed_DeleteBlockVolume_FullMethodName = "/master_pb.Seaweed/DeleteBlockVolume"
Seaweed_LookupBlockVolume_FullMethodName = "/master_pb.Seaweed/LookupBlockVolume"
Seaweed_CreateBlockSnapshot_FullMethodName = "/master_pb.Seaweed/CreateBlockSnapshot"
Seaweed_DeleteBlockSnapshot_FullMethodName = "/master_pb.Seaweed/DeleteBlockSnapshot"
Seaweed_ListBlockSnapshots_FullMethodName = "/master_pb.Seaweed/ListBlockSnapshots"
Seaweed_ExpandBlockVolume_FullMethodName = "/master_pb.Seaweed/ExpandBlockVolume"
)
// SeaweedClient is the client API for Seaweed service.
@@ -79,6 +83,10 @@ type SeaweedClient interface {
CreateBlockVolume(ctx context.Context, in *CreateBlockVolumeRequest, opts ...grpc.CallOption) (*CreateBlockVolumeResponse, error)
DeleteBlockVolume(ctx context.Context, in *DeleteBlockVolumeRequest, opts ...grpc.CallOption) (*DeleteBlockVolumeResponse, error)
LookupBlockVolume(ctx context.Context, in *LookupBlockVolumeRequest, opts ...grpc.CallOption) (*LookupBlockVolumeResponse, error)
CreateBlockSnapshot(ctx context.Context, in *CreateBlockSnapshotRequest, opts ...grpc.CallOption) (*CreateBlockSnapshotResponse, error)
DeleteBlockSnapshot(ctx context.Context, in *DeleteBlockSnapshotRequest, opts ...grpc.CallOption) (*DeleteBlockSnapshotResponse, error)
ListBlockSnapshots(ctx context.Context, in *ListBlockSnapshotsRequest, opts ...grpc.CallOption) (*ListBlockSnapshotsResponse, error)
ExpandBlockVolume(ctx context.Context, in *ExpandBlockVolumeRequest, opts ...grpc.CallOption) (*ExpandBlockVolumeResponse, error)
}
type seaweedClient struct {
@@ -368,6 +376,46 @@ func (c *seaweedClient) LookupBlockVolume(ctx context.Context, in *LookupBlockVo
return out, nil
}
func (c *seaweedClient) CreateBlockSnapshot(ctx context.Context, in *CreateBlockSnapshotRequest, opts ...grpc.CallOption) (*CreateBlockSnapshotResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateBlockSnapshotResponse)
err := c.cc.Invoke(ctx, Seaweed_CreateBlockSnapshot_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *seaweedClient) DeleteBlockSnapshot(ctx context.Context, in *DeleteBlockSnapshotRequest, opts ...grpc.CallOption) (*DeleteBlockSnapshotResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteBlockSnapshotResponse)
err := c.cc.Invoke(ctx, Seaweed_DeleteBlockSnapshot_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *seaweedClient) ListBlockSnapshots(ctx context.Context, in *ListBlockSnapshotsRequest, opts ...grpc.CallOption) (*ListBlockSnapshotsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListBlockSnapshotsResponse)
err := c.cc.Invoke(ctx, Seaweed_ListBlockSnapshots_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *seaweedClient) ExpandBlockVolume(ctx context.Context, in *ExpandBlockVolumeRequest, opts ...grpc.CallOption) (*ExpandBlockVolumeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ExpandBlockVolumeResponse)
err := c.cc.Invoke(ctx, Seaweed_ExpandBlockVolume_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// SeaweedServer is the server API for Seaweed service.
// All implementations must embed UnimplementedSeaweedServer
// for forward compatibility.
@@ -399,6 +447,10 @@ type SeaweedServer interface {
CreateBlockVolume(context.Context, *CreateBlockVolumeRequest) (*CreateBlockVolumeResponse, error)
DeleteBlockVolume(context.Context, *DeleteBlockVolumeRequest) (*DeleteBlockVolumeResponse, error)
LookupBlockVolume(context.Context, *LookupBlockVolumeRequest) (*LookupBlockVolumeResponse, error)
CreateBlockSnapshot(context.Context, *CreateBlockSnapshotRequest) (*CreateBlockSnapshotResponse, error)
DeleteBlockSnapshot(context.Context, *DeleteBlockSnapshotRequest) (*DeleteBlockSnapshotResponse, error)
ListBlockSnapshots(context.Context, *ListBlockSnapshotsRequest) (*ListBlockSnapshotsResponse, error)
ExpandBlockVolume(context.Context, *ExpandBlockVolumeRequest) (*ExpandBlockVolumeResponse, error)
mustEmbedUnimplementedSeaweedServer()
}
@@ -490,6 +542,18 @@ func (UnimplementedSeaweedServer) DeleteBlockVolume(context.Context, *DeleteBloc
func (UnimplementedSeaweedServer) LookupBlockVolume(context.Context, *LookupBlockVolumeRequest) (*LookupBlockVolumeResponse, error) {
return nil, status.Error(codes.Unimplemented, "method LookupBlockVolume not implemented")
}
func (UnimplementedSeaweedServer) CreateBlockSnapshot(context.Context, *CreateBlockSnapshotRequest) (*CreateBlockSnapshotResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreateBlockSnapshot not implemented")
}
func (UnimplementedSeaweedServer) DeleteBlockSnapshot(context.Context, *DeleteBlockSnapshotRequest) (*DeleteBlockSnapshotResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteBlockSnapshot not implemented")
}
func (UnimplementedSeaweedServer) ListBlockSnapshots(context.Context, *ListBlockSnapshotsRequest) (*ListBlockSnapshotsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListBlockSnapshots not implemented")
}
func (UnimplementedSeaweedServer) ExpandBlockVolume(context.Context, *ExpandBlockVolumeRequest) (*ExpandBlockVolumeResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ExpandBlockVolume not implemented")
}
func (UnimplementedSeaweedServer) mustEmbedUnimplementedSeaweedServer() {}
func (UnimplementedSeaweedServer) testEmbeddedByValue() {}
@@ -964,6 +1028,78 @@ func _Seaweed_LookupBlockVolume_Handler(srv interface{}, ctx context.Context, de
return interceptor(ctx, in, info, handler)
}
func _Seaweed_CreateBlockSnapshot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateBlockSnapshotRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SeaweedServer).CreateBlockSnapshot(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Seaweed_CreateBlockSnapshot_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SeaweedServer).CreateBlockSnapshot(ctx, req.(*CreateBlockSnapshotRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Seaweed_DeleteBlockSnapshot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteBlockSnapshotRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SeaweedServer).DeleteBlockSnapshot(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Seaweed_DeleteBlockSnapshot_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SeaweedServer).DeleteBlockSnapshot(ctx, req.(*DeleteBlockSnapshotRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Seaweed_ListBlockSnapshots_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListBlockSnapshotsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SeaweedServer).ListBlockSnapshots(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Seaweed_ListBlockSnapshots_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SeaweedServer).ListBlockSnapshots(ctx, req.(*ListBlockSnapshotsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Seaweed_ExpandBlockVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExpandBlockVolumeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SeaweedServer).ExpandBlockVolume(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Seaweed_ExpandBlockVolume_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SeaweedServer).ExpandBlockVolume(ctx, req.(*ExpandBlockVolumeRequest))
}
return interceptor(ctx, in, info, handler)
}
// Seaweed_ServiceDesc is the grpc.ServiceDesc for Seaweed service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -1067,6 +1203,22 @@ var Seaweed_ServiceDesc = grpc.ServiceDesc{
MethodName: "LookupBlockVolume",
Handler: _Seaweed_LookupBlockVolume_Handler,
},
{
MethodName: "CreateBlockSnapshot",
Handler: _Seaweed_CreateBlockSnapshot_Handler,
},
{
MethodName: "DeleteBlockSnapshot",
Handler: _Seaweed_DeleteBlockSnapshot_Handler,
},
{
MethodName: "ListBlockSnapshots",
Handler: _Seaweed_ListBlockSnapshots_Handler,
},
{
MethodName: "ExpandBlockVolume",
Handler: _Seaweed_ExpandBlockVolume_Handler,
},
},
Streams: []grpc.StreamDesc{
{
+45
View File
@@ -143,6 +143,14 @@ service VolumeServer {
}
rpc VolumeServerDeleteBlockVolume (VolumeServerDeleteBlockVolumeRequest) returns (VolumeServerDeleteBlockVolumeResponse) {
}
rpc SnapshotBlockVolume (SnapshotBlockVolumeRequest) returns (SnapshotBlockVolumeResponse) {
}
rpc DeleteBlockSnapshot (DeleteBlockSnapshotRequest) returns (DeleteBlockSnapshotResponse) {
}
rpc ListBlockSnapshots (ListBlockSnapshotsRequest) returns (ListBlockSnapshotsResponse) {
}
rpc ExpandBlockVolume (ExpandBlockVolumeRequest) returns (ExpandBlockVolumeResponse) {
}
}
@@ -786,3 +794,40 @@ message VolumeServerDeleteBlockVolumeRequest {
}
message VolumeServerDeleteBlockVolumeResponse {
}
message SnapshotBlockVolumeRequest {
string name = 1;
uint32 snapshot_id = 2;
}
message SnapshotBlockVolumeResponse {
uint32 snapshot_id = 1;
int64 created_at = 2; // Unix timestamp in seconds
uint64 size_bytes = 3;
}
message DeleteBlockSnapshotRequest {
string name = 1;
uint32 snapshot_id = 2;
}
message DeleteBlockSnapshotResponse {
}
message ListBlockSnapshotsRequest {
string name = 1;
}
message ListBlockSnapshotsResponse {
repeated BlockSnapshotInfo snapshots = 1;
}
message BlockSnapshotInfo {
uint32 snapshot_id = 1;
int64 created_at = 2; // Unix timestamp in seconds
uint64 volume_size_bytes = 3;
}
message ExpandBlockVolumeRequest {
string name = 1;
uint64 new_size_bytes = 2;
}
message ExpandBlockVolumeResponse {
uint64 capacity_bytes = 1;
}
+661 -161
View File
@@ -6246,15 +6246,15 @@ func (x *AllocateBlockVolumeRequest) GetDiskType() string {
}
type AllocateBlockVolumeResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
Iqn string `protobuf:"bytes,2,opt,name=iqn,proto3" json:"iqn,omitempty"`
IscsiAddr string `protobuf:"bytes,3,opt,name=iscsi_addr,json=iscsiAddr,proto3" json:"iscsi_addr,omitempty"`
ReplicaDataAddr string `protobuf:"bytes,4,opt,name=replica_data_addr,json=replicaDataAddr,proto3" json:"replica_data_addr,omitempty"`
ReplicaCtrlAddr string `protobuf:"bytes,5,opt,name=replica_ctrl_addr,json=replicaCtrlAddr,proto3" json:"replica_ctrl_addr,omitempty"`
RebuildListenAddr string `protobuf:"bytes,6,opt,name=rebuild_listen_addr,json=rebuildListenAddr,proto3" json:"rebuild_listen_addr,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
Iqn string `protobuf:"bytes,2,opt,name=iqn,proto3" json:"iqn,omitempty"`
IscsiAddr string `protobuf:"bytes,3,opt,name=iscsi_addr,json=iscsiAddr,proto3" json:"iscsi_addr,omitempty"`
ReplicaDataAddr string `protobuf:"bytes,4,opt,name=replica_data_addr,json=replicaDataAddr,proto3" json:"replica_data_addr,omitempty"`
ReplicaCtrlAddr string `protobuf:"bytes,5,opt,name=replica_ctrl_addr,json=replicaCtrlAddr,proto3" json:"replica_ctrl_addr,omitempty"`
RebuildListenAddr string `protobuf:"bytes,6,opt,name=rebuild_listen_addr,json=rebuildListenAddr,proto3" json:"rebuild_listen_addr,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AllocateBlockVolumeResponse) Reset() {
@@ -6409,6 +6409,450 @@ func (*VolumeServerDeleteBlockVolumeResponse) Descriptor() ([]byte, []int) {
return file_volume_server_proto_rawDescGZIP(), []int{110}
}
type SnapshotBlockVolumeRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
SnapshotId uint32 `protobuf:"varint,2,opt,name=snapshot_id,json=snapshotId,proto3" json:"snapshot_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SnapshotBlockVolumeRequest) Reset() {
*x = SnapshotBlockVolumeRequest{}
mi := &file_volume_server_proto_msgTypes[111]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SnapshotBlockVolumeRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SnapshotBlockVolumeRequest) ProtoMessage() {}
func (x *SnapshotBlockVolumeRequest) ProtoReflect() protoreflect.Message {
mi := &file_volume_server_proto_msgTypes[111]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SnapshotBlockVolumeRequest.ProtoReflect.Descriptor instead.
func (*SnapshotBlockVolumeRequest) Descriptor() ([]byte, []int) {
return file_volume_server_proto_rawDescGZIP(), []int{111}
}
func (x *SnapshotBlockVolumeRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *SnapshotBlockVolumeRequest) GetSnapshotId() uint32 {
if x != nil {
return x.SnapshotId
}
return 0
}
type SnapshotBlockVolumeResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
SnapshotId uint32 `protobuf:"varint,1,opt,name=snapshot_id,json=snapshotId,proto3" json:"snapshot_id,omitempty"`
CreatedAt int64 `protobuf:"varint,2,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` // Unix timestamp in seconds
SizeBytes uint64 `protobuf:"varint,3,opt,name=size_bytes,json=sizeBytes,proto3" json:"size_bytes,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SnapshotBlockVolumeResponse) Reset() {
*x = SnapshotBlockVolumeResponse{}
mi := &file_volume_server_proto_msgTypes[112]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SnapshotBlockVolumeResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SnapshotBlockVolumeResponse) ProtoMessage() {}
func (x *SnapshotBlockVolumeResponse) ProtoReflect() protoreflect.Message {
mi := &file_volume_server_proto_msgTypes[112]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SnapshotBlockVolumeResponse.ProtoReflect.Descriptor instead.
func (*SnapshotBlockVolumeResponse) Descriptor() ([]byte, []int) {
return file_volume_server_proto_rawDescGZIP(), []int{112}
}
func (x *SnapshotBlockVolumeResponse) GetSnapshotId() uint32 {
if x != nil {
return x.SnapshotId
}
return 0
}
func (x *SnapshotBlockVolumeResponse) GetCreatedAt() int64 {
if x != nil {
return x.CreatedAt
}
return 0
}
func (x *SnapshotBlockVolumeResponse) GetSizeBytes() uint64 {
if x != nil {
return x.SizeBytes
}
return 0
}
type DeleteBlockSnapshotRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
SnapshotId uint32 `protobuf:"varint,2,opt,name=snapshot_id,json=snapshotId,proto3" json:"snapshot_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteBlockSnapshotRequest) Reset() {
*x = DeleteBlockSnapshotRequest{}
mi := &file_volume_server_proto_msgTypes[113]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteBlockSnapshotRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteBlockSnapshotRequest) ProtoMessage() {}
func (x *DeleteBlockSnapshotRequest) ProtoReflect() protoreflect.Message {
mi := &file_volume_server_proto_msgTypes[113]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteBlockSnapshotRequest.ProtoReflect.Descriptor instead.
func (*DeleteBlockSnapshotRequest) Descriptor() ([]byte, []int) {
return file_volume_server_proto_rawDescGZIP(), []int{113}
}
func (x *DeleteBlockSnapshotRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *DeleteBlockSnapshotRequest) GetSnapshotId() uint32 {
if x != nil {
return x.SnapshotId
}
return 0
}
type DeleteBlockSnapshotResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteBlockSnapshotResponse) Reset() {
*x = DeleteBlockSnapshotResponse{}
mi := &file_volume_server_proto_msgTypes[114]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteBlockSnapshotResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteBlockSnapshotResponse) ProtoMessage() {}
func (x *DeleteBlockSnapshotResponse) ProtoReflect() protoreflect.Message {
mi := &file_volume_server_proto_msgTypes[114]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteBlockSnapshotResponse.ProtoReflect.Descriptor instead.
func (*DeleteBlockSnapshotResponse) Descriptor() ([]byte, []int) {
return file_volume_server_proto_rawDescGZIP(), []int{114}
}
type ListBlockSnapshotsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListBlockSnapshotsRequest) Reset() {
*x = ListBlockSnapshotsRequest{}
mi := &file_volume_server_proto_msgTypes[115]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListBlockSnapshotsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListBlockSnapshotsRequest) ProtoMessage() {}
func (x *ListBlockSnapshotsRequest) ProtoReflect() protoreflect.Message {
mi := &file_volume_server_proto_msgTypes[115]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListBlockSnapshotsRequest.ProtoReflect.Descriptor instead.
func (*ListBlockSnapshotsRequest) Descriptor() ([]byte, []int) {
return file_volume_server_proto_rawDescGZIP(), []int{115}
}
func (x *ListBlockSnapshotsRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
type ListBlockSnapshotsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Snapshots []*BlockSnapshotInfo `protobuf:"bytes,1,rep,name=snapshots,proto3" json:"snapshots,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListBlockSnapshotsResponse) Reset() {
*x = ListBlockSnapshotsResponse{}
mi := &file_volume_server_proto_msgTypes[116]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListBlockSnapshotsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListBlockSnapshotsResponse) ProtoMessage() {}
func (x *ListBlockSnapshotsResponse) ProtoReflect() protoreflect.Message {
mi := &file_volume_server_proto_msgTypes[116]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListBlockSnapshotsResponse.ProtoReflect.Descriptor instead.
func (*ListBlockSnapshotsResponse) Descriptor() ([]byte, []int) {
return file_volume_server_proto_rawDescGZIP(), []int{116}
}
func (x *ListBlockSnapshotsResponse) GetSnapshots() []*BlockSnapshotInfo {
if x != nil {
return x.Snapshots
}
return nil
}
type BlockSnapshotInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
SnapshotId uint32 `protobuf:"varint,1,opt,name=snapshot_id,json=snapshotId,proto3" json:"snapshot_id,omitempty"`
CreatedAt int64 `protobuf:"varint,2,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` // Unix timestamp in seconds
VolumeSizeBytes uint64 `protobuf:"varint,3,opt,name=volume_size_bytes,json=volumeSizeBytes,proto3" json:"volume_size_bytes,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BlockSnapshotInfo) Reset() {
*x = BlockSnapshotInfo{}
mi := &file_volume_server_proto_msgTypes[117]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *BlockSnapshotInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*BlockSnapshotInfo) ProtoMessage() {}
func (x *BlockSnapshotInfo) ProtoReflect() protoreflect.Message {
mi := &file_volume_server_proto_msgTypes[117]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use BlockSnapshotInfo.ProtoReflect.Descriptor instead.
func (*BlockSnapshotInfo) Descriptor() ([]byte, []int) {
return file_volume_server_proto_rawDescGZIP(), []int{117}
}
func (x *BlockSnapshotInfo) GetSnapshotId() uint32 {
if x != nil {
return x.SnapshotId
}
return 0
}
func (x *BlockSnapshotInfo) GetCreatedAt() int64 {
if x != nil {
return x.CreatedAt
}
return 0
}
func (x *BlockSnapshotInfo) GetVolumeSizeBytes() uint64 {
if x != nil {
return x.VolumeSizeBytes
}
return 0
}
type ExpandBlockVolumeRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
NewSizeBytes uint64 `protobuf:"varint,2,opt,name=new_size_bytes,json=newSizeBytes,proto3" json:"new_size_bytes,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExpandBlockVolumeRequest) Reset() {
*x = ExpandBlockVolumeRequest{}
mi := &file_volume_server_proto_msgTypes[118]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExpandBlockVolumeRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExpandBlockVolumeRequest) ProtoMessage() {}
func (x *ExpandBlockVolumeRequest) ProtoReflect() protoreflect.Message {
mi := &file_volume_server_proto_msgTypes[118]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExpandBlockVolumeRequest.ProtoReflect.Descriptor instead.
func (*ExpandBlockVolumeRequest) Descriptor() ([]byte, []int) {
return file_volume_server_proto_rawDescGZIP(), []int{118}
}
func (x *ExpandBlockVolumeRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *ExpandBlockVolumeRequest) GetNewSizeBytes() uint64 {
if x != nil {
return x.NewSizeBytes
}
return 0
}
type ExpandBlockVolumeResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
CapacityBytes uint64 `protobuf:"varint,1,opt,name=capacity_bytes,json=capacityBytes,proto3" json:"capacity_bytes,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExpandBlockVolumeResponse) Reset() {
*x = ExpandBlockVolumeResponse{}
mi := &file_volume_server_proto_msgTypes[119]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExpandBlockVolumeResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExpandBlockVolumeResponse) ProtoMessage() {}
func (x *ExpandBlockVolumeResponse) ProtoReflect() protoreflect.Message {
mi := &file_volume_server_proto_msgTypes[119]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExpandBlockVolumeResponse.ProtoReflect.Descriptor instead.
func (*ExpandBlockVolumeResponse) Descriptor() ([]byte, []int) {
return file_volume_server_proto_rawDescGZIP(), []int{119}
}
func (x *ExpandBlockVolumeResponse) GetCapacityBytes() uint64 {
if x != nil {
return x.CapacityBytes
}
return 0
}
type FetchAndWriteNeedleRequest_Replica struct {
state protoimpl.MessageState `protogen:"open.v1"`
Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"`
@@ -6420,7 +6864,7 @@ type FetchAndWriteNeedleRequest_Replica struct {
func (x *FetchAndWriteNeedleRequest_Replica) Reset() {
*x = FetchAndWriteNeedleRequest_Replica{}
mi := &file_volume_server_proto_msgTypes[111]
mi := &file_volume_server_proto_msgTypes[120]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6432,7 +6876,7 @@ func (x *FetchAndWriteNeedleRequest_Replica) String() string {
func (*FetchAndWriteNeedleRequest_Replica) ProtoMessage() {}
func (x *FetchAndWriteNeedleRequest_Replica) ProtoReflect() protoreflect.Message {
mi := &file_volume_server_proto_msgTypes[111]
mi := &file_volume_server_proto_msgTypes[120]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6480,7 +6924,7 @@ type QueryRequest_Filter struct {
func (x *QueryRequest_Filter) Reset() {
*x = QueryRequest_Filter{}
mi := &file_volume_server_proto_msgTypes[112]
mi := &file_volume_server_proto_msgTypes[121]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6492,7 +6936,7 @@ func (x *QueryRequest_Filter) String() string {
func (*QueryRequest_Filter) ProtoMessage() {}
func (x *QueryRequest_Filter) ProtoReflect() protoreflect.Message {
mi := &file_volume_server_proto_msgTypes[112]
mi := &file_volume_server_proto_msgTypes[121]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6542,7 +6986,7 @@ type QueryRequest_InputSerialization struct {
func (x *QueryRequest_InputSerialization) Reset() {
*x = QueryRequest_InputSerialization{}
mi := &file_volume_server_proto_msgTypes[113]
mi := &file_volume_server_proto_msgTypes[122]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6554,7 +6998,7 @@ func (x *QueryRequest_InputSerialization) String() string {
func (*QueryRequest_InputSerialization) ProtoMessage() {}
func (x *QueryRequest_InputSerialization) ProtoReflect() protoreflect.Message {
mi := &file_volume_server_proto_msgTypes[113]
mi := &file_volume_server_proto_msgTypes[122]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6608,7 +7052,7 @@ type QueryRequest_OutputSerialization struct {
func (x *QueryRequest_OutputSerialization) Reset() {
*x = QueryRequest_OutputSerialization{}
mi := &file_volume_server_proto_msgTypes[114]
mi := &file_volume_server_proto_msgTypes[123]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6620,7 +7064,7 @@ func (x *QueryRequest_OutputSerialization) String() string {
func (*QueryRequest_OutputSerialization) ProtoMessage() {}
func (x *QueryRequest_OutputSerialization) ProtoReflect() protoreflect.Message {
mi := &file_volume_server_proto_msgTypes[114]
mi := &file_volume_server_proto_msgTypes[123]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6666,7 +7110,7 @@ type QueryRequest_InputSerialization_CSVInput struct {
func (x *QueryRequest_InputSerialization_CSVInput) Reset() {
*x = QueryRequest_InputSerialization_CSVInput{}
mi := &file_volume_server_proto_msgTypes[115]
mi := &file_volume_server_proto_msgTypes[124]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6678,7 +7122,7 @@ func (x *QueryRequest_InputSerialization_CSVInput) String() string {
func (*QueryRequest_InputSerialization_CSVInput) ProtoMessage() {}
func (x *QueryRequest_InputSerialization_CSVInput) ProtoReflect() protoreflect.Message {
mi := &file_volume_server_proto_msgTypes[115]
mi := &file_volume_server_proto_msgTypes[124]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6752,7 +7196,7 @@ type QueryRequest_InputSerialization_JSONInput struct {
func (x *QueryRequest_InputSerialization_JSONInput) Reset() {
*x = QueryRequest_InputSerialization_JSONInput{}
mi := &file_volume_server_proto_msgTypes[116]
mi := &file_volume_server_proto_msgTypes[125]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6764,7 +7208,7 @@ func (x *QueryRequest_InputSerialization_JSONInput) String() string {
func (*QueryRequest_InputSerialization_JSONInput) ProtoMessage() {}
func (x *QueryRequest_InputSerialization_JSONInput) ProtoReflect() protoreflect.Message {
mi := &file_volume_server_proto_msgTypes[116]
mi := &file_volume_server_proto_msgTypes[125]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6795,7 +7239,7 @@ type QueryRequest_InputSerialization_ParquetInput struct {
func (x *QueryRequest_InputSerialization_ParquetInput) Reset() {
*x = QueryRequest_InputSerialization_ParquetInput{}
mi := &file_volume_server_proto_msgTypes[117]
mi := &file_volume_server_proto_msgTypes[126]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6807,7 +7251,7 @@ func (x *QueryRequest_InputSerialization_ParquetInput) String() string {
func (*QueryRequest_InputSerialization_ParquetInput) ProtoMessage() {}
func (x *QueryRequest_InputSerialization_ParquetInput) ProtoReflect() protoreflect.Message {
mi := &file_volume_server_proto_msgTypes[117]
mi := &file_volume_server_proto_msgTypes[126]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6836,7 +7280,7 @@ type QueryRequest_OutputSerialization_CSVOutput struct {
func (x *QueryRequest_OutputSerialization_CSVOutput) Reset() {
*x = QueryRequest_OutputSerialization_CSVOutput{}
mi := &file_volume_server_proto_msgTypes[118]
mi := &file_volume_server_proto_msgTypes[127]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6848,7 +7292,7 @@ func (x *QueryRequest_OutputSerialization_CSVOutput) String() string {
func (*QueryRequest_OutputSerialization_CSVOutput) ProtoMessage() {}
func (x *QueryRequest_OutputSerialization_CSVOutput) ProtoReflect() protoreflect.Message {
mi := &file_volume_server_proto_msgTypes[118]
mi := &file_volume_server_proto_msgTypes[127]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6908,7 +7352,7 @@ type QueryRequest_OutputSerialization_JSONOutput struct {
func (x *QueryRequest_OutputSerialization_JSONOutput) Reset() {
*x = QueryRequest_OutputSerialization_JSONOutput{}
mi := &file_volume_server_proto_msgTypes[119]
mi := &file_volume_server_proto_msgTypes[128]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6920,7 +7364,7 @@ func (x *QueryRequest_OutputSerialization_JSONOutput) String() string {
func (*QueryRequest_OutputSerialization_JSONOutput) ProtoMessage() {}
func (x *QueryRequest_OutputSerialization_JSONOutput) ProtoReflect() protoreflect.Message {
mi := &file_volume_server_proto_msgTypes[119]
mi := &file_volume_server_proto_msgTypes[128]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -7451,20 +7895,54 @@ const file_volume_server_proto_rawDesc = "" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" +
"\n" +
"size_bytes\x18\x02 \x01(\x04R\tsizeBytes\x12\x1b\n" +
"\tdisk_type\x18\x03 \x01(\tR\bdiskType\"b\n" +
"\tdisk_type\x18\x03 \x01(\tR\bdiskType\"\xea\x01\n" +
"\x1bAllocateBlockVolumeResponse\x12\x12\n" +
"\x04path\x18\x01 \x01(\tR\x04path\x12\x10\n" +
"\x03iqn\x18\x02 \x01(\tR\x03iqn\x12\x1d\n" +
"\n" +
"iscsi_addr\x18\x03 \x01(\tR\tiscsiAddr\":\n" +
"iscsi_addr\x18\x03 \x01(\tR\tiscsiAddr\x12*\n" +
"\x11replica_data_addr\x18\x04 \x01(\tR\x0freplicaDataAddr\x12*\n" +
"\x11replica_ctrl_addr\x18\x05 \x01(\tR\x0freplicaCtrlAddr\x12.\n" +
"\x13rebuild_listen_addr\x18\x06 \x01(\tR\x11rebuildListenAddr\":\n" +
"$VolumeServerDeleteBlockVolumeRequest\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\"'\n" +
"%VolumeServerDeleteBlockVolumeResponse*>\n" +
"%VolumeServerDeleteBlockVolumeResponse\"Q\n" +
"\x1aSnapshotBlockVolumeRequest\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x1f\n" +
"\vsnapshot_id\x18\x02 \x01(\rR\n" +
"snapshotId\"|\n" +
"\x1bSnapshotBlockVolumeResponse\x12\x1f\n" +
"\vsnapshot_id\x18\x01 \x01(\rR\n" +
"snapshotId\x12\x1d\n" +
"\n" +
"created_at\x18\x02 \x01(\x03R\tcreatedAt\x12\x1d\n" +
"\n" +
"size_bytes\x18\x03 \x01(\x04R\tsizeBytes\"Q\n" +
"\x1aDeleteBlockSnapshotRequest\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x1f\n" +
"\vsnapshot_id\x18\x02 \x01(\rR\n" +
"snapshotId\"\x1d\n" +
"\x1bDeleteBlockSnapshotResponse\"/\n" +
"\x19ListBlockSnapshotsRequest\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\"_\n" +
"\x1aListBlockSnapshotsResponse\x12A\n" +
"\tsnapshots\x18\x01 \x03(\v2#.volume_server_pb.BlockSnapshotInfoR\tsnapshots\"\x7f\n" +
"\x11BlockSnapshotInfo\x12\x1f\n" +
"\vsnapshot_id\x18\x01 \x01(\rR\n" +
"snapshotId\x12\x1d\n" +
"\n" +
"created_at\x18\x02 \x01(\x03R\tcreatedAt\x12*\n" +
"\x11volume_size_bytes\x18\x03 \x01(\x04R\x0fvolumeSizeBytes\"T\n" +
"\x18ExpandBlockVolumeRequest\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12$\n" +
"\x0enew_size_bytes\x18\x02 \x01(\x04R\fnewSizeBytes\"B\n" +
"\x19ExpandBlockVolumeResponse\x12%\n" +
"\x0ecapacity_bytes\x18\x01 \x01(\x04R\rcapacityBytes*>\n" +
"\x0fVolumeScrubMode\x12\v\n" +
"\aUNKNOWN\x10\x00\x12\t\n" +
"\x05INDEX\x10\x01\x12\b\n" +
"\x04FULL\x10\x02\x12\t\n" +
"\x05LOCAL\x10\x032\x86+\n" +
"\x05LOCAL\x10\x032\xd5.\n" +
"\fVolumeServer\x12\\\n" +
"\vBatchDelete\x12$.volume_server_pb.BatchDeleteRequest\x1a%.volume_server_pb.BatchDeleteResponse\"\x00\x12n\n" +
"\x11VacuumVolumeCheck\x12*.volume_server_pb.VacuumVolumeCheckRequest\x1a+.volume_server_pb.VacuumVolumeCheckResponse\"\x00\x12v\n" +
@@ -7516,7 +7994,11 @@ const file_volume_server_proto_rawDesc = "" +
"\x12VolumeNeedleStatus\x12+.volume_server_pb.VolumeNeedleStatusRequest\x1a,.volume_server_pb.VolumeNeedleStatusResponse\"\x00\x12G\n" +
"\x04Ping\x12\x1d.volume_server_pb.PingRequest\x1a\x1e.volume_server_pb.PingResponse\"\x00\x12t\n" +
"\x13AllocateBlockVolume\x12,.volume_server_pb.AllocateBlockVolumeRequest\x1a-.volume_server_pb.AllocateBlockVolumeResponse\"\x00\x12\x92\x01\n" +
"\x1dVolumeServerDeleteBlockVolume\x126.volume_server_pb.VolumeServerDeleteBlockVolumeRequest\x1a7.volume_server_pb.VolumeServerDeleteBlockVolumeResponse\"\x00B9Z7github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pbb\x06proto3"
"\x1dVolumeServerDeleteBlockVolume\x126.volume_server_pb.VolumeServerDeleteBlockVolumeRequest\x1a7.volume_server_pb.VolumeServerDeleteBlockVolumeResponse\"\x00\x12t\n" +
"\x13SnapshotBlockVolume\x12,.volume_server_pb.SnapshotBlockVolumeRequest\x1a-.volume_server_pb.SnapshotBlockVolumeResponse\"\x00\x12t\n" +
"\x13DeleteBlockSnapshot\x12,.volume_server_pb.DeleteBlockSnapshotRequest\x1a-.volume_server_pb.DeleteBlockSnapshotResponse\"\x00\x12q\n" +
"\x12ListBlockSnapshots\x12+.volume_server_pb.ListBlockSnapshotsRequest\x1a,.volume_server_pb.ListBlockSnapshotsResponse\"\x00\x12n\n" +
"\x11ExpandBlockVolume\x12*.volume_server_pb.ExpandBlockVolumeRequest\x1a+.volume_server_pb.ExpandBlockVolumeResponse\"\x00B9Z7github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pbb\x06proto3"
var (
file_volume_server_proto_rawDescOnce sync.Once
@@ -7531,7 +8013,7 @@ func file_volume_server_proto_rawDescGZIP() []byte {
}
var file_volume_server_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_volume_server_proto_msgTypes = make([]protoimpl.MessageInfo, 120)
var file_volume_server_proto_msgTypes = make([]protoimpl.MessageInfo, 129)
var file_volume_server_proto_goTypes = []any{
(VolumeScrubMode)(0), // 0: volume_server_pb.VolumeScrubMode
(*VolumeServerState)(nil), // 1: volume_server_pb.VolumeServerState
@@ -7645,17 +8127,26 @@ var file_volume_server_proto_goTypes = []any{
(*AllocateBlockVolumeResponse)(nil), // 109: volume_server_pb.AllocateBlockVolumeResponse
(*VolumeServerDeleteBlockVolumeRequest)(nil), // 110: volume_server_pb.VolumeServerDeleteBlockVolumeRequest
(*VolumeServerDeleteBlockVolumeResponse)(nil), // 111: volume_server_pb.VolumeServerDeleteBlockVolumeResponse
(*FetchAndWriteNeedleRequest_Replica)(nil), // 112: volume_server_pb.FetchAndWriteNeedleRequest.Replica
(*QueryRequest_Filter)(nil), // 113: volume_server_pb.QueryRequest.Filter
(*QueryRequest_InputSerialization)(nil), // 114: volume_server_pb.QueryRequest.InputSerialization
(*QueryRequest_OutputSerialization)(nil), // 115: volume_server_pb.QueryRequest.OutputSerialization
(*QueryRequest_InputSerialization_CSVInput)(nil), // 116: volume_server_pb.QueryRequest.InputSerialization.CSVInput
(*QueryRequest_InputSerialization_JSONInput)(nil), // 117: volume_server_pb.QueryRequest.InputSerialization.JSONInput
(*QueryRequest_InputSerialization_ParquetInput)(nil), // 118: volume_server_pb.QueryRequest.InputSerialization.ParquetInput
(*QueryRequest_OutputSerialization_CSVOutput)(nil), // 119: volume_server_pb.QueryRequest.OutputSerialization.CSVOutput
(*QueryRequest_OutputSerialization_JSONOutput)(nil), // 120: volume_server_pb.QueryRequest.OutputSerialization.JSONOutput
(*remote_pb.RemoteConf)(nil), // 121: remote_pb.RemoteConf
(*remote_pb.RemoteStorageLocation)(nil), // 122: remote_pb.RemoteStorageLocation
(*SnapshotBlockVolumeRequest)(nil), // 112: volume_server_pb.SnapshotBlockVolumeRequest
(*SnapshotBlockVolumeResponse)(nil), // 113: volume_server_pb.SnapshotBlockVolumeResponse
(*DeleteBlockSnapshotRequest)(nil), // 114: volume_server_pb.DeleteBlockSnapshotRequest
(*DeleteBlockSnapshotResponse)(nil), // 115: volume_server_pb.DeleteBlockSnapshotResponse
(*ListBlockSnapshotsRequest)(nil), // 116: volume_server_pb.ListBlockSnapshotsRequest
(*ListBlockSnapshotsResponse)(nil), // 117: volume_server_pb.ListBlockSnapshotsResponse
(*BlockSnapshotInfo)(nil), // 118: volume_server_pb.BlockSnapshotInfo
(*ExpandBlockVolumeRequest)(nil), // 119: volume_server_pb.ExpandBlockVolumeRequest
(*ExpandBlockVolumeResponse)(nil), // 120: volume_server_pb.ExpandBlockVolumeResponse
(*FetchAndWriteNeedleRequest_Replica)(nil), // 121: volume_server_pb.FetchAndWriteNeedleRequest.Replica
(*QueryRequest_Filter)(nil), // 122: volume_server_pb.QueryRequest.Filter
(*QueryRequest_InputSerialization)(nil), // 123: volume_server_pb.QueryRequest.InputSerialization
(*QueryRequest_OutputSerialization)(nil), // 124: volume_server_pb.QueryRequest.OutputSerialization
(*QueryRequest_InputSerialization_CSVInput)(nil), // 125: volume_server_pb.QueryRequest.InputSerialization.CSVInput
(*QueryRequest_InputSerialization_JSONInput)(nil), // 126: volume_server_pb.QueryRequest.InputSerialization.JSONInput
(*QueryRequest_InputSerialization_ParquetInput)(nil), // 127: volume_server_pb.QueryRequest.InputSerialization.ParquetInput
(*QueryRequest_OutputSerialization_CSVOutput)(nil), // 128: volume_server_pb.QueryRequest.OutputSerialization.CSVOutput
(*QueryRequest_OutputSerialization_JSONOutput)(nil), // 129: volume_server_pb.QueryRequest.OutputSerialization.JSONOutput
(*remote_pb.RemoteConf)(nil), // 130: remote_pb.RemoteConf
(*remote_pb.RemoteStorageLocation)(nil), // 131: remote_pb.RemoteStorageLocation
}
var file_volume_server_proto_depIdxs = []int32{
4, // 0: volume_server_pb.BatchDeleteResponse.results:type_name -> volume_server_pb.DeleteResult
@@ -7671,125 +8162,134 @@ var file_volume_server_proto_depIdxs = []int32{
82, // 10: volume_server_pb.VolumeServerStatusResponse.disk_statuses:type_name -> volume_server_pb.DiskStatus
83, // 11: volume_server_pb.VolumeServerStatusResponse.memory_status:type_name -> volume_server_pb.MemStatus
1, // 12: volume_server_pb.VolumeServerStatusResponse.state:type_name -> volume_server_pb.VolumeServerState
112, // 13: volume_server_pb.FetchAndWriteNeedleRequest.replicas:type_name -> volume_server_pb.FetchAndWriteNeedleRequest.Replica
121, // 14: volume_server_pb.FetchAndWriteNeedleRequest.remote_conf:type_name -> remote_pb.RemoteConf
122, // 15: volume_server_pb.FetchAndWriteNeedleRequest.remote_location:type_name -> remote_pb.RemoteStorageLocation
121, // 13: volume_server_pb.FetchAndWriteNeedleRequest.replicas:type_name -> volume_server_pb.FetchAndWriteNeedleRequest.Replica
130, // 14: volume_server_pb.FetchAndWriteNeedleRequest.remote_conf:type_name -> remote_pb.RemoteConf
131, // 15: volume_server_pb.FetchAndWriteNeedleRequest.remote_location:type_name -> remote_pb.RemoteStorageLocation
0, // 16: volume_server_pb.ScrubVolumeRequest.mode:type_name -> volume_server_pb.VolumeScrubMode
0, // 17: volume_server_pb.ScrubEcVolumeRequest.mode:type_name -> volume_server_pb.VolumeScrubMode
79, // 18: volume_server_pb.ScrubEcVolumeResponse.broken_shard_infos:type_name -> volume_server_pb.EcShardInfo
113, // 19: volume_server_pb.QueryRequest.filter:type_name -> volume_server_pb.QueryRequest.Filter
114, // 20: volume_server_pb.QueryRequest.input_serialization:type_name -> volume_server_pb.QueryRequest.InputSerialization
115, // 21: volume_server_pb.QueryRequest.output_serialization:type_name -> volume_server_pb.QueryRequest.OutputSerialization
116, // 22: volume_server_pb.QueryRequest.InputSerialization.csv_input:type_name -> volume_server_pb.QueryRequest.InputSerialization.CSVInput
117, // 23: volume_server_pb.QueryRequest.InputSerialization.json_input:type_name -> volume_server_pb.QueryRequest.InputSerialization.JSONInput
118, // 24: volume_server_pb.QueryRequest.InputSerialization.parquet_input:type_name -> volume_server_pb.QueryRequest.InputSerialization.ParquetInput
119, // 25: volume_server_pb.QueryRequest.OutputSerialization.csv_output:type_name -> volume_server_pb.QueryRequest.OutputSerialization.CSVOutput
120, // 26: volume_server_pb.QueryRequest.OutputSerialization.json_output:type_name -> volume_server_pb.QueryRequest.OutputSerialization.JSONOutput
2, // 27: volume_server_pb.VolumeServer.BatchDelete:input_type -> volume_server_pb.BatchDeleteRequest
6, // 28: volume_server_pb.VolumeServer.VacuumVolumeCheck:input_type -> volume_server_pb.VacuumVolumeCheckRequest
8, // 29: volume_server_pb.VolumeServer.VacuumVolumeCompact:input_type -> volume_server_pb.VacuumVolumeCompactRequest
10, // 30: volume_server_pb.VolumeServer.VacuumVolumeCommit:input_type -> volume_server_pb.VacuumVolumeCommitRequest
12, // 31: volume_server_pb.VolumeServer.VacuumVolumeCleanup:input_type -> volume_server_pb.VacuumVolumeCleanupRequest
14, // 32: volume_server_pb.VolumeServer.DeleteCollection:input_type -> volume_server_pb.DeleteCollectionRequest
16, // 33: volume_server_pb.VolumeServer.AllocateVolume:input_type -> volume_server_pb.AllocateVolumeRequest
18, // 34: volume_server_pb.VolumeServer.VolumeSyncStatus:input_type -> volume_server_pb.VolumeSyncStatusRequest
20, // 35: volume_server_pb.VolumeServer.VolumeIncrementalCopy:input_type -> volume_server_pb.VolumeIncrementalCopyRequest
22, // 36: volume_server_pb.VolumeServer.VolumeMount:input_type -> volume_server_pb.VolumeMountRequest
24, // 37: volume_server_pb.VolumeServer.VolumeUnmount:input_type -> volume_server_pb.VolumeUnmountRequest
26, // 38: volume_server_pb.VolumeServer.VolumeDelete:input_type -> volume_server_pb.VolumeDeleteRequest
28, // 39: volume_server_pb.VolumeServer.VolumeMarkReadonly:input_type -> volume_server_pb.VolumeMarkReadonlyRequest
30, // 40: volume_server_pb.VolumeServer.VolumeMarkWritable:input_type -> volume_server_pb.VolumeMarkWritableRequest
32, // 41: volume_server_pb.VolumeServer.VolumeConfigure:input_type -> volume_server_pb.VolumeConfigureRequest
34, // 42: volume_server_pb.VolumeServer.VolumeStatus:input_type -> volume_server_pb.VolumeStatusRequest
36, // 43: volume_server_pb.VolumeServer.GetState:input_type -> volume_server_pb.GetStateRequest
38, // 44: volume_server_pb.VolumeServer.SetState:input_type -> volume_server_pb.SetStateRequest
40, // 45: volume_server_pb.VolumeServer.VolumeCopy:input_type -> volume_server_pb.VolumeCopyRequest
80, // 46: volume_server_pb.VolumeServer.ReadVolumeFileStatus:input_type -> volume_server_pb.ReadVolumeFileStatusRequest
42, // 47: volume_server_pb.VolumeServer.CopyFile:input_type -> volume_server_pb.CopyFileRequest
44, // 48: volume_server_pb.VolumeServer.ReceiveFile:input_type -> volume_server_pb.ReceiveFileRequest
47, // 49: volume_server_pb.VolumeServer.ReadNeedleBlob:input_type -> volume_server_pb.ReadNeedleBlobRequest
49, // 50: volume_server_pb.VolumeServer.ReadNeedleMeta:input_type -> volume_server_pb.ReadNeedleMetaRequest
51, // 51: volume_server_pb.VolumeServer.WriteNeedleBlob:input_type -> volume_server_pb.WriteNeedleBlobRequest
53, // 52: volume_server_pb.VolumeServer.ReadAllNeedles:input_type -> volume_server_pb.ReadAllNeedlesRequest
55, // 53: volume_server_pb.VolumeServer.VolumeTailSender:input_type -> volume_server_pb.VolumeTailSenderRequest
57, // 54: volume_server_pb.VolumeServer.VolumeTailReceiver:input_type -> volume_server_pb.VolumeTailReceiverRequest
59, // 55: volume_server_pb.VolumeServer.VolumeEcShardsGenerate:input_type -> volume_server_pb.VolumeEcShardsGenerateRequest
61, // 56: volume_server_pb.VolumeServer.VolumeEcShardsRebuild:input_type -> volume_server_pb.VolumeEcShardsRebuildRequest
63, // 57: volume_server_pb.VolumeServer.VolumeEcShardsCopy:input_type -> volume_server_pb.VolumeEcShardsCopyRequest
65, // 58: volume_server_pb.VolumeServer.VolumeEcShardsDelete:input_type -> volume_server_pb.VolumeEcShardsDeleteRequest
67, // 59: volume_server_pb.VolumeServer.VolumeEcShardsMount:input_type -> volume_server_pb.VolumeEcShardsMountRequest
69, // 60: volume_server_pb.VolumeServer.VolumeEcShardsUnmount:input_type -> volume_server_pb.VolumeEcShardsUnmountRequest
71, // 61: volume_server_pb.VolumeServer.VolumeEcShardRead:input_type -> volume_server_pb.VolumeEcShardReadRequest
73, // 62: volume_server_pb.VolumeServer.VolumeEcBlobDelete:input_type -> volume_server_pb.VolumeEcBlobDeleteRequest
75, // 63: volume_server_pb.VolumeServer.VolumeEcShardsToVolume:input_type -> volume_server_pb.VolumeEcShardsToVolumeRequest
77, // 64: volume_server_pb.VolumeServer.VolumeEcShardsInfo:input_type -> volume_server_pb.VolumeEcShardsInfoRequest
88, // 65: volume_server_pb.VolumeServer.VolumeTierMoveDatToRemote:input_type -> volume_server_pb.VolumeTierMoveDatToRemoteRequest
90, // 66: volume_server_pb.VolumeServer.VolumeTierMoveDatFromRemote:input_type -> volume_server_pb.VolumeTierMoveDatFromRemoteRequest
92, // 67: volume_server_pb.VolumeServer.VolumeServerStatus:input_type -> volume_server_pb.VolumeServerStatusRequest
94, // 68: volume_server_pb.VolumeServer.VolumeServerLeave:input_type -> volume_server_pb.VolumeServerLeaveRequest
96, // 69: volume_server_pb.VolumeServer.FetchAndWriteNeedle:input_type -> volume_server_pb.FetchAndWriteNeedleRequest
98, // 70: volume_server_pb.VolumeServer.ScrubVolume:input_type -> volume_server_pb.ScrubVolumeRequest
100, // 71: volume_server_pb.VolumeServer.ScrubEcVolume:input_type -> volume_server_pb.ScrubEcVolumeRequest
102, // 72: volume_server_pb.VolumeServer.Query:input_type -> volume_server_pb.QueryRequest
104, // 73: volume_server_pb.VolumeServer.VolumeNeedleStatus:input_type -> volume_server_pb.VolumeNeedleStatusRequest
106, // 74: volume_server_pb.VolumeServer.Ping:input_type -> volume_server_pb.PingRequest
108, // 75: volume_server_pb.VolumeServer.AllocateBlockVolume:input_type -> volume_server_pb.AllocateBlockVolumeRequest
110, // 76: volume_server_pb.VolumeServer.VolumeServerDeleteBlockVolume:input_type -> volume_server_pb.VolumeServerDeleteBlockVolumeRequest
3, // 77: volume_server_pb.VolumeServer.BatchDelete:output_type -> volume_server_pb.BatchDeleteResponse
7, // 78: volume_server_pb.VolumeServer.VacuumVolumeCheck:output_type -> volume_server_pb.VacuumVolumeCheckResponse
9, // 79: volume_server_pb.VolumeServer.VacuumVolumeCompact:output_type -> volume_server_pb.VacuumVolumeCompactResponse
11, // 80: volume_server_pb.VolumeServer.VacuumVolumeCommit:output_type -> volume_server_pb.VacuumVolumeCommitResponse
13, // 81: volume_server_pb.VolumeServer.VacuumVolumeCleanup:output_type -> volume_server_pb.VacuumVolumeCleanupResponse
15, // 82: volume_server_pb.VolumeServer.DeleteCollection:output_type -> volume_server_pb.DeleteCollectionResponse
17, // 83: volume_server_pb.VolumeServer.AllocateVolume:output_type -> volume_server_pb.AllocateVolumeResponse
19, // 84: volume_server_pb.VolumeServer.VolumeSyncStatus:output_type -> volume_server_pb.VolumeSyncStatusResponse
21, // 85: volume_server_pb.VolumeServer.VolumeIncrementalCopy:output_type -> volume_server_pb.VolumeIncrementalCopyResponse
23, // 86: volume_server_pb.VolumeServer.VolumeMount:output_type -> volume_server_pb.VolumeMountResponse
25, // 87: volume_server_pb.VolumeServer.VolumeUnmount:output_type -> volume_server_pb.VolumeUnmountResponse
27, // 88: volume_server_pb.VolumeServer.VolumeDelete:output_type -> volume_server_pb.VolumeDeleteResponse
29, // 89: volume_server_pb.VolumeServer.VolumeMarkReadonly:output_type -> volume_server_pb.VolumeMarkReadonlyResponse
31, // 90: volume_server_pb.VolumeServer.VolumeMarkWritable:output_type -> volume_server_pb.VolumeMarkWritableResponse
33, // 91: volume_server_pb.VolumeServer.VolumeConfigure:output_type -> volume_server_pb.VolumeConfigureResponse
35, // 92: volume_server_pb.VolumeServer.VolumeStatus:output_type -> volume_server_pb.VolumeStatusResponse
37, // 93: volume_server_pb.VolumeServer.GetState:output_type -> volume_server_pb.GetStateResponse
39, // 94: volume_server_pb.VolumeServer.SetState:output_type -> volume_server_pb.SetStateResponse
41, // 95: volume_server_pb.VolumeServer.VolumeCopy:output_type -> volume_server_pb.VolumeCopyResponse
81, // 96: volume_server_pb.VolumeServer.ReadVolumeFileStatus:output_type -> volume_server_pb.ReadVolumeFileStatusResponse
43, // 97: volume_server_pb.VolumeServer.CopyFile:output_type -> volume_server_pb.CopyFileResponse
46, // 98: volume_server_pb.VolumeServer.ReceiveFile:output_type -> volume_server_pb.ReceiveFileResponse
48, // 99: volume_server_pb.VolumeServer.ReadNeedleBlob:output_type -> volume_server_pb.ReadNeedleBlobResponse
50, // 100: volume_server_pb.VolumeServer.ReadNeedleMeta:output_type -> volume_server_pb.ReadNeedleMetaResponse
52, // 101: volume_server_pb.VolumeServer.WriteNeedleBlob:output_type -> volume_server_pb.WriteNeedleBlobResponse
54, // 102: volume_server_pb.VolumeServer.ReadAllNeedles:output_type -> volume_server_pb.ReadAllNeedlesResponse
56, // 103: volume_server_pb.VolumeServer.VolumeTailSender:output_type -> volume_server_pb.VolumeTailSenderResponse
58, // 104: volume_server_pb.VolumeServer.VolumeTailReceiver:output_type -> volume_server_pb.VolumeTailReceiverResponse
60, // 105: volume_server_pb.VolumeServer.VolumeEcShardsGenerate:output_type -> volume_server_pb.VolumeEcShardsGenerateResponse
62, // 106: volume_server_pb.VolumeServer.VolumeEcShardsRebuild:output_type -> volume_server_pb.VolumeEcShardsRebuildResponse
64, // 107: volume_server_pb.VolumeServer.VolumeEcShardsCopy:output_type -> volume_server_pb.VolumeEcShardsCopyResponse
66, // 108: volume_server_pb.VolumeServer.VolumeEcShardsDelete:output_type -> volume_server_pb.VolumeEcShardsDeleteResponse
68, // 109: volume_server_pb.VolumeServer.VolumeEcShardsMount:output_type -> volume_server_pb.VolumeEcShardsMountResponse
70, // 110: volume_server_pb.VolumeServer.VolumeEcShardsUnmount:output_type -> volume_server_pb.VolumeEcShardsUnmountResponse
72, // 111: volume_server_pb.VolumeServer.VolumeEcShardRead:output_type -> volume_server_pb.VolumeEcShardReadResponse
74, // 112: volume_server_pb.VolumeServer.VolumeEcBlobDelete:output_type -> volume_server_pb.VolumeEcBlobDeleteResponse
76, // 113: volume_server_pb.VolumeServer.VolumeEcShardsToVolume:output_type -> volume_server_pb.VolumeEcShardsToVolumeResponse
78, // 114: volume_server_pb.VolumeServer.VolumeEcShardsInfo:output_type -> volume_server_pb.VolumeEcShardsInfoResponse
89, // 115: volume_server_pb.VolumeServer.VolumeTierMoveDatToRemote:output_type -> volume_server_pb.VolumeTierMoveDatToRemoteResponse
91, // 116: volume_server_pb.VolumeServer.VolumeTierMoveDatFromRemote:output_type -> volume_server_pb.VolumeTierMoveDatFromRemoteResponse
93, // 117: volume_server_pb.VolumeServer.VolumeServerStatus:output_type -> volume_server_pb.VolumeServerStatusResponse
95, // 118: volume_server_pb.VolumeServer.VolumeServerLeave:output_type -> volume_server_pb.VolumeServerLeaveResponse
97, // 119: volume_server_pb.VolumeServer.FetchAndWriteNeedle:output_type -> volume_server_pb.FetchAndWriteNeedleResponse
99, // 120: volume_server_pb.VolumeServer.ScrubVolume:output_type -> volume_server_pb.ScrubVolumeResponse
101, // 121: volume_server_pb.VolumeServer.ScrubEcVolume:output_type -> volume_server_pb.ScrubEcVolumeResponse
103, // 122: volume_server_pb.VolumeServer.Query:output_type -> volume_server_pb.QueriedStripe
105, // 123: volume_server_pb.VolumeServer.VolumeNeedleStatus:output_type -> volume_server_pb.VolumeNeedleStatusResponse
107, // 124: volume_server_pb.VolumeServer.Ping:output_type -> volume_server_pb.PingResponse
109, // 125: volume_server_pb.VolumeServer.AllocateBlockVolume:output_type -> volume_server_pb.AllocateBlockVolumeResponse
111, // 126: volume_server_pb.VolumeServer.VolumeServerDeleteBlockVolume:output_type -> volume_server_pb.VolumeServerDeleteBlockVolumeResponse
77, // [77:127] is the sub-list for method output_type
27, // [27:77] is the sub-list for method input_type
27, // [27:27] is the sub-list for extension type_name
27, // [27:27] is the sub-list for extension extendee
0, // [0:27] is the sub-list for field type_name
122, // 19: volume_server_pb.QueryRequest.filter:type_name -> volume_server_pb.QueryRequest.Filter
123, // 20: volume_server_pb.QueryRequest.input_serialization:type_name -> volume_server_pb.QueryRequest.InputSerialization
124, // 21: volume_server_pb.QueryRequest.output_serialization:type_name -> volume_server_pb.QueryRequest.OutputSerialization
118, // 22: volume_server_pb.ListBlockSnapshotsResponse.snapshots:type_name -> volume_server_pb.BlockSnapshotInfo
125, // 23: volume_server_pb.QueryRequest.InputSerialization.csv_input:type_name -> volume_server_pb.QueryRequest.InputSerialization.CSVInput
126, // 24: volume_server_pb.QueryRequest.InputSerialization.json_input:type_name -> volume_server_pb.QueryRequest.InputSerialization.JSONInput
127, // 25: volume_server_pb.QueryRequest.InputSerialization.parquet_input:type_name -> volume_server_pb.QueryRequest.InputSerialization.ParquetInput
128, // 26: volume_server_pb.QueryRequest.OutputSerialization.csv_output:type_name -> volume_server_pb.QueryRequest.OutputSerialization.CSVOutput
129, // 27: volume_server_pb.QueryRequest.OutputSerialization.json_output:type_name -> volume_server_pb.QueryRequest.OutputSerialization.JSONOutput
2, // 28: volume_server_pb.VolumeServer.BatchDelete:input_type -> volume_server_pb.BatchDeleteRequest
6, // 29: volume_server_pb.VolumeServer.VacuumVolumeCheck:input_type -> volume_server_pb.VacuumVolumeCheckRequest
8, // 30: volume_server_pb.VolumeServer.VacuumVolumeCompact:input_type -> volume_server_pb.VacuumVolumeCompactRequest
10, // 31: volume_server_pb.VolumeServer.VacuumVolumeCommit:input_type -> volume_server_pb.VacuumVolumeCommitRequest
12, // 32: volume_server_pb.VolumeServer.VacuumVolumeCleanup:input_type -> volume_server_pb.VacuumVolumeCleanupRequest
14, // 33: volume_server_pb.VolumeServer.DeleteCollection:input_type -> volume_server_pb.DeleteCollectionRequest
16, // 34: volume_server_pb.VolumeServer.AllocateVolume:input_type -> volume_server_pb.AllocateVolumeRequest
18, // 35: volume_server_pb.VolumeServer.VolumeSyncStatus:input_type -> volume_server_pb.VolumeSyncStatusRequest
20, // 36: volume_server_pb.VolumeServer.VolumeIncrementalCopy:input_type -> volume_server_pb.VolumeIncrementalCopyRequest
22, // 37: volume_server_pb.VolumeServer.VolumeMount:input_type -> volume_server_pb.VolumeMountRequest
24, // 38: volume_server_pb.VolumeServer.VolumeUnmount:input_type -> volume_server_pb.VolumeUnmountRequest
26, // 39: volume_server_pb.VolumeServer.VolumeDelete:input_type -> volume_server_pb.VolumeDeleteRequest
28, // 40: volume_server_pb.VolumeServer.VolumeMarkReadonly:input_type -> volume_server_pb.VolumeMarkReadonlyRequest
30, // 41: volume_server_pb.VolumeServer.VolumeMarkWritable:input_type -> volume_server_pb.VolumeMarkWritableRequest
32, // 42: volume_server_pb.VolumeServer.VolumeConfigure:input_type -> volume_server_pb.VolumeConfigureRequest
34, // 43: volume_server_pb.VolumeServer.VolumeStatus:input_type -> volume_server_pb.VolumeStatusRequest
36, // 44: volume_server_pb.VolumeServer.GetState:input_type -> volume_server_pb.GetStateRequest
38, // 45: volume_server_pb.VolumeServer.SetState:input_type -> volume_server_pb.SetStateRequest
40, // 46: volume_server_pb.VolumeServer.VolumeCopy:input_type -> volume_server_pb.VolumeCopyRequest
80, // 47: volume_server_pb.VolumeServer.ReadVolumeFileStatus:input_type -> volume_server_pb.ReadVolumeFileStatusRequest
42, // 48: volume_server_pb.VolumeServer.CopyFile:input_type -> volume_server_pb.CopyFileRequest
44, // 49: volume_server_pb.VolumeServer.ReceiveFile:input_type -> volume_server_pb.ReceiveFileRequest
47, // 50: volume_server_pb.VolumeServer.ReadNeedleBlob:input_type -> volume_server_pb.ReadNeedleBlobRequest
49, // 51: volume_server_pb.VolumeServer.ReadNeedleMeta:input_type -> volume_server_pb.ReadNeedleMetaRequest
51, // 52: volume_server_pb.VolumeServer.WriteNeedleBlob:input_type -> volume_server_pb.WriteNeedleBlobRequest
53, // 53: volume_server_pb.VolumeServer.ReadAllNeedles:input_type -> volume_server_pb.ReadAllNeedlesRequest
55, // 54: volume_server_pb.VolumeServer.VolumeTailSender:input_type -> volume_server_pb.VolumeTailSenderRequest
57, // 55: volume_server_pb.VolumeServer.VolumeTailReceiver:input_type -> volume_server_pb.VolumeTailReceiverRequest
59, // 56: volume_server_pb.VolumeServer.VolumeEcShardsGenerate:input_type -> volume_server_pb.VolumeEcShardsGenerateRequest
61, // 57: volume_server_pb.VolumeServer.VolumeEcShardsRebuild:input_type -> volume_server_pb.VolumeEcShardsRebuildRequest
63, // 58: volume_server_pb.VolumeServer.VolumeEcShardsCopy:input_type -> volume_server_pb.VolumeEcShardsCopyRequest
65, // 59: volume_server_pb.VolumeServer.VolumeEcShardsDelete:input_type -> volume_server_pb.VolumeEcShardsDeleteRequest
67, // 60: volume_server_pb.VolumeServer.VolumeEcShardsMount:input_type -> volume_server_pb.VolumeEcShardsMountRequest
69, // 61: volume_server_pb.VolumeServer.VolumeEcShardsUnmount:input_type -> volume_server_pb.VolumeEcShardsUnmountRequest
71, // 62: volume_server_pb.VolumeServer.VolumeEcShardRead:input_type -> volume_server_pb.VolumeEcShardReadRequest
73, // 63: volume_server_pb.VolumeServer.VolumeEcBlobDelete:input_type -> volume_server_pb.VolumeEcBlobDeleteRequest
75, // 64: volume_server_pb.VolumeServer.VolumeEcShardsToVolume:input_type -> volume_server_pb.VolumeEcShardsToVolumeRequest
77, // 65: volume_server_pb.VolumeServer.VolumeEcShardsInfo:input_type -> volume_server_pb.VolumeEcShardsInfoRequest
88, // 66: volume_server_pb.VolumeServer.VolumeTierMoveDatToRemote:input_type -> volume_server_pb.VolumeTierMoveDatToRemoteRequest
90, // 67: volume_server_pb.VolumeServer.VolumeTierMoveDatFromRemote:input_type -> volume_server_pb.VolumeTierMoveDatFromRemoteRequest
92, // 68: volume_server_pb.VolumeServer.VolumeServerStatus:input_type -> volume_server_pb.VolumeServerStatusRequest
94, // 69: volume_server_pb.VolumeServer.VolumeServerLeave:input_type -> volume_server_pb.VolumeServerLeaveRequest
96, // 70: volume_server_pb.VolumeServer.FetchAndWriteNeedle:input_type -> volume_server_pb.FetchAndWriteNeedleRequest
98, // 71: volume_server_pb.VolumeServer.ScrubVolume:input_type -> volume_server_pb.ScrubVolumeRequest
100, // 72: volume_server_pb.VolumeServer.ScrubEcVolume:input_type -> volume_server_pb.ScrubEcVolumeRequest
102, // 73: volume_server_pb.VolumeServer.Query:input_type -> volume_server_pb.QueryRequest
104, // 74: volume_server_pb.VolumeServer.VolumeNeedleStatus:input_type -> volume_server_pb.VolumeNeedleStatusRequest
106, // 75: volume_server_pb.VolumeServer.Ping:input_type -> volume_server_pb.PingRequest
108, // 76: volume_server_pb.VolumeServer.AllocateBlockVolume:input_type -> volume_server_pb.AllocateBlockVolumeRequest
110, // 77: volume_server_pb.VolumeServer.VolumeServerDeleteBlockVolume:input_type -> volume_server_pb.VolumeServerDeleteBlockVolumeRequest
112, // 78: volume_server_pb.VolumeServer.SnapshotBlockVolume:input_type -> volume_server_pb.SnapshotBlockVolumeRequest
114, // 79: volume_server_pb.VolumeServer.DeleteBlockSnapshot:input_type -> volume_server_pb.DeleteBlockSnapshotRequest
116, // 80: volume_server_pb.VolumeServer.ListBlockSnapshots:input_type -> volume_server_pb.ListBlockSnapshotsRequest
119, // 81: volume_server_pb.VolumeServer.ExpandBlockVolume:input_type -> volume_server_pb.ExpandBlockVolumeRequest
3, // 82: volume_server_pb.VolumeServer.BatchDelete:output_type -> volume_server_pb.BatchDeleteResponse
7, // 83: volume_server_pb.VolumeServer.VacuumVolumeCheck:output_type -> volume_server_pb.VacuumVolumeCheckResponse
9, // 84: volume_server_pb.VolumeServer.VacuumVolumeCompact:output_type -> volume_server_pb.VacuumVolumeCompactResponse
11, // 85: volume_server_pb.VolumeServer.VacuumVolumeCommit:output_type -> volume_server_pb.VacuumVolumeCommitResponse
13, // 86: volume_server_pb.VolumeServer.VacuumVolumeCleanup:output_type -> volume_server_pb.VacuumVolumeCleanupResponse
15, // 87: volume_server_pb.VolumeServer.DeleteCollection:output_type -> volume_server_pb.DeleteCollectionResponse
17, // 88: volume_server_pb.VolumeServer.AllocateVolume:output_type -> volume_server_pb.AllocateVolumeResponse
19, // 89: volume_server_pb.VolumeServer.VolumeSyncStatus:output_type -> volume_server_pb.VolumeSyncStatusResponse
21, // 90: volume_server_pb.VolumeServer.VolumeIncrementalCopy:output_type -> volume_server_pb.VolumeIncrementalCopyResponse
23, // 91: volume_server_pb.VolumeServer.VolumeMount:output_type -> volume_server_pb.VolumeMountResponse
25, // 92: volume_server_pb.VolumeServer.VolumeUnmount:output_type -> volume_server_pb.VolumeUnmountResponse
27, // 93: volume_server_pb.VolumeServer.VolumeDelete:output_type -> volume_server_pb.VolumeDeleteResponse
29, // 94: volume_server_pb.VolumeServer.VolumeMarkReadonly:output_type -> volume_server_pb.VolumeMarkReadonlyResponse
31, // 95: volume_server_pb.VolumeServer.VolumeMarkWritable:output_type -> volume_server_pb.VolumeMarkWritableResponse
33, // 96: volume_server_pb.VolumeServer.VolumeConfigure:output_type -> volume_server_pb.VolumeConfigureResponse
35, // 97: volume_server_pb.VolumeServer.VolumeStatus:output_type -> volume_server_pb.VolumeStatusResponse
37, // 98: volume_server_pb.VolumeServer.GetState:output_type -> volume_server_pb.GetStateResponse
39, // 99: volume_server_pb.VolumeServer.SetState:output_type -> volume_server_pb.SetStateResponse
41, // 100: volume_server_pb.VolumeServer.VolumeCopy:output_type -> volume_server_pb.VolumeCopyResponse
81, // 101: volume_server_pb.VolumeServer.ReadVolumeFileStatus:output_type -> volume_server_pb.ReadVolumeFileStatusResponse
43, // 102: volume_server_pb.VolumeServer.CopyFile:output_type -> volume_server_pb.CopyFileResponse
46, // 103: volume_server_pb.VolumeServer.ReceiveFile:output_type -> volume_server_pb.ReceiveFileResponse
48, // 104: volume_server_pb.VolumeServer.ReadNeedleBlob:output_type -> volume_server_pb.ReadNeedleBlobResponse
50, // 105: volume_server_pb.VolumeServer.ReadNeedleMeta:output_type -> volume_server_pb.ReadNeedleMetaResponse
52, // 106: volume_server_pb.VolumeServer.WriteNeedleBlob:output_type -> volume_server_pb.WriteNeedleBlobResponse
54, // 107: volume_server_pb.VolumeServer.ReadAllNeedles:output_type -> volume_server_pb.ReadAllNeedlesResponse
56, // 108: volume_server_pb.VolumeServer.VolumeTailSender:output_type -> volume_server_pb.VolumeTailSenderResponse
58, // 109: volume_server_pb.VolumeServer.VolumeTailReceiver:output_type -> volume_server_pb.VolumeTailReceiverResponse
60, // 110: volume_server_pb.VolumeServer.VolumeEcShardsGenerate:output_type -> volume_server_pb.VolumeEcShardsGenerateResponse
62, // 111: volume_server_pb.VolumeServer.VolumeEcShardsRebuild:output_type -> volume_server_pb.VolumeEcShardsRebuildResponse
64, // 112: volume_server_pb.VolumeServer.VolumeEcShardsCopy:output_type -> volume_server_pb.VolumeEcShardsCopyResponse
66, // 113: volume_server_pb.VolumeServer.VolumeEcShardsDelete:output_type -> volume_server_pb.VolumeEcShardsDeleteResponse
68, // 114: volume_server_pb.VolumeServer.VolumeEcShardsMount:output_type -> volume_server_pb.VolumeEcShardsMountResponse
70, // 115: volume_server_pb.VolumeServer.VolumeEcShardsUnmount:output_type -> volume_server_pb.VolumeEcShardsUnmountResponse
72, // 116: volume_server_pb.VolumeServer.VolumeEcShardRead:output_type -> volume_server_pb.VolumeEcShardReadResponse
74, // 117: volume_server_pb.VolumeServer.VolumeEcBlobDelete:output_type -> volume_server_pb.VolumeEcBlobDeleteResponse
76, // 118: volume_server_pb.VolumeServer.VolumeEcShardsToVolume:output_type -> volume_server_pb.VolumeEcShardsToVolumeResponse
78, // 119: volume_server_pb.VolumeServer.VolumeEcShardsInfo:output_type -> volume_server_pb.VolumeEcShardsInfoResponse
89, // 120: volume_server_pb.VolumeServer.VolumeTierMoveDatToRemote:output_type -> volume_server_pb.VolumeTierMoveDatToRemoteResponse
91, // 121: volume_server_pb.VolumeServer.VolumeTierMoveDatFromRemote:output_type -> volume_server_pb.VolumeTierMoveDatFromRemoteResponse
93, // 122: volume_server_pb.VolumeServer.VolumeServerStatus:output_type -> volume_server_pb.VolumeServerStatusResponse
95, // 123: volume_server_pb.VolumeServer.VolumeServerLeave:output_type -> volume_server_pb.VolumeServerLeaveResponse
97, // 124: volume_server_pb.VolumeServer.FetchAndWriteNeedle:output_type -> volume_server_pb.FetchAndWriteNeedleResponse
99, // 125: volume_server_pb.VolumeServer.ScrubVolume:output_type -> volume_server_pb.ScrubVolumeResponse
101, // 126: volume_server_pb.VolumeServer.ScrubEcVolume:output_type -> volume_server_pb.ScrubEcVolumeResponse
103, // 127: volume_server_pb.VolumeServer.Query:output_type -> volume_server_pb.QueriedStripe
105, // 128: volume_server_pb.VolumeServer.VolumeNeedleStatus:output_type -> volume_server_pb.VolumeNeedleStatusResponse
107, // 129: volume_server_pb.VolumeServer.Ping:output_type -> volume_server_pb.PingResponse
109, // 130: volume_server_pb.VolumeServer.AllocateBlockVolume:output_type -> volume_server_pb.AllocateBlockVolumeResponse
111, // 131: volume_server_pb.VolumeServer.VolumeServerDeleteBlockVolume:output_type -> volume_server_pb.VolumeServerDeleteBlockVolumeResponse
113, // 132: volume_server_pb.VolumeServer.SnapshotBlockVolume:output_type -> volume_server_pb.SnapshotBlockVolumeResponse
115, // 133: volume_server_pb.VolumeServer.DeleteBlockSnapshot:output_type -> volume_server_pb.DeleteBlockSnapshotResponse
117, // 134: volume_server_pb.VolumeServer.ListBlockSnapshots:output_type -> volume_server_pb.ListBlockSnapshotsResponse
120, // 135: volume_server_pb.VolumeServer.ExpandBlockVolume:output_type -> volume_server_pb.ExpandBlockVolumeResponse
82, // [82:136] is the sub-list for method output_type
28, // [28:82] is the sub-list for method input_type
28, // [28:28] is the sub-list for extension type_name
28, // [28:28] is the sub-list for extension extendee
0, // [0:28] is the sub-list for field type_name
}
func init() { file_volume_server_proto_init() }
@@ -7807,7 +8307,7 @@ func file_volume_server_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_volume_server_proto_rawDesc), len(file_volume_server_proto_rawDesc)),
NumEnums: 1,
NumMessages: 120,
NumMessages: 129,
NumExtensions: 0,
NumServices: 1,
},
@@ -69,6 +69,10 @@ const (
VolumeServer_Ping_FullMethodName = "/volume_server_pb.VolumeServer/Ping"
VolumeServer_AllocateBlockVolume_FullMethodName = "/volume_server_pb.VolumeServer/AllocateBlockVolume"
VolumeServer_VolumeServerDeleteBlockVolume_FullMethodName = "/volume_server_pb.VolumeServer/VolumeServerDeleteBlockVolume"
VolumeServer_SnapshotBlockVolume_FullMethodName = "/volume_server_pb.VolumeServer/SnapshotBlockVolume"
VolumeServer_DeleteBlockSnapshot_FullMethodName = "/volume_server_pb.VolumeServer/DeleteBlockSnapshot"
VolumeServer_ListBlockSnapshots_FullMethodName = "/volume_server_pb.VolumeServer/ListBlockSnapshots"
VolumeServer_ExpandBlockVolume_FullMethodName = "/volume_server_pb.VolumeServer/ExpandBlockVolume"
)
// VolumeServerClient is the client API for VolumeServer service.
@@ -133,6 +137,10 @@ type VolumeServerClient interface {
// block volumes
AllocateBlockVolume(ctx context.Context, in *AllocateBlockVolumeRequest, opts ...grpc.CallOption) (*AllocateBlockVolumeResponse, error)
VolumeServerDeleteBlockVolume(ctx context.Context, in *VolumeServerDeleteBlockVolumeRequest, opts ...grpc.CallOption) (*VolumeServerDeleteBlockVolumeResponse, error)
SnapshotBlockVolume(ctx context.Context, in *SnapshotBlockVolumeRequest, opts ...grpc.CallOption) (*SnapshotBlockVolumeResponse, error)
DeleteBlockSnapshot(ctx context.Context, in *DeleteBlockSnapshotRequest, opts ...grpc.CallOption) (*DeleteBlockSnapshotResponse, error)
ListBlockSnapshots(ctx context.Context, in *ListBlockSnapshotsRequest, opts ...grpc.CallOption) (*ListBlockSnapshotsResponse, error)
ExpandBlockVolume(ctx context.Context, in *ExpandBlockVolumeRequest, opts ...grpc.CallOption) (*ExpandBlockVolumeResponse, error)
}
type volumeServerClient struct {
@@ -736,6 +744,46 @@ func (c *volumeServerClient) VolumeServerDeleteBlockVolume(ctx context.Context,
return out, nil
}
func (c *volumeServerClient) SnapshotBlockVolume(ctx context.Context, in *SnapshotBlockVolumeRequest, opts ...grpc.CallOption) (*SnapshotBlockVolumeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SnapshotBlockVolumeResponse)
err := c.cc.Invoke(ctx, VolumeServer_SnapshotBlockVolume_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *volumeServerClient) DeleteBlockSnapshot(ctx context.Context, in *DeleteBlockSnapshotRequest, opts ...grpc.CallOption) (*DeleteBlockSnapshotResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteBlockSnapshotResponse)
err := c.cc.Invoke(ctx, VolumeServer_DeleteBlockSnapshot_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *volumeServerClient) ListBlockSnapshots(ctx context.Context, in *ListBlockSnapshotsRequest, opts ...grpc.CallOption) (*ListBlockSnapshotsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListBlockSnapshotsResponse)
err := c.cc.Invoke(ctx, VolumeServer_ListBlockSnapshots_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *volumeServerClient) ExpandBlockVolume(ctx context.Context, in *ExpandBlockVolumeRequest, opts ...grpc.CallOption) (*ExpandBlockVolumeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ExpandBlockVolumeResponse)
err := c.cc.Invoke(ctx, VolumeServer_ExpandBlockVolume_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// VolumeServerServer is the server API for VolumeServer service.
// All implementations must embed UnimplementedVolumeServerServer
// for forward compatibility.
@@ -798,6 +846,10 @@ type VolumeServerServer interface {
// block volumes
AllocateBlockVolume(context.Context, *AllocateBlockVolumeRequest) (*AllocateBlockVolumeResponse, error)
VolumeServerDeleteBlockVolume(context.Context, *VolumeServerDeleteBlockVolumeRequest) (*VolumeServerDeleteBlockVolumeResponse, error)
SnapshotBlockVolume(context.Context, *SnapshotBlockVolumeRequest) (*SnapshotBlockVolumeResponse, error)
DeleteBlockSnapshot(context.Context, *DeleteBlockSnapshotRequest) (*DeleteBlockSnapshotResponse, error)
ListBlockSnapshots(context.Context, *ListBlockSnapshotsRequest) (*ListBlockSnapshotsResponse, error)
ExpandBlockVolume(context.Context, *ExpandBlockVolumeRequest) (*ExpandBlockVolumeResponse, error)
mustEmbedUnimplementedVolumeServerServer()
}
@@ -958,6 +1010,18 @@ func (UnimplementedVolumeServerServer) AllocateBlockVolume(context.Context, *All
func (UnimplementedVolumeServerServer) VolumeServerDeleteBlockVolume(context.Context, *VolumeServerDeleteBlockVolumeRequest) (*VolumeServerDeleteBlockVolumeResponse, error) {
return nil, status.Error(codes.Unimplemented, "method VolumeServerDeleteBlockVolume not implemented")
}
func (UnimplementedVolumeServerServer) SnapshotBlockVolume(context.Context, *SnapshotBlockVolumeRequest) (*SnapshotBlockVolumeResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SnapshotBlockVolume not implemented")
}
func (UnimplementedVolumeServerServer) DeleteBlockSnapshot(context.Context, *DeleteBlockSnapshotRequest) (*DeleteBlockSnapshotResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteBlockSnapshot not implemented")
}
func (UnimplementedVolumeServerServer) ListBlockSnapshots(context.Context, *ListBlockSnapshotsRequest) (*ListBlockSnapshotsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListBlockSnapshots not implemented")
}
func (UnimplementedVolumeServerServer) ExpandBlockVolume(context.Context, *ExpandBlockVolumeRequest) (*ExpandBlockVolumeResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ExpandBlockVolume not implemented")
}
func (UnimplementedVolumeServerServer) mustEmbedUnimplementedVolumeServerServer() {}
func (UnimplementedVolumeServerServer) testEmbeddedByValue() {}
@@ -1798,6 +1862,78 @@ func _VolumeServer_VolumeServerDeleteBlockVolume_Handler(srv interface{}, ctx co
return interceptor(ctx, in, info, handler)
}
func _VolumeServer_SnapshotBlockVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SnapshotBlockVolumeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VolumeServerServer).SnapshotBlockVolume(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: VolumeServer_SnapshotBlockVolume_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VolumeServerServer).SnapshotBlockVolume(ctx, req.(*SnapshotBlockVolumeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _VolumeServer_DeleteBlockSnapshot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteBlockSnapshotRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VolumeServerServer).DeleteBlockSnapshot(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: VolumeServer_DeleteBlockSnapshot_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VolumeServerServer).DeleteBlockSnapshot(ctx, req.(*DeleteBlockSnapshotRequest))
}
return interceptor(ctx, in, info, handler)
}
func _VolumeServer_ListBlockSnapshots_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListBlockSnapshotsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VolumeServerServer).ListBlockSnapshots(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: VolumeServer_ListBlockSnapshots_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VolumeServerServer).ListBlockSnapshots(ctx, req.(*ListBlockSnapshotsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _VolumeServer_ExpandBlockVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExpandBlockVolumeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VolumeServerServer).ExpandBlockVolume(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: VolumeServer_ExpandBlockVolume_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VolumeServerServer).ExpandBlockVolume(ctx, req.(*ExpandBlockVolumeRequest))
}
return interceptor(ctx, in, info, handler)
}
// VolumeServer_ServiceDesc is the grpc.ServiceDesc for VolumeServer service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -1961,6 +2097,22 @@ var VolumeServer_ServiceDesc = grpc.ServiceDesc{
MethodName: "VolumeServerDeleteBlockVolume",
Handler: _VolumeServer_VolumeServerDeleteBlockVolume_Handler,
},
{
MethodName: "SnapshotBlockVolume",
Handler: _VolumeServer_SnapshotBlockVolume_Handler,
},
{
MethodName: "DeleteBlockSnapshot",
Handler: _VolumeServer_DeleteBlockSnapshot_Handler,
},
{
MethodName: "ListBlockSnapshots",
Handler: _VolumeServer_ListBlockSnapshots_Handler,
},
{
MethodName: "ExpandBlockVolume",
Handler: _VolumeServer_ExpandBlockVolume_Handler,
},
},
Streams: []grpc.StreamDesc{
{
+21 -5
View File
@@ -641,7 +641,22 @@ func TestIntegration_DoubleFailover(t *testing.T) {
t.Fatalf("first failover epoch: got %d, want 2", e1.Epoch)
}
// Second failover: vs2 dies → vs1 promoted (it's now the replica).
// CP8-2: PromoteBestReplica does NOT add old primary back as replica.
// Reconnect vs1 first so it becomes a replica (via recoverBlockVolumes).
ms.recoverBlockVolumes(vs1)
// Simulate heartbeat from vs1 that restores iSCSI addr and health score
// (in production this happens when the VS re-registers after reconnect).
e1, _ = ms.blockRegistry.Lookup("pvc-double-1")
for i := range e1.Replicas {
if e1.Replicas[i].Server == vs1 {
e1.Replicas[i].ISCSIAddr = vs1 + ":3260"
e1.Replicas[i].HealthScore = 1.0
}
}
// Now vs1 is back as replica. Second failover: vs2 dies → vs1 promoted.
e1, _ = ms.blockRegistry.Lookup("pvc-double-1")
e1.LastLeaseGrant = time.Now().Add(-1 * time.Minute)
ms.failoverBlockVolumes(vs2)
@@ -724,9 +739,10 @@ func TestIntegration_MultiVolumeFailoverRebuild(t *testing.T) {
}
}
_ = otherServer
// rebuildCount should equal the number of volumes that were primary on deadServer.
if rebuildCount != primaryCounts[deadServer] {
t.Fatalf("expected %d rebuild assignments for %s, got %d",
primaryCounts[deadServer], deadServer, rebuildCount)
// CP8-2: With 2 servers and 3 volumes, deadServer hosts all 3 volumes
// (as primary for some, replica for others). All need rebuild on reconnect.
if rebuildCount != 3 {
t.Fatalf("expected 3 rebuild assignments for %s (all volumes), got %d",
deadServer, rebuildCount)
}
}
@@ -123,3 +123,14 @@ func (q *BlockAssignmentQueue) Pending(server string) int {
defer q.mu.Unlock()
return len(q.queues[server])
}
// TotalPending returns the total number of pending assignments across all servers.
func (q *BlockAssignmentQueue) TotalPending() int {
q.mu.Lock()
defer q.mu.Unlock()
total := 0
for _, queue := range q.queues {
total += len(queue)
}
return total
}
+77 -38
View File
@@ -32,82 +32,116 @@ func newBlockFailoverState() *blockFailoverState {
}
// failoverBlockVolumes is called when a volume server disconnects.
// It checks each block volume on that server and promotes the replica
// if the lease has expired (F2).
// It checks each block volume on that server and:
// - If dead server is primary: promote best replica (if lease expired).
// - If dead server hosts a replica: remove from replica list, record pending rebuild.
func (ms *MasterServer) failoverBlockVolumes(deadServer string) {
if ms.blockRegistry == nil {
return
}
ms.blockRegistry.FailoversTotal.Add(1)
entries := ms.blockRegistry.ListByServer(deadServer)
now := time.Now()
for _, entry := range entries {
if blockvol.RoleFromWire(entry.Role) != blockvol.RolePrimary {
// Case 1: Dead server is the primary.
if entry.VolumeServer == deadServer &&
blockvol.RoleFromWire(entry.Role) == blockvol.RolePrimary {
if !entry.HasReplica() {
glog.Warningf("failover: %q has no replica, cannot promote", entry.Name)
continue
}
// F2: Wait for lease expiry before promoting.
leaseExpiry := entry.LastLeaseGrant.Add(entry.LeaseTTL)
if now.Before(leaseExpiry) {
delay := leaseExpiry.Sub(now)
glog.V(0).Infof("failover: %q lease expires in %v, deferring promotion", entry.Name, delay)
volumeName := entry.Name
timer := time.AfterFunc(delay, func() {
ms.promoteReplica(volumeName)
})
ms.blockFailover.mu.Lock()
ms.blockFailover.deferredTimers[deadServer] = append(
ms.blockFailover.deferredTimers[deadServer], timer)
ms.blockFailover.mu.Unlock()
continue
}
// Lease already expired — promote immediately.
ms.promoteReplica(entry.Name)
continue
}
// Only failover volumes whose primary is the dead server.
// Case 2: Dead server hosts a replica (not the primary).
if entry.VolumeServer != deadServer {
continue
ri := entry.ReplicaByServer(deadServer)
if ri != nil {
replicaPath := ri.Path
// Remove dead replica from registry.
if err := ms.blockRegistry.RemoveReplica(entry.Name, deadServer); err != nil {
glog.Warningf("failover: RemoveReplica %q on %s: %v", entry.Name, deadServer, err)
continue
}
// Record pending rebuild for when dead server reconnects.
ms.recordPendingRebuild(deadServer, pendingRebuild{
VolumeName: entry.Name,
OldPath: replicaPath,
NewPrimary: entry.VolumeServer, // current primary (unchanged)
Epoch: entry.Epoch,
})
glog.V(0).Infof("failover: removed dead replica %s for %q, pending rebuild",
deadServer, entry.Name)
}
}
if entry.ReplicaServer == "" {
glog.Warningf("failover: %q has no replica, cannot promote", entry.Name)
continue
}
// F2: Wait for lease expiry before promoting.
leaseExpiry := entry.LastLeaseGrant.Add(entry.LeaseTTL)
if now.Before(leaseExpiry) {
delay := leaseExpiry.Sub(now)
glog.V(0).Infof("failover: %q lease expires in %v, deferring promotion", entry.Name, delay)
volumeName := entry.Name
timer := time.AfterFunc(delay, func() {
ms.promoteReplica(volumeName)
})
// R2-F2: Store timer so it can be cancelled if the server reconnects.
ms.blockFailover.mu.Lock()
ms.blockFailover.deferredTimers[deadServer] = append(
ms.blockFailover.deferredTimers[deadServer], timer)
ms.blockFailover.mu.Unlock()
continue
}
// Lease already expired — promote immediately.
ms.promoteReplica(entry.Name)
}
}
// promoteReplica swaps primary and replica for the named volume,
// promoteReplica promotes the best replica to primary for the named volume,
// enqueues an assignment for the new primary, and records a pending rebuild.
func (ms *MasterServer) promoteReplica(volumeName string) {
entry, ok := ms.blockRegistry.Lookup(volumeName)
if !ok {
return
}
if entry.ReplicaServer == "" {
if !entry.HasReplica() {
return
}
oldPrimary := entry.VolumeServer
oldPath := entry.Path
// R2-F5: Epoch computed atomically inside SwapPrimaryReplica (under lock).
newEpoch, err := ms.blockRegistry.SwapPrimaryReplica(volumeName)
// CP8-2: Use PromoteBestReplica (picks by health score, tie-break by WALHeadLSN).
newEpoch, err := ms.blockRegistry.PromoteBestReplica(volumeName)
if err != nil {
glog.Warningf("failover: SwapPrimaryReplica %q: %v", volumeName, err)
glog.Warningf("failover: PromoteBestReplica %q: %v", volumeName, err)
return
}
// Re-read entry after swap.
// Re-read entry after promotion.
entry, ok = ms.blockRegistry.Lookup(volumeName)
if !ok {
return
}
// Enqueue assignment for new primary.
// Build assignment for new primary. Include ReplicaAddrs for remaining replicas.
leaseTTLMs := blockvol.LeaseTTLToWire(30 * time.Second)
ms.blockAssignmentQueue.Enqueue(entry.VolumeServer, blockvol.BlockVolumeAssignment{
assignment := blockvol.BlockVolumeAssignment{
Path: entry.Path,
Epoch: newEpoch,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
LeaseTtlMs: leaseTTLMs,
})
}
for _, ri := range entry.Replicas {
assignment.ReplicaAddrs = append(assignment.ReplicaAddrs, blockvol.ReplicaAddr{
DataAddr: ri.DataAddr,
CtrlAddr: ri.CtrlAddr,
})
}
// Backward compat: also set scalar fields if exactly 1 replica.
if len(entry.Replicas) == 1 {
assignment.ReplicaDataAddr = entry.Replicas[0].DataAddr
assignment.ReplicaCtrlAddr = entry.Replicas[0].CtrlAddr
}
ms.blockAssignmentQueue.Enqueue(entry.VolumeServer, assignment)
// Record pending rebuild for when dead server reconnects.
ms.recordPendingRebuild(oldPrimary, pendingRebuild{
@@ -117,6 +151,7 @@ func (ms *MasterServer) promoteReplica(volumeName string) {
Epoch: newEpoch,
})
ms.blockRegistry.PromotionsTotal.Add(1)
glog.V(0).Infof("failover: promoted replica for %q: new primary=%s epoch=%d (old primary=%s)",
volumeName, entry.VolumeServer, newEpoch, oldPrimary)
}
@@ -180,8 +215,11 @@ func (ms *MasterServer) recoverBlockVolumes(reconnectedServer string) {
continue
}
// Update registry: reconnected server becomes the new replica.
ms.blockRegistry.SetReplica(rb.VolumeName, reconnectedServer, rb.OldPath, "", "")
// Update registry: reconnected server becomes a replica (via AddReplica for RF≥2 support).
ms.blockRegistry.AddReplica(rb.VolumeName, ReplicaInfo{
Server: reconnectedServer,
Path: rb.OldPath,
})
// Enqueue rebuild assignment for the reconnected server.
ms.blockAssignmentQueue.Enqueue(reconnectedServer, blockvol.BlockVolumeAssignment{
@@ -191,6 +229,7 @@ func (ms *MasterServer) recoverBlockVolumes(reconnectedServer string) {
RebuildAddr: entry.RebuildListenAddr,
})
ms.blockRegistry.RebuildsTotal.Add(1)
glog.V(0).Infof("rebuild: enqueued rebuild for %q on %s (epoch=%d, rebuildAddr=%s)",
rb.VolumeName, reconnectedServer, entry.Epoch, entry.RebuildListenAddr)
}
+368 -6
View File
@@ -50,6 +50,16 @@ func registerVolumeWithReplica(t *testing.T, ms *MasterServer, name, primary, re
ReplicaISCSIAddr: replica + ":3260",
LeaseTTL: leaseTTL,
LastLeaseGrant: time.Now().Add(-2 * leaseTTL), // expired
// CP8-2: also populate Replicas[] for PromoteBestReplica.
Replicas: []ReplicaInfo{
{
Server: replica,
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s-replica", name),
ISCSIAddr: replica + ":3260",
HealthScore: 1.0,
},
},
}
if err := ms.blockRegistry.Register(entry); err != nil {
t.Fatalf("register %s: %v", name, err)
@@ -128,12 +138,14 @@ func TestFailover_RegistryUpdated(t *testing.T) {
ms.failoverBlockVolumes("vs1")
entry, _ := ms.blockRegistry.Lookup("vol1")
// After swap: new primary = vs2, old primary (vs1) becomes replica.
// After PromoteBestReplica: new primary = vs2.
// Old primary (vs1) is NOT added back to Replicas (needs rebuild).
if entry.VolumeServer != "vs2" {
t.Fatalf("VolumeServer: got %q, want vs2", entry.VolumeServer)
}
if entry.ReplicaServer != "vs1" {
t.Fatalf("ReplicaServer: got %q, want vs1 (old primary)", entry.ReplicaServer)
// Replicas should be empty (only had 1 replica which was promoted).
if len(entry.Replicas) != 0 {
t.Fatalf("Replicas should be empty after promotion, got %d", len(entry.Replicas))
}
}
@@ -196,6 +208,9 @@ func TestFailover_LeaseNotExpired_DeferredPromotion(t *testing.T) {
ReplicaISCSIAddr: "vs2:3260",
LeaseTTL: 200 * time.Millisecond,
LastLeaseGrant: time.Now(), // just granted, NOT expired yet
Replicas: []ReplicaInfo{
{Server: "vs2", Path: "/data/vol1.blk", IQN: "iqn:vol1-r", ISCSIAddr: "vs2:3260", HealthScore: 1.0},
},
}
ms.blockRegistry.Register(entry)
@@ -369,13 +384,14 @@ func TestRebuild_RegistryUpdatedWithNewReplica(t *testing.T) {
ms.failoverBlockVolumes("vs1")
ms.recoverBlockVolumes("vs1")
// After recovery, vs1 should be the new replica for vol1.
// After recovery, vs1 should be a replica for vol1 (added back via AddReplica).
entry, _ := ms.blockRegistry.Lookup("vol1")
if entry.VolumeServer != "vs2" {
t.Fatalf("primary should be vs2, got %q", entry.VolumeServer)
}
if entry.ReplicaServer != "vs1" {
t.Fatalf("replica should be vs1 (reconnected), got %q", entry.ReplicaServer)
// Replicas[] should contain vs1 (added by recoverBlockVolumes).
if len(entry.Replicas) != 1 || entry.Replicas[0].Server != "vs1" {
t.Fatalf("replica should be vs1 (reconnected), got %+v", entry.Replicas)
}
}
@@ -396,6 +412,9 @@ func TestRebuild_AssignmentContainsRebuildAddr(t *testing.T) {
RebuildListenAddr: "vs1:15000",
LeaseTTL: 5 * time.Second,
LastLeaseGrant: time.Now().Add(-10 * time.Second),
Replicas: []ReplicaInfo{
{Server: "vs2", Path: "/data/vol1.blk", IQN: "iqn:vol1-r", ISCSIAddr: "vs2:3260", HealthScore: 1.0},
},
}
ms.blockRegistry.Register(entry)
@@ -437,6 +456,9 @@ func TestFailover_TransientDisconnect_NoPromotion(t *testing.T) {
ReplicaISCSIAddr: "vs2:3260",
LeaseTTL: 30 * time.Second,
LastLeaseGrant: time.Now(), // just granted
Replicas: []ReplicaInfo{
{Server: "vs2", Path: "/data/vol1.blk", IQN: "iqn:vol1-r", ISCSIAddr: "vs2:3260", HealthScore: 1.0},
},
}
ms.blockRegistry.Register(entry)
@@ -526,3 +548,343 @@ func TestLifecycle_CreateFailoverRebuild(t *testing.T) {
t.Fatal("expected rebuild assignment for reconnected server")
}
}
// ============================================================
// CP8-2 T8: RF=3 (N>2) Failover Tests
// ============================================================
// registerVolumeRF3 creates a volume entry with primary + 2 replicas for RF=3 tests.
func registerVolumeRF3(t *testing.T, ms *MasterServer, name, primary, replica1, replica2 string, epoch uint64, leaseTTL time.Duration) {
t.Helper()
entry := &BlockVolumeEntry{
Name: name,
VolumeServer: primary,
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s", name),
ISCSIAddr: primary + ":3260",
SizeBytes: 1 << 30,
Epoch: epoch,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive,
ReplicaFactor: 3,
LeaseTTL: leaseTTL,
LastLeaseGrant: time.Now().Add(-2 * leaseTTL), // expired
// Deprecated scalar fields (backward compat): first replica only.
ReplicaServer: replica1,
ReplicaPath: fmt.Sprintf("/data/%s.blk", name),
ReplicaIQN: fmt.Sprintf("iqn.2024.test:%s-r1", name),
ReplicaISCSIAddr: replica1 + ":3260",
Replicas: []ReplicaInfo{
{
Server: replica1,
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s-r1", name),
ISCSIAddr: replica1 + ":3260",
HealthScore: 1.0,
WALHeadLSN: 100,
},
{
Server: replica2,
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s-r2", name),
ISCSIAddr: replica2 + ":3260",
HealthScore: 1.0,
WALHeadLSN: 100,
},
},
}
if err := ms.blockRegistry.Register(entry); err != nil {
t.Fatalf("register %s: %v", name, err)
}
}
// RF3: Primary dies → best replica promoted, other replica survives.
func TestRF3_PrimaryDies_BestReplicaPromoted(t *testing.T) {
ms := testMasterServerForFailover(t)
registerVolumeRF3(t, ms, "vol1", "vs1", "vs2", "vs3", 1, 5*time.Second)
// Give vs3 a higher health score so it should be promoted.
entry, _ := ms.blockRegistry.Lookup("vol1")
entry.Replicas[0].HealthScore = 0.8 // vs2
entry.Replicas[1].HealthScore = 1.0 // vs3
ms.failoverBlockVolumes("vs1")
entry, _ = ms.blockRegistry.Lookup("vol1")
if entry.VolumeServer != "vs3" {
t.Fatalf("primary should be vs3 (highest health), got %q", entry.VolumeServer)
}
if entry.Epoch != 2 {
t.Fatalf("epoch: got %d, want 2", entry.Epoch)
}
// vs2 should remain as the only replica.
if len(entry.Replicas) != 1 {
t.Fatalf("expected 1 remaining replica, got %d", len(entry.Replicas))
}
if entry.Replicas[0].Server != "vs2" {
t.Fatalf("remaining replica should be vs2, got %q", entry.Replicas[0].Server)
}
}
// RF3: One replica dies → primary stays, dead replica removed, pending rebuild.
func TestRF3_OneReplicaDies_PrimaryUnchanged(t *testing.T) {
ms := testMasterServerForFailover(t)
registerVolumeRF3(t, ms, "vol1", "vs1", "vs2", "vs3", 1, 5*time.Second)
// vs3 dies (a replica, not the primary).
ms.failoverBlockVolumes("vs3")
entry, _ := ms.blockRegistry.Lookup("vol1")
// Primary should remain vs1.
if entry.VolumeServer != "vs1" {
t.Fatalf("primary should remain vs1, got %q", entry.VolumeServer)
}
// Epoch should NOT change (no promotion).
if entry.Epoch != 1 {
t.Fatalf("epoch should remain 1, got %d", entry.Epoch)
}
// vs3 should be removed from replicas, leaving only vs2.
if len(entry.Replicas) != 1 {
t.Fatalf("expected 1 replica after vs3 death, got %d", len(entry.Replicas))
}
if entry.Replicas[0].Server != "vs2" {
t.Fatalf("remaining replica should be vs2, got %q", entry.Replicas[0].Server)
}
// Pending rebuild should be recorded for vs3.
ms.blockFailover.mu.Lock()
rebuilds := ms.blockFailover.pendingRebuilds["vs3"]
ms.blockFailover.mu.Unlock()
if len(rebuilds) != 1 || rebuilds[0].VolumeName != "vol1" {
t.Fatalf("expected 1 pending rebuild for vs3/vol1, got %+v", rebuilds)
}
}
// RF3: Dead replica reconnects → rebuilds, rejoins replica list.
func TestRF3_RecoverRebuildsDeadReplica(t *testing.T) {
ms := testMasterServerForFailover(t)
registerVolumeRF3(t, ms, "vol1", "vs1", "vs2", "vs3", 1, 5*time.Second)
// vs3 dies.
ms.failoverBlockVolumes("vs3")
// vs3 reconnects.
ms.recoverBlockVolumes("vs3")
entry, _ := ms.blockRegistry.Lookup("vol1")
// Primary still vs1.
if entry.VolumeServer != "vs1" {
t.Fatalf("primary should remain vs1, got %q", entry.VolumeServer)
}
// vs3 should be back in replicas (added by recoverBlockVolumes via AddReplica).
if len(entry.Replicas) != 2 {
t.Fatalf("expected 2 replicas after recovery, got %d", len(entry.Replicas))
}
// Verify rebuild assignment was queued for vs3.
assignments := ms.blockAssignmentQueue.Peek("vs3")
foundRebuild := false
for _, a := range assignments {
if blockvol.RoleFromWire(a.Role) == blockvol.RoleRebuilding {
foundRebuild = true
}
}
if !foundRebuild {
t.Fatal("expected rebuild assignment for vs3")
}
}
// RF3: Other replicas survive promotion.
func TestRF3_OtherReplicasSurvivePromotion(t *testing.T) {
ms := testMasterServerForFailover(t)
registerVolumeRF3(t, ms, "vol1", "vs1", "vs2", "vs3", 1, 5*time.Second)
// Primary (vs1) dies → one replica promoted.
ms.failoverBlockVolumes("vs1")
entry, _ := ms.blockRegistry.Lookup("vol1")
newPrimary := entry.VolumeServer
// New primary should be vs2 or vs3 (both have equal health/LSN, so first wins).
if newPrimary != "vs2" && newPrimary != "vs3" {
t.Fatalf("new primary should be vs2 or vs3, got %q", newPrimary)
}
// The other replica should still be in the list.
if len(entry.Replicas) != 1 {
t.Fatalf("expected 1 surviving replica, got %d", len(entry.Replicas))
}
// The surviving replica is whichever wasn't promoted.
expectedSurvivor := "vs3"
if newPrimary == "vs3" {
expectedSurvivor = "vs2"
}
if entry.Replicas[0].Server != expectedSurvivor {
t.Fatalf("surviving replica should be %q, got %q", expectedSurvivor, entry.Replicas[0].Server)
}
// Assignment for new primary should include ReplicaAddrs for the surviving replica.
assignments := ms.blockAssignmentQueue.Peek(newPrimary)
foundPrimary := false
for _, a := range assignments {
if blockvol.RoleFromWire(a.Role) == blockvol.RolePrimary {
foundPrimary = true
if len(a.ReplicaAddrs) != 1 {
t.Fatalf("expected 1 ReplicaAddr in assignment, got %d", len(a.ReplicaAddrs))
}
}
}
if !foundPrimary {
t.Fatal("expected primary assignment for new primary")
}
}
// RF2 unchanged: verify RF=2 still works identically.
func TestRF2_Unchanged_AfterCP82(t *testing.T) {
ms := testMasterServerForFailover(t)
registerVolumeWithReplica(t, ms, "vol1", "vs1", "vs2", 1, 5*time.Second)
ms.failoverBlockVolumes("vs1")
entry, _ := ms.blockRegistry.Lookup("vol1")
if entry.VolumeServer != "vs2" {
t.Fatalf("RF2: primary should be vs2, got %q", entry.VolumeServer)
}
if entry.Epoch != 2 {
t.Fatalf("RF2: epoch should be 2, got %d", entry.Epoch)
}
// No replicas left (RF=2, one promoted).
if len(entry.Replicas) != 0 {
t.Fatalf("RF2: expected 0 replicas after promotion, got %d", len(entry.Replicas))
}
// Rebuild works the same.
ms.recoverBlockVolumes("vs1")
entry, _ = ms.blockRegistry.Lookup("vol1")
if len(entry.Replicas) != 1 || entry.Replicas[0].Server != "vs1" {
t.Fatalf("RF2: vs1 should be back as replica after recovery, got %+v", entry.Replicas)
}
}
// RF3: All replicas dead → no promotion possible.
func TestRF3_AllReplicasDead_NoPromotion(t *testing.T) {
ms := testMasterServerForFailover(t)
registerVolumeRF3(t, ms, "vol1", "vs1", "vs2", "vs3", 1, 5*time.Second)
// Both replicas die first (not primary).
ms.failoverBlockVolumes("vs2")
ms.failoverBlockVolumes("vs3")
entry, _ := ms.blockRegistry.Lookup("vol1")
// Primary still vs1, replicas removed.
if entry.VolumeServer != "vs1" {
t.Fatalf("primary should remain vs1, got %q", entry.VolumeServer)
}
if len(entry.Replicas) != 0 {
t.Fatalf("expected 0 replicas, got %d", len(entry.Replicas))
}
// Now primary dies → no promotion possible (no replicas left).
ms.failoverBlockVolumes("vs1")
entry, _ = ms.blockRegistry.Lookup("vol1")
if entry.VolumeServer != "vs1" {
t.Fatalf("should remain vs1 (no replicas to promote), got %q", entry.VolumeServer)
}
}
// RF3: Lease deferred promotion with RF=3.
func TestRF3_LeaseDeferred_Promotion(t *testing.T) {
ms := testMasterServerForFailover(t)
entry := &BlockVolumeEntry{
Name: "vol1",
VolumeServer: "vs1",
Path: "/data/vol1.blk",
IQN: "iqn:vol1",
ISCSIAddr: "vs1:3260",
SizeBytes: 1 << 30,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive,
ReplicaFactor: 3,
LeaseTTL: 200 * time.Millisecond,
LastLeaseGrant: time.Now(), // just granted → NOT expired
Replicas: []ReplicaInfo{
{Server: "vs2", Path: "/data/vol1.blk", ISCSIAddr: "vs2:3260", HealthScore: 1.0, WALHeadLSN: 50},
{Server: "vs3", Path: "/data/vol1.blk", ISCSIAddr: "vs3:3260", HealthScore: 0.9, WALHeadLSN: 50},
},
// Deprecated scalar fields.
ReplicaServer: "vs2", ReplicaPath: "/data/vol1.blk", ReplicaISCSIAddr: "vs2:3260",
}
ms.blockRegistry.Register(entry)
ms.failoverBlockVolumes("vs1")
// Immediately: no promotion (lease not expired).
e, _ := ms.blockRegistry.Lookup("vol1")
if e.VolumeServer != "vs1" {
t.Fatalf("should NOT promote yet (lease active), got %q", e.VolumeServer)
}
// Wait for lease to expire + deferred timer.
time.Sleep(350 * time.Millisecond)
e, _ = ms.blockRegistry.Lookup("vol1")
if e.VolumeServer != "vs2" {
t.Fatalf("should promote vs2 (highest health) after lease expires, got %q", e.VolumeServer)
}
// vs3 should survive as the remaining replica.
if len(e.Replicas) != 1 || e.Replicas[0].Server != "vs3" {
t.Fatalf("vs3 should remain as replica, got %+v", e.Replicas)
}
}
// RF3: Cancel deferred timers on reconnect.
func TestRF3_CancelDeferredOnReconnect(t *testing.T) {
ms := testMasterServerForFailover(t)
entry := &BlockVolumeEntry{
Name: "vol1",
VolumeServer: "vs1",
Path: "/data/vol1.blk",
IQN: "iqn:vol1",
ISCSIAddr: "vs1:3260",
SizeBytes: 1 << 30,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive,
ReplicaFactor: 3,
LeaseTTL: 5 * time.Second,
LastLeaseGrant: time.Now(), // just granted → long lease
Replicas: []ReplicaInfo{
{Server: "vs2", Path: "/data/vol1.blk", ISCSIAddr: "vs2:3260", HealthScore: 1.0},
{Server: "vs3", Path: "/data/vol1.blk", ISCSIAddr: "vs3:3260", HealthScore: 1.0},
},
ReplicaServer: "vs2", ReplicaPath: "/data/vol1.blk", ReplicaISCSIAddr: "vs2:3260",
}
ms.blockRegistry.Register(entry)
// vs1 disconnects → deferred timer created (lease not expired).
ms.failoverBlockVolumes("vs1")
ms.blockFailover.mu.Lock()
timerCount := len(ms.blockFailover.deferredTimers["vs1"])
ms.blockFailover.mu.Unlock()
if timerCount == 0 {
t.Fatal("expected deferred timer for vs1")
}
// vs1 reconnects before lease expires → timers cancelled.
ms.recoverBlockVolumes("vs1")
ms.blockFailover.mu.Lock()
timerCount = len(ms.blockFailover.deferredTimers["vs1"])
ms.blockFailover.mu.Unlock()
if timerCount != 0 {
t.Fatalf("expected 0 deferred timers after reconnect, got %d", timerCount)
}
// Wait past original lease time — no promotion should happen.
time.Sleep(200 * time.Millisecond)
e, _ := ms.blockRegistry.Lookup("vol1")
if e.VolumeServer != "vs1" {
t.Fatalf("vs1 should remain primary (timer cancelled), got %q", e.VolumeServer)
}
}
+591 -43
View File
@@ -2,9 +2,14 @@ package weed_server
import (
"fmt"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
)
@@ -17,32 +22,102 @@ const (
StatusActive // Confirmed by heartbeat from volume server
)
// ReplicaInfo tracks one replica of a block volume (CP8-2).
type ReplicaInfo struct {
Server string // replica VS address
Path string // file path on replica VS
ISCSIAddr string // iSCSI target address
IQN string // iSCSI qualified name
DataAddr string // WAL receiver data listen addr
CtrlAddr string // WAL receiver ctrl listen addr
HealthScore float64 // from heartbeat (0.0-1.0)
WALHeadLSN uint64 // from heartbeat
WALLag uint64 // computed: primary.WALHeadLSN - replica.WALHeadLSN
LastHeartbeat time.Time // last heartbeat received from this replica
Role uint32 // replica role (RoleReplica, RoleRebuilding, etc.)
}
const (
// DefaultPromotionLSNTolerance is the max WAL LSN lag allowed for promotion eligibility.
// Configurable per-registry via SetPromotionLSNTolerance.
DefaultPromotionLSNTolerance uint64 = 100
)
// BlockVolumeEntry tracks one block volume across the cluster.
type BlockVolumeEntry struct {
Name string
VolumeServer string // volume server address (ip:port or grpc addr)
Path string // file path on volume server
IQN string
ISCSIAddr string
SizeBytes uint64
Epoch uint64
Role uint32
Status VolumeStatus
Name string
VolumeServer string // volume server address (ip:port or grpc addr)
Path string // file path on volume server
IQN string
ISCSIAddr string
SizeBytes uint64
ReplicaPlacement string // SeaweedFS placement string: "000", "001", "010", "100"
Epoch uint64
Role uint32
Status VolumeStatus
// Replica tracking (CP6-3).
ReplicaServer string // replica VS address
ReplicaPath string // file path on replica VS
// Deprecated scalar replica fields (CP6-3). Use Replicas[] for new code.
ReplicaServer string
ReplicaPath string
ReplicaISCSIAddr string
ReplicaIQN string
ReplicaDataAddr string // replica receiver data listen addr
ReplicaCtrlAddr string // replica receiver ctrl listen addr
ReplicaDataAddr string
ReplicaCtrlAddr string
RebuildListenAddr string // rebuild server listen addr on primary
// CP8-2: Multi-replica support.
ReplicaFactor int // 2 or 3 (default 2)
Replicas []ReplicaInfo // one per replica (RF-1 entries)
HealthScore float64 // primary health score from heartbeat
ReplicaDegraded bool // primary reports degraded replicas
WALHeadLSN uint64 // primary WAL head LSN from heartbeat
// Lease tracking for failover (CP6-3 F2).
LastLeaseGrant time.Time
LeaseTTL time.Duration
}
// HasReplica returns true if this volume has any replica (checks both new and deprecated fields).
func (e *BlockVolumeEntry) HasReplica() bool {
return len(e.Replicas) > 0 || e.ReplicaServer != ""
}
// FirstReplica returns the first replica info, or nil if none.
func (e *BlockVolumeEntry) FirstReplica() *ReplicaInfo {
if len(e.Replicas) > 0 {
return &e.Replicas[0]
}
return nil
}
// ReplicaByServer returns the replica hosted on the given server, or nil.
func (e *BlockVolumeEntry) ReplicaByServer(server string) *ReplicaInfo {
for i := range e.Replicas {
if e.Replicas[i].Server == server {
return &e.Replicas[i]
}
}
return nil
}
// BestReplicaForPromotion returns the best replica for promotion, or nil if none eligible.
// Criteria: highest HealthScore, tie-break by highest WALHeadLSN, then first in list.
func (e *BlockVolumeEntry) BestReplicaForPromotion() *ReplicaInfo {
if len(e.Replicas) == 0 {
return nil
}
best := 0
for i := 1; i < len(e.Replicas); i++ {
if e.Replicas[i].HealthScore > e.Replicas[best].HealthScore {
best = i
} else if e.Replicas[i].HealthScore == e.Replicas[best].HealthScore &&
e.Replicas[i].WALHeadLSN > e.Replicas[best].WALHeadLSN {
best = i
}
}
return &e.Replicas[best]
}
// BlockVolumeRegistry is the in-memory registry of block volumes.
// Rebuilt from heartbeats on master restart (no persistence).
type BlockVolumeRegistry struct {
@@ -51,8 +126,16 @@ type BlockVolumeRegistry struct {
byServer map[string]map[string]bool // server -> set of volume names
blockServers map[string]bool // servers known to support block volumes
// Promotion eligibility: max WAL LSN lag for replica to be promotable.
promotionLSNTolerance uint64
// inflight guards concurrent CreateBlockVolume for the same name.
inflight sync.Map // name -> *inflightEntry
// Metrics (CP8-4).
PromotionsTotal atomic.Uint64
FailoversTotal atomic.Uint64
RebuildsTotal atomic.Uint64
}
type inflightEntry struct{}
@@ -60,9 +143,10 @@ type inflightEntry struct{}
// NewBlockVolumeRegistry creates an empty registry.
func NewBlockVolumeRegistry() *BlockVolumeRegistry {
return &BlockVolumeRegistry{
volumes: make(map[string]*BlockVolumeEntry),
byServer: make(map[string]map[string]bool),
blockServers: make(map[string]bool),
volumes: make(map[string]*BlockVolumeEntry),
byServer: make(map[string]map[string]bool),
blockServers: make(map[string]bool),
promotionLSNTolerance: DefaultPromotionLSNTolerance,
}
}
@@ -76,6 +160,10 @@ func (r *BlockVolumeRegistry) Register(entry *BlockVolumeEntry) error {
}
r.volumes[entry.Name] = entry
r.addToServer(entry.VolumeServer, entry.Name)
// Also index replica servers so ListByServer finds them.
for _, ri := range entry.Replicas {
r.addToServer(ri.Server, entry.Name)
}
return nil
}
@@ -89,9 +177,25 @@ func (r *BlockVolumeRegistry) Unregister(name string) *BlockVolumeEntry {
}
delete(r.volumes, name)
r.removeFromServer(entry.VolumeServer, name)
for _, ri := range entry.Replicas {
r.removeFromServer(ri.Server, name)
}
return entry
}
// UpdateSize updates the size of a registered volume.
// Called only after a successful VS expand to keep registry in sync.
func (r *BlockVolumeRegistry) UpdateSize(name string, newSizeBytes uint64) error {
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.volumes[name]
if !ok {
return fmt.Errorf("block volume %q not found in registry", name)
}
entry.SizeBytes = newSizeBytes
return nil
}
// Lookup returns the entry for the given name.
func (r *BlockVolumeRegistry) Lookup(name string) (*BlockVolumeEntry, bool) {
r.mu.RLock()
@@ -134,51 +238,172 @@ func (r *BlockVolumeRegistry) UpdateFullHeartbeat(server string, infos []*master
reported[info.Path] = info
}
// Find entries for this server that are NOT reported -> remove them.
// Find entries for this server that are NOT reported -> reconcile.
if names, ok := r.byServer[server]; ok {
for name := range names {
entry := r.volumes[name]
if entry == nil {
continue
}
if _, found := reported[entry.Path]; !found {
delete(r.volumes, name)
delete(names, name)
if entry.VolumeServer == server {
// Server is the primary: check if primary path is reported.
if _, found := reported[entry.Path]; !found {
delete(r.volumes, name)
delete(names, name)
// Also clean up replica entries from byServer.
for _, ri := range entry.Replicas {
r.removeFromServer(ri.Server, name)
}
}
} else {
// Server is a replica: check if replica path is reported.
ri := entry.ReplicaByServer(server)
if ri == nil {
// No replica record — stale byServer index, just clean up.
delete(names, name)
continue
}
if _, found := reported[ri.Path]; !found {
// Replica path not reported — remove this replica, NOT the whole volume.
r.removeReplicaLocked(entry, server, name)
delete(names, name)
glog.V(0).Infof("block registry: removed stale replica %s for %q (path %s not in heartbeat)",
server, name, ri.Path)
}
}
}
}
// Update or add entries for reported volumes.
for _, info := range infos {
// Find existing entry by path on this server.
// Find existing entry: search byServer index for matching path (primary or replica).
var existing *BlockVolumeEntry
var existingName string
if names, ok := r.byServer[server]; ok {
for name := range names {
if e := r.volumes[name]; e != nil && e.Path == info.Path {
existing = e
break
for vname := range names {
if e := r.volumes[vname]; e != nil {
if e.VolumeServer == server && e.Path == info.Path {
existing = e
existingName = vname
break
}
if ri := e.ReplicaByServer(server); ri != nil && ri.Path == info.Path {
existing = e
existingName = vname
break
}
}
}
}
// Also try lookup by name derived from path (handles post-restart).
if existing == nil {
name := nameFromPath(info.Path)
if name != "" {
if e, ok := r.volumes[name]; ok {
existing = e
existingName = name
}
}
}
if existing != nil {
// Update fields from heartbeat.
existing.SizeBytes = info.VolumeSize
existing.Epoch = info.Epoch
existing.Role = info.Role
existing.Status = StatusActive
// R1-5: Refresh lease on heartbeat — VS is alive and running this volume.
existing.LastLeaseGrant = time.Now()
// F5: update replica addresses from heartbeat info.
if info.ReplicaDataAddr != "" {
existing.ReplicaDataAddr = info.ReplicaDataAddr
isPrimary := existing.VolumeServer == server
isReplica := existing.ReplicaByServer(server) != nil
if isPrimary {
// Primary heartbeat: update primary fields.
existing.SizeBytes = info.VolumeSize
existing.Epoch = info.Epoch
existing.Role = info.Role
existing.Status = StatusActive
existing.LastLeaseGrant = time.Now()
existing.HealthScore = info.HealthScore
existing.ReplicaDegraded = info.ReplicaDegraded
existing.WALHeadLSN = info.WalHeadLsn
// F5: update replica addresses from heartbeat info.
if info.ReplicaDataAddr != "" {
existing.ReplicaDataAddr = info.ReplicaDataAddr
}
if info.ReplicaCtrlAddr != "" {
existing.ReplicaCtrlAddr = info.ReplicaCtrlAddr
}
// Sync first replica's data addrs to Replicas[].
if info.ReplicaDataAddr != "" && len(existing.Replicas) > 0 {
existing.Replicas[0].DataAddr = info.ReplicaDataAddr
existing.Replicas[0].CtrlAddr = info.ReplicaCtrlAddr
}
} else if isReplica {
// Replica heartbeat: update ReplicaInfo fields.
for i := range existing.Replicas {
if existing.Replicas[i].Server == server {
existing.Replicas[i].Path = info.Path
existing.Replicas[i].WALHeadLSN = info.WalHeadLsn
existing.Replicas[i].HealthScore = info.HealthScore
existing.Replicas[i].LastHeartbeat = time.Now()
existing.Replicas[i].Role = info.Role
if existing.WALHeadLSN > info.WalHeadLsn {
existing.Replicas[i].WALLag = existing.WALHeadLSN - info.WalHeadLsn
} else {
existing.Replicas[i].WALLag = 0
}
break
}
}
} else {
// Fix #3: Server reports a volume that exists but has no record of this server.
// This happens after master restart: primary heartbeat re-created the entry,
// but replica heartbeat arrives and isn't linked. Add as replica.
ri := ReplicaInfo{
Server: server,
Path: info.Path,
HealthScore: info.HealthScore,
WALHeadLSN: info.WalHeadLsn,
LastHeartbeat: time.Now(),
Role: info.Role,
}
existing.Replicas = append(existing.Replicas, ri)
r.addToServer(server, existingName)
// Sync deprecated scalar fields.
if len(existing.Replicas) == 1 {
existing.ReplicaServer = ri.Server
existing.ReplicaPath = ri.Path
}
glog.V(0).Infof("block registry: attached replica %s for %q from heartbeat (path=%s)",
server, existingName, info.Path)
}
} else {
// Auto-register volumes reported by heartbeat but not in registry.
// This recovers state after master restart.
name := nameFromPath(info.Path)
if name == "" {
continue
}
if info.ReplicaCtrlAddr != "" {
existing.ReplicaCtrlAddr = info.ReplicaCtrlAddr
if _, dup := r.volumes[name]; !dup {
entry := &BlockVolumeEntry{
Name: name,
VolumeServer: server,
Path: info.Path,
SizeBytes: info.VolumeSize,
Epoch: info.Epoch,
Role: info.Role,
Status: StatusActive,
LastLeaseGrant: time.Now(),
LeaseTTL: 30 * time.Second,
HealthScore: info.HealthScore,
WALHeadLSN: info.WalHeadLsn,
}
if info.ReplicaDataAddr != "" {
entry.ReplicaDataAddr = info.ReplicaDataAddr
}
if info.ReplicaCtrlAddr != "" {
entry.ReplicaCtrlAddr = info.ReplicaCtrlAddr
}
r.volumes[name] = entry
r.addToServer(server, name)
glog.V(0).Infof("block registry: auto-registered %q from heartbeat (server=%s, path=%s, size=%d)",
name, server, info.Path, info.VolumeSize)
}
}
// If no existing entry found by path, it was created outside master
// (e.g., manually). We don't auto-register unknown volumes — they
// must be created via CreateBlockVolume RPC.
}
}
@@ -274,7 +499,38 @@ func (r *BlockVolumeRegistry) removeFromServer(server, name string) {
}
}
// removeReplicaLocked removes a replica from an entry by server address.
// Caller must hold r.mu. Also syncs deprecated scalar fields.
func (r *BlockVolumeRegistry) removeReplicaLocked(entry *BlockVolumeEntry, server, name string) {
newReplicas := make([]ReplicaInfo, 0, len(entry.Replicas))
for _, ri := range entry.Replicas {
if ri.Server == server {
continue
}
newReplicas = append(newReplicas, ri)
}
entry.Replicas = newReplicas
// Sync deprecated scalar fields.
if len(entry.Replicas) > 0 {
r0 := &entry.Replicas[0]
entry.ReplicaServer = r0.Server
entry.ReplicaPath = r0.Path
entry.ReplicaISCSIAddr = r0.ISCSIAddr
entry.ReplicaIQN = r0.IQN
entry.ReplicaDataAddr = r0.DataAddr
entry.ReplicaCtrlAddr = r0.CtrlAddr
} else {
entry.ReplicaServer = ""
entry.ReplicaPath = ""
entry.ReplicaISCSIAddr = ""
entry.ReplicaIQN = ""
entry.ReplicaDataAddr = ""
entry.ReplicaCtrlAddr = ""
}
}
// SetReplica sets replica info for a registered volume.
// Deprecated: use AddReplica for new code. This method syncs both scalar and Replicas[].
func (r *BlockVolumeRegistry) SetReplica(name, server, path, iscsiAddr, iqn string) error {
r.mu.Lock()
defer r.mu.Unlock()
@@ -290,12 +546,31 @@ func (r *BlockVolumeRegistry) SetReplica(name, server, path, iscsiAddr, iqn stri
entry.ReplicaPath = path
entry.ReplicaISCSIAddr = iscsiAddr
entry.ReplicaIQN = iqn
// Also add to byServer index for the replica server.
r.addToServer(server, name)
// CP8-2: also sync to Replicas[].
info := ReplicaInfo{Server: server, Path: path, ISCSIAddr: iscsiAddr, IQN: iqn}
replaced := false
for i := range entry.Replicas {
if entry.Replicas[i].Server == server {
// Preserve existing health/LSN data.
info.HealthScore = entry.Replicas[i].HealthScore
info.WALHeadLSN = entry.Replicas[i].WALHeadLSN
info.DataAddr = entry.Replicas[i].DataAddr
info.CtrlAddr = entry.Replicas[i].CtrlAddr
entry.Replicas[i] = info
replaced = true
break
}
}
if !replaced {
entry.Replicas = append(entry.Replicas, info)
}
return nil
}
// ClearReplica removes replica info for a registered volume.
// ClearReplica removes all replica info for a registered volume.
// Deprecated: use RemoveReplica for new code. This method clears both scalar and Replicas[].
func (r *BlockVolumeRegistry) ClearReplica(name string) error {
r.mu.Lock()
defer r.mu.Unlock()
@@ -306,12 +581,19 @@ func (r *BlockVolumeRegistry) ClearReplica(name string) error {
if entry.ReplicaServer != "" {
r.removeFromServer(entry.ReplicaServer, name)
}
// Remove all replicas from byServer index.
for _, ri := range entry.Replicas {
if ri.Server != entry.ReplicaServer {
r.removeFromServer(ri.Server, name)
}
}
entry.ReplicaServer = ""
entry.ReplicaPath = ""
entry.ReplicaISCSIAddr = ""
entry.ReplicaIQN = ""
entry.ReplicaDataAddr = ""
entry.ReplicaCtrlAddr = ""
entry.Replicas = nil
return nil
}
@@ -363,6 +645,198 @@ func (r *BlockVolumeRegistry) SwapPrimaryReplica(name string) (uint64, error) {
return newEpoch, nil
}
// AddReplica adds or replaces a replica in the Replicas slice (by server).
// Also updates the byServer index and deprecated scalar fields for backward compat.
func (r *BlockVolumeRegistry) AddReplica(name string, info ReplicaInfo) error {
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.volumes[name]
if !ok {
return fmt.Errorf("block volume %q not found", name)
}
// Replace if same server already exists.
replaced := false
for i := range entry.Replicas {
if entry.Replicas[i].Server == info.Server {
entry.Replicas[i] = info
replaced = true
break
}
}
if !replaced {
entry.Replicas = append(entry.Replicas, info)
}
r.addToServer(info.Server, name)
// Sync deprecated scalar fields (first replica → scalar).
if len(entry.Replicas) > 0 {
r0 := &entry.Replicas[0]
entry.ReplicaServer = r0.Server
entry.ReplicaPath = r0.Path
entry.ReplicaISCSIAddr = r0.ISCSIAddr
entry.ReplicaIQN = r0.IQN
entry.ReplicaDataAddr = r0.DataAddr
entry.ReplicaCtrlAddr = r0.CtrlAddr
}
return nil
}
// RemoveReplica removes a replica by server address.
func (r *BlockVolumeRegistry) RemoveReplica(name, server string) error {
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.volumes[name]
if !ok {
return fmt.Errorf("block volume %q not found", name)
}
found := false
newReplicas := make([]ReplicaInfo, 0, len(entry.Replicas))
for _, ri := range entry.Replicas {
if ri.Server == server {
found = true
r.removeFromServer(server, name)
continue
}
newReplicas = append(newReplicas, ri)
}
if !found {
return fmt.Errorf("replica on %q not found for volume %q", server, name)
}
entry.Replicas = newReplicas
// Sync deprecated scalar fields.
if len(entry.Replicas) > 0 {
r0 := &entry.Replicas[0]
entry.ReplicaServer = r0.Server
entry.ReplicaPath = r0.Path
entry.ReplicaISCSIAddr = r0.ISCSIAddr
entry.ReplicaIQN = r0.IQN
entry.ReplicaDataAddr = r0.DataAddr
entry.ReplicaCtrlAddr = r0.CtrlAddr
} else {
entry.ReplicaServer = ""
entry.ReplicaPath = ""
entry.ReplicaISCSIAddr = ""
entry.ReplicaIQN = ""
entry.ReplicaDataAddr = ""
entry.ReplicaCtrlAddr = ""
}
return nil
}
// SetPromotionLSNTolerance configures the max WAL LSN lag for promotion eligibility.
func (r *BlockVolumeRegistry) SetPromotionLSNTolerance(tolerance uint64) {
r.mu.Lock()
defer r.mu.Unlock()
r.promotionLSNTolerance = tolerance
}
// PromotionLSNTolerance returns the current promotion LSN tolerance.
func (r *BlockVolumeRegistry) PromotionLSNTolerance() uint64 {
r.mu.RLock()
defer r.mu.RUnlock()
return r.promotionLSNTolerance
}
// PromoteBestReplica promotes the best eligible replica to primary.
// Eligibility: heartbeat fresh (within 2×LeaseTTL), WALHeadLSN within tolerance of primary,
// and role must be RoleReplica (not RoleRebuilding).
// The promoted replica is removed from Replicas[]. Other replicas stay.
// Old primary is NOT added to Replicas (needs rebuild).
// Returns the new epoch.
func (r *BlockVolumeRegistry) PromoteBestReplica(name string) (uint64, error) {
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.volumes[name]
if !ok {
return 0, fmt.Errorf("block volume %q not found", name)
}
if len(entry.Replicas) == 0 {
return 0, fmt.Errorf("block volume %q has no replicas", name)
}
// Filter eligible replicas.
now := time.Now()
freshnessCutoff := 2 * entry.LeaseTTL
if freshnessCutoff == 0 {
freshnessCutoff = 60 * time.Second // default if LeaseTTL not set
}
primaryLSN := entry.WALHeadLSN
bestIdx := -1
for i := range entry.Replicas {
ri := &entry.Replicas[i]
// Gate 1: heartbeat freshness.
if !ri.LastHeartbeat.IsZero() && now.Sub(ri.LastHeartbeat) > freshnessCutoff {
continue
}
// Gate 2: WAL LSN recency (skip if primary LSN is 0 — no data yet, all eligible).
if primaryLSN > 0 && ri.WALHeadLSN+r.promotionLSNTolerance < primaryLSN {
continue
}
// Gate 3: role must be RoleReplica (not rebuilding/stale).
if ri.Role != 0 && blockvol.RoleFromWire(ri.Role) != blockvol.RoleReplica {
continue
}
// Eligible — pick best by health score, tie-break by WALHeadLSN.
if bestIdx == -1 {
bestIdx = i
} else if ri.HealthScore > entry.Replicas[bestIdx].HealthScore {
bestIdx = i
} else if ri.HealthScore == entry.Replicas[bestIdx].HealthScore &&
ri.WALHeadLSN > entry.Replicas[bestIdx].WALHeadLSN {
bestIdx = i
}
}
if bestIdx == -1 {
return 0, fmt.Errorf("block volume %q: no eligible replicas for promotion", name)
}
promoted := entry.Replicas[bestIdx]
// Remove old primary from byServer index.
r.removeFromServer(entry.VolumeServer, name)
// Bump epoch atomically.
newEpoch := entry.Epoch + 1
// Promote replica to primary.
entry.VolumeServer = promoted.Server
entry.Path = promoted.Path
entry.IQN = promoted.IQN
entry.ISCSIAddr = promoted.ISCSIAddr
entry.Epoch = newEpoch
entry.Role = blockvol.RoleToWire(blockvol.RolePrimary)
entry.LastLeaseGrant = time.Now()
// Remove promoted from Replicas. Others stay.
entry.Replicas = append(entry.Replicas[:bestIdx], entry.Replicas[bestIdx+1:]...)
// Sync deprecated scalar fields.
if len(entry.Replicas) > 0 {
r0 := &entry.Replicas[0]
entry.ReplicaServer = r0.Server
entry.ReplicaPath = r0.Path
entry.ReplicaISCSIAddr = r0.ISCSIAddr
entry.ReplicaIQN = r0.IQN
entry.ReplicaDataAddr = r0.DataAddr
entry.ReplicaCtrlAddr = r0.CtrlAddr
} else {
entry.ReplicaServer = ""
entry.ReplicaPath = ""
entry.ReplicaISCSIAddr = ""
entry.ReplicaIQN = ""
entry.ReplicaDataAddr = ""
entry.ReplicaCtrlAddr = ""
}
// Update byServer index: new primary server now hosts this volume.
r.addToServer(entry.VolumeServer, name)
return newEpoch, nil
}
// MarkBlockCapable records that the given server supports block volumes.
func (r *BlockVolumeRegistry) MarkBlockCapable(server string) {
r.mu.Lock()
@@ -377,6 +851,45 @@ func (r *BlockVolumeRegistry) UnmarkBlockCapable(server string) {
r.mu.Unlock()
}
// ListAll returns all registered block volume entries, sorted by name.
func (r *BlockVolumeRegistry) ListAll() []*BlockVolumeEntry {
r.mu.RLock()
defer r.mu.RUnlock()
entries := make([]*BlockVolumeEntry, 0, len(r.volumes))
for _, e := range r.volumes {
entries = append(entries, e)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
return entries
}
// BlockServerSummary summarizes a block-capable volume server.
type BlockServerSummary struct {
Address string
VolumeCount int
BlockCapable bool
}
// ServerSummaries returns a summary for each block-capable server.
func (r *BlockVolumeRegistry) ServerSummaries() []BlockServerSummary {
r.mu.RLock()
defer r.mu.RUnlock()
summaries := make([]BlockServerSummary, 0, len(r.blockServers))
for addr := range r.blockServers {
count := 0
if names, ok := r.byServer[addr]; ok {
count = len(names)
}
summaries = append(summaries, BlockServerSummary{
Address: addr,
VolumeCount: count,
BlockCapable: true,
})
}
sort.Slice(summaries, func(i, j int) bool { return summaries[i].Address < summaries[j].Address })
return summaries
}
// BlockCapableServers returns the list of servers known to support block volumes.
func (r *BlockVolumeRegistry) BlockCapableServers() []string {
r.mu.RLock()
@@ -387,3 +900,38 @@ func (r *BlockVolumeRegistry) BlockCapableServers() []string {
}
return servers
}
// MaxBarrierLagLSN returns the maximum WAL lag across all volumes and replicas.
// This is the primary durability-risk metric: primary WALHeadLSN minus
// replica's WALHeadLSN. SLO threshold: < 100 under normal load.
func (r *BlockVolumeRegistry) MaxBarrierLagLSN() uint64 {
r.mu.RLock()
defer r.mu.RUnlock()
var maxLag uint64
for _, entry := range r.volumes {
for _, ri := range entry.Replicas {
if ri.WALLag > maxLag {
maxLag = ri.WALLag
}
}
}
return maxLag
}
// AssignmentQueueDepth returns the total number of pending assignments across all servers.
func (r *BlockVolumeRegistry) AssignmentQueueDepth() int {
// Delegated to the assignment queue, not the registry.
// Placeholder: the queue tracks its own depth.
return 0
}
// nameFromPath extracts the volume name from a .blk file path.
// e.g. "/opt/data/block/my-volume.blk" -> "my-volume"
func nameFromPath(path string) string {
base := filepath.Base(path)
if strings.HasSuffix(base, ".blk") {
return strings.TrimSuffix(base, ".blk")
}
return base
}
+557
View File
@@ -4,8 +4,10 @@ import (
"fmt"
"sync"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
)
func TestRegistry_RegisterLookup(t *testing.T) {
@@ -434,3 +436,558 @@ func TestFullHeartbeat_UpdatesReplicaAddrs(t *testing.T) {
t.Fatalf("ReplicaCtrlAddr: got %q, want 10.0.0.2:14261", entry.ReplicaCtrlAddr)
}
}
// --- CP8-2 new tests ---
func TestRegistry_AddReplica(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{Name: "vol1", VolumeServer: "s1", Path: "/v1.blk"})
err := r.AddReplica("vol1", ReplicaInfo{
Server: "s2",
Path: "/replica/v1.blk",
ISCSIAddr: "10.0.0.2:3260",
IQN: "iqn:vol1-r1",
DataAddr: "s2:14260",
CtrlAddr: "s2:14261",
})
if err != nil {
t.Fatalf("AddReplica: %v", err)
}
e, _ := r.Lookup("vol1")
if len(e.Replicas) != 1 {
t.Fatalf("Replicas len: got %d, want 1", len(e.Replicas))
}
if e.Replicas[0].Server != "s2" {
t.Fatalf("Replicas[0].Server: got %q", e.Replicas[0].Server)
}
// Deprecated scalar should be synced.
if e.ReplicaServer != "s2" {
t.Fatalf("ReplicaServer (deprecated): got %q", e.ReplicaServer)
}
// byServer index should include replica.
if len(r.ListByServer("s2")) != 1 {
t.Fatalf("ListByServer(s2): got %d, want 1", len(r.ListByServer("s2")))
}
}
func TestRegistry_AddReplica_TwoRF3(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{Name: "vol1", VolumeServer: "s1", Path: "/v1.blk", ReplicaFactor: 3})
r.AddReplica("vol1", ReplicaInfo{Server: "s2", Path: "/r1.blk", IQN: "iqn:r1"})
r.AddReplica("vol1", ReplicaInfo{Server: "s3", Path: "/r2.blk", IQN: "iqn:r2"})
e, _ := r.Lookup("vol1")
if len(e.Replicas) != 2 {
t.Fatalf("Replicas len: got %d, want 2", len(e.Replicas))
}
if e.Replicas[0].Server != "s2" || e.Replicas[1].Server != "s3" {
t.Fatalf("Replicas: got %+v", e.Replicas)
}
// byServer index should include both.
if len(r.ListByServer("s2")) != 1 || len(r.ListByServer("s3")) != 1 {
t.Fatal("byServer should include both replica servers")
}
}
func TestRegistry_AddReplica_Upsert(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{Name: "vol1", VolumeServer: "s1", Path: "/v1.blk"})
r.AddReplica("vol1", ReplicaInfo{Server: "s2", Path: "/r1.blk"})
r.AddReplica("vol1", ReplicaInfo{Server: "s2", Path: "/r1-new.blk"})
e, _ := r.Lookup("vol1")
if len(e.Replicas) != 1 {
t.Fatalf("Replicas len: got %d, want 1 (upsert, not duplicate)", len(e.Replicas))
}
if e.Replicas[0].Path != "/r1-new.blk" {
t.Fatalf("Replicas[0].Path: got %q, want /r1-new.blk", e.Replicas[0].Path)
}
}
func TestRegistry_RemoveReplica(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{Name: "vol1", VolumeServer: "s1", Path: "/v1.blk"})
r.AddReplica("vol1", ReplicaInfo{Server: "s2", Path: "/r1.blk"})
r.AddReplica("vol1", ReplicaInfo{Server: "s3", Path: "/r2.blk"})
err := r.RemoveReplica("vol1", "s2")
if err != nil {
t.Fatalf("RemoveReplica: %v", err)
}
e, _ := r.Lookup("vol1")
if len(e.Replicas) != 1 {
t.Fatalf("Replicas len: got %d, want 1", len(e.Replicas))
}
if e.Replicas[0].Server != "s3" {
t.Fatalf("remaining replica should be s3, got %q", e.Replicas[0].Server)
}
// Deprecated scalar should sync to first remaining replica.
if e.ReplicaServer != "s3" {
t.Fatalf("ReplicaServer (deprecated): got %q, want s3", e.ReplicaServer)
}
// s2 should be removed from byServer.
if len(r.ListByServer("s2")) != 0 {
t.Fatalf("ListByServer(s2): got %d, want 0", len(r.ListByServer("s2")))
}
}
func TestRegistry_PromoteBestReplica_PicksHighest(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "s1",
Path: "/v1.blk",
Epoch: 5,
Role: 1,
Replicas: []ReplicaInfo{
{Server: "s2", Path: "/r1.blk", IQN: "iqn:r1", ISCSIAddr: "s2:3260", HealthScore: 0.8, WALHeadLSN: 100},
{Server: "s3", Path: "/r2.blk", IQN: "iqn:r2", ISCSIAddr: "s3:3260", HealthScore: 0.95, WALHeadLSN: 90},
},
})
// Add to byServer for s2 and s3.
r.mu.Lock()
r.addToServer("s2", "vol1")
r.addToServer("s3", "vol1")
r.mu.Unlock()
newEpoch, err := r.PromoteBestReplica("vol1")
if err != nil {
t.Fatalf("PromoteBestReplica: %v", err)
}
if newEpoch != 6 {
t.Fatalf("newEpoch: got %d, want 6", newEpoch)
}
e, _ := r.Lookup("vol1")
// s3 had higher health score → promoted.
if e.VolumeServer != "s3" {
t.Fatalf("VolumeServer: got %q, want s3 (higher health)", e.VolumeServer)
}
if e.Path != "/r2.blk" {
t.Fatalf("Path: got %q", e.Path)
}
// s2 should remain in Replicas.
if len(e.Replicas) != 1 {
t.Fatalf("Replicas len: got %d, want 1 (s2 stays)", len(e.Replicas))
}
if e.Replicas[0].Server != "s2" {
t.Fatalf("remaining replica: got %q, want s2", e.Replicas[0].Server)
}
}
func TestRegistry_PromoteBestReplica_NoReplica(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{Name: "vol1", VolumeServer: "s1", Path: "/v1.blk"})
_, err := r.PromoteBestReplica("vol1")
if err == nil {
t.Fatal("PromoteBestReplica with no replicas should return error")
}
}
func TestRegistry_PromoteBestReplica_TiebreakByLSN(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "s1",
Path: "/v1.blk",
Epoch: 3,
Replicas: []ReplicaInfo{
{Server: "s2", Path: "/r1.blk", IQN: "iqn:r1", ISCSIAddr: "s2:3260", HealthScore: 0.9, WALHeadLSN: 50},
{Server: "s3", Path: "/r2.blk", IQN: "iqn:r2", ISCSIAddr: "s3:3260", HealthScore: 0.9, WALHeadLSN: 100},
},
})
r.mu.Lock()
r.addToServer("s2", "vol1")
r.addToServer("s3", "vol1")
r.mu.Unlock()
newEpoch, err := r.PromoteBestReplica("vol1")
if err != nil {
t.Fatalf("PromoteBestReplica: %v", err)
}
if newEpoch != 4 {
t.Fatalf("newEpoch: got %d, want 4", newEpoch)
}
e, _ := r.Lookup("vol1")
// Same health → tie-break by WALHeadLSN → s3 wins.
if e.VolumeServer != "s3" {
t.Fatalf("VolumeServer: got %q, want s3 (higher LSN)", e.VolumeServer)
}
if len(e.Replicas) != 1 || e.Replicas[0].Server != "s2" {
t.Fatalf("remaining replica: got %+v, want [s2]", e.Replicas)
}
}
func TestRegistry_PromoteBestReplica_KeepsOthers(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "s1",
Path: "/v1.blk",
Epoch: 1,
Replicas: []ReplicaInfo{
{Server: "s2", Path: "/r1.blk", IQN: "iqn:r1", ISCSIAddr: "s2:3260", HealthScore: 1.0, WALHeadLSN: 100},
{Server: "s3", Path: "/r2.blk", IQN: "iqn:r2", ISCSIAddr: "s3:3260", HealthScore: 0.5, WALHeadLSN: 100},
},
})
r.mu.Lock()
r.addToServer("s2", "vol1")
r.addToServer("s3", "vol1")
r.mu.Unlock()
r.PromoteBestReplica("vol1")
e, _ := r.Lookup("vol1")
// s2 promoted, s3 stays.
if e.VolumeServer != "s2" {
t.Fatalf("VolumeServer: got %q, want s2", e.VolumeServer)
}
if len(e.Replicas) != 1 || e.Replicas[0].Server != "s3" {
t.Fatalf("remaining replicas: got %+v, want [s3]", e.Replicas)
}
}
func TestRegistry_BackwardCompatAccessors(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{Name: "vol1", VolumeServer: "s1", Path: "/v1.blk"})
e, _ := r.Lookup("vol1")
if e.HasReplica() {
t.Fatal("HasReplica should be false with no replicas")
}
if e.FirstReplica() != nil {
t.Fatal("FirstReplica should be nil")
}
if e.BestReplicaForPromotion() != nil {
t.Fatal("BestReplicaForPromotion should be nil")
}
r.AddReplica("vol1", ReplicaInfo{Server: "s2", Path: "/r.blk", HealthScore: 0.9})
e, _ = r.Lookup("vol1")
if !e.HasReplica() {
t.Fatal("HasReplica should be true after AddReplica")
}
if e.FirstReplica() == nil || e.FirstReplica().Server != "s2" {
t.Fatal("FirstReplica should return s2")
}
if e.ReplicaByServer("s2") == nil {
t.Fatal("ReplicaByServer(s2) should not be nil")
}
if e.ReplicaByServer("s3") != nil {
t.Fatal("ReplicaByServer(s3) should be nil")
}
}
func TestRegistry_ReplicaFactorDefault(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{Name: "vol1", VolumeServer: "s1", Path: "/v1.blk"})
e, _ := r.Lookup("vol1")
// ReplicaFactor defaults to 0 (zero value). API handler defaults to 2.
if e.ReplicaFactor != 0 {
t.Fatalf("default ReplicaFactor: got %d, want 0", e.ReplicaFactor)
}
// Explicit RF=3.
r.Register(&BlockVolumeEntry{Name: "vol2", VolumeServer: "s1", Path: "/v2.blk", ReplicaFactor: 3})
e2, _ := r.Lookup("vol2")
if e2.ReplicaFactor != 3 {
t.Fatalf("ReplicaFactor: got %d, want 3", e2.ReplicaFactor)
}
}
func TestRegistry_FullHeartbeat_UpdatesHealthScore(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "s1",
Path: "/v1.blk",
Status: StatusPending,
})
r.UpdateFullHeartbeat("s1", []*master_pb.BlockVolumeInfoMessage{
{
Path: "/v1.blk",
VolumeSize: 1 << 30,
Epoch: 1,
Role: 1,
HealthScore: 0.85,
ScrubErrors: 2,
WalHeadLsn: 500,
},
})
e, _ := r.Lookup("vol1")
if e.HealthScore != 0.85 {
t.Fatalf("HealthScore: got %f, want 0.85", e.HealthScore)
}
if e.WALHeadLSN != 500 {
t.Fatalf("WALHeadLSN: got %d, want 500", e.WALHeadLSN)
}
}
// Fix #1: Replica heartbeat must NOT delete the volume.
func TestRegistry_ReplicaHeartbeat_DoesNotDeleteVolume(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "primary",
Path: "/data/vol1.blk",
Status: StatusActive,
Replicas: []ReplicaInfo{
{Server: "replica1", Path: "/data/vol1.blk"},
},
})
// Replica sends heartbeat reporting its path.
r.UpdateFullHeartbeat("replica1", []*master_pb.BlockVolumeInfoMessage{
{Path: "/data/vol1.blk", Epoch: 1, Role: 2},
})
// Volume must still exist with primary intact.
e, ok := r.Lookup("vol1")
if !ok {
t.Fatal("vol1 should not be deleted when replica sends heartbeat")
}
if e.VolumeServer != "primary" {
t.Fatalf("primary should remain 'primary', got %q", e.VolumeServer)
}
}
// Fix #1: Replica path NOT reported → replica removed, volume preserved.
func TestRegistry_ReplicaHeartbeat_StaleReplicaRemoved(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "primary",
Path: "/data/vol1.blk",
Status: StatusActive,
Replicas: []ReplicaInfo{
{Server: "replica1", Path: "/data/vol1.blk"},
{Server: "replica2", Path: "/data/vol1.blk"},
},
})
// replica1 heartbeat does NOT report vol1 path → stale replica.
r.UpdateFullHeartbeat("replica1", []*master_pb.BlockVolumeInfoMessage{})
// Volume still exists, but replica1 removed.
e, ok := r.Lookup("vol1")
if !ok {
t.Fatal("vol1 should exist (only replica removed)")
}
if len(e.Replicas) != 1 {
t.Fatalf("expected 1 replica after stale removal, got %d", len(e.Replicas))
}
if e.Replicas[0].Server != "replica2" {
t.Fatalf("remaining replica should be replica2, got %q", e.Replicas[0].Server)
}
}
// Fix #3: Replica heartbeat after master restart reconstructs ReplicaInfo.
func TestRegistry_ReplicaHeartbeat_ReconstructsAfterRestart(t *testing.T) {
r := NewBlockVolumeRegistry()
// Simulate master restart: primary heartbeat re-created entry without replicas.
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "primary",
Path: "/data/vol1.blk",
Status: StatusActive,
})
// Replica heartbeat arrives — vol1 exists but has no record of this server.
r.UpdateFullHeartbeat("replica1", []*master_pb.BlockVolumeInfoMessage{
{Path: "/data/vol1.blk", Epoch: 1, Role: 2, HealthScore: 0.95, WalHeadLsn: 42},
})
// vol1 should now have replica1 in Replicas[].
e, ok := r.Lookup("vol1")
if !ok {
t.Fatal("vol1 should exist")
}
if len(e.Replicas) != 1 {
t.Fatalf("expected 1 replica after reconstruction, got %d", len(e.Replicas))
}
ri := e.Replicas[0]
if ri.Server != "replica1" {
t.Fatalf("replica server: got %q, want replica1", ri.Server)
}
if ri.HealthScore != 0.95 {
t.Fatalf("replica health: got %f, want 0.95", ri.HealthScore)
}
if ri.WALHeadLSN != 42 {
t.Fatalf("replica WALHeadLSN: got %d, want 42", ri.WALHeadLSN)
}
// byServer index should include replica1.
entries := r.ListByServer("replica1")
if len(entries) != 1 || entries[0].Name != "vol1" {
t.Fatalf("ListByServer(replica1) should return vol1, got %+v", entries)
}
}
// Fix #2: Stale replica (old heartbeat) not eligible for promotion.
func TestRegistry_PromoteBestReplica_StaleHeartbeatIneligible(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "primary",
Path: "/data/vol1.blk",
Epoch: 1,
LeaseTTL: 5 * time.Second,
WALHeadLSN: 100,
Replicas: []ReplicaInfo{
{
Server: "stale-replica",
Path: "/data/vol1.blk",
HealthScore: 1.0,
WALHeadLSN: 100,
LastHeartbeat: time.Now().Add(-30 * time.Second), // stale (>2×5s)
},
},
})
_, err := r.PromoteBestReplica("vol1")
if err == nil {
t.Fatal("expected error: stale replica should not be eligible")
}
}
// Fix #2: Replica with WAL lag too large is not eligible.
func TestRegistry_PromoteBestReplica_WALLagIneligible(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "primary",
Path: "/data/vol1.blk",
Epoch: 1,
LeaseTTL: 30 * time.Second,
WALHeadLSN: 1000,
Replicas: []ReplicaInfo{
{
Server: "lagging",
Path: "/data/vol1.blk",
HealthScore: 1.0,
WALHeadLSN: 800, // lag=200, tolerance=100
LastHeartbeat: time.Now(),
},
},
})
_, err := r.PromoteBestReplica("vol1")
if err == nil {
t.Fatal("expected error: lagging replica should not be eligible")
}
}
// Fix #2: Rebuilding replica is not eligible for promotion.
func TestRegistry_PromoteBestReplica_RebuildingIneligible(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "primary",
Path: "/data/vol1.blk",
Epoch: 1,
LeaseTTL: 30 * time.Second,
WALHeadLSN: 100,
Replicas: []ReplicaInfo{
{
Server: "rebuilding",
Path: "/data/vol1.blk",
HealthScore: 1.0,
WALHeadLSN: 100,
LastHeartbeat: time.Now(),
Role: blockvol.RoleToWire(blockvol.RoleRebuilding),
},
},
})
_, err := r.PromoteBestReplica("vol1")
if err == nil {
t.Fatal("expected error: rebuilding replica should not be eligible")
}
}
// Fix #2: Among eligible replicas, best (health+LSN) wins.
func TestRegistry_PromoteBestReplica_EligibilityFiltersCorrectly(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "primary",
Path: "/data/vol1.blk",
Epoch: 1,
LeaseTTL: 30 * time.Second,
WALHeadLSN: 100,
Replicas: []ReplicaInfo{
{
Server: "stale", // ineligible: old heartbeat
Path: "/data/vol1.blk",
HealthScore: 1.0,
WALHeadLSN: 100,
LastHeartbeat: time.Now().Add(-2 * time.Minute),
},
{
Server: "good", // eligible
Path: "/data/vol1.blk",
HealthScore: 0.8,
WALHeadLSN: 95,
LastHeartbeat: time.Now(),
},
},
})
_, err := r.PromoteBestReplica("vol1")
if err != nil {
t.Fatalf("expected promotion to succeed: %v", err)
}
e, _ := r.Lookup("vol1")
if e.VolumeServer != "good" {
t.Fatalf("expected 'good' promoted (only eligible), got %q", e.VolumeServer)
}
}
// Configurable tolerance: widen tolerance to allow lagging replicas.
func TestRegistry_PromoteBestReplica_ConfigurableTolerance(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "primary",
Path: "/data/vol1.blk",
Epoch: 1,
LeaseTTL: 30 * time.Second,
WALHeadLSN: 1000,
Replicas: []ReplicaInfo{
{
Server: "lagging",
Path: "/data/vol1.blk",
HealthScore: 1.0,
WALHeadLSN: 800, // lag=200
LastHeartbeat: time.Now(),
},
},
})
// Default tolerance (100): lag 200 > tolerance → ineligible.
_, err := r.PromoteBestReplica("vol1")
if err == nil {
t.Fatal("expected error with default tolerance")
}
// Widen tolerance to 250: lag 200 < tolerance → eligible.
r.SetPromotionLSNTolerance(250)
_, err = r.PromoteBestReplica("vol1")
if err != nil {
t.Fatalf("expected success with widened tolerance: %v", err)
}
e, _ := r.Lookup("vol1")
if e.VolumeServer != "lagging" {
t.Fatalf("expected 'lagging' promoted, got %q", e.VolumeServer)
}
}
+244 -75
View File
@@ -8,8 +8,21 @@ import (
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
"github.com/seaweedfs/seaweedfs/weed/util/request_id"
)
// blockReqID extracts a short request ID from context for log correlation.
func blockReqID(ctx context.Context) string {
id := request_id.Get(ctx)
if len(id) > 8 {
return id[:8]
}
if id == "" {
return "no-id"
}
return id
}
// CreateBlockVolume picks a volume server, delegates creation, and records
// the mapping in the block volume registry.
func (ms *MasterServer) CreateBlockVolume(ctx context.Context, req *master_pb.CreateBlockVolumeRequest) (*master_pb.CreateBlockVolumeResponse, error) {
@@ -25,13 +38,7 @@ func (ms *MasterServer) CreateBlockVolume(ctx context.Context, req *master_pb.Cr
if entry.SizeBytes < req.SizeBytes {
return nil, fmt.Errorf("block volume %q exists with size %d (requested %d)", req.Name, entry.SizeBytes, req.SizeBytes)
}
return &master_pb.CreateBlockVolumeResponse{
VolumeId: entry.Name,
VolumeServer: entry.VolumeServer,
IscsiAddr: entry.ISCSIAddr,
Iqn: entry.IQN,
CapacityBytes: entry.SizeBytes,
}, nil
return ms.createBlockVolumeResponseFromEntry(entry), nil
}
// Per-name inflight lock prevents concurrent creates for the same name.
@@ -42,13 +49,7 @@ func (ms *MasterServer) CreateBlockVolume(ctx context.Context, req *master_pb.Cr
// Double-check after acquiring lock (another goroutine may have finished).
if entry, ok := ms.blockRegistry.Lookup(req.Name); ok {
return &master_pb.CreateBlockVolumeResponse{
VolumeId: entry.Name,
VolumeServer: entry.VolumeServer,
IscsiAddr: entry.ISCSIAddr,
Iqn: entry.IQN,
CapacityBytes: entry.SizeBytes,
}, nil
return ms.createBlockVolumeResponseFromEntry(entry), nil
}
// Get candidate servers.
@@ -73,11 +74,17 @@ func (ms *MasterServer) CreateBlockVolume(ctx context.Context, req *master_pb.Cr
result, err := ms.blockVSAllocate(ctx, server, req.Name, req.SizeBytes, req.DiskType)
if err != nil {
lastErr = fmt.Errorf("server %s: %w", server, err)
glog.V(0).Infof("CreateBlockVolume %q: attempt %d on %s failed: %v", req.Name, attempt+1, server, err)
glog.V(0).Infof("[reqID=%s] CreateBlockVolume %q: attempt %d on %s failed: %v", blockReqID(ctx), req.Name, attempt+1, server, err)
servers = removeServer(servers, server)
continue
}
// CP8-2: determine replica factor from request (default 2).
replicaFactor := 2
if req.ReplicaFactor > 0 && req.ReplicaFactor <= 3 {
replicaFactor = int(req.ReplicaFactor)
}
entry := &BlockVolumeEntry{
Name: req.Name,
VolumeServer: server,
@@ -88,66 +95,85 @@ func (ms *MasterServer) CreateBlockVolume(ctx context.Context, req *master_pb.Cr
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive,
ReplicaFactor: replicaFactor,
LeaseTTL: 30 * time.Second,
LastLeaseGrant: time.Now(), // R2-F1: set BEFORE Register to avoid stale-lease race
}
// Try to create replica on a different server (F4: partial create OK).
var replicaServer string
remainingServers := removeServer(servers, server)
if len(remainingServers) > 0 {
replicaServer = ms.tryCreateReplica(ctx, req, entry, result, remainingServers)
} else {
glog.V(0).Infof("CreateBlockVolume %q: single-copy mode (only 1 server)", req.Name)
// Create replicaFactor-1 replicas on different servers (F4: partial create OK).
remaining := removeServer(servers, server)
for i := 0; i < replicaFactor-1 && len(remaining) > 0; i++ {
replicaServer := ms.tryCreateOneReplica(ctx, req, entry, result, remaining)
if replicaServer != "" {
remaining = removeServer(remaining, replicaServer)
}
}
if len(entry.Replicas) == 0 && replicaFactor > 1 {
glog.V(0).Infof("[reqID=%s] CreateBlockVolume %q: single-copy mode (replica allocation failed)", blockReqID(ctx), req.Name)
}
// Sync deprecated scalar fields from first replica.
if len(entry.Replicas) > 0 {
r0 := &entry.Replicas[0]
entry.ReplicaServer = r0.Server
entry.ReplicaPath = r0.Path
entry.ReplicaIQN = r0.IQN
entry.ReplicaISCSIAddr = r0.ISCSIAddr
entry.ReplicaDataAddr = r0.DataAddr
entry.ReplicaCtrlAddr = r0.CtrlAddr
}
// Register in registry as Active (VS confirmed creation).
if err := ms.blockRegistry.Register(entry); err != nil {
// Already registered (race condition) — return the existing entry.
if existing, ok := ms.blockRegistry.Lookup(req.Name); ok {
return &master_pb.CreateBlockVolumeResponse{
VolumeId: existing.Name,
VolumeServer: existing.VolumeServer,
IscsiAddr: existing.ISCSIAddr,
Iqn: existing.IQN,
CapacityBytes: existing.SizeBytes,
ReplicaServer: existing.ReplicaServer,
}, nil
return ms.createBlockVolumeResponseFromEntry(existing), nil
}
return nil, fmt.Errorf("register block volume: %w", err)
}
// Enqueue assignments for primary (and replica if available).
// Enqueue assignments for primary (and replicas if available).
leaseTTLMs := blockvol.LeaseTTLToWire(30 * time.Second)
ms.blockAssignmentQueue.Enqueue(server, blockvol.BlockVolumeAssignment{
Path: result.Path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
LeaseTtlMs: leaseTTLMs,
ReplicaDataAddr: entry.ReplicaDataAddr,
ReplicaCtrlAddr: entry.ReplicaCtrlAddr,
})
if entry.ReplicaServer != "" {
ms.blockAssignmentQueue.Enqueue(entry.ReplicaServer, blockvol.BlockVolumeAssignment{
Path: entry.ReplicaPath,
primaryAssignment := blockvol.BlockVolumeAssignment{
Path: result.Path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
LeaseTtlMs: leaseTTLMs,
}
// CP8-2: populate ReplicaAddrs for multi-replica.
for _, ri := range entry.Replicas {
primaryAssignment.ReplicaAddrs = append(primaryAssignment.ReplicaAddrs, blockvol.ReplicaAddr{
DataAddr: ri.DataAddr,
CtrlAddr: ri.CtrlAddr,
})
}
// Backward compat: also set scalar fields if exactly 1 replica.
if len(entry.Replicas) == 1 {
primaryAssignment.ReplicaDataAddr = entry.Replicas[0].DataAddr
primaryAssignment.ReplicaCtrlAddr = entry.Replicas[0].CtrlAddr
}
ms.blockAssignmentQueue.Enqueue(server, primaryAssignment)
// Enqueue assignments for each replica.
for _, ri := range entry.Replicas {
ms.blockAssignmentQueue.Enqueue(ri.Server, blockvol.BlockVolumeAssignment{
Path: ri.Path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
LeaseTtlMs: leaseTTLMs,
ReplicaDataAddr: entry.ReplicaDataAddr,
ReplicaCtrlAddr: entry.ReplicaCtrlAddr,
ReplicaDataAddr: ri.DataAddr,
ReplicaCtrlAddr: ri.CtrlAddr,
})
}
glog.V(0).Infof("CreateBlockVolume %q: created on %s (path=%s, iqn=%s, replica=%s)",
req.Name, server, result.Path, result.IQN, replicaServer)
return &master_pb.CreateBlockVolumeResponse{
VolumeId: req.Name,
VolumeServer: server,
IscsiAddr: result.ISCSIAddr,
Iqn: result.IQN,
CapacityBytes: req.SizeBytes,
ReplicaServer: replicaServer,
}, nil
// Collect replica server addresses for response.
var replicaServers []string
for _, ri := range entry.Replicas {
replicaServers = append(replicaServers, ri.Server)
}
glog.V(0).Infof("[reqID=%s] CreateBlockVolume %q: created on %s (path=%s, iqn=%s, replicas=%v)",
blockReqID(ctx), req.Name, server, result.Path, result.IQN, replicaServers)
return ms.createBlockVolumeResponseFromEntry(entry), nil
}
return nil, fmt.Errorf("all volume servers failed for %q: %v", req.Name, lastErr)
@@ -170,16 +196,16 @@ func (ms *MasterServer) DeleteBlockVolume(ctx context.Context, req *master_pb.De
return nil, fmt.Errorf("delete block volume %q on %s: %w", req.Name, entry.VolumeServer, err)
}
// R2-F4: Also delete replica (best-effort, don't fail if replica is down).
if entry.ReplicaServer != "" {
if err := ms.blockVSDelete(ctx, entry.ReplicaServer, req.Name); err != nil {
glog.Warningf("DeleteBlockVolume %q: replica delete on %s failed (best-effort): %v",
req.Name, entry.ReplicaServer, err)
// CP8-2: Delete ALL replicas (best-effort, don't fail if replica is down).
for _, ri := range entry.Replicas {
if err := ms.blockVSDelete(ctx, ri.Server, req.Name); err != nil {
glog.Warningf("[reqID=%s] DeleteBlockVolume %q: replica delete on %s failed (best-effort): %v",
blockReqID(ctx), req.Name, ri.Server, err)
}
}
ms.blockRegistry.Unregister(req.Name)
glog.V(0).Infof("DeleteBlockVolume %q: removed from %s (replica=%s)", req.Name, entry.VolumeServer, entry.ReplicaServer)
glog.V(0).Infof("[reqID=%s] DeleteBlockVolume %q: removed from %s (replicas=%d)", blockReqID(ctx), req.Name, entry.VolumeServer, len(entry.Replicas))
return &master_pb.DeleteBlockVolumeResponse{}, nil
}
@@ -194,37 +220,180 @@ func (ms *MasterServer) LookupBlockVolume(ctx context.Context, req *master_pb.Lo
return nil, fmt.Errorf("block volume %q not found", req.Name)
}
replicaServers := replicaServerList(entry)
rf := entry.ReplicaFactor
if rf == 0 {
rf = 2 // default for pre-CP8-2 entries
}
return &master_pb.LookupBlockVolumeResponse{
VolumeServer: entry.VolumeServer,
IscsiAddr: entry.ISCSIAddr,
Iqn: entry.IQN,
CapacityBytes: entry.SizeBytes,
ReplicaServer: entry.ReplicaServer,
VolumeServer: entry.VolumeServer,
IscsiAddr: entry.ISCSIAddr,
Iqn: entry.IQN,
CapacityBytes: entry.SizeBytes,
ReplicaServer: entry.ReplicaServer, // backward compat
ReplicaFactor: uint32(rf),
ReplicaServers: replicaServers,
}, nil
}
// tryCreateReplica attempts to create a replica volume on a different server.
// tryCreateOneReplica attempts to create one replica volume on a different server.
// Returns the replica server address on success, or empty string on failure (F4).
func (ms *MasterServer) tryCreateReplica(ctx context.Context, req *master_pb.CreateBlockVolumeRequest, entry *BlockVolumeEntry, primaryResult *blockAllocResult, candidates []string) string {
func (ms *MasterServer) tryCreateOneReplica(ctx context.Context, req *master_pb.CreateBlockVolumeRequest, entry *BlockVolumeEntry, primaryResult *blockAllocResult, candidates []string) string {
for _, replicaServerStr := range candidates {
replicaResult, err := ms.blockVSAllocate(ctx, replicaServerStr, req.Name, req.SizeBytes, req.DiskType)
if err != nil {
glog.V(0).Infof("CreateBlockVolume %q: replica on %s failed: %v", req.Name, replicaServerStr, err)
glog.V(0).Infof("[reqID=%s] CreateBlockVolume %q: replica on %s failed: %v", blockReqID(ctx), req.Name, replicaServerStr, err)
continue
}
entry.ReplicaServer = replicaServerStr
entry.ReplicaPath = replicaResult.Path
entry.ReplicaIQN = replicaResult.IQN
entry.ReplicaISCSIAddr = replicaResult.ISCSIAddr
entry.ReplicaDataAddr = replicaResult.ReplicaDataAddr
entry.ReplicaCtrlAddr = replicaResult.ReplicaCtrlAddr
entry.RebuildListenAddr = primaryResult.RebuildListenAddr
// CP8-2: populate Replicas[].
entry.Replicas = append(entry.Replicas, ReplicaInfo{
Server: replicaServerStr,
Path: replicaResult.Path,
ISCSIAddr: replicaResult.ISCSIAddr,
IQN: replicaResult.IQN,
DataAddr: replicaResult.ReplicaDataAddr,
CtrlAddr: replicaResult.ReplicaCtrlAddr,
})
return replicaServerStr
}
glog.Warningf("CreateBlockVolume %q: created without replica (replica allocation failed)", req.Name)
glog.Warningf("[reqID=%s] CreateBlockVolume %q: created without replica (replica allocation failed)", blockReqID(ctx), req.Name)
return ""
}
// CreateBlockSnapshot creates a snapshot on a block volume via the volume server.
func (ms *MasterServer) CreateBlockSnapshot(ctx context.Context, req *master_pb.CreateBlockSnapshotRequest) (*master_pb.CreateBlockSnapshotResponse, error) {
if req.VolumeName == "" {
return nil, fmt.Errorf("volume_name is required")
}
entry, ok := ms.blockRegistry.Lookup(req.VolumeName)
if !ok {
return nil, fmt.Errorf("block volume %q not found", req.VolumeName)
}
createdAt, sizeBytes, err := ms.blockVSSnapshot(ctx, entry.VolumeServer, req.VolumeName, req.SnapshotId)
if err != nil {
return nil, fmt.Errorf("create snapshot on %s: %w", entry.VolumeServer, err)
}
return &master_pb.CreateBlockSnapshotResponse{
SnapshotId: req.SnapshotId,
CreatedAt: createdAt,
SizeBytes: sizeBytes,
}, nil
}
// DeleteBlockSnapshot deletes a snapshot from a block volume via the volume server.
func (ms *MasterServer) DeleteBlockSnapshot(ctx context.Context, req *master_pb.DeleteBlockSnapshotRequest) (*master_pb.DeleteBlockSnapshotResponse, error) {
if req.VolumeName == "" {
return nil, fmt.Errorf("volume_name is required")
}
entry, ok := ms.blockRegistry.Lookup(req.VolumeName)
if !ok {
// Idempotent: volume not found → snapshot doesn't exist either.
return &master_pb.DeleteBlockSnapshotResponse{}, nil
}
if err := ms.blockVSDeleteSnap(ctx, entry.VolumeServer, req.VolumeName, req.SnapshotId); err != nil {
return nil, fmt.Errorf("delete snapshot on %s: %w", entry.VolumeServer, err)
}
return &master_pb.DeleteBlockSnapshotResponse{}, nil
}
// ListBlockSnapshots lists all snapshots on a block volume via the volume server.
func (ms *MasterServer) ListBlockSnapshots(ctx context.Context, req *master_pb.ListBlockSnapshotsRequest) (*master_pb.ListBlockSnapshotsResponse, error) {
if req.VolumeName == "" {
return nil, fmt.Errorf("volume_name is required")
}
entry, ok := ms.blockRegistry.Lookup(req.VolumeName)
if !ok {
return nil, fmt.Errorf("block volume %q not found", req.VolumeName)
}
vsInfos, err := ms.blockVSListSnaps(ctx, entry.VolumeServer, req.VolumeName)
if err != nil {
return nil, fmt.Errorf("list snapshots on %s: %w", entry.VolumeServer, err)
}
resp := &master_pb.ListBlockSnapshotsResponse{}
for _, si := range vsInfos {
resp.Snapshots = append(resp.Snapshots, &master_pb.BlockSnapshotInfo{
SnapshotId: si.SnapshotId,
CreatedAt: si.CreatedAt,
VolumeSizeBytes: si.VolumeSizeBytes,
})
}
return resp, nil
}
// ExpandBlockVolume expands a block volume via the volume server, then updates registry.
func (ms *MasterServer) ExpandBlockVolume(ctx context.Context, req *master_pb.ExpandBlockVolumeRequest) (*master_pb.ExpandBlockVolumeResponse, error) {
if req.Name == "" {
return nil, fmt.Errorf("name is required")
}
if req.NewSizeBytes == 0 {
return nil, fmt.Errorf("new_size_bytes must be > 0")
}
entry, ok := ms.blockRegistry.Lookup(req.Name)
if !ok {
return nil, fmt.Errorf("block volume %q not found", req.Name)
}
// Expand primary first; only update registry on success.
capacity, err := ms.blockVSExpand(ctx, entry.VolumeServer, req.Name, req.NewSizeBytes)
if err != nil {
return nil, fmt.Errorf("expand on %s: %w", entry.VolumeServer, err)
}
// CP8-2: Expand ALL replicas (best-effort, log warning on failure).
for _, ri := range entry.Replicas {
if _, err := ms.blockVSExpand(ctx, ri.Server, req.Name, req.NewSizeBytes); err != nil {
glog.Warningf("[reqID=%s] ExpandBlockVolume %q: replica expand on %s failed (best-effort): %v",
blockReqID(ctx), req.Name, ri.Server, err)
}
}
// Update registry with actual new size.
if uerr := ms.blockRegistry.UpdateSize(req.Name, capacity); uerr != nil {
glog.Warningf("[reqID=%s] ExpandBlockVolume %q: registry update failed (VS succeeded): %v", blockReqID(ctx), req.Name, uerr)
}
return &master_pb.ExpandBlockVolumeResponse{
CapacityBytes: capacity,
}, nil
}
// createBlockVolumeResponseFromEntry builds a CreateBlockVolumeResponse from a registry entry.
func (ms *MasterServer) createBlockVolumeResponseFromEntry(entry *BlockVolumeEntry) *master_pb.CreateBlockVolumeResponse {
return &master_pb.CreateBlockVolumeResponse{
VolumeId: entry.Name,
VolumeServer: entry.VolumeServer,
IscsiAddr: entry.ISCSIAddr,
Iqn: entry.IQN,
CapacityBytes: entry.SizeBytes,
ReplicaServer: entry.ReplicaServer, // backward compat
ReplicaServers: replicaServerList(entry),
}
}
// replicaServerList returns the list of replica server addresses.
// Order matches Replicas[] (append-order), ensuring ReplicaServers[0] == ReplicaServer (legacy).
func replicaServerList(entry *BlockVolumeEntry) []string {
if len(entry.Replicas) == 0 {
return nil
}
servers := make([]string, len(entry.Replicas))
for i, ri := range entry.Replicas {
servers[i] = ri.Server
}
return servers
}
// removeServer returns a new slice without the specified server.
func removeServer(servers []string, server string) []string {
result := make([]string, 0, len(servers)-1)
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
)
// testMasterServer creates a minimal MasterServer with mock VS calls for testing.
@@ -492,6 +493,142 @@ func TestMaster_LookupReturnsReplicaServer(t *testing.T) {
}
}
func TestMaster_CreateBlockSnapshot(t *testing.T) {
ms := testMasterServer(t)
ms.blockRegistry.MarkBlockCapable("vs1:9333")
ms.blockVSSnapshot = func(ctx context.Context, server string, name string, snapID uint32) (int64, uint64, error) {
return 1709654400, 1 << 30, nil
}
// Create volume first.
ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: "snap-vol", SizeBytes: 1 << 30,
})
resp, err := ms.CreateBlockSnapshot(context.Background(), &master_pb.CreateBlockSnapshotRequest{
VolumeName: "snap-vol", SnapshotId: 42,
})
if err != nil {
t.Fatalf("CreateBlockSnapshot: %v", err)
}
if resp.SnapshotId != 42 {
t.Fatalf("SnapshotId: got %d, want 42", resp.SnapshotId)
}
if resp.CreatedAt != 1709654400 {
t.Fatalf("CreatedAt: got %d, want 1709654400", resp.CreatedAt)
}
}
func TestMaster_CreateBlockSnapshot_VolumeNotFound(t *testing.T) {
ms := testMasterServer(t)
_, err := ms.CreateBlockSnapshot(context.Background(), &master_pb.CreateBlockSnapshotRequest{
VolumeName: "nonexistent", SnapshotId: 1,
})
if err == nil {
t.Fatal("expected error for nonexistent volume")
}
}
func TestMaster_DeleteBlockSnapshot(t *testing.T) {
ms := testMasterServer(t)
ms.blockRegistry.MarkBlockCapable("vs1:9333")
ms.blockVSDeleteSnap = func(ctx context.Context, server string, name string, snapID uint32) error {
return nil
}
ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: "snap-vol", SizeBytes: 1 << 30,
})
_, err := ms.DeleteBlockSnapshot(context.Background(), &master_pb.DeleteBlockSnapshotRequest{
VolumeName: "snap-vol", SnapshotId: 42,
})
if err != nil {
t.Fatalf("DeleteBlockSnapshot: %v", err)
}
}
func TestMaster_ListBlockSnapshots(t *testing.T) {
ms := testMasterServer(t)
ms.blockRegistry.MarkBlockCapable("vs1:9333")
ms.blockVSListSnaps = func(ctx context.Context, server string, name string) ([]*volume_server_pb.BlockSnapshotInfo, error) {
return []*volume_server_pb.BlockSnapshotInfo{
{SnapshotId: 1, CreatedAt: 100},
{SnapshotId: 2, CreatedAt: 200},
}, nil
}
ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: "snap-vol", SizeBytes: 1 << 30,
})
resp, err := ms.ListBlockSnapshots(context.Background(), &master_pb.ListBlockSnapshotsRequest{
VolumeName: "snap-vol",
})
if err != nil {
t.Fatalf("ListBlockSnapshots: %v", err)
}
if len(resp.Snapshots) != 2 {
t.Fatalf("expected 2 snapshots, got %d", len(resp.Snapshots))
}
}
func TestMaster_ExpandBlockVolume(t *testing.T) {
ms := testMasterServer(t)
ms.blockRegistry.MarkBlockCapable("vs1:9333")
ms.blockVSExpand = func(ctx context.Context, server string, name string, newSize uint64) (uint64, error) {
return newSize, nil
}
ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: "expand-vol", SizeBytes: 1 << 30,
})
resp, err := ms.ExpandBlockVolume(context.Background(), &master_pb.ExpandBlockVolumeRequest{
Name: "expand-vol", NewSizeBytes: 2 << 30,
})
if err != nil {
t.Fatalf("ExpandBlockVolume: %v", err)
}
if resp.CapacityBytes != 2<<30 {
t.Fatalf("CapacityBytes: got %d, want %d", resp.CapacityBytes, 2<<30)
}
// Verify registry was updated.
entry, ok := ms.blockRegistry.Lookup("expand-vol")
if !ok {
t.Fatal("volume not found in registry")
}
if entry.SizeBytes != 2<<30 {
t.Fatalf("registry size: got %d, want %d", entry.SizeBytes, 2<<30)
}
}
func TestMaster_ExpandBlockVolume_VSFailure(t *testing.T) {
ms := testMasterServer(t)
ms.blockRegistry.MarkBlockCapable("vs1:9333")
ms.blockVSExpand = func(ctx context.Context, server string, name string, newSize uint64) (uint64, error) {
return 0, fmt.Errorf("disk full")
}
ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: "expand-vol", SizeBytes: 1 << 30,
})
_, err := ms.ExpandBlockVolume(context.Background(), &master_pb.ExpandBlockVolumeRequest{
Name: "expand-vol", NewSizeBytes: 2 << 30,
})
if err == nil {
t.Fatal("expected error when VS expand fails")
}
// Registry should NOT have been updated.
entry, _ := ms.blockRegistry.Lookup("expand-vol")
if entry.SizeBytes != 1<<30 {
t.Fatalf("registry size should be unchanged: got %d, want %d", entry.SizeBytes, 1<<30)
}
}
func TestMaster_LookupBlockVolume(t *testing.T) {
ms := testMasterServer(t)
ms.blockRegistry.MarkBlockCapable("vs1:9333")
@@ -525,3 +662,354 @@ func TestMaster_LookupBlockVolume(t *testing.T) {
t.Fatal("lookup nonexistent should return error")
}
}
// ============================================================
// CP8-2 T9: Multi-Replica Create/Delete/Assign Tests
// ============================================================
func testMasterServerRF3(t *testing.T) *MasterServer {
t.Helper()
ms := testMasterServer(t)
ms.blockVSAllocate = func(ctx context.Context, server string, name string, sizeBytes uint64, diskType string) (*blockAllocResult, error) {
return &blockAllocResult{
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s", name),
ISCSIAddr: server + ":3260",
ReplicaDataAddr: server + ":14260",
ReplicaCtrlAddr: server + ":14261",
}, nil
}
ms.blockRegistry.MarkBlockCapable("vs1:9333")
ms.blockRegistry.MarkBlockCapable("vs2:9333")
ms.blockRegistry.MarkBlockCapable("vs3:9333")
return ms
}
// RF=3 with 3 servers: should create 2 replicas.
func TestMaster_CreateRF3_ThreeServers(t *testing.T) {
ms := testMasterServerRF3(t)
resp, err := ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: "vol1",
SizeBytes: 1 << 30,
ReplicaFactor: 3,
})
if err != nil {
t.Fatalf("CreateBlockVolume: %v", err)
}
entry, ok := ms.blockRegistry.Lookup("vol1")
if !ok {
t.Fatal("vol1 not in registry")
}
if entry.ReplicaFactor != 3 {
t.Fatalf("ReplicaFactor: got %d, want 3", entry.ReplicaFactor)
}
if len(entry.Replicas) != 2 {
t.Fatalf("expected 2 replicas, got %d", len(entry.Replicas))
}
// All 3 servers should be different.
servers := map[string]bool{resp.VolumeServer: true}
for _, ri := range entry.Replicas {
if servers[ri.Server] {
t.Fatalf("duplicate server: %q", ri.Server)
}
servers[ri.Server] = true
}
if len(servers) != 3 {
t.Fatalf("expected 3 distinct servers, got %d", len(servers))
}
}
// RF=3 with only 2 servers: should create 1 replica (partial).
func TestMaster_CreateRF3_TwoServers(t *testing.T) {
ms := testMasterServer(t)
ms.blockVSAllocate = func(ctx context.Context, server string, name string, sizeBytes uint64, diskType string) (*blockAllocResult, error) {
return &blockAllocResult{
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s", name),
ISCSIAddr: server + ":3260",
ReplicaDataAddr: server + ":14260",
ReplicaCtrlAddr: server + ":14261",
}, nil
}
ms.blockRegistry.MarkBlockCapable("vs1:9333")
ms.blockRegistry.MarkBlockCapable("vs2:9333")
_, err := ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: "vol1",
SizeBytes: 1 << 30,
ReplicaFactor: 3,
})
if err != nil {
t.Fatalf("CreateBlockVolume: %v", err)
}
entry, _ := ms.blockRegistry.Lookup("vol1")
// Only 2 servers available, so only 1 replica (not 2).
if len(entry.Replicas) != 1 {
t.Fatalf("expected 1 replica (only 2 servers), got %d", len(entry.Replicas))
}
}
// RF=2 unchanged after CP8-2 changes.
func TestMaster_CreateRF2_Unchanged(t *testing.T) {
ms := testMasterServerRF3(t)
_, err := ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: "vol1",
SizeBytes: 1 << 30,
// No ReplicaFactor → defaults to 2.
})
if err != nil {
t.Fatalf("CreateBlockVolume: %v", err)
}
entry, _ := ms.blockRegistry.Lookup("vol1")
if entry.ReplicaFactor != 2 {
t.Fatalf("ReplicaFactor: got %d, want 2 (default)", entry.ReplicaFactor)
}
if len(entry.Replicas) != 1 {
t.Fatalf("expected 1 replica for RF=2, got %d", len(entry.Replicas))
}
// Backward compat scalar fields should be set.
if entry.ReplicaServer == "" {
t.Fatal("ReplicaServer (deprecated) should still be set for backward compat")
}
}
// DeleteBlockVolume RF=3 deletes all replicas.
func TestMaster_DeleteRF3_DeletesAllReplicas(t *testing.T) {
ms := testMasterServerRF3(t)
var deletedServers []string
ms.blockVSDelete = func(ctx context.Context, server string, name string) error {
deletedServers = append(deletedServers, server)
return nil
}
_, err := ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: "vol1",
SizeBytes: 1 << 30,
ReplicaFactor: 3,
})
if err != nil {
t.Fatalf("create: %v", err)
}
_, err = ms.DeleteBlockVolume(context.Background(), &master_pb.DeleteBlockVolumeRequest{
Name: "vol1",
})
if err != nil {
t.Fatalf("delete: %v", err)
}
// Should have deleted on primary + 2 replicas = 3 servers.
if len(deletedServers) != 3 {
t.Fatalf("expected 3 delete calls (primary + 2 replicas), got %d: %v",
len(deletedServers), deletedServers)
}
}
// ExpandBlockVolume RF=3 expands all replicas.
func TestMaster_ExpandRF3_ExpandsAllReplicas(t *testing.T) {
ms := testMasterServerRF3(t)
var expandedServers []string
ms.blockVSExpand = func(ctx context.Context, server string, name string, newSize uint64) (uint64, error) {
expandedServers = append(expandedServers, server)
return newSize, nil
}
_, err := ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: "vol1",
SizeBytes: 1 << 30,
ReplicaFactor: 3,
})
if err != nil {
t.Fatalf("create: %v", err)
}
_, err = ms.ExpandBlockVolume(context.Background(), &master_pb.ExpandBlockVolumeRequest{
Name: "vol1", NewSizeBytes: 2 << 30,
})
if err != nil {
t.Fatalf("expand: %v", err)
}
// Should have expanded on primary + 2 replicas = 3 servers.
if len(expandedServers) != 3 {
t.Fatalf("expected 3 expand calls (primary + 2 replicas), got %d: %v",
len(expandedServers), expandedServers)
}
}
// ProcessAssignments with multi-replica addrs.
func TestMaster_CreateRF3_AssignmentsIncludeReplicaAddrs(t *testing.T) {
ms := testMasterServerRF3(t)
resp, err := ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: "vol1",
SizeBytes: 1 << 30,
ReplicaFactor: 3,
})
if err != nil {
t.Fatalf("create: %v", err)
}
// Primary assignment should have ReplicaAddrs with 2 entries.
assignments := ms.blockAssignmentQueue.Peek(resp.VolumeServer)
if len(assignments) != 1 {
t.Fatalf("expected 1 primary assignment, got %d", len(assignments))
}
pa := assignments[0]
if len(pa.ReplicaAddrs) != 2 {
t.Fatalf("primary assignment ReplicaAddrs: got %d, want 2", len(pa.ReplicaAddrs))
}
// Each replica addr should have non-empty data/ctrl.
for i, ra := range pa.ReplicaAddrs {
if ra.DataAddr == "" || ra.CtrlAddr == "" {
t.Fatalf("ReplicaAddrs[%d] has empty addr: %+v", i, ra)
}
}
}
// Fix #4: CreateBlockVolumeResponse includes ReplicaServers.
func TestMaster_CreateResponse_IncludesReplicaServers(t *testing.T) {
ms := testMasterServerRF3(t)
resp, err := ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: "vol1",
SizeBytes: 1 << 30,
ReplicaFactor: 3,
})
if err != nil {
t.Fatalf("CreateBlockVolume: %v", err)
}
if len(resp.ReplicaServers) != 2 {
t.Fatalf("ReplicaServers: got %d, want 2", len(resp.ReplicaServers))
}
// ReplicaServers should match registry entry Replicas.
entry, _ := ms.blockRegistry.Lookup("vol1")
for i, ri := range entry.Replicas {
if resp.ReplicaServers[i] != ri.Server {
t.Errorf("ReplicaServers[%d]: got %q, want %q", i, resp.ReplicaServers[i], ri.Server)
}
}
// Backward compat: ReplicaServer (scalar) should be first replica.
if resp.ReplicaServer != entry.ReplicaServer {
t.Errorf("ReplicaServer: got %q, want %q", resp.ReplicaServer, entry.ReplicaServer)
}
}
// Fix #4: LookupBlockVolumeResponse includes ReplicaFactor and ReplicaServers.
func TestMaster_LookupResponse_IncludesReplicaFields(t *testing.T) {
ms := testMasterServerRF3(t)
_, err := ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: "vol1",
SizeBytes: 1 << 30,
ReplicaFactor: 3,
})
if err != nil {
t.Fatalf("CreateBlockVolume: %v", err)
}
resp, err := ms.LookupBlockVolume(context.Background(), &master_pb.LookupBlockVolumeRequest{
Name: "vol1",
})
if err != nil {
t.Fatalf("LookupBlockVolume: %v", err)
}
if resp.ReplicaFactor != 3 {
t.Fatalf("ReplicaFactor: got %d, want 3", resp.ReplicaFactor)
}
if len(resp.ReplicaServers) != 2 {
t.Fatalf("ReplicaServers: got %d, want 2", len(resp.ReplicaServers))
}
// Backward compat: ReplicaServer (scalar).
if resp.ReplicaServer == "" {
t.Error("ReplicaServer (scalar) should be non-empty for backward compat")
}
}
// Fix #4: Idempotent create returns ReplicaServers.
func TestMaster_CreateIdempotent_IncludesReplicaServers(t *testing.T) {
ms := testMasterServerRF3(t)
_, err := ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: "vol1",
SizeBytes: 1 << 30,
ReplicaFactor: 3,
})
if err != nil {
t.Fatalf("first create: %v", err)
}
// Second create (idempotent) should also include ReplicaServers.
resp, err := ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: "vol1",
SizeBytes: 1 << 30,
ReplicaFactor: 3,
})
if err != nil {
t.Fatalf("idempotent create: %v", err)
}
if len(resp.ReplicaServers) != 2 {
t.Fatalf("idempotent ReplicaServers: got %d, want 2", len(resp.ReplicaServers))
}
}
// Lookup returns ReplicaFactor=2 for pre-CP8-2 entries where ReplicaFactor is 0.
func TestMaster_LookupResponse_ReplicaFactorDefault(t *testing.T) {
ms := testMasterServer(t)
ms.blockRegistry.MarkBlockCapable("vs1:9333")
// Manually register entry with ReplicaFactor=0 (pre-CP8-2 legacy).
ms.blockRegistry.Register(&BlockVolumeEntry{
Name: "legacy-vol",
VolumeServer: "vs1:9333",
Path: "/data/legacy-vol.blk",
SizeBytes: 1 << 30,
Status: StatusActive,
// ReplicaFactor intentionally 0 (zero value).
})
resp, err := ms.LookupBlockVolume(context.Background(), &master_pb.LookupBlockVolumeRequest{
Name: "legacy-vol",
})
if err != nil {
t.Fatalf("LookupBlockVolume: %v", err)
}
if resp.ReplicaFactor != 2 {
t.Fatalf("ReplicaFactor: got %d, want 2 (default for pre-CP8-2)", resp.ReplicaFactor)
}
}
// ReplicaServers[0] matches ReplicaServer (legacy scalar).
func TestMaster_ResponseConsistency_ReplicaServerVsReplicaServers(t *testing.T) {
ms := testMasterServerRF3(t)
resp, err := ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: "vol1",
SizeBytes: 1 << 30,
ReplicaFactor: 3,
})
if err != nil {
t.Fatalf("CreateBlockVolume: %v", err)
}
if len(resp.ReplicaServers) < 1 {
t.Fatal("expected at least 1 replica server")
}
if resp.ReplicaServer != resp.ReplicaServers[0] {
t.Fatalf("ReplicaServer=%q != ReplicaServers[0]=%q",
resp.ReplicaServer, resp.ReplicaServers[0])
}
// Same for Lookup.
lresp, err := ms.LookupBlockVolume(context.Background(), &master_pb.LookupBlockVolumeRequest{
Name: "vol1",
})
if err != nil {
t.Fatalf("LookupBlockVolume: %v", err)
}
if lresp.ReplicaServer != lresp.ReplicaServers[0] {
t.Fatalf("Lookup: ReplicaServer=%q != ReplicaServers[0]=%q",
lresp.ReplicaServer, lresp.ReplicaServers[0])
}
}
+88 -5
View File
@@ -60,9 +60,10 @@ type MasterOption struct {
MetricsAddress string
MetricsIntervalSec int
IsFollower bool
TelemetryUrl string
TelemetryEnabled bool
VolumeGrowthDisabled bool
TelemetryUrl string
TelemetryEnabled bool
VolumeGrowthDisabled bool
BlockPromotionLSNTolerance int
}
type MasterServer struct {
@@ -97,8 +98,12 @@ type MasterServer struct {
blockRegistry *BlockVolumeRegistry
blockAssignmentQueue *BlockAssignmentQueue
blockFailover *blockFailoverState
blockVSAllocate func(ctx context.Context, server string, name string, sizeBytes uint64, diskType string) (*blockAllocResult, error)
blockVSDelete func(ctx context.Context, server string, name string) error
blockVSAllocate func(ctx context.Context, server string, name string, sizeBytes uint64, diskType string) (*blockAllocResult, error)
blockVSDelete func(ctx context.Context, server string, name string) error
blockVSSnapshot func(ctx context.Context, server string, name string, snapID uint32) (int64, uint64, error)
blockVSDeleteSnap func(ctx context.Context, server string, name string, snapID uint32) error
blockVSListSnaps func(ctx context.Context, server string, name string) ([]*volume_server_pb.BlockSnapshotInfo, error)
blockVSExpand func(ctx context.Context, server string, name string, newSize uint64) (uint64, error)
}
func NewMasterServer(r *mux.Router, option *MasterOption, peers map[string]pb.ServerAddress) *MasterServer {
@@ -148,10 +153,17 @@ func NewMasterServer(r *mux.Router, option *MasterOption, peers map[string]pb.Se
}
ms.blockRegistry = NewBlockVolumeRegistry()
if option.BlockPromotionLSNTolerance > 0 {
ms.blockRegistry.SetPromotionLSNTolerance(uint64(option.BlockPromotionLSNTolerance))
}
ms.blockAssignmentQueue = NewBlockAssignmentQueue()
ms.blockFailover = newBlockFailoverState()
ms.blockVSAllocate = ms.defaultBlockVSAllocate
ms.blockVSDelete = ms.defaultBlockVSDelete
ms.blockVSSnapshot = ms.defaultBlockVSSnapshot
ms.blockVSDeleteSnap = ms.defaultBlockVSDeleteSnap
ms.blockVSListSnaps = ms.defaultBlockVSListSnaps
ms.blockVSExpand = ms.defaultBlockVSExpand
ms.MasterClient.SetOnPeerUpdateFn(ms.OnPeerUpdate)
@@ -197,6 +209,18 @@ func NewMasterServer(r *mux.Router, option *MasterOption, peers map[string]pb.Se
r.HandleFunc("/stats/counter", ms.guard.WhiteList(statsCounterHandler))
r.HandleFunc("/stats/memory", ms.guard.WhiteList(statsMemoryHandler))
*/
// Block volume management routes.
r.HandleFunc("/block/volume", ms.proxyToLeader(ms.guard.WhiteList(requestIDMiddleware(ms.blockVolumeCreateHandler)))).Methods("POST")
r.HandleFunc("/block/volume/{name}", ms.proxyToLeader(ms.guard.WhiteList(requestIDMiddleware(ms.blockVolumeDeleteHandler)))).Methods("DELETE")
r.HandleFunc("/block/volume/{name}", ms.guard.WhiteList(requestIDMiddleware(ms.blockVolumeLookupHandler))).Methods("GET")
r.HandleFunc("/block/volumes", ms.guard.WhiteList(requestIDMiddleware(ms.blockVolumeListHandler))).Methods("GET")
r.HandleFunc("/block/assign", ms.proxyToLeader(ms.guard.WhiteList(requestIDMiddleware(ms.blockAssignHandler)))).Methods("POST")
r.HandleFunc("/block/servers", ms.guard.WhiteList(requestIDMiddleware(ms.blockServersHandler))).Methods("GET")
r.HandleFunc("/block/status", ms.guard.WhiteList(requestIDMiddleware(ms.blockStatusHandler))).Methods("GET")
r.HandleFunc("/block/ops", requestIDMiddleware(ms.blockOpsHandler))
r.HandleFunc("/block/", requestIDMiddleware(ms.blockUIHandler))
r.HandleFunc("/{fileId}", requestIDMiddleware(ms.redirectHandler))
}
@@ -560,3 +584,62 @@ func (ms *MasterServer) defaultBlockVSDelete(ctx context.Context, server string,
return err
})
}
func (ms *MasterServer) defaultBlockVSSnapshot(ctx context.Context, server string, name string, snapID uint32) (int64, uint64, error) {
var createdAt int64
var sizeBytes uint64
err := operation.WithVolumeServerClient(false, pb.ServerAddress(server), ms.grpcDialOption, func(client volume_server_pb.VolumeServerClient) error {
resp, rerr := client.SnapshotBlockVolume(ctx, &volume_server_pb.SnapshotBlockVolumeRequest{
Name: name,
SnapshotId: snapID,
})
if rerr != nil {
return rerr
}
createdAt = resp.CreatedAt
sizeBytes = resp.SizeBytes
return nil
})
return createdAt, sizeBytes, err
}
func (ms *MasterServer) defaultBlockVSDeleteSnap(ctx context.Context, server string, name string, snapID uint32) error {
return operation.WithVolumeServerClient(false, pb.ServerAddress(server), ms.grpcDialOption, func(client volume_server_pb.VolumeServerClient) error {
_, err := client.DeleteBlockSnapshot(ctx, &volume_server_pb.DeleteBlockSnapshotRequest{
Name: name,
SnapshotId: snapID,
})
return err
})
}
func (ms *MasterServer) defaultBlockVSListSnaps(ctx context.Context, server string, name string) ([]*volume_server_pb.BlockSnapshotInfo, error) {
var infos []*volume_server_pb.BlockSnapshotInfo
err := operation.WithVolumeServerClient(false, pb.ServerAddress(server), ms.grpcDialOption, func(client volume_server_pb.VolumeServerClient) error {
resp, rerr := client.ListBlockSnapshots(ctx, &volume_server_pb.ListBlockSnapshotsRequest{
Name: name,
})
if rerr != nil {
return rerr
}
infos = resp.Snapshots
return nil
})
return infos, err
}
func (ms *MasterServer) defaultBlockVSExpand(ctx context.Context, server string, name string, newSize uint64) (uint64, error) {
var capacity uint64
err := operation.WithVolumeServerClient(false, pb.ServerAddress(server), ms.grpcDialOption, func(client volume_server_pb.VolumeServerClient) error {
resp, rerr := client.ExpandBlockVolume(ctx, &volume_server_pb.ExpandBlockVolumeRequest{
Name: name,
NewSizeBytes: newSize,
})
if rerr != nil {
return rerr
}
capacity = resp.CapacityBytes
return nil
})
return capacity, err
}
+209
View File
@@ -0,0 +1,209 @@
package weed_server
import (
"encoding/json"
"fmt"
"net/http"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol/blockapi"
)
// blockVolumeCreateHandler handles POST /block/volume.
func (ms *MasterServer) blockVolumeCreateHandler(w http.ResponseWriter, r *http.Request) {
var req blockapi.CreateVolumeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJsonError(w, r, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err))
return
}
// Store replica_placement in registry after creation.
replicaPlacement := req.ReplicaPlacement
if replicaPlacement == "" {
replicaPlacement = "000"
}
resp, err := ms.CreateBlockVolume(r.Context(), &master_pb.CreateBlockVolumeRequest{
Name: req.Name,
SizeBytes: req.SizeBytes,
DiskType: req.DiskType,
})
if err != nil {
writeJsonError(w, r, http.StatusInternalServerError, err)
return
}
// Store replica_placement on the registry entry.
if entry, ok := ms.blockRegistry.Lookup(resp.VolumeId); ok {
entry.ReplicaPlacement = replicaPlacement
}
// Look up the full entry to populate all fields.
info := blockapi.VolumeInfo{
Name: resp.VolumeId,
VolumeServer: resp.VolumeServer,
SizeBytes: resp.CapacityBytes,
ReplicaPlacement: replicaPlacement,
ISCSIAddr: resp.IscsiAddr,
IQN: resp.Iqn,
}
if entry, ok := ms.blockRegistry.Lookup(resp.VolumeId); ok {
info = entryToVolumeInfo(entry)
}
writeJsonQuiet(w, r, http.StatusOK, info)
}
// blockVolumeDeleteHandler handles DELETE /block/volume/{name}.
func (ms *MasterServer) blockVolumeDeleteHandler(w http.ResponseWriter, r *http.Request) {
name := mux.Vars(r)["name"]
if name == "" {
writeJsonError(w, r, http.StatusBadRequest, fmt.Errorf("name is required"))
return
}
_, err := ms.DeleteBlockVolume(r.Context(), &master_pb.DeleteBlockVolumeRequest{
Name: name,
})
if err != nil {
writeJsonError(w, r, http.StatusInternalServerError, err)
return
}
writeJsonQuiet(w, r, http.StatusOK, map[string]string{"status": "deleted"})
}
// blockVolumeLookupHandler handles GET /block/volume/{name}.
func (ms *MasterServer) blockVolumeLookupHandler(w http.ResponseWriter, r *http.Request) {
name := mux.Vars(r)["name"]
if name == "" {
writeJsonError(w, r, http.StatusBadRequest, fmt.Errorf("name is required"))
return
}
entry, ok := ms.blockRegistry.Lookup(name)
if !ok {
writeJsonError(w, r, http.StatusNotFound, fmt.Errorf("block volume %q not found", name))
return
}
writeJsonQuiet(w, r, http.StatusOK, entryToVolumeInfo(entry))
}
// blockVolumeListHandler handles GET /block/volumes.
func (ms *MasterServer) blockVolumeListHandler(w http.ResponseWriter, r *http.Request) {
entries := ms.blockRegistry.ListAll()
infos := make([]blockapi.VolumeInfo, len(entries))
for i, e := range entries {
infos[i] = entryToVolumeInfo(e)
}
writeJsonQuiet(w, r, http.StatusOK, infos)
}
// blockAssignHandler handles POST /block/assign.
func (ms *MasterServer) blockAssignHandler(w http.ResponseWriter, r *http.Request) {
var req blockapi.AssignRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJsonError(w, r, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err))
return
}
if req.Name == "" {
writeJsonError(w, r, http.StatusBadRequest, fmt.Errorf("name is required"))
return
}
// Resolve name → registry entry.
entry, ok := ms.blockRegistry.Lookup(req.Name)
if !ok {
writeJsonError(w, r, http.StatusNotFound, fmt.Errorf("block volume %q not found", req.Name))
return
}
// Determine target server + path based on role.
server, path := entry.VolumeServer, entry.Path
if req.Role == "replica" {
if entry.ReplicaServer == "" {
writeJsonError(w, r, http.StatusBadRequest, fmt.Errorf("block volume %q has no replica", req.Name))
return
}
server, path = entry.ReplicaServer, entry.ReplicaPath
}
ms.blockAssignmentQueue.Enqueue(server, blockvol.BlockVolumeAssignment{
Path: path,
Epoch: req.Epoch,
Role: blockapi.RoleFromString(req.Role),
LeaseTtlMs: uint32(req.LeaseTTLMs),
})
writeJsonQuiet(w, r, http.StatusOK, map[string]string{"status": "queued"})
}
// blockServersHandler handles GET /block/servers.
func (ms *MasterServer) blockServersHandler(w http.ResponseWriter, r *http.Request) {
summaries := ms.blockRegistry.ServerSummaries()
infos := make([]blockapi.ServerInfo, len(summaries))
for i, s := range summaries {
infos[i] = blockapi.ServerInfo{
Address: s.Address,
VolumeCount: s.VolumeCount,
BlockCapable: s.BlockCapable,
}
}
writeJsonQuiet(w, r, http.StatusOK, infos)
}
// blockStatusHandler handles GET /block/status — returns registry configuration for debugging.
func (ms *MasterServer) blockStatusHandler(w http.ResponseWriter, r *http.Request) {
status := map[string]interface{}{
"promotion_lsn_tolerance": ms.blockRegistry.PromotionLSNTolerance(),
"volume_count": len(ms.blockRegistry.ListAll()),
"server_count": len(ms.blockRegistry.BlockCapableServers()),
"barrier_lag_lsn": ms.blockRegistry.MaxBarrierLagLSN(),
"promotions_total": ms.blockRegistry.PromotionsTotal.Load(),
"failovers_total": ms.blockRegistry.FailoversTotal.Load(),
"rebuilds_total": ms.blockRegistry.RebuildsTotal.Load(),
"assignment_queue_depth": ms.blockAssignmentQueue.TotalPending(),
}
writeJsonQuiet(w, r, http.StatusOK, status)
}
// entryToVolumeInfo converts a BlockVolumeEntry to a blockapi.VolumeInfo.
func entryToVolumeInfo(e *BlockVolumeEntry) blockapi.VolumeInfo {
status := "pending"
if e.Status == StatusActive {
status = "active"
}
rf := e.ReplicaFactor
if rf == 0 {
rf = 2 // default
}
info := blockapi.VolumeInfo{
Name: e.Name,
VolumeServer: e.VolumeServer,
SizeBytes: e.SizeBytes,
ReplicaPlacement: e.ReplicaPlacement,
Epoch: e.Epoch,
Role: blockvol.RoleFromWire(e.Role).String(),
Status: status,
ISCSIAddr: e.ISCSIAddr,
IQN: e.IQN,
ReplicaServer: e.ReplicaServer,
ReplicaISCSIAddr: e.ReplicaISCSIAddr,
ReplicaIQN: e.ReplicaIQN,
ReplicaDataAddr: e.ReplicaDataAddr,
ReplicaCtrlAddr: e.ReplicaCtrlAddr,
ReplicaFactor: rf,
HealthScore: e.HealthScore,
ReplicaDegraded: e.ReplicaDegraded,
}
for _, ri := range e.Replicas {
info.Replicas = append(info.Replicas, blockapi.ReplicaDetail{
Server: ri.Server,
ISCSIAddr: ri.ISCSIAddr,
IQN: ri.IQN,
HealthScore: ri.HealthScore,
WALLag: ri.WALLag,
})
}
return info
}
@@ -0,0 +1,288 @@
package weed_server
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol/blockapi"
)
// blockTestServer creates a MasterServer + httptest.Server with block routes registered.
func blockTestServer(t *testing.T) (*MasterServer, *httptest.Server) {
t.Helper()
ms := &MasterServer{
blockRegistry: NewBlockVolumeRegistry(),
blockAssignmentQueue: NewBlockAssignmentQueue(),
}
ms.blockVSAllocate = func(ctx context.Context, server string, name string, sizeBytes uint64, diskType string) (*blockAllocResult, error) {
return &blockAllocResult{
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s", name),
ISCSIAddr: server + ":3260",
}, nil
}
ms.blockVSDelete = func(ctx context.Context, server string, name string) error {
return nil
}
ms.blockRegistry.MarkBlockCapable("vs1:9333")
r := mux.NewRouter()
r.HandleFunc("/block/volume", ms.blockVolumeCreateHandler).Methods("POST")
r.HandleFunc("/block/volume/{name}", ms.blockVolumeDeleteHandler).Methods("DELETE")
r.HandleFunc("/block/volume/{name}", ms.blockVolumeLookupHandler).Methods("GET")
r.HandleFunc("/block/volumes", ms.blockVolumeListHandler).Methods("GET")
r.HandleFunc("/block/assign", ms.blockAssignHandler).Methods("POST")
r.HandleFunc("/block/servers", ms.blockServersHandler).Methods("GET")
ts := httptest.NewServer(r)
t.Cleanup(ts.Close)
return ms, ts
}
func TestBlockVolumeCreateHandler(t *testing.T) {
_, ts := blockTestServer(t)
body, _ := json.Marshal(blockapi.CreateVolumeRequest{
Name: "vol1",
SizeBytes: 1 << 30,
})
resp, err := http.Post(ts.URL+"/block/volume", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var info blockapi.VolumeInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
t.Fatal(err)
}
if info.Name != "vol1" {
t.Errorf("expected name vol1, got %s", info.Name)
}
if info.VolumeServer == "" {
t.Error("expected non-empty volume server")
}
}
func TestBlockVolumeListHandler(t *testing.T) {
ms, ts := blockTestServer(t)
// Create two volumes via gRPC.
for _, name := range []string{"alpha", "beta"} {
ms.blockRegistry.Register(&BlockVolumeEntry{
Name: name,
VolumeServer: "vs1:9333",
SizeBytes: 1 << 20,
Status: StatusActive,
})
}
resp, err := http.Get(ts.URL + "/block/volumes")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var infos []blockapi.VolumeInfo
if err := json.NewDecoder(resp.Body).Decode(&infos); err != nil {
t.Fatal(err)
}
if len(infos) != 2 {
t.Fatalf("expected 2 volumes, got %d", len(infos))
}
if infos[0].Name != "alpha" || infos[1].Name != "beta" {
t.Errorf("expected sorted [alpha, beta], got [%s, %s]", infos[0].Name, infos[1].Name)
}
}
func TestBlockVolumeLookupHandler(t *testing.T) {
ms, ts := blockTestServer(t)
ms.blockRegistry.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "vs1:9333",
SizeBytes: 1 << 30,
Epoch: 3,
Role: 1, // RolePrimary
Status: StatusActive,
ReplicaServer: "vs2:9333",
})
resp, err := http.Get(ts.URL + "/block/volume/vol1")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var info blockapi.VolumeInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
t.Fatal(err)
}
if info.Epoch != 3 {
t.Errorf("expected epoch 3, got %d", info.Epoch)
}
if info.Role != "primary" {
t.Errorf("expected role primary, got %s", info.Role)
}
if info.ReplicaServer != "vs2:9333" {
t.Errorf("expected replica vs2:9333, got %s", info.ReplicaServer)
}
// Not found case.
resp2, err := http.Get(ts.URL + "/block/volume/nonexistent")
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp2.StatusCode)
}
}
func TestBlockVolumeDeleteHandler(t *testing.T) {
ms, ts := blockTestServer(t)
ms.blockRegistry.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "vs1:9333",
SizeBytes: 1 << 30,
Status: StatusActive,
})
req, _ := http.NewRequest("DELETE", ts.URL+"/block/volume/vol1", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
// Verify it's gone.
if _, ok := ms.blockRegistry.Lookup("vol1"); ok {
t.Error("expected vol1 to be unregistered")
}
}
func TestBlockAssignHandler(t *testing.T) {
ms, ts := blockTestServer(t)
ms.blockRegistry.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "vs1:9333",
Path: "/data/vol1.blk",
SizeBytes: 1 << 30,
Status: StatusActive,
})
body, _ := json.Marshal(blockapi.AssignRequest{
Name: "vol1",
Epoch: 5,
Role: "primary",
LeaseTTLMs: 30000,
})
resp, err := http.Post(ts.URL+"/block/assign", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
// Verify assignment was queued.
pending := ms.blockAssignmentQueue.Peek("vs1:9333")
if len(pending) != 1 {
t.Fatalf("expected 1 pending assignment, got %d", len(pending))
}
if pending[0].Epoch != 5 {
t.Errorf("expected epoch 5, got %d", pending[0].Epoch)
}
// Not-found case.
body2, _ := json.Marshal(blockapi.AssignRequest{Name: "missing", Epoch: 1, Role: "primary"})
resp2, err := http.Post(ts.URL+"/block/assign", "application/json", bytes.NewReader(body2))
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp2.StatusCode)
}
}
func TestBlockServersHandler(t *testing.T) {
_, ts := blockTestServer(t)
resp, err := http.Get(ts.URL + "/block/servers")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var servers []blockapi.ServerInfo
if err := json.NewDecoder(resp.Body).Decode(&servers); err != nil {
t.Fatal(err)
}
if len(servers) != 1 {
t.Fatalf("expected 1 server, got %d", len(servers))
}
if servers[0].Address != "vs1:9333" {
t.Errorf("expected vs1:9333, got %s", servers[0].Address)
}
}
func TestListAll(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{Name: "charlie", VolumeServer: "vs1:9333"})
r.Register(&BlockVolumeEntry{Name: "alpha", VolumeServer: "vs1:9333"})
r.Register(&BlockVolumeEntry{Name: "bravo", VolumeServer: "vs1:9333"})
entries := r.ListAll()
if len(entries) != 3 {
t.Fatalf("expected 3 entries, got %d", len(entries))
}
if entries[0].Name != "alpha" || entries[1].Name != "bravo" || entries[2].Name != "charlie" {
t.Errorf("expected sorted order, got %s %s %s", entries[0].Name, entries[1].Name, entries[2].Name)
}
}
func TestServerSummaries(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("vs1:9333")
r.MarkBlockCapable("vs2:9333")
r.Register(&BlockVolumeEntry{Name: "vol1", VolumeServer: "vs1:9333"})
r.Register(&BlockVolumeEntry{Name: "vol2", VolumeServer: "vs1:9333"})
r.Register(&BlockVolumeEntry{Name: "vol3", VolumeServer: "vs2:9333"})
summaries := r.ServerSummaries()
if len(summaries) != 2 {
t.Fatalf("expected 2 summaries, got %d", len(summaries))
}
// Sorted by address.
if summaries[0].Address != "vs1:9333" || summaries[0].VolumeCount != 2 {
t.Errorf("vs1: got %+v", summaries[0])
}
if summaries[1].Address != "vs2:9333" || summaries[1].VolumeCount != 1 {
t.Errorf("vs2: got %+v", summaries[1])
}
}
@@ -0,0 +1,304 @@
package weed_server
import (
"html/template"
"net/http"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol/blockapi"
)
var blockDashTemplate = template.Must(template.New("blockDash").Parse(blockLayoutHTML + blockDashContentHTML))
var blockOpsTemplate = template.Must(template.New("blockOps").Parse(blockLayoutHTML + blockOpsContentHTML))
type blockUIVolume struct {
blockapi.VolumeInfo
SizeMB uint64
}
type blockUIData struct {
Tab string // "dashboard" or "ops"
Volumes []blockUIVolume
Servers []blockapi.ServerInfo
// Dashboard stats
TotalVolumes int
ActiveCount int
PendingCount int
TotalSizeMB uint64
}
func (ms *MasterServer) buildBlockUIData(tab string) blockUIData {
entries := ms.blockRegistry.ListAll()
volumes := make([]blockUIVolume, len(entries))
var totalSizeMB uint64
var activeCount, pendingCount int
for i, e := range entries {
info := entryToVolumeInfo(e)
mb := info.SizeBytes / (1024 * 1024)
volumes[i] = blockUIVolume{
VolumeInfo: info,
SizeMB: mb,
}
totalSizeMB += mb
if e.Status == StatusActive {
activeCount++
} else {
pendingCount++
}
}
summaries := ms.blockRegistry.ServerSummaries()
servers := make([]blockapi.ServerInfo, len(summaries))
for i, s := range summaries {
servers[i] = blockapi.ServerInfo{
Address: s.Address,
VolumeCount: s.VolumeCount,
BlockCapable: s.BlockCapable,
}
}
return blockUIData{
Tab: tab,
Volumes: volumes,
Servers: servers,
TotalVolumes: len(entries),
ActiveCount: activeCount,
PendingCount: pendingCount,
TotalSizeMB: totalSizeMB,
}
}
// blockUIHandler serves the /block/ dashboard page.
func (ms *MasterServer) blockUIHandler(w http.ResponseWriter, r *http.Request) {
data := ms.buildBlockUIData("dashboard")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := blockDashTemplate.Execute(w, data); err != nil {
glog.V(0).Infof("block UI template error: %v", err)
}
}
// blockOpsHandler serves the /block/ops operations page.
func (ms *MasterServer) blockOpsHandler(w http.ResponseWriter, r *http.Request) {
data := ms.buildBlockUIData("ops")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := blockOpsTemplate.Execute(w, data); err != nil {
glog.V(0).Infof("block UI template error: %v", err)
}
}
// blockLayoutHTML is the shared HTML layout with tab navigation.
const blockLayoutHTML = `<!DOCTYPE html>
<html>
<head>
<title>SeaweedFS Block Storage</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 0; background: #f5f6fa; color: #333; }
.header { background: #2d3436; color: white; padding: 16px 24px; }
.header h1 { margin: 0; font-size: 20px; font-weight: 500; }
.tabs { display: flex; background: #fff; border-bottom: 2px solid #dfe6e9; padding: 0 24px; }
.tab { padding: 12px 20px; text-decoration: none; color: #636e72; font-size: 14px; font-weight: 500; border-bottom: 2px solid transparent; margin-bottom: -2px; }
.tab:hover { color: #2d3436; }
.tab.active { color: #0984e3; border-bottom-color: #0984e3; }
.content { padding: 24px; max-width: 1400px; }
.cards { display: flex; gap: 16px; margin-bottom: 24px; flex-wrap: wrap; }
.card { background: #fff; border-radius: 8px; padding: 20px; min-width: 180px; flex: 1; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.card .label { font-size: 12px; color: #636e72; text-transform: uppercase; letter-spacing: 0.5px; }
.card .value { font-size: 28px; font-weight: 600; margin-top: 4px; }
.card .value.green { color: #00b894; }
.card .value.orange { color: #fdcb6e; }
.card .value.blue { color: #0984e3; }
table { border-collapse: collapse; width: 100%; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 24px; }
th { background: #dfe6e9; color: #2d3436; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; padding: 10px 12px; text-align: left; }
td { padding: 10px 12px; border-top: 1px solid #f1f2f6; font-size: 13px; }
tr:hover td { background: #f8f9fa; }
h2 { font-size: 16px; color: #2d3436; margin: 24px 0 12px; }
.form-section { background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 24px; }
.form-section h3 { margin-top: 0; font-size: 15px; }
.form-row { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
.form-row label { width: 90px; font-size: 13px; color: #636e72; }
.form-row input, .form-row select { padding: 6px 10px; border: 1px solid #dfe6e9; border-radius: 4px; font-size: 13px; }
.form-row input[type="text"] { width: 200px; }
.form-row input[type="number"] { width: 120px; }
.btn-create { background: #0984e3; color: white; border: none; border-radius: 4px; padding: 8px 20px; cursor: pointer; font-size: 13px; }
.btn-create:hover { background: #0874c9; }
.btn-delete { background: #d63031; color: white; border: none; border-radius: 4px; font-size: 12px; padding: 4px 12px; cursor: pointer; }
.btn-delete:hover { background: #c0392b; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
.badge-active { background: #dff9ec; color: #00b894; }
.badge-pending { background: #ffeaa7; color: #d68910; }
.badge-primary { background: #dfe6e9; color: #2d3436; }
.badge-replica { background: #e8daef; color: #6c3483; }
.empty { color: #b2bec3; font-style: italic; padding: 20px; text-align: center; }
</style>
</head>
<body>
<div class="header"><h1>SeaweedFS Block Storage</h1></div>
<div class="tabs">
<a class="tab {{if eq .Tab "dashboard"}}active{{end}}" href="/block/">Dashboard</a>
<a class="tab {{if eq .Tab "ops"}}active{{end}}" href="/block/ops">Operations</a>
</div>
<div class="content">
`
// blockDashContentHTML is the Dashboard tab content.
const blockDashContentHTML = `
<div class="cards">
<div class="card">
<div class="label">Block Servers</div>
<div class="value blue">{{len .Servers}}</div>
</div>
<div class="card">
<div class="label">Total Volumes</div>
<div class="value">{{.TotalVolumes}}</div>
</div>
<div class="card">
<div class="label">Active</div>
<div class="value green">{{.ActiveCount}}</div>
</div>
<div class="card">
<div class="label">Pending</div>
<div class="value orange">{{.PendingCount}}</div>
</div>
<div class="card">
<div class="label">Total Capacity</div>
<div class="value">{{.TotalSizeMB}} MB</div>
</div>
</div>
<h2>Servers</h2>
{{if .Servers}}
<table>
<tr><th>Address</th><th>Volume Count</th><th>Block Capable</th></tr>
{{range .Servers}}
<tr>
<td>{{.Address}}</td>
<td>{{.VolumeCount}}</td>
<td>{{if .BlockCapable}}Yes{{else}}No{{end}}</td>
</tr>
{{end}}
</table>
{{else}}
<div class="empty">No block servers connected</div>
{{end}}
<h2>Volumes</h2>
{{if .Volumes}}
<table>
<tr>
<th>Name</th><th>Server</th><th>Size</th><th>Placement</th>
<th>Epoch</th><th>Role</th><th>Status</th><th>iSCSI</th><th>Replica</th>
</tr>
{{range .Volumes}}
<tr>
<td>{{.Name}}</td>
<td>{{.VolumeServer}}</td>
<td>{{.SizeMB}} MB</td>
<td>{{.ReplicaPlacement}}</td>
<td>{{.Epoch}}</td>
<td>{{if eq .Role "primary"}}<span class="badge badge-primary">primary</span>{{else if eq .Role "replica"}}<span class="badge badge-replica">replica</span>{{else}}{{.Role}}{{end}}</td>
<td>{{if eq .Status "active"}}<span class="badge badge-active">active</span>{{else}}<span class="badge badge-pending">{{.Status}}</span>{{end}}</td>
<td>{{.ISCSIAddr}}</td>
<td>{{.ReplicaServer}}</td>
</tr>
{{end}}
</table>
{{else}}
<div class="empty">No block volumes</div>
{{end}}
</div></body></html>`
// blockOpsContentHTML is the Operations tab content.
const blockOpsContentHTML = `
<div class="form-section">
<h3>Create Volume</h3>
<form id="createForm">
<div class="form-row">
<label>Name:</label>
<input type="text" id="cName" required placeholder="my-volume">
</div>
<div class="form-row">
<label>Size (MB):</label>
<input type="number" id="cSize" required min="1" placeholder="100">
</div>
<div class="form-row">
<label>Placement:</label>
<select id="cPlacement">
<option value="000">000 - No replica</option>
<option value="001" selected>001 - Different server</option>
<option value="010">010 - Different rack</option>
<option value="100">100 - Different DC</option>
</select>
</div>
<div class="form-row">
<label>Disk type:</label>
<input type="text" id="cDisk" placeholder="ssd">
</div>
<div class="form-row">
<label></label>
<button type="submit" class="btn-create">Create Volume</button>
</div>
</form>
</div>
<h2>Volumes</h2>
{{if .Volumes}}
<table>
<tr>
<th>Name</th><th>Server</th><th>Size</th><th>Placement</th>
<th>Epoch</th><th>Role</th><th>Status</th><th>iSCSI</th><th>Replica</th><th>Action</th>
</tr>
{{range .Volumes}}
<tr>
<td>{{.Name}}</td>
<td>{{.VolumeServer}}</td>
<td>{{.SizeMB}} MB</td>
<td>{{.ReplicaPlacement}}</td>
<td>{{.Epoch}}</td>
<td>{{if eq .Role "primary"}}<span class="badge badge-primary">primary</span>{{else if eq .Role "replica"}}<span class="badge badge-replica">replica</span>{{else}}{{.Role}}{{end}}</td>
<td>{{if eq .Status "active"}}<span class="badge badge-active">active</span>{{else}}<span class="badge badge-pending">{{.Status}}</span>{{end}}</td>
<td>{{.ISCSIAddr}}</td>
<td>{{.ReplicaServer}}</td>
<td><button class="btn-delete" onclick="deleteVol('{{.Name}}')">Delete</button></td>
</tr>
{{end}}
</table>
{{else}}
<div class="empty">No block volumes</div>
{{end}}
<script>
document.getElementById('createForm').addEventListener('submit', function(e) {
e.preventDefault();
var btn = e.target.querySelector('button[type="submit"]');
btn.disabled = true;
btn.textContent = 'Creating...';
fetch('/block/volume', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: document.getElementById('cName').value,
size_bytes: parseInt(document.getElementById('cSize').value) * 1024 * 1024,
replica_placement: document.getElementById('cPlacement').value,
disk_type: document.getElementById('cDisk').value
})
}).then(function(r) {
if (!r.ok) return r.json().then(function(j) { throw new Error(j.error || r.statusText); });
window.location.reload();
}).catch(function(err) {
alert('Create failed: ' + err.message);
btn.disabled = false;
btn.textContent = 'Create Volume';
});
});
function deleteVol(name) {
if (!confirm('Delete block volume "' + name + '"?')) return;
fetch('/block/volume/' + encodeURIComponent(name), {method: 'DELETE'})
.then(function(r) {
if (!r.ok) return r.json().then(function(j) { throw new Error(j.error || r.statusText); });
window.location.reload();
}).catch(function(err) { alert('Delete failed: ' + err.message); });
}
</script>
</div></body></html>`
+23 -9
View File
@@ -62,6 +62,16 @@ func registerQAVolume(t *testing.T, ms *MasterServer, name, primary, replica str
entry.ReplicaPath = fmt.Sprintf("/data/%s.blk", name)
entry.ReplicaIQN = fmt.Sprintf("iqn.2024.test:%s-r", name)
entry.ReplicaISCSIAddr = replica + ":3260"
// CP8-2: also populate Replicas[].
entry.Replicas = []ReplicaInfo{
{
Server: replica,
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s-r", name),
ISCSIAddr: replica + ":3260",
HealthScore: 1.0,
},
}
}
if err := ms.blockRegistry.Register(entry); err != nil {
t.Fatalf("register %s: %v", name, err)
@@ -382,11 +392,12 @@ func TestQA_Failover_PromoteIdempotent_NoReplicaAfterFirstSwap(t *testing.T) {
ms := testMSForQA(t)
registerQAVolume(t, ms, "vol1", "vs1", "vs2", 1, 5*time.Second, true)
ms.failoverBlockVolumes("vs1") // promotes vs2, vs1 becomes replica
ms.failoverBlockVolumes("vs1") // promotes vs2
// CP8-2: PromoteBestReplica does NOT add old primary back as replica.
// Reconnect vs1 first so it becomes a replica.
ms.recoverBlockVolumes("vs1")
// Now if vs2 also disconnects, it should try to failover.
// After first failover: primary=vs2, replica=vs1.
// vs2 disconnects: primary IS vs2, replica=vs1 — should swap back.
e, _ := ms.blockRegistry.Lookup("vol1")
e.LastLeaseGrant = time.Now().Add(-1 * time.Minute) // expire the new lease
ms.failoverBlockVolumes("vs2")
@@ -555,17 +566,20 @@ func TestQA_Create_ReplicaDeleteFailure_PrimaryStillDeleted(t *testing.T) {
ms.blockRegistry.MarkBlockCapable("vs1")
ms.blockRegistry.MarkBlockCapable("vs2")
ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: "vol1", SizeBytes: 1 << 30,
})
// Find the replica server and make its delete fail.
entry, _ := ms.blockRegistry.Lookup("vol1")
replicaServer := entry.ReplicaServer
ms.blockVSDelete = func(ctx context.Context, server string, name string) error {
if server == "vs2" {
if server == replicaServer {
return fmt.Errorf("replica down")
}
return nil
}
ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: "vol1", SizeBytes: 1 << 30,
})
// Delete should succeed even if replica delete fails (best-effort).
_, err := ms.DeleteBlockVolume(context.Background(), &master_pb.DeleteBlockVolumeRequest{Name: "vol1"})
if err != nil {
@@ -0,0 +1,849 @@
package weed_server
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
)
// ============================================================
// CP8-2 Adversarial Test Suite
//
// 12 scenarios covering multi-replica, scrub, promotion gating,
// heartbeat reconciliation, and partial-failure edge cases.
// ============================================================
// qaCP82Master creates a MasterServer with 3 block-capable servers
// and configurable mocks for adversarial testing.
func qaCP82Master(t *testing.T) *MasterServer {
t.Helper()
ms := &MasterServer{
blockRegistry: NewBlockVolumeRegistry(),
blockAssignmentQueue: NewBlockAssignmentQueue(),
blockFailover: newBlockFailoverState(),
}
ms.blockVSAllocate = func(ctx context.Context, server string, name string, sizeBytes uint64, diskType string) (*blockAllocResult, error) {
return &blockAllocResult{
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s", name),
ISCSIAddr: server + ":3260",
ReplicaDataAddr: server + ":14260",
ReplicaCtrlAddr: server + ":14261",
RebuildListenAddr: server + ":15000",
}, nil
}
ms.blockVSDelete = func(ctx context.Context, server string, name string) error {
return nil
}
ms.blockVSExpand = func(ctx context.Context, server string, name string, newSize uint64) (uint64, error) {
return newSize, nil
}
ms.blockRegistry.MarkBlockCapable("vs1:9333")
ms.blockRegistry.MarkBlockCapable("vs2:9333")
ms.blockRegistry.MarkBlockCapable("vs3:9333")
return ms
}
// qaRegisterRF2WithLSN creates an RF=2 volume with explicit WAL LSN state.
func qaRegisterRF2WithLSN(t *testing.T, ms *MasterServer, name, primary, replica string, epoch, primaryLSN, replicaLSN uint64) {
t.Helper()
entry := &BlockVolumeEntry{
Name: name,
VolumeServer: primary,
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s", name),
ISCSIAddr: primary + ":3260",
SizeBytes: 1 << 30,
Epoch: epoch,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive,
WALHeadLSN: primaryLSN,
LeaseTTL: 5 * time.Second,
LastLeaseGrant: time.Now().Add(-10 * time.Second), // expired
ReplicaServer: replica,
ReplicaPath: fmt.Sprintf("/data/%s.blk", name),
ReplicaIQN: fmt.Sprintf("iqn.2024.test:%s-r", name),
ReplicaISCSIAddr: replica + ":3260",
Replicas: []ReplicaInfo{
{
Server: replica,
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s-r", name),
ISCSIAddr: replica + ":3260",
HealthScore: 1.0,
WALHeadLSN: replicaLSN,
LastHeartbeat: time.Now(), // fresh
Role: blockvol.RoleToWire(blockvol.RoleReplica),
},
},
}
if err := ms.blockRegistry.Register(entry); err != nil {
t.Fatalf("register %s: %v", name, err)
}
}
// qaRegisterRF3WithState creates an RF=3 volume with per-replica state control.
func qaRegisterRF3WithState(t *testing.T, ms *MasterServer, name, primary string,
replicas []struct {
Server string
HealthScore float64
WALHeadLSN uint64
Role blockvol.Role
Heartbeat time.Time
},
primaryLSN uint64,
) {
t.Helper()
entry := &BlockVolumeEntry{
Name: name,
VolumeServer: primary,
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s", name),
ISCSIAddr: primary + ":3260",
SizeBytes: 1 << 30,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive,
WALHeadLSN: primaryLSN,
ReplicaFactor: len(replicas) + 1,
LeaseTTL: 5 * time.Second,
LastLeaseGrant: time.Now().Add(-10 * time.Second), // expired
}
for i, r := range replicas {
ri := ReplicaInfo{
Server: r.Server,
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s-r%d", name, i+1),
ISCSIAddr: r.Server + ":3260",
HealthScore: r.HealthScore,
WALHeadLSN: r.WALHeadLSN,
Role: blockvol.RoleToWire(r.Role),
LastHeartbeat: r.Heartbeat,
}
entry.Replicas = append(entry.Replicas, ri)
}
// Sync scalar fields from first replica.
if len(entry.Replicas) > 0 {
entry.ReplicaServer = entry.Replicas[0].Server
entry.ReplicaPath = entry.Replicas[0].Path
entry.ReplicaIQN = entry.Replicas[0].IQN
entry.ReplicaISCSIAddr = entry.Replicas[0].ISCSIAddr
}
if err := ms.blockRegistry.Register(entry); err != nil {
t.Fatalf("register %s: %v", name, err)
}
}
// ────────────────────────────────────────────────────────────
// QA-1: PrimaryCrash_AfterAck_BeforeReplicaBarrier
//
// Verify failover data loss window is bounded by barrier_lag_lsn.
// Simulates primary at LSN 200, replica at barrier LSN 190.
// After failover, new primary should be at replica's LSN (190).
// The gap (10 LSN entries) is the bounded loss window.
// ────────────────────────────────────────────────────────────
func TestQA_CP82_PrimaryCrash_AfterAck_BeforeReplicaBarrier(t *testing.T) {
ms := qaCP82Master(t)
qaRegisterRF2WithLSN(t, ms, "vol-lag", "vs1:9333", "vs2:9333", 1, 200, 190)
// Kill primary.
ms.failoverBlockVolumes("vs1:9333")
entry, ok := ms.blockRegistry.Lookup("vol-lag")
if !ok {
t.Fatal("volume should still exist after failover")
}
if entry.VolumeServer != "vs2:9333" {
t.Fatalf("replica should be promoted, got %q", entry.VolumeServer)
}
// The promoted replica was at LSN 190. The 10-LSN gap represents
// acknowledged-but-unbarriered writes (bounded data loss).
// New epoch should be 2.
if entry.Epoch != 2 {
t.Fatalf("epoch: got %d, want 2", entry.Epoch)
}
}
// ────────────────────────────────────────────────────────────
// QA-2: ReplicaHeartbeatSpoof_DoesNotDeletePrimary
//
// Regression test for Fix #1. A replica heartbeat that does NOT
// include the primary path must NOT delete the volume entry.
// ────────────────────────────────────────────────────────────
func TestQA_CP82_ReplicaHeartbeatSpoof_DoesNotDeletePrimary(t *testing.T) {
ms := qaCP82Master(t)
qaRegisterRF2WithLSN(t, ms, "vol-spoof", "vs1:9333", "vs2:9333", 1, 100, 100)
// Simulate replica heartbeat from vs2 with ONLY its replica path.
// This must NOT remove the volume (vs1 is the primary, not vs2).
ms.blockRegistry.UpdateFullHeartbeat("vs2:9333", []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/vol-spoof.blk",
VolumeSize: 1 << 30,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
},
})
entry, ok := ms.blockRegistry.Lookup("vol-spoof")
if !ok {
t.Fatal("volume must NOT be deleted by replica heartbeat (Fix #1 regression)")
}
if entry.VolumeServer != "vs1:9333" {
t.Fatalf("primary must remain vs1:9333, got %q", entry.VolumeServer)
}
// Now simulate a full heartbeat from vs2 with NO volumes at all.
// This should remove vs2 as replica but NOT delete the volume.
ms.blockRegistry.UpdateFullHeartbeat("vs2:9333", []*master_pb.BlockVolumeInfoMessage{})
entry, ok = ms.blockRegistry.Lookup("vol-spoof")
if !ok {
t.Fatal("volume must still exist after empty replica heartbeat")
}
if entry.VolumeServer != "vs1:9333" {
t.Fatal("primary must still be vs1:9333")
}
}
// ────────────────────────────────────────────────────────────
// QA-3: PromotionRejects_StaleButHealthyReplica
//
// Replica has perfect health (1.0) but stale heartbeat.
// Must NOT be promoted (Gate 1: heartbeat freshness).
// ────────────────────────────────────────────────────────────
func TestQA_CP82_PromotionRejects_StaleButHealthyReplica(t *testing.T) {
ms := qaCP82Master(t)
staleTime := time.Now().Add(-5 * time.Minute) // way beyond 2×LeaseTTL
qaRegisterRF3WithState(t, ms, "vol-stale", "vs1:9333",
[]struct {
Server string
HealthScore float64
WALHeadLSN uint64
Role blockvol.Role
Heartbeat time.Time
}{
{"vs2:9333", 1.0, 100, blockvol.RoleReplica, staleTime}, // healthy but stale
{"vs3:9333", 0.5, 100, blockvol.RoleReplica, staleTime}, // also stale
},
100,
)
_, err := ms.blockRegistry.PromoteBestReplica("vol-stale")
if err == nil {
t.Fatal("expected promotion to fail — all replicas have stale heartbeats")
}
// Volume should remain unchanged.
entry, _ := ms.blockRegistry.Lookup("vol-stale")
if entry.VolumeServer != "vs1:9333" {
t.Fatalf("primary must not change, got %q", entry.VolumeServer)
}
}
// ────────────────────────────────────────────────────────────
// QA-4: PromotionRejects_RebuildingReplica
//
// Replica is fresh and healthy but in RoleRebuilding.
// Must NOT be promoted (Gate 3: role check).
// ────────────────────────────────────────────────────────────
func TestQA_CP82_PromotionRejects_RebuildingReplica(t *testing.T) {
ms := qaCP82Master(t)
now := time.Now()
qaRegisterRF3WithState(t, ms, "vol-rebuild", "vs1:9333",
[]struct {
Server string
HealthScore float64
WALHeadLSN uint64
Role blockvol.Role
Heartbeat time.Time
}{
{"vs2:9333", 1.0, 100, blockvol.RoleRebuilding, now}, // rebuilding
{"vs3:9333", 1.0, 100, blockvol.RoleRebuilding, now}, // also rebuilding
},
100,
)
_, err := ms.blockRegistry.PromoteBestReplica("vol-rebuild")
if err == nil {
t.Fatal("expected promotion to fail — all replicas are rebuilding")
}
// One rebuilding, one ready: only the ready one should be promoted.
ms2 := qaCP82Master(t)
qaRegisterRF3WithState(t, ms2, "vol-mixed", "vs1:9333",
[]struct {
Server string
HealthScore float64
WALHeadLSN uint64
Role blockvol.Role
Heartbeat time.Time
}{
{"vs2:9333", 1.0, 100, blockvol.RoleRebuilding, now},
{"vs3:9333", 0.7, 100, blockvol.RoleReplica, now}, // eligible despite lower health
},
100,
)
_, err = ms2.blockRegistry.PromoteBestReplica("vol-mixed")
if err != nil {
t.Fatalf("expected promotion to succeed: %v", err)
}
entry, _ := ms2.blockRegistry.Lookup("vol-mixed")
if entry.VolumeServer != "vs3:9333" {
t.Fatalf("vs3 should be promoted (only eligible), got %q", entry.VolumeServer)
}
}
// ────────────────────────────────────────────────────────────
// QA-5: PromotionToleranceBoundary_ExactLSN
//
// Tests promotion_lsn_tolerance boundary: tolerance-1, exact, tolerance+1.
// Catches off-by-one in the eligibility gate.
// ────────────────────────────────────────────────────────────
func TestQA_CP82_PromotionToleranceBoundary_ExactLSN(t *testing.T) {
const tolerance uint64 = 50
const primaryLSN uint64 = 200
cases := []struct {
name string
replicaLSN uint64
wantErr bool
}{
// replicaLSN + tolerance < primaryLSN → ineligible
// 149 + 50 = 199 < 200 → INELIGIBLE
{"below_tolerance", primaryLSN - tolerance - 1, true},
// 150 + 50 = 200 == 200 → ELIGIBLE (not strict less-than)
{"at_tolerance", primaryLSN - tolerance, false},
// 151 + 50 = 201 > 200 → ELIGIBLE
{"above_tolerance", primaryLSN - tolerance + 1, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ms := qaCP82Master(t)
ms.blockRegistry.SetPromotionLSNTolerance(tolerance)
now := time.Now()
qaRegisterRF3WithState(t, ms, "vol-tol", "vs1:9333",
[]struct {
Server string
HealthScore float64
WALHeadLSN uint64
Role blockvol.Role
Heartbeat time.Time
}{
{"vs2:9333", 1.0, tc.replicaLSN, blockvol.RoleReplica, now},
},
primaryLSN,
)
_, err := ms.blockRegistry.PromoteBestReplica("vol-tol")
if tc.wantErr && err == nil {
t.Fatalf("replicaLSN=%d: expected promotion to fail", tc.replicaLSN)
}
if !tc.wantErr && err != nil {
t.Fatalf("replicaLSN=%d: expected promotion to succeed: %v", tc.replicaLSN, err)
}
})
}
}
// ────────────────────────────────────────────────────────────
// QA-6: MasterRestart_ReconstructReplicas_ThenFailover
//
// Simulates master restart: empty registry, full heartbeats
// from primary and replica servers reconstruct state, then
// failover correctly promotes the replica.
// ────────────────────────────────────────────────────────────
func TestQA_CP82_MasterRestart_ReconstructReplicas_ThenFailover(t *testing.T) {
ms := qaCP82Master(t)
// Phase 1: Primary heartbeat — auto-registers volume from heartbeat.
ms.blockRegistry.UpdateFullHeartbeat("vs1:9333", []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/vol-restart.blk",
VolumeSize: 1 << 30,
Epoch: 5,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
WalHeadLsn: 500,
},
})
entry, ok := ms.blockRegistry.Lookup("vol-restart")
if !ok {
t.Fatal("volume should be auto-registered from primary heartbeat")
}
if entry.VolumeServer != "vs1:9333" {
t.Fatalf("primary: got %q, want vs1:9333", entry.VolumeServer)
}
// Phase 2: Replica heartbeat — should reconstruct ReplicaInfo.
ms.blockRegistry.UpdateFullHeartbeat("vs2:9333", []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/vol-restart.blk",
VolumeSize: 1 << 30,
Epoch: 5,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
WalHeadLsn: 498,
},
})
entry, _ = ms.blockRegistry.Lookup("vol-restart")
if len(entry.Replicas) == 0 {
t.Fatal("replica should be reconstructed from heartbeat (Fix #3)")
}
if entry.Replicas[0].Server != "vs2:9333" {
t.Fatalf("replica server: got %q, want vs2:9333", entry.Replicas[0].Server)
}
// Phase 3: Set lease expired and trigger failover.
entry.LeaseTTL = 5 * time.Second
entry.LastLeaseGrant = time.Now().Add(-1 * time.Minute)
// Ensure replica is eligible for promotion.
entry.Replicas[0].LastHeartbeat = time.Now()
entry.Replicas[0].Role = blockvol.RoleToWire(blockvol.RoleReplica)
ms.failoverBlockVolumes("vs1:9333")
entry, ok = ms.blockRegistry.Lookup("vol-restart")
if !ok {
t.Fatal("volume should survive failover")
}
if entry.VolumeServer != "vs2:9333" {
t.Fatalf("after failover: primary should be vs2:9333, got %q", entry.VolumeServer)
}
if entry.Epoch <= 5 {
t.Fatalf("epoch should be bumped, got %d", entry.Epoch)
}
}
// ────────────────────────────────────────────────────────────
// QA-7: RF3_OneReplicaFlaps_UnderWriteLoad
//
// One replica rapidly disconnects/reconnects while concurrent
// failover checks run. Must not cause split-brain or panic.
// Simulates via concurrent UpdateFullHeartbeat + failoverBlockVolumes.
// ────────────────────────────────────────────────────────────
func TestQA_CP82_RF3_OneReplicaFlaps_UnderWriteLoad(t *testing.T) {
ms := qaCP82Master(t)
now := time.Now()
qaRegisterRF3WithState(t, ms, "vol-flap", "vs1:9333",
[]struct {
Server string
HealthScore float64
WALHeadLSN uint64
Role blockvol.Role
Heartbeat time.Time
}{
{"vs2:9333", 1.0, 100, blockvol.RoleReplica, now},
{"vs3:9333", 1.0, 100, blockvol.RoleReplica, now},
},
100,
)
var wg sync.WaitGroup
const rounds = 50
// Goroutine 1: flapping replica vs3 (heartbeat with/without volume).
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
if i%2 == 0 {
// Replica reports volume.
ms.blockRegistry.UpdateFullHeartbeat("vs3:9333", []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/vol-flap.blk",
VolumeSize: 1 << 30,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
WalHeadLsn: 100,
},
})
} else {
// Replica reports no volumes (simulates disconnect).
ms.blockRegistry.UpdateFullHeartbeat("vs3:9333", []*master_pb.BlockVolumeInfoMessage{})
}
}
}()
// Goroutine 2: concurrent heartbeats from primary.
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
ms.blockRegistry.UpdateFullHeartbeat("vs1:9333", []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/vol-flap.blk",
VolumeSize: 1 << 30,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
WalHeadLsn: uint64(100 + i),
},
})
}
}()
// Goroutine 3: concurrent heartbeats from stable replica vs2.
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
ms.blockRegistry.UpdateFullHeartbeat("vs2:9333", []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/vol-flap.blk",
VolumeSize: 1 << 30,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
WalHeadLsn: uint64(100 + i),
},
})
}
}()
wg.Wait()
// Invariant: volume must still exist, primary must be vs1.
entry, ok := ms.blockRegistry.Lookup("vol-flap")
if !ok {
t.Fatal("volume must survive flapping replica")
}
if entry.VolumeServer != "vs1:9333" {
t.Fatalf("primary must remain vs1:9333, got %q (split-brain!)", entry.VolumeServer)
}
}
// ────────────────────────────────────────────────────────────
// QA-8: AssignmentPrecedence_ReplicaAddrsVsScalar
//
// Creates RF=3 volume and verifies primary assignment includes
// ReplicaAddrs (not just scalar fields). VS should use
// ReplicaAddrs when non-empty.
// ────────────────────────────────────────────────────────────
func TestQA_CP82_AssignmentPrecedence_ReplicaAddrsVsScalar(t *testing.T) {
ms := qaCP82Master(t)
ctx := context.Background()
resp, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
Name: "vol-addrs",
SizeBytes: 1 << 30,
ReplicaFactor: 3,
})
if err != nil {
t.Fatalf("create: %v", err)
}
primary := resp.VolumeServer
assignments := ms.blockAssignmentQueue.Peek(primary)
// Find the primary assignment.
var primaryAssignment *blockvol.BlockVolumeAssignment
for i := range assignments {
if blockvol.RoleFromWire(assignments[i].Role) == blockvol.RolePrimary {
primaryAssignment = &assignments[i]
break
}
}
if primaryAssignment == nil {
t.Fatal("no primary assignment found")
}
// RF=3 → 2 replicas → ReplicaAddrs should have 2 entries.
if len(primaryAssignment.ReplicaAddrs) != 2 {
t.Fatalf("primary assignment ReplicaAddrs: got %d, want 2", len(primaryAssignment.ReplicaAddrs))
}
// Verify each ReplicaAddr has data+ctrl.
for i, ra := range primaryAssignment.ReplicaAddrs {
if ra.DataAddr == "" || ra.CtrlAddr == "" {
t.Errorf("ReplicaAddrs[%d]: data=%q ctrl=%q — both must be non-empty", i, ra.DataAddr, ra.CtrlAddr)
}
}
// For RF=3, legacy scalar ReplicaDataAddr is NOT set on the primary
// assignment (only ReplicaAddrs is used). Verify precedence: ReplicaAddrs
// is the authoritative source when non-empty.
if len(primaryAssignment.ReplicaAddrs) > 0 && primaryAssignment.ReplicaDataAddr != "" {
t.Error("legacy ReplicaDataAddr should be empty when ReplicaAddrs is populated (RF=3)")
}
}
// ────────────────────────────────────────────────────────────
// QA-9: ScrubConcurrentWrites_NoFalseCorruption
//
// Heavy writes while scrub runs. Expect zero false positives.
// Tests at the HealthScore level (engine scrub tested separately).
// ────────────────────────────────────────────────────────────
func TestQA_CP82_ScrubConcurrentWrites_NoFalseCorruption(t *testing.T) {
ms := qaCP82Master(t)
now := time.Now()
qaRegisterRF3WithState(t, ms, "vol-scrub", "vs1:9333",
[]struct {
Server string
HealthScore float64
WALHeadLSN uint64
Role blockvol.Role
Heartbeat time.Time
}{
{"vs2:9333", 1.0, 500, blockvol.RoleReplica, now},
{"vs3:9333", 1.0, 500, blockvol.RoleReplica, now},
},
500,
)
var wg sync.WaitGroup
const rounds = 100
// Simulate heartbeats with varying health scores
// (as would happen during scrub with concurrent writes).
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
// Health stays 1.0 — no corruption detected (correct behavior
// when writes are excluded from scrub checks).
ms.blockRegistry.UpdateFullHeartbeat("vs1:9333", []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/vol-scrub.blk",
VolumeSize: 1 << 30,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
WalHeadLsn: uint64(500 + i),
HealthScore: 1.0, // Clean — no false positives
},
})
}
}()
// Concurrent replica heartbeats.
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
ms.blockRegistry.UpdateFullHeartbeat("vs2:9333", []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/vol-scrub.blk",
VolumeSize: 1 << 30,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
WalHeadLsn: uint64(500 + i),
HealthScore: 1.0,
},
})
}
}()
wg.Wait()
entry, _ := ms.blockRegistry.Lookup("vol-scrub")
if entry.HealthScore < 1.0 {
t.Fatalf("health score should be 1.0 (no corruption), got %f", entry.HealthScore)
}
}
// ────────────────────────────────────────────────────────────
// QA-10: ScrubDetectsCorruption_HealthDrops_PromotionAvoids
//
// One replica has corruption (low health), the other is clean.
// Promotion must prefer the healthy replica.
// ────────────────────────────────────────────────────────────
func TestQA_CP82_ScrubDetectsCorruption_HealthDrops_PromotionAvoids(t *testing.T) {
ms := qaCP82Master(t)
now := time.Now()
qaRegisterRF3WithState(t, ms, "vol-corrupt", "vs1:9333",
[]struct {
Server string
HealthScore float64
WALHeadLSN uint64
Role blockvol.Role
Heartbeat time.Time
}{
{"vs2:9333", 0.3, 100, blockvol.RoleReplica, now}, // corrupted (low health)
{"vs3:9333", 1.0, 100, blockvol.RoleReplica, now}, // clean
},
100,
)
// Simulate heartbeat reporting low health on vs2 (scrub found errors).
ms.blockRegistry.UpdateFullHeartbeat("vs2:9333", []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/vol-corrupt.blk",
VolumeSize: 1 << 30,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
WalHeadLsn: 100,
HealthScore: 0.3,
ScrubErrors: 5,
},
})
// Trigger failover — vs3 (healthy) should be promoted, not vs2.
newEpoch, err := ms.blockRegistry.PromoteBestReplica("vol-corrupt")
if err != nil {
t.Fatalf("promotion should succeed: %v", err)
}
if newEpoch != 2 {
t.Fatalf("epoch: got %d, want 2", newEpoch)
}
entry, _ := ms.blockRegistry.Lookup("vol-corrupt")
if entry.VolumeServer != "vs3:9333" {
t.Fatalf("vs3 (healthy) should be promoted, got %q", entry.VolumeServer)
}
// vs2 (corrupted) should remain as replica.
if len(entry.Replicas) != 1 || entry.Replicas[0].Server != "vs2:9333" {
t.Fatalf("vs2 should remain as replica, got %+v", entry.Replicas)
}
}
// ────────────────────────────────────────────────────────────
// QA-11: ExpandRF3_PartialReplicaFailure
//
// Primary expand succeeds, one replica expand fails.
// Verify: operation succeeds (best-effort), registry updated,
// failed replica state is degraded.
// ────────────────────────────────────────────────────────────
func TestQA_CP82_ExpandRF3_PartialReplicaFailure(t *testing.T) {
ms := qaCP82Master(t)
ctx := context.Background()
// Create RF=3 volume first.
_, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
Name: "vol-expand",
SizeBytes: 1 << 30,
ReplicaFactor: 3,
})
if err != nil {
t.Fatalf("create: %v", err)
}
entry, _ := ms.blockRegistry.Lookup("vol-expand")
failServer := entry.Replicas[1].Server
// Override expand mock: one replica fails.
ms.blockVSExpand = func(ctx context.Context, server string, name string, newSize uint64) (uint64, error) {
if server == failServer {
return 0, fmt.Errorf("disk full on %s", server)
}
return newSize, nil
}
// Expand should succeed (primary + one replica succeed, one fails best-effort).
resp, err := ms.ExpandBlockVolume(ctx, &master_pb.ExpandBlockVolumeRequest{
Name: "vol-expand",
NewSizeBytes: 2 << 30,
})
if err != nil {
t.Fatalf("expand should succeed despite partial replica failure: %v", err)
}
if resp.CapacityBytes != 2<<30 {
t.Fatalf("capacity: got %d, want %d", resp.CapacityBytes, uint64(2<<30))
}
// Registry should reflect new size.
entry, _ = ms.blockRegistry.Lookup("vol-expand")
if entry.SizeBytes != 2<<30 {
t.Fatalf("registry size: got %d, want %d", entry.SizeBytes, uint64(2<<30))
}
}
// ────────────────────────────────────────────────────────────
// QA-12: ByServerIndexConsistency_AfterReplicaMove
//
// Moves replica between servers repeatedly. ListByServer and
// byServer counts must stay exact — no dangling references.
// ────────────────────────────────────────────────────────────
func TestQA_CP82_ByServerIndexConsistency_AfterReplicaMove(t *testing.T) {
ms := qaCP82Master(t)
now := time.Now()
qaRegisterRF3WithState(t, ms, "vol-move", "vs1:9333",
[]struct {
Server string
HealthScore float64
WALHeadLSN uint64
Role blockvol.Role
Heartbeat time.Time
}{
{"vs2:9333", 1.0, 100, blockvol.RoleReplica, now},
},
100,
)
// Verify initial state: vs1 and vs2 both have vol-move.
assertServerHasVolume(t, ms, "vs1:9333", "vol-move")
assertServerHasVolume(t, ms, "vs2:9333", "vol-move")
// Move replica from vs2 to vs3 repeatedly.
servers := []string{"vs3:9333", "vs2:9333", "vs3:9333", "vs2:9333", "vs3:9333"}
for _, newServer := range servers {
// Remove old replica.
entry, _ := ms.blockRegistry.Lookup("vol-move")
oldServer := ""
if len(entry.Replicas) > 0 {
oldServer = entry.Replicas[0].Server
}
if oldServer != "" {
if err := ms.blockRegistry.RemoveReplica("vol-move", oldServer); err != nil {
t.Fatalf("remove replica from %s: %v", oldServer, err)
}
}
// Add new replica.
if err := ms.blockRegistry.AddReplica("vol-move", ReplicaInfo{
Server: newServer,
Path: "/data/vol-move.blk",
IQN: fmt.Sprintf("iqn.2024.test:vol-move-r"),
ISCSIAddr: newServer + ":3260",
HealthScore: 1.0,
}); err != nil {
t.Fatalf("add replica on %s: %v", newServer, err)
}
// Invariant: primary always indexed.
assertServerHasVolume(t, ms, "vs1:9333", "vol-move")
// New replica indexed.
assertServerHasVolume(t, ms, newServer, "vol-move")
// Old server should NOT have it (unless it's the primary).
if oldServer != "" && oldServer != "vs1:9333" && oldServer != newServer {
assertServerDoesNotHaveVolume(t, ms, oldServer, "vol-move")
}
}
// Final check: ListAll should have exactly 1 volume.
all := ms.blockRegistry.ListAll()
if len(all) != 1 {
t.Fatalf("expected 1 volume, got %d", len(all))
}
}
// ────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────
func assertServerHasVolume(t *testing.T, ms *MasterServer, server, volName string) {
t.Helper()
entries := ms.blockRegistry.ListByServer(server)
for _, e := range entries {
if e.Name == volName {
return
}
}
t.Errorf("server %q should have volume %q in byServer index", server, volName)
}
func assertServerDoesNotHaveVolume(t *testing.T, ms *MasterServer, server, volName string) {
t.Helper()
entries := ms.blockRegistry.ListByServer(server)
for _, e := range entries {
if e.Name == volName {
t.Errorf("server %q should NOT have volume %q in byServer index", server, volName)
return
}
}
}
+430
View File
@@ -0,0 +1,430 @@
package weed_server
import (
"context"
"fmt"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
)
// ============================================================
// CP8-2 T10: RF=3 QA + Integration Tests
//
// Adversarial and integration tests for multi-replica (N>2)
// support introduced in CP8-2.
// ============================================================
// Helper: create master with 3 block-capable servers and RF=3-aware alloc mock.
func qaRF3Master(t *testing.T) *MasterServer {
t.Helper()
ms := &MasterServer{
blockRegistry: NewBlockVolumeRegistry(),
blockAssignmentQueue: NewBlockAssignmentQueue(),
blockFailover: newBlockFailoverState(),
}
ms.blockVSAllocate = func(ctx context.Context, server string, name string, sizeBytes uint64, diskType string) (*blockAllocResult, error) {
return &blockAllocResult{
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s", name),
ISCSIAddr: server + ":3260",
ReplicaDataAddr: server + ":14260",
ReplicaCtrlAddr: server + ":14261",
RebuildListenAddr: server + ":15000",
}, nil
}
ms.blockVSDelete = func(ctx context.Context, server string, name string) error {
return nil
}
ms.blockVSExpand = func(ctx context.Context, server string, name string, newSize uint64) (uint64, error) {
return newSize, nil
}
ms.blockRegistry.MarkBlockCapable("vs1:9333")
ms.blockRegistry.MarkBlockCapable("vs2:9333")
ms.blockRegistry.MarkBlockCapable("vs3:9333")
return ms
}
// 1. Create RF=3, verify 2 replicas in registry with correct data.
func TestQA_RF3_CreateAndVerifyReplicas(t *testing.T) {
ms := qaRF3Master(t)
ctx := context.Background()
resp, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
Name: "pvc-rf3-1",
SizeBytes: 1 << 30,
ReplicaFactor: 3,
})
if err != nil {
t.Fatalf("create: %v", err)
}
entry, ok := ms.blockRegistry.Lookup("pvc-rf3-1")
if !ok {
t.Fatal("volume not in registry")
}
// Verify ReplicaFactor.
if entry.ReplicaFactor != 3 {
t.Fatalf("ReplicaFactor: got %d, want 3", entry.ReplicaFactor)
}
// Verify 2 replicas.
if len(entry.Replicas) != 2 {
t.Fatalf("expected 2 replicas, got %d", len(entry.Replicas))
}
// All 3 servers are distinct.
servers := map[string]bool{resp.VolumeServer: true}
for _, ri := range entry.Replicas {
if servers[ri.Server] {
t.Fatalf("duplicate server in RF=3 volume: %q", ri.Server)
}
servers[ri.Server] = true
// Each replica should have path, IQN, ISCSIAddr set.
if ri.Path == "" || ri.IQN == "" || ri.ISCSIAddr == "" {
t.Fatalf("replica %q missing fields: path=%q iqn=%q iscsi=%q",
ri.Server, ri.Path, ri.IQN, ri.ISCSIAddr)
}
}
// Backward compat: ReplicaServer (deprecated) should point to first replica.
if entry.ReplicaServer == "" {
t.Fatal("deprecated ReplicaServer should be set for backward compat")
}
if entry.ReplicaServer != entry.Replicas[0].Server {
t.Fatalf("deprecated ReplicaServer %q != Replicas[0].Server %q",
entry.ReplicaServer, entry.Replicas[0].Server)
}
// Assignments: primary should have 2 ReplicaAddrs.
primaryAssignments := ms.blockAssignmentQueue.Peek(resp.VolumeServer)
if len(primaryAssignments) != 1 {
t.Fatalf("expected 1 primary assignment, got %d", len(primaryAssignments))
}
if len(primaryAssignments[0].ReplicaAddrs) != 2 {
t.Fatalf("primary assignment ReplicaAddrs: got %d, want 2",
len(primaryAssignments[0].ReplicaAddrs))
}
// Each replica server should have 1 assignment.
for _, ri := range entry.Replicas {
ra := ms.blockAssignmentQueue.Peek(ri.Server)
if len(ra) != 1 {
t.Fatalf("replica %q: expected 1 assignment, got %d", ri.Server, len(ra))
}
if blockvol.RoleFromWire(ra[0].Role) != blockvol.RoleReplica {
t.Fatalf("replica %q: expected RoleReplica, got %d", ri.Server, ra[0].Role)
}
}
}
// 2. Kill primary → best replica (highest health score) promoted.
func TestQA_RF3_PrimaryDies_BestReplicaPromoted(t *testing.T) {
ms := qaRF3Master(t)
ctx := context.Background()
resp, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
Name: "pvc-rf3-promo",
SizeBytes: 1 << 30,
ReplicaFactor: 3,
})
if err != nil {
t.Fatalf("create: %v", err)
}
entry, _ := ms.blockRegistry.Lookup("pvc-rf3-promo")
primary := resp.VolumeServer
// Set different health scores on replicas.
for i := range entry.Replicas {
if i == 0 {
entry.Replicas[i].HealthScore = 0.7
} else {
entry.Replicas[i].HealthScore = 1.0
}
}
expectedPromoted := entry.Replicas[1].Server // higher health score
entry.LastLeaseGrant = time.Now().Add(-1 * time.Minute) // expired
// Kill primary.
ms.failoverBlockVolumes(primary)
entry, _ = ms.blockRegistry.Lookup("pvc-rf3-promo")
if entry.VolumeServer != expectedPromoted {
t.Fatalf("expected %q promoted (highest health), got %q", expectedPromoted, entry.VolumeServer)
}
// 1 replica should remain (the one not promoted).
if len(entry.Replicas) != 1 {
t.Fatalf("expected 1 remaining replica, got %d", len(entry.Replicas))
}
// Pending rebuild for old primary.
ms.blockFailover.mu.Lock()
rebuilds := ms.blockFailover.pendingRebuilds[primary]
ms.blockFailover.mu.Unlock()
if len(rebuilds) != 1 || rebuilds[0].VolumeName != "pvc-rf3-promo" {
t.Fatalf("expected 1 pending rebuild for %q, got %+v", primary, rebuilds)
}
}
// 3. Kill one replica → writes unaffected (primary unchanged, dead replica removed).
func TestQA_RF3_OneReplicaDies_WritesUnaffected(t *testing.T) {
ms := qaRF3Master(t)
ctx := context.Background()
_, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
Name: "pvc-rf3-repdeath",
SizeBytes: 1 << 30,
ReplicaFactor: 3,
})
if err != nil {
t.Fatalf("create: %v", err)
}
entry, _ := ms.blockRegistry.Lookup("pvc-rf3-repdeath")
originalPrimary := entry.VolumeServer
originalEpoch := entry.Epoch
deadReplica := entry.Replicas[1].Server // kill second replica
ms.failoverBlockVolumes(deadReplica)
entry, _ = ms.blockRegistry.Lookup("pvc-rf3-repdeath")
// Primary unchanged.
if entry.VolumeServer != originalPrimary {
t.Fatalf("primary should remain %q, got %q", originalPrimary, entry.VolumeServer)
}
// Epoch unchanged (no promotion).
if entry.Epoch != originalEpoch {
t.Fatalf("epoch should remain %d, got %d", originalEpoch, entry.Epoch)
}
// Dead replica removed, 1 replica remains.
if len(entry.Replicas) != 1 {
t.Fatalf("expected 1 replica after death, got %d", len(entry.Replicas))
}
if entry.Replicas[0].Server == deadReplica {
t.Fatalf("dead replica %q should be removed", deadReplica)
}
}
// 4. Dead replica reconnects → rebuilds and rejoins.
func TestQA_RF3_Rebuild_DeadReplicaCatchesUp(t *testing.T) {
ms := qaRF3Master(t)
ctx := context.Background()
_, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
Name: "pvc-rf3-rebuild",
SizeBytes: 1 << 30,
ReplicaFactor: 3,
})
if err != nil {
t.Fatalf("create: %v", err)
}
entry, _ := ms.blockRegistry.Lookup("pvc-rf3-rebuild")
deadReplica := entry.Replicas[0].Server
// Kill one replica.
ms.failoverBlockVolumes(deadReplica)
// Verify it's removed.
entry, _ = ms.blockRegistry.Lookup("pvc-rf3-rebuild")
if len(entry.Replicas) != 1 {
t.Fatalf("after kill: expected 1 replica, got %d", len(entry.Replicas))
}
// Dead replica reconnects.
ms.recoverBlockVolumes(deadReplica)
// Verify it's back.
entry, _ = ms.blockRegistry.Lookup("pvc-rf3-rebuild")
if len(entry.Replicas) != 2 {
t.Fatalf("after reconnect: expected 2 replicas, got %d", len(entry.Replicas))
}
// Rebuild assignment should be queued.
assignments := ms.blockAssignmentQueue.Peek(deadReplica)
foundRebuild := false
for _, a := range assignments {
if blockvol.RoleFromWire(a.Role) == blockvol.RoleRebuilding {
foundRebuild = true
if a.RebuildAddr == "" {
t.Fatal("rebuild assignment missing RebuildAddr")
}
}
}
if !foundRebuild {
t.Fatalf("expected rebuild assignment for %q", deadReplica)
}
}
// 5. Health score influences failover preference.
func TestQA_RF3_HealthScore_FailoverPreference(t *testing.T) {
ms := qaRF3Master(t)
ctx := context.Background()
_, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
Name: "pvc-rf3-health",
SizeBytes: 1 << 30,
ReplicaFactor: 3,
})
if err != nil {
t.Fatalf("create: %v", err)
}
entry, _ := ms.blockRegistry.Lookup("pvc-rf3-health")
primary := entry.VolumeServer
// Set health scores: replica[0] = 0.3 (low), replica[1] = 0.9 (high).
entry.Replicas[0].HealthScore = 0.3
entry.Replicas[0].WALHeadLSN = 100
entry.Replicas[1].HealthScore = 0.9
entry.Replicas[1].WALHeadLSN = 100
expectedWinner := entry.Replicas[1].Server
entry.LastLeaseGrant = time.Now().Add(-1 * time.Minute) // expired
ms.failoverBlockVolumes(primary)
entry, _ = ms.blockRegistry.Lookup("pvc-rf3-health")
if entry.VolumeServer != expectedWinner {
t.Fatalf("expected %q (health=0.9) promoted, got %q", expectedWinner, entry.VolumeServer)
}
// The low-health replica should remain in the list.
if len(entry.Replicas) != 1 {
t.Fatalf("expected 1 remaining replica, got %d", len(entry.Replicas))
}
if entry.Replicas[0].HealthScore != 0.3 {
t.Fatalf("remaining replica health: got %.1f, want 0.3", entry.Replicas[0].HealthScore)
}
}
// 6. RF=2 backward compat: identical behavior to pre-CP8-2.
func TestQA_RF3_BackwardCompat_RF2_Unchanged(t *testing.T) {
ms := qaRF3Master(t)
ctx := context.Background()
// Create with default RF (should be 2).
resp, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
Name: "pvc-rf2-compat",
SizeBytes: 1 << 30,
})
if err != nil {
t.Fatalf("create: %v", err)
}
entry, _ := ms.blockRegistry.Lookup("pvc-rf2-compat")
// RF should default to 2.
if entry.ReplicaFactor != 2 {
t.Fatalf("RF default: got %d, want 2", entry.ReplicaFactor)
}
// Exactly 1 replica.
if len(entry.Replicas) != 1 {
t.Fatalf("expected 1 replica for RF=2, got %d", len(entry.Replicas))
}
// Failover should work identically.
primary := resp.VolumeServer
entry.LastLeaseGrant = time.Now().Add(-1 * time.Minute)
ms.failoverBlockVolumes(primary)
entry, _ = ms.blockRegistry.Lookup("pvc-rf2-compat")
if entry.VolumeServer == primary {
t.Fatalf("failover failed: primary should have changed from %q", primary)
}
// No replicas left after RF=2 promotion.
if len(entry.Replicas) != 0 {
t.Fatalf("expected 0 replicas after RF=2 promotion, got %d", len(entry.Replicas))
}
// Recover → rebuild.
ms.recoverBlockVolumes(primary)
entry, _ = ms.blockRegistry.Lookup("pvc-rf2-compat")
if len(entry.Replicas) != 1 || entry.Replicas[0].Server != primary {
t.Fatalf("after recovery: expected %q as replica, got %+v", primary, entry.Replicas)
}
}
// 7. Full lifecycle: create RF=3 → kill primary → promote → kill another → promote → recover all.
func TestQA_RF3_FullLifecycle(t *testing.T) {
ms := qaRF3Master(t)
ctx := context.Background()
resp, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
Name: "pvc-rf3-lifecycle",
SizeBytes: 1 << 30,
ReplicaFactor: 3,
})
if err != nil {
t.Fatalf("create: %v", err)
}
entry, _ := ms.blockRegistry.Lookup("pvc-rf3-lifecycle")
vs1 := resp.VolumeServer
vs2 := entry.Replicas[0].Server
vs3 := entry.Replicas[1].Server
entry.LastLeaseGrant = time.Now().Add(-1 * time.Minute)
// Step 1: Kill vs1 (primary). One of vs2/vs3 promoted.
ms.failoverBlockVolumes(vs1)
entry, _ = ms.blockRegistry.Lookup("pvc-rf3-lifecycle")
if entry.VolumeServer == vs1 {
t.Fatal("step1: primary should not be vs1 after failover")
}
step1Primary := entry.VolumeServer
if len(entry.Replicas) != 1 {
t.Fatalf("step1: expected 1 replica, got %d", len(entry.Replicas))
}
step1Replica := entry.Replicas[0].Server
// Step 2: Recover vs1 → becomes replica (rebuilding).
ms.recoverBlockVolumes(vs1)
entry, _ = ms.blockRegistry.Lookup("pvc-rf3-lifecycle")
if len(entry.Replicas) != 2 {
t.Fatalf("step2: expected 2 replicas after vs1 recovery, got %d", len(entry.Replicas))
}
// Step 3: Kill step1Primary. The surviving replica (step1Replica or vs1) should be promoted.
entry.LastLeaseGrant = time.Now().Add(-1 * time.Minute)
// Set health scores: vs1 low (just rebuilt), step1Replica high.
for i := range entry.Replicas {
if entry.Replicas[i].Server == vs1 {
entry.Replicas[i].HealthScore = 0.5
} else {
entry.Replicas[i].HealthScore = 1.0
}
}
ms.failoverBlockVolumes(step1Primary)
entry, _ = ms.blockRegistry.Lookup("pvc-rf3-lifecycle")
if entry.VolumeServer == step1Primary {
t.Fatal("step3: primary should not be step1Primary after second failover")
}
// The higher health replica should win.
if entry.VolumeServer != step1Replica {
t.Fatalf("step3: expected %q promoted (health=1.0), got %q", step1Replica, entry.VolumeServer)
}
// Step 4: Recover step1Primary → becomes replica.
ms.recoverBlockVolumes(step1Primary)
entry, _ = ms.blockRegistry.Lookup("pvc-rf3-lifecycle")
// Should have 2 replicas now (vs1 from step2 still there + step1Primary recovered).
// Actually vs1's health was 0.5 and was NOT promoted, so vs1 stays as replica.
if len(entry.Replicas) < 1 {
t.Fatalf("step4: expected at least 1 replica after recovery, got %d", len(entry.Replicas))
}
// Step 5: Delete.
_, err = ms.DeleteBlockVolume(ctx, &master_pb.DeleteBlockVolumeRequest{Name: "pvc-rf3-lifecycle"})
if err != nil {
t.Fatalf("delete: %v", err)
}
if _, ok := ms.blockRegistry.Lookup("pvc-rf3-lifecycle"); ok {
t.Fatal("volume should be deleted")
}
_ = vs2
_ = vs3
}
+84
View File
@@ -57,3 +57,87 @@ func (vs *VolumeServer) VolumeServerDeleteBlockVolume(_ context.Context, req *vo
return &volume_server_pb.VolumeServerDeleteBlockVolumeResponse{}, nil
}
// SnapshotBlockVolume creates a snapshot on a block volume.
func (vs *VolumeServer) SnapshotBlockVolume(_ context.Context, req *volume_server_pb.SnapshotBlockVolumeRequest) (*volume_server_pb.SnapshotBlockVolumeResponse, error) {
if vs.blockService == nil {
return nil, fmt.Errorf("block service not enabled on this volume server")
}
if req.Name == "" {
return nil, fmt.Errorf("name is required")
}
createdAt, sizeBytes, err := vs.blockService.SnapshotBlockVol(req.Name, req.SnapshotId)
if err != nil {
return nil, fmt.Errorf("snapshot block volume %q: %w", req.Name, err)
}
return &volume_server_pb.SnapshotBlockVolumeResponse{
SnapshotId: req.SnapshotId,
CreatedAt: createdAt,
SizeBytes: sizeBytes,
}, nil
}
// DeleteBlockSnapshot deletes a snapshot from a block volume.
func (vs *VolumeServer) DeleteBlockSnapshot(_ context.Context, req *volume_server_pb.DeleteBlockSnapshotRequest) (*volume_server_pb.DeleteBlockSnapshotResponse, error) {
if vs.blockService == nil {
return nil, fmt.Errorf("block service not enabled on this volume server")
}
if req.Name == "" {
return nil, fmt.Errorf("name is required")
}
if err := vs.blockService.DeleteBlockSnapshot(req.Name, req.SnapshotId); err != nil {
return nil, fmt.Errorf("delete block snapshot %q/%d: %w", req.Name, req.SnapshotId, err)
}
return &volume_server_pb.DeleteBlockSnapshotResponse{}, nil
}
// ListBlockSnapshots lists all snapshots on a block volume.
func (vs *VolumeServer) ListBlockSnapshots(_ context.Context, req *volume_server_pb.ListBlockSnapshotsRequest) (*volume_server_pb.ListBlockSnapshotsResponse, error) {
if vs.blockService == nil {
return nil, fmt.Errorf("block service not enabled on this volume server")
}
if req.Name == "" {
return nil, fmt.Errorf("name is required")
}
infos, volSize, err := vs.blockService.ListBlockSnapshots(req.Name)
if err != nil {
return nil, fmt.Errorf("list block snapshots %q: %w", req.Name, err)
}
resp := &volume_server_pb.ListBlockSnapshotsResponse{}
for _, s := range infos {
resp.Snapshots = append(resp.Snapshots, &volume_server_pb.BlockSnapshotInfo{
SnapshotId: s.ID,
CreatedAt: s.CreatedAt.Unix(),
VolumeSizeBytes: volSize,
})
}
return resp, nil
}
// ExpandBlockVolume expands a block volume to a new size.
func (vs *VolumeServer) ExpandBlockVolume(_ context.Context, req *volume_server_pb.ExpandBlockVolumeRequest) (*volume_server_pb.ExpandBlockVolumeResponse, error) {
if vs.blockService == nil {
return nil, fmt.Errorf("block service not enabled on this volume server")
}
if req.Name == "" {
return nil, fmt.Errorf("name is required")
}
if req.NewSizeBytes == 0 {
return nil, fmt.Errorf("new_size_bytes must be > 0")
}
actualSize, err := vs.blockService.ExpandBlockVol(req.Name, req.NewSizeBytes)
if err != nil {
return nil, fmt.Errorf("expand block volume %q: %w", req.Name, err)
}
return &volume_server_pb.ExpandBlockVolumeResponse{
CapacityBytes: actualSize,
}, nil
}
+93
View File
@@ -108,3 +108,96 @@ func TestVS_DeleteNotFound(t *testing.T) {
t.Fatalf("delete non-existent should not error: %v", err)
}
}
func TestVS_SnapshotBlockVol(t *testing.T) {
bs, _ := newTestBlockServiceWithDir(t)
bs.CreateBlockVol("snap-vol", 4*1024*1024, "")
createdAt, sizeBytes, err := bs.SnapshotBlockVol("snap-vol", 1)
if err != nil {
t.Fatalf("SnapshotBlockVol: %v", err)
}
if createdAt == 0 {
t.Fatal("createdAt should not be zero")
}
if sizeBytes == 0 {
t.Fatal("sizeBytes should not be zero")
}
}
func TestVS_SnapshotVolumeNotFound(t *testing.T) {
bs, _ := newTestBlockServiceWithDir(t)
_, _, err := bs.SnapshotBlockVol("nonexistent", 1)
if err == nil {
t.Fatal("expected error for nonexistent volume")
}
}
func TestVS_DeleteBlockSnapshot(t *testing.T) {
bs, _ := newTestBlockServiceWithDir(t)
bs.CreateBlockVol("snap-vol", 4*1024*1024, "")
bs.SnapshotBlockVol("snap-vol", 1)
if err := bs.DeleteBlockSnapshot("snap-vol", 1); err != nil {
t.Fatalf("DeleteBlockSnapshot: %v", err)
}
// Delete again should be idempotent.
if err := bs.DeleteBlockSnapshot("snap-vol", 1); err != nil {
t.Fatalf("idempotent delete: %v", err)
}
}
func TestVS_ListBlockSnapshots(t *testing.T) {
bs, _ := newTestBlockServiceWithDir(t)
bs.CreateBlockVol("snap-vol", 4*1024*1024, "")
bs.SnapshotBlockVol("snap-vol", 1)
bs.SnapshotBlockVol("snap-vol", 2)
infos, volSize, err := bs.ListBlockSnapshots("snap-vol")
if err != nil {
t.Fatalf("ListBlockSnapshots: %v", err)
}
if len(infos) != 2 {
t.Fatalf("expected 2 snapshots, got %d", len(infos))
}
if volSize != 4*1024*1024 {
t.Fatalf("volSize: got %d, want %d", volSize, 4*1024*1024)
}
}
func TestVS_ListSnapshotsVolumeNotFound(t *testing.T) {
bs, _ := newTestBlockServiceWithDir(t)
_, _, err := bs.ListBlockSnapshots("nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent volume")
}
}
func TestVS_ExpandBlockVol(t *testing.T) {
bs, _ := newTestBlockServiceWithDir(t)
bs.CreateBlockVol("expand-vol", 4*1024*1024, "")
actualSize, err := bs.ExpandBlockVol("expand-vol", 8*1024*1024)
if err != nil {
t.Fatalf("ExpandBlockVol: %v", err)
}
if actualSize != 8*1024*1024 {
t.Fatalf("expected 8MiB, got %d", actualSize)
}
}
func TestVS_ExpandVolumeNotFound(t *testing.T) {
bs, _ := newTestBlockServiceWithDir(t)
_, err := bs.ExpandBlockVol("nonexistent", 8*1024*1024)
if err == nil {
t.Fatal("expected error for nonexistent volume")
}
}
+113 -1
View File
@@ -228,7 +228,10 @@ func (bs *BlockService) ProcessAssignments(assignments []blockvol.BlockVolumeAss
// 2. Replication setup based on role + addresses.
switch role {
case blockvol.RolePrimary:
if a.ReplicaDataAddr != "" && a.ReplicaCtrlAddr != "" {
// CP8-2: ReplicaAddrs (multi-replica) takes precedence over scalar fields.
if len(a.ReplicaAddrs) > 0 {
bs.setupPrimaryReplicationMulti(a.Path, a.ReplicaAddrs)
} else if a.ReplicaDataAddr != "" && a.ReplicaCtrlAddr != "" {
bs.setupPrimaryReplication(a.Path, a.ReplicaDataAddr, a.ReplicaCtrlAddr)
}
case blockvol.RoleReplica:
@@ -279,6 +282,43 @@ func (bs *BlockService) setupPrimaryReplication(path, replicaDataAddr, replicaCt
glog.V(0).Infof("block service: primary %s shipping WAL to %s/%s (rebuild=%s)", path, replicaDataAddr, replicaCtrlAddr, rebuildAddr)
}
// setupPrimaryReplicationMulti configures WAL shipping from primary to N replicas
// using SetReplicaAddrs (CP8-2: multi-replica support).
func (bs *BlockService) setupPrimaryReplicationMulti(path string, addrs []blockvol.ReplicaAddr) {
// Compute deterministic rebuild listen address.
_, _, rebuildPort := bs.ReplicationPorts(path)
host := bs.listenAddr
if idx := strings.LastIndex(host, ":"); idx >= 0 {
host = host[:idx]
}
rebuildAddr := fmt.Sprintf("%s:%d", host, rebuildPort)
if err := bs.blockStore.WithVolume(path, func(vol *blockvol.BlockVol) error {
vol.SetReplicaAddrs(addrs)
if err := vol.StartRebuildServer(rebuildAddr); err != nil {
glog.Warningf("block service: start rebuild server %s on %s: %v", path, rebuildAddr, err)
}
return nil
}); err != nil {
glog.Warningf("block service: setup primary replication (multi) %s: %v", path, err)
return
}
// Track replication state for heartbeat reporting.
bs.replMu.Lock()
if bs.replStates == nil {
bs.replStates = make(map[string]*volReplState)
}
// Store first replica in replState for backward compat heartbeat reporting.
if len(addrs) > 0 {
bs.replStates[path] = &volReplState{
replicaDataAddr: addrs[0].DataAddr,
replicaCtrlAddr: addrs[0].CtrlAddr,
}
}
bs.replMu.Unlock()
glog.V(0).Infof("block service: primary %s shipping WAL to %d replicas (rebuild=%s)", path, len(addrs), rebuildAddr)
}
// setupReplicaReceiver starts the replica WAL receiver.
func (bs *BlockService) setupReplicaReceiver(path, dataAddr, ctrlAddr string) {
if err := bs.blockStore.WithVolume(path, func(vol *blockvol.BlockVol) error {
@@ -318,6 +358,72 @@ func (bs *BlockService) startRebuild(path, rebuildAddr string, epoch uint64) {
}()
}
// SnapshotBlockVol creates a snapshot on the named volume.
func (bs *BlockService) SnapshotBlockVol(name string, snapID uint32) (createdAt int64, sizeBytes uint64, err error) {
path := bs.volumePath(name)
err = bs.blockStore.WithVolume(path, func(vol *blockvol.BlockVol) error {
if serr := vol.CreateSnapshot(snapID); serr != nil {
return serr
}
// Find the snapshot we just created for its metadata.
for _, s := range vol.ListSnapshots() {
if s.ID == snapID {
createdAt = s.CreatedAt.Unix()
sizeBytes = vol.Info().VolumeSize
return nil
}
}
return fmt.Errorf("snapshot %d created but not found in list", snapID)
})
return
}
// DeleteBlockSnapshot deletes a snapshot on the named volume.
// Idempotent: returns nil if snapshot does not exist.
func (bs *BlockService) DeleteBlockSnapshot(name string, snapID uint32) error {
path := bs.volumePath(name)
return bs.blockStore.WithVolume(path, func(vol *blockvol.BlockVol) error {
err := vol.DeleteSnapshot(snapID)
if err != nil && err.Error() == "blockvol: snapshot not found" {
return nil // idempotent
}
return err
})
}
// ListBlockSnapshots lists all snapshots on the named volume.
func (bs *BlockService) ListBlockSnapshots(name string) ([]blockvol.SnapshotInfo, uint64, error) {
path := bs.volumePath(name)
var infos []blockvol.SnapshotInfo
var volSize uint64
err := bs.blockStore.WithVolume(path, func(vol *blockvol.BlockVol) error {
infos = vol.ListSnapshots()
volSize = vol.Info().VolumeSize
return nil
})
return infos, volSize, err
}
// ExpandBlockVol expands the named volume to newSize bytes.
func (bs *BlockService) ExpandBlockVol(name string, newSize uint64) (uint64, error) {
path := bs.volumePath(name)
var actualSize uint64
err := bs.blockStore.WithVolume(path, func(vol *blockvol.BlockVol) error {
if eerr := vol.Expand(newSize); eerr != nil {
return eerr
}
actualSize = vol.Info().VolumeSize
return nil
})
return actualSize, err
}
// volumePath converts a volume name to its .blk file path.
func (bs *BlockService) volumePath(name string) string {
sanitized := blockvol.SanitizeFilename(name)
return filepath.Join(bs.blockDir, sanitized+".blk")
}
// GetReplState returns the replication state for a volume path.
func (bs *BlockService) GetReplState(path string) (dataAddr, ctrlAddr string) {
bs.replMu.RLock()
@@ -374,3 +480,9 @@ func (bs *BlockService) Shutdown() {
bs.blockStore.Close()
glog.V(0).Infof("block service: shut down")
}
// SetBlockService wires a BlockService into the VolumeServer so that
// heartbeats include block volume info and the server is marked block-capable.
func (vs *VolumeServer) SetBlockService(bs *BlockService) {
vs.blockService = bs
}
+220
View File
@@ -0,0 +1,220 @@
package shell
import (
"context"
"flag"
"fmt"
"io"
"strings"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol/blockapi"
)
func init() {
Commands = append(Commands, &commandBlockCreate{})
Commands = append(Commands, &commandBlockList{})
Commands = append(Commands, &commandBlockStatus{})
Commands = append(Commands, &commandBlockDelete{})
Commands = append(Commands, &commandBlockAssign{})
}
// mastersHTTP builds a comma-separated list of "http://host:port" URLs from
// the shell environment's master addresses.
func mastersHTTP(commandEnv *CommandEnv) string {
addrs := commandEnv.MasterClient.GetMasters(context.Background())
parts := make([]string, len(addrs))
for i, a := range addrs {
s := string(a)
if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") {
s = "http://" + s
}
parts[i] = s
}
return strings.Join(parts, ",")
}
// ---- block.create ----
type commandBlockCreate struct{}
func (c *commandBlockCreate) Name() string { return "block.create" }
func (c *commandBlockCreate) Help() string {
return `create a block volume
block.create -name <name> -size <bytes> [-replicaPlacement <xyz>] [-disk <type>]
`
}
func (c *commandBlockCreate) HasTag(CommandTag) bool { return false }
func (c *commandBlockCreate) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error {
f := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
name := f.String("name", "", "volume name")
size := f.Uint64("size", 0, "volume size in bytes")
placement := f.String("replicaPlacement", "000", "placement string: 000, 001, 010, 100")
disk := f.String("disk", "", "disk type (e.g. ssd, hdd)")
if err := f.Parse(args); err != nil {
return nil
}
if *name == "" || *size == 0 {
return fmt.Errorf("both -name and -size are required")
}
client := blockapi.NewClient(mastersHTTP(commandEnv))
info, err := client.CreateVolume(context.Background(), blockapi.CreateVolumeRequest{
Name: *name,
SizeBytes: *size,
ReplicaPlacement: *placement,
DiskType: *disk,
})
if err != nil {
return err
}
fmt.Fprintf(writer, "created block volume %q on %s (iqn=%s, iscsi=%s)\n",
info.Name, info.VolumeServer, info.IQN, info.ISCSIAddr)
if info.ReplicaServer != "" {
fmt.Fprintf(writer, " replica on %s (iqn=%s, iscsi=%s)\n",
info.ReplicaServer, info.ReplicaIQN, info.ReplicaISCSIAddr)
}
return nil
}
// ---- block.list ----
type commandBlockList struct{}
func (c *commandBlockList) Name() string { return "block.list" }
func (c *commandBlockList) Help() string {
return `list all block volumes
block.list
`
}
func (c *commandBlockList) HasTag(CommandTag) bool { return false }
func (c *commandBlockList) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error {
client := blockapi.NewClient(mastersHTTP(commandEnv))
vols, err := client.ListVolumes(context.Background())
if err != nil {
return err
}
if len(vols) == 0 {
fmt.Fprintln(writer, "no block volumes")
return nil
}
fmt.Fprintf(writer, "%-20s %-20s %-12s %-8s %-8s %-20s\n",
"NAME", "SERVER", "SIZE", "EPOCH", "ROLE", "STATUS")
for _, v := range vols {
fmt.Fprintf(writer, "%-20s %-20s %-12d %-8d %-8s %-20s\n",
v.Name, v.VolumeServer, v.SizeBytes, v.Epoch, v.Role, v.Status)
}
return nil
}
// ---- block.status ----
type commandBlockStatus struct{}
func (c *commandBlockStatus) Name() string { return "block.status" }
func (c *commandBlockStatus) Help() string {
return `show status of a block volume
block.status <name>
`
}
func (c *commandBlockStatus) HasTag(CommandTag) bool { return false }
func (c *commandBlockStatus) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error {
if len(args) == 0 {
return fmt.Errorf("usage: block.status <name>")
}
name := args[0]
client := blockapi.NewClient(mastersHTTP(commandEnv))
info, err := client.LookupVolume(context.Background(), name)
if err != nil {
return err
}
fmt.Fprintf(writer, "Name: %s\n", info.Name)
fmt.Fprintf(writer, "VolumeServer: %s\n", info.VolumeServer)
fmt.Fprintf(writer, "SizeBytes: %d\n", info.SizeBytes)
fmt.Fprintf(writer, "Epoch: %d\n", info.Epoch)
fmt.Fprintf(writer, "Role: %s\n", info.Role)
fmt.Fprintf(writer, "Status: %s\n", info.Status)
fmt.Fprintf(writer, "ISCSIAddr: %s\n", info.ISCSIAddr)
fmt.Fprintf(writer, "IQN: %s\n", info.IQN)
if info.ReplicaServer != "" {
fmt.Fprintf(writer, "ReplicaServer: %s\n", info.ReplicaServer)
fmt.Fprintf(writer, "ReplicaISCSI: %s\n", info.ReplicaISCSIAddr)
fmt.Fprintf(writer, "ReplicaIQN: %s\n", info.ReplicaIQN)
fmt.Fprintf(writer, "ReplicaData: %s\n", info.ReplicaDataAddr)
fmt.Fprintf(writer, "ReplicaCtrl: %s\n", info.ReplicaCtrlAddr)
}
return nil
}
// ---- block.delete ----
type commandBlockDelete struct{}
func (c *commandBlockDelete) Name() string { return "block.delete" }
func (c *commandBlockDelete) Help() string {
return `delete a block volume
block.delete <name>
`
}
func (c *commandBlockDelete) HasTag(CommandTag) bool { return false }
func (c *commandBlockDelete) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error {
if len(args) == 0 {
return fmt.Errorf("usage: block.delete <name>")
}
name := args[0]
client := blockapi.NewClient(mastersHTTP(commandEnv))
if err := client.DeleteVolume(context.Background(), name); err != nil {
return err
}
fmt.Fprintf(writer, "deleted block volume %q\n", name)
return nil
}
// ---- block.assign ----
type commandBlockAssign struct{}
func (c *commandBlockAssign) Name() string { return "block.assign" }
func (c *commandBlockAssign) Help() string {
return `assign a role to a block volume
block.assign -name <name> -epoch <n> -role <primary|replica> [-lease <ms>]
`
}
func (c *commandBlockAssign) HasTag(CommandTag) bool { return false }
func (c *commandBlockAssign) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error {
f := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
name := f.String("name", "", "volume name")
epoch := f.Uint64("epoch", 0, "epoch number")
role := f.String("role", "", "role: primary or replica")
leaseTTLMs := f.Uint64("lease", 30000, "lease TTL in milliseconds")
if err := f.Parse(args); err != nil {
return nil
}
if *name == "" || *role == "" {
return fmt.Errorf("both -name and -role are required")
}
client := blockapi.NewClient(mastersHTTP(commandEnv))
if err := client.AssignRole(context.Background(), blockapi.AssignRequest{
Name: *name,
Epoch: *epoch,
Role: *role,
LeaseTTLMs: *leaseTTLMs,
}); err != nil {
return err
}
fmt.Fprintf(writer, "assignment queued: %s epoch=%d role=%s lease=%dms\n",
*name, *epoch, *role, *leaseTTLMs)
return nil
}
+37 -27
View File
@@ -8,17 +8,21 @@ import (
// BlockVolumeInfoMessage is the heartbeat status for one block volume.
// Mirrors the proto message that will be generated from master.proto.
type BlockVolumeInfoMessage struct {
Path string // volume file path (unique ID on this server)
VolumeSize uint64 // logical size in bytes
BlockSize uint32 // block size in bytes
Epoch uint64 // current fencing epoch
Role uint32 // blockvol.Role as uint32 for wire compat
WalHeadLsn uint64 // WAL head LSN
CheckpointLsn uint64 // last flushed LSN
HasLease bool // whether volume holds a valid lease
DiskType string // e.g., "ssd", "hdd"
ReplicaDataAddr string // receiver data listen addr (VS reports in heartbeat)
ReplicaCtrlAddr string // receiver ctrl listen addr
Path string // volume file path (unique ID on this server)
VolumeSize uint64 // logical size in bytes
BlockSize uint32 // block size in bytes
Epoch uint64 // current fencing epoch
Role uint32 // blockvol.Role as uint32 for wire compat
WalHeadLsn uint64 // WAL head LSN
CheckpointLsn uint64 // last flushed LSN
HasLease bool // whether volume holds a valid lease
DiskType string // e.g., "ssd", "hdd"
ReplicaDataAddr string // receiver data listen addr (VS reports in heartbeat)
ReplicaCtrlAddr string // receiver ctrl listen addr
HealthScore float64 // CP8-2: 0.0-1.0
ScrubErrors int64 // CP8-2: lifetime scrub error count
LastScrubTime int64 // CP8-2: unix seconds
ReplicaDegraded bool // CP8-2: true if any replica shipper degraded
}
// BlockVolumeShortInfoMessage is used for delta heartbeats
@@ -33,13 +37,14 @@ type BlockVolumeShortInfoMessage struct {
// BlockVolumeAssignment carries a role/epoch/lease assignment
// from master to volume server for one block volume.
type BlockVolumeAssignment struct {
Path string // which block volume
Epoch uint64 // new epoch
Role uint32 // target role (blockvol.Role as uint32)
LeaseTtlMs uint32 // lease TTL in milliseconds (0 = no lease)
ReplicaDataAddr string // where primary ships WAL data
ReplicaCtrlAddr string // where primary sends barriers
RebuildAddr string // where rebuild server listens
Path string // which block volume
Epoch uint64 // new epoch
Role uint32 // target role (blockvol.Role as uint32)
LeaseTtlMs uint32 // lease TTL in milliseconds (0 = no lease)
ReplicaDataAddr string // where primary ships WAL data (scalar, RF=2 compat)
ReplicaCtrlAddr string // where primary sends barriers (scalar, RF=2 compat)
RebuildAddr string // where rebuild server listens
ReplicaAddrs []ReplicaAddr // CP8-2: multi-replica addrs (precedence over scalar)
}
// ToBlockVolumeInfoMessage converts a BlockVol's current state
@@ -48,16 +53,21 @@ type BlockVolumeAssignment struct {
func ToBlockVolumeInfoMessage(path, diskType string, vol *BlockVol) BlockVolumeInfoMessage {
info := vol.Info()
status := vol.Status()
hs := vol.HealthStats()
return BlockVolumeInfoMessage{
Path: path,
VolumeSize: info.VolumeSize,
BlockSize: info.BlockSize,
Epoch: status.Epoch,
Role: RoleToWire(status.Role),
WalHeadLsn: status.WALHeadLSN,
CheckpointLsn: status.CheckpointLSN,
HasLease: status.HasLease,
DiskType: diskType,
Path: path,
VolumeSize: info.VolumeSize,
BlockSize: info.BlockSize,
Epoch: status.Epoch,
Role: RoleToWire(status.Role),
WalHeadLsn: status.WALHeadLSN,
CheckpointLsn: status.CheckpointLSN,
HasLease: status.HasLease,
DiskType: diskType,
HealthScore: status.HealthScore,
ScrubErrors: hs.ScrubErrors,
LastScrubTime: hs.LastScrubTime,
ReplicaDegraded: status.ReplicaDegraded,
}
}
+37 -9
View File
@@ -18,6 +18,10 @@ func InfoMessageToProto(m BlockVolumeInfoMessage) *master_pb.BlockVolumeInfoMess
DiskType: m.DiskType,
ReplicaDataAddr: m.ReplicaDataAddr,
ReplicaCtrlAddr: m.ReplicaCtrlAddr,
HealthScore: m.HealthScore,
ScrubErrors: m.ScrubErrors,
LastScrubTime: m.LastScrubTime,
ReplicaDegraded: m.ReplicaDegraded,
}
}
@@ -38,6 +42,10 @@ func InfoMessageFromProto(p *master_pb.BlockVolumeInfoMessage) BlockVolumeInfoMe
DiskType: p.DiskType,
ReplicaDataAddr: p.ReplicaDataAddr,
ReplicaCtrlAddr: p.ReplicaCtrlAddr,
HealthScore: p.HealthScore,
ScrubErrors: p.ScrubErrors,
LastScrubTime: p.LastScrubTime,
ReplicaDegraded: p.ReplicaDegraded,
}
}
@@ -84,7 +92,7 @@ func ShortInfoFromProto(p *master_pb.BlockVolumeShortInfoMessage) BlockVolumeSho
// AssignmentToProto converts a Go assignment to proto.
func AssignmentToProto(a BlockVolumeAssignment) *master_pb.BlockVolumeAssignment {
return &master_pb.BlockVolumeAssignment{
pb := &master_pb.BlockVolumeAssignment{
Path: a.Path,
Epoch: a.Epoch,
Role: a.Role,
@@ -93,22 +101,42 @@ func AssignmentToProto(a BlockVolumeAssignment) *master_pb.BlockVolumeAssignment
ReplicaCtrlAddr: a.ReplicaCtrlAddr,
RebuildAddr: a.RebuildAddr,
}
for _, ra := range a.ReplicaAddrs {
pb.ReplicaAddrs = append(pb.ReplicaAddrs, &master_pb.ReplicaAddrMessage{
DataAddr: ra.DataAddr,
CtrlAddr: ra.CtrlAddr,
})
}
return pb
}
// AssignmentFromProto converts a proto assignment to Go wire type.
// Precedence: if ReplicaAddrs is non-empty, use it and ignore scalar fields.
func AssignmentFromProto(p *master_pb.BlockVolumeAssignment) BlockVolumeAssignment {
if p == nil {
return BlockVolumeAssignment{}
}
return BlockVolumeAssignment{
Path: p.Path,
Epoch: p.Epoch,
Role: p.Role,
LeaseTtlMs: p.LeaseTtlMs,
ReplicaDataAddr: p.ReplicaDataAddr,
ReplicaCtrlAddr: p.ReplicaCtrlAddr,
RebuildAddr: p.RebuildAddr,
a := BlockVolumeAssignment{
Path: p.Path,
Epoch: p.Epoch,
Role: p.Role,
LeaseTtlMs: p.LeaseTtlMs,
RebuildAddr: p.RebuildAddr,
}
if len(p.ReplicaAddrs) > 0 {
// Multi-replica: populate ReplicaAddrs, leave scalar fields empty.
for _, ra := range p.ReplicaAddrs {
a.ReplicaAddrs = append(a.ReplicaAddrs, ReplicaAddr{
DataAddr: ra.DataAddr,
CtrlAddr: ra.CtrlAddr,
})
}
} else {
// Backward compat: use scalar fields.
a.ReplicaDataAddr = p.ReplicaDataAddr
a.ReplicaCtrlAddr = p.ReplicaCtrlAddr
}
return a
}
// AssignmentsToProto converts a slice of Go assignments to proto.
@@ -1,6 +1,7 @@
package blockvol
import (
"reflect"
"testing"
)
@@ -46,7 +47,7 @@ func TestAssignmentRoundTrip(t *testing.T) {
}
pb := AssignmentToProto(orig)
back := AssignmentFromProto(pb)
if back != orig {
if !reflect.DeepEqual(back, orig) {
t.Fatalf("round-trip mismatch:\n got %+v\n want %+v", back, orig)
}
}
@@ -80,7 +81,7 @@ func TestAssignmentRoundTripWithReplicaAddrs(t *testing.T) {
}
pb := AssignmentToProto(orig)
back := AssignmentFromProto(pb)
if back != orig {
if !reflect.DeepEqual(back, orig) {
t.Fatalf("round-trip mismatch:\n got %+v\n want %+v", back, orig)
}
}
@@ -195,7 +196,199 @@ func TestNilProtoConversions(t *testing.T) {
t.Fatalf("nil short proto should yield zero value, got %+v", short)
}
assign := AssignmentFromProto(nil)
if assign != (BlockVolumeAssignment{}) {
if !reflect.DeepEqual(assign, BlockVolumeAssignment{}) {
t.Fatalf("nil assignment proto should yield zero value, got %+v", assign)
}
}
// --- CP8-2 new tests ---
func TestInfoMessage_HealthScoreRoundTrip(t *testing.T) {
orig := BlockVolumeInfoMessage{
Path: "/data/vol.blk",
VolumeSize: 1 << 30,
BlockSize: 4096,
Epoch: 5,
HealthScore: 0.85,
ScrubErrors: 3,
LastScrubTime: 1709000000,
}
pb := InfoMessageToProto(orig)
back := InfoMessageFromProto(pb)
if back.HealthScore != 0.85 {
t.Fatalf("HealthScore: got %f, want 0.85", back.HealthScore)
}
if back.ScrubErrors != 3 {
t.Fatalf("ScrubErrors: got %d, want 3", back.ScrubErrors)
}
if back.LastScrubTime != 1709000000 {
t.Fatalf("LastScrubTime: got %d, want 1709000000", back.LastScrubTime)
}
}
func TestInfoMessage_ReplicaDegradedRoundTrip(t *testing.T) {
orig := BlockVolumeInfoMessage{
Path: "/data/vol.blk",
Epoch: 1,
ReplicaDegraded: true,
}
pb := InfoMessageToProto(orig)
back := InfoMessageFromProto(pb)
if !back.ReplicaDegraded {
t.Fatal("ReplicaDegraded should be true after round-trip")
}
// Also verify false round-trips.
orig.ReplicaDegraded = false
pb = InfoMessageToProto(orig)
back = InfoMessageFromProto(pb)
if back.ReplicaDegraded {
t.Fatal("ReplicaDegraded should be false after round-trip")
}
}
func TestAssignment_MultiReplicaRoundTrip(t *testing.T) {
orig := BlockVolumeAssignment{
Path: "/data/vol.blk",
Epoch: 10,
Role: RoleToWire(RolePrimary),
LeaseTtlMs: 30000,
ReplicaAddrs: []ReplicaAddr{
{DataAddr: "10.0.0.2:14260", CtrlAddr: "10.0.0.2:14261"},
{DataAddr: "10.0.0.3:14260", CtrlAddr: "10.0.0.3:14261"},
},
}
pb := AssignmentToProto(orig)
back := AssignmentFromProto(pb)
if len(back.ReplicaAddrs) != 2 {
t.Fatalf("ReplicaAddrs len: got %d, want 2", len(back.ReplicaAddrs))
}
if back.ReplicaAddrs[0].DataAddr != "10.0.0.2:14260" {
t.Fatalf("ReplicaAddrs[0].DataAddr: got %q", back.ReplicaAddrs[0].DataAddr)
}
if back.ReplicaAddrs[1].CtrlAddr != "10.0.0.3:14261" {
t.Fatalf("ReplicaAddrs[1].CtrlAddr: got %q", back.ReplicaAddrs[1].CtrlAddr)
}
// Scalar fields should be empty when ReplicaAddrs is populated.
if back.ReplicaDataAddr != "" || back.ReplicaCtrlAddr != "" {
t.Fatalf("scalar addrs should be empty when ReplicaAddrs set, got data=%q ctrl=%q",
back.ReplicaDataAddr, back.ReplicaCtrlAddr)
}
}
func TestAssignment_PrecedenceRule(t *testing.T) {
// When proto has BOTH ReplicaAddrs AND scalar fields,
// ReplicaAddrs takes precedence and scalar fields are ignored.
orig := BlockVolumeAssignment{
Path: "/data/vol.blk",
Epoch: 5,
Role: RoleToWire(RolePrimary),
ReplicaDataAddr: "scalar-data:1234",
ReplicaCtrlAddr: "scalar-ctrl:1235",
ReplicaAddrs: []ReplicaAddr{
{DataAddr: "multi-data:4260", CtrlAddr: "multi-ctrl:4261"},
},
}
pb := AssignmentToProto(orig)
// Proto will have both scalar and repeated fields set.
if pb.ReplicaDataAddr != "scalar-data:1234" {
t.Fatalf("proto should preserve scalar, got %q", pb.ReplicaDataAddr)
}
if len(pb.ReplicaAddrs) != 1 {
t.Fatalf("proto should have 1 ReplicaAddr, got %d", len(pb.ReplicaAddrs))
}
// FromProto: ReplicaAddrs non-empty → use it, ignore scalar.
back := AssignmentFromProto(pb)
if len(back.ReplicaAddrs) != 1 {
t.Fatalf("back.ReplicaAddrs len: got %d, want 1", len(back.ReplicaAddrs))
}
if back.ReplicaDataAddr != "" {
t.Fatalf("scalar ReplicaDataAddr should be empty due to precedence, got %q", back.ReplicaDataAddr)
}
if back.ReplicaCtrlAddr != "" {
t.Fatalf("scalar ReplicaCtrlAddr should be empty due to precedence, got %q", back.ReplicaCtrlAddr)
}
}
func TestAssignment_BackwardCompatScalar(t *testing.T) {
// When proto has only scalar fields (no ReplicaAddrs), backward compat path.
orig := BlockVolumeAssignment{
Path: "/data/vol.blk",
Epoch: 3,
Role: RoleToWire(RolePrimary),
LeaseTtlMs: 5000,
ReplicaDataAddr: "host:4260",
ReplicaCtrlAddr: "host:4261",
}
pb := AssignmentToProto(orig)
back := AssignmentFromProto(pb)
if back.ReplicaDataAddr != "host:4260" {
t.Fatalf("scalar ReplicaDataAddr: got %q, want %q", back.ReplicaDataAddr, "host:4260")
}
if back.ReplicaCtrlAddr != "host:4261" {
t.Fatalf("scalar ReplicaCtrlAddr: got %q, want %q", back.ReplicaCtrlAddr, "host:4261")
}
if len(back.ReplicaAddrs) != 0 {
t.Fatalf("ReplicaAddrs should be empty for backward compat, got %d", len(back.ReplicaAddrs))
}
}
func TestAssignmentsSlice_MultiReplicaRoundTrip(t *testing.T) {
as := []BlockVolumeAssignment{
{
Path: "/a.blk",
Epoch: 1,
ReplicaAddrs: []ReplicaAddr{
{DataAddr: "h1:1", CtrlAddr: "h1:2"},
{DataAddr: "h2:1", CtrlAddr: "h2:2"},
},
},
{
Path: "/b.blk",
Epoch: 2,
ReplicaDataAddr: "scalar:1",
},
}
pbs := AssignmentsToProto(as)
back := AssignmentsFromProto(pbs)
if len(back) != 2 {
t.Fatalf("len: got %d, want 2", len(back))
}
// First: multi-replica path.
if len(back[0].ReplicaAddrs) != 2 {
t.Fatalf("back[0].ReplicaAddrs len: got %d, want 2", len(back[0].ReplicaAddrs))
}
// Second: scalar path.
if back[1].ReplicaDataAddr != "scalar:1" {
t.Fatalf("back[1].ReplicaDataAddr: got %q", back[1].ReplicaDataAddr)
}
if len(back[1].ReplicaAddrs) != 0 {
t.Fatalf("back[1].ReplicaAddrs should be empty, got %d", len(back[1].ReplicaAddrs))
}
}
func TestInfoMessage_HealthFieldsZeroDefault(t *testing.T) {
// Verify zero-valued health fields round-trip correctly (backward compat).
orig := BlockVolumeInfoMessage{
Path: "/data/old.blk",
Epoch: 1,
// All CP8-2 fields at zero default.
}
pb := InfoMessageToProto(orig)
back := InfoMessageFromProto(pb)
if back.HealthScore != 0 {
t.Fatalf("HealthScore zero default: got %f", back.HealthScore)
}
if back.ScrubErrors != 0 {
t.Fatalf("ScrubErrors zero default: got %d", back.ScrubErrors)
}
if back.LastScrubTime != 0 {
t.Fatalf("LastScrubTime zero default: got %d", back.LastScrubTime)
}
if back.ReplicaDegraded {
t.Fatal("ReplicaDegraded zero default should be false")
}
}
+189
View File
@@ -0,0 +1,189 @@
package blockapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client is a Go HTTP client for the master's block volume REST API.
// It supports multiple master addresses for failover.
type Client struct {
Masters []string
HTTPClient *http.Client
}
// NewClient creates a Client from a comma-separated list of master URLs.
// Example: "http://m1:9333,http://m2:9333"
func NewClient(masters string) *Client {
var addrs []string
for _, m := range strings.Split(masters, ",") {
m = strings.TrimSpace(m)
if m != "" {
addrs = append(addrs, m)
}
}
return &Client{
Masters: addrs,
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// CreateVolume creates a new block volume.
func (c *Client) CreateVolume(ctx context.Context, req CreateVolumeRequest) (*VolumeInfo, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
resp, err := c.doRequest(ctx, http.MethodPost, "/block/volume", bytes.NewReader(body))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if err := checkStatus(resp, http.StatusOK, http.StatusCreated); err != nil {
return nil, err
}
var info VolumeInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &info, nil
}
// DeleteVolume deletes a block volume by name.
func (c *Client) DeleteVolume(ctx context.Context, name string) error {
resp, err := c.doRequest(ctx, http.MethodDelete, "/block/volume/"+name, nil)
if err != nil {
return err
}
defer resp.Body.Close()
return checkStatus(resp, http.StatusOK)
}
// LookupVolume looks up a single block volume by name.
func (c *Client) LookupVolume(ctx context.Context, name string) (*VolumeInfo, error) {
resp, err := c.doRequest(ctx, http.MethodGet, "/block/volume/"+name, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if err := checkStatus(resp, http.StatusOK); err != nil {
return nil, err
}
var info VolumeInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &info, nil
}
// ListVolumes lists all block volumes.
func (c *Client) ListVolumes(ctx context.Context) ([]VolumeInfo, error) {
resp, err := c.doRequest(ctx, http.MethodGet, "/block/volumes", nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if err := checkStatus(resp, http.StatusOK); err != nil {
return nil, err
}
var infos []VolumeInfo
if err := json.NewDecoder(resp.Body).Decode(&infos); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return infos, nil
}
// AssignRole enqueues a role assignment for a block volume.
func (c *Client) AssignRole(ctx context.Context, req AssignRequest) error {
body, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
resp, err := c.doRequest(ctx, http.MethodPost, "/block/assign", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
return checkStatus(resp, http.StatusOK)
}
// ListServers lists all block-capable volume servers.
func (c *Client) ListServers(ctx context.Context) ([]ServerInfo, error) {
resp, err := c.doRequest(ctx, http.MethodGet, "/block/servers", nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if err := checkStatus(resp, http.StatusOK); err != nil {
return nil, err
}
var infos []ServerInfo
if err := json.NewDecoder(resp.Body).Decode(&infos); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return infos, nil
}
// doRequest tries each master in order until one responds.
func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
var lastErr error
for _, master := range c.Masters {
url := strings.TrimRight(master, "/") + path
var bodyReader io.Reader
if body != nil {
// Re-read body for retries: buffer it
if lastErr != nil {
// body was already consumed on previous attempt
// We need a seekable body — caller should pass bytes.Reader
if seeker, ok := body.(io.Seeker); ok {
seeker.Seek(0, io.SeekStart)
}
}
bodyReader = body
}
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil {
lastErr = fmt.Errorf("master %s: %w", master, err)
continue
}
if method == http.MethodPost || method == http.MethodPut {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("master %s: %w", master, err)
continue
}
return resp, nil
}
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("no master addresses configured")
}
// checkStatus returns an error if the response status is not in the accepted list.
func checkStatus(resp *http.Response, accepted ...int) error {
for _, code := range accepted {
if resp.StatusCode == code {
return nil
}
}
body, _ := io.ReadAll(resp.Body)
// Try to extract error message from JSON.
var errResp struct {
Error string `json:"error"`
}
if json.Unmarshal(body, &errResp) == nil && errResp.Error != "" {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error)
}
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
@@ -0,0 +1,103 @@
package blockapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestClientCreateVolume(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.URL.Path != "/block/volume" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
var req CreateVolumeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode request: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
if req.Name != "test-vol" {
t.Errorf("expected name test-vol, got %s", req.Name)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(VolumeInfo{
Name: req.Name,
VolumeServer: "vs1:9333",
SizeBytes: req.SizeBytes,
Epoch: 1,
Role: "primary",
Status: "active",
})
}))
defer ts.Close()
client := NewClient(ts.URL)
info, err := client.CreateVolume(context.Background(), CreateVolumeRequest{
Name: "test-vol",
SizeBytes: 1 << 30,
})
if err != nil {
t.Fatal(err)
}
if info.Name != "test-vol" {
t.Errorf("expected name test-vol, got %s", info.Name)
}
if info.VolumeServer != "vs1:9333" {
t.Errorf("expected vs1:9333, got %s", info.VolumeServer)
}
}
func TestClientListVolumes(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/block/volumes" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]VolumeInfo{
{Name: "alpha", VolumeServer: "vs1:9333"},
{Name: "beta", VolumeServer: "vs2:9333"},
})
}))
defer ts.Close()
client := NewClient(ts.URL)
vols, err := client.ListVolumes(context.Background())
if err != nil {
t.Fatal(err)
}
if len(vols) != 2 {
t.Fatalf("expected 2 volumes, got %d", len(vols))
}
if vols[0].Name != "alpha" || vols[1].Name != "beta" {
t.Errorf("unexpected volumes: %+v", vols)
}
}
func TestClientMultiMasterFallback(t *testing.T) {
// First server immediately rejects connections (closed listener).
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
deadURL := dead.URL
dead.Close() // close it so connections are refused
// Second server responds normally.
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]VolumeInfo{})
}))
defer ts.Close()
client := NewClient(deadURL + "," + ts.URL)
vols, err := client.ListVolumes(context.Background())
if err != nil {
t.Fatal(err)
}
if vols == nil {
t.Error("expected non-nil result")
}
}
+81
View File
@@ -0,0 +1,81 @@
package blockapi
import (
"strings"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
)
// CreateVolumeRequest is the request body for POST /block/volume.
type CreateVolumeRequest struct {
Name string `json:"name"`
SizeBytes uint64 `json:"size_bytes"`
ReplicaPlacement string `json:"replica_placement"` // SeaweedFS placement string: "000", "001", "010", "100"
DiskType string `json:"disk_type"` // e.g. "ssd", "hdd"
}
// VolumeInfo describes a block volume.
type VolumeInfo struct {
Name string `json:"name"`
VolumeServer string `json:"volume_server"`
SizeBytes uint64 `json:"size_bytes"`
ReplicaPlacement string `json:"replica_placement,omitempty"`
Epoch uint64 `json:"epoch"`
Role string `json:"role"`
Status string `json:"status"`
ISCSIAddr string `json:"iscsi_addr"`
IQN string `json:"iqn"`
ReplicaServer string `json:"replica_server,omitempty"`
ReplicaISCSIAddr string `json:"replica_iscsi_addr,omitempty"`
ReplicaIQN string `json:"replica_iqn,omitempty"`
ReplicaDataAddr string `json:"replica_data_addr,omitempty"`
ReplicaCtrlAddr string `json:"replica_ctrl_addr,omitempty"`
// CP8-2: Multi-replica fields.
ReplicaFactor int `json:"replica_factor"`
Replicas []ReplicaDetail `json:"replicas,omitempty"`
HealthScore float64 `json:"health_score"`
ReplicaDegraded bool `json:"replica_degraded,omitempty"`
}
// ReplicaDetail describes one replica in the API response.
type ReplicaDetail struct {
Server string `json:"server"`
ISCSIAddr string `json:"iscsi_addr,omitempty"`
IQN string `json:"iqn,omitempty"`
HealthScore float64 `json:"health_score"`
WALLag uint64 `json:"wal_lag,omitempty"`
}
// AssignRequest is the request body for POST /block/assign.
type AssignRequest struct {
Name string `json:"name"`
Epoch uint64 `json:"epoch"`
Role string `json:"role"` // "primary" | "replica"
LeaseTTLMs uint64 `json:"lease_ttl_ms"` // lease TTL in milliseconds
}
// ServerInfo describes a block-capable volume server.
type ServerInfo struct {
Address string `json:"address"`
VolumeCount int `json:"volume_count"`
BlockCapable bool `json:"block_capable"`
}
// RoleFromString converts a role string to its uint32 wire value.
// Returns 0 (RoleNone) for unrecognized strings.
func RoleFromString(s string) uint32 {
switch strings.ToLower(s) {
case "primary":
return uint32(blockvol.RolePrimary)
case "replica":
return uint32(blockvol.RoleReplica)
case "stale":
return uint32(blockvol.RoleStale)
case "rebuilding":
return uint32(blockvol.RoleRebuilding)
case "draining":
return uint32(blockvol.RoleDraining)
default:
return uint32(blockvol.RoleNone)
}
}
+151 -46
View File
@@ -44,9 +44,9 @@ type BlockVol struct {
opsOutstanding atomic.Int64 // in-flight Read/Write/Trim/SyncCache ops
opsDrained chan struct{}
// Replication fields (Phase 4A CP2).
shipper *WALShipper
replRecv *ReplicaReceiver
// Replication fields (Phase 4A CP2, generalized to N replicas in CP8-2).
shipperGroup *ShipperGroup
replRecv *ReplicaReceiver
// Fencing fields (Phase 4A).
epoch atomic.Uint64 // current persisted epoch
@@ -60,6 +60,13 @@ type BlockVol struct {
assignMu sync.Mutex // serializes HandleAssignment calls
drainTimeout time.Duration // default 10s, for demote drain
// Health score and scrub (CP8-2).
healthScore *HealthScore
scrubber *Scrubber
// Observability (CP8-4).
Metrics *EngineMetrics
// Snapshot fields (Phase 5 CP5-2).
snapMu sync.RWMutex
snapshots map[uint32]*activeSnapshot
@@ -116,14 +123,16 @@ func CreateBlockVol(path string, opts CreateOptions, cfgs ...BlockVolConfig) (*B
dm := NewDirtyMap(cfg.DirtyMapShards)
wal := NewWALWriter(fd, sb.WALOffset, sb.WALSize, 0, 0)
v := &BlockVol{
fd: fd,
path: path,
super: sb,
config: cfg,
wal: wal,
dirtyMap: dm,
opsDrained: make(chan struct{}, 1),
snapshots: make(map[uint32]*activeSnapshot),
fd: fd,
path: path,
super: sb,
config: cfg,
wal: wal,
dirtyMap: dm,
healthScore: NewHealthScore(),
Metrics: NewEngineMetrics(),
opsDrained: make(chan struct{}, 1),
snapshots: make(map[uint32]*activeSnapshot),
}
v.nextLSN.Store(1)
v.healthy.Store(true)
@@ -134,6 +143,7 @@ func CreateBlockVol(path string, opts CreateOptions, cfgs ...BlockVolConfig) (*B
LowWatermark: cfg.GroupCommitLowWatermark,
OnDegraded: func() { v.healthy.Store(false) },
PostSyncCheck: v.writeGate,
Metrics: v.Metrics,
})
go v.groupCommit.Run()
v.flusher = NewFlusher(FlusherConfig{
@@ -142,6 +152,7 @@ func CreateBlockVol(path string, opts CreateOptions, cfgs ...BlockVolConfig) (*B
WAL: wal,
DirtyMap: dm,
Interval: cfg.FlushInterval,
Metrics: v.Metrics,
})
go v.flusher.Run()
return v, nil
@@ -189,13 +200,15 @@ func OpenBlockVol(path string, cfgs ...BlockVolConfig) (*BlockVol, error) {
wal := NewWALWriter(fd, sb.WALOffset, sb.WALSize, sb.WALHead, sb.WALTail)
v := &BlockVol{
fd: fd,
path: path,
super: sb,
config: cfg,
wal: wal,
dirtyMap: dirtyMap,
opsDrained: make(chan struct{}, 1),
fd: fd,
path: path,
super: sb,
config: cfg,
wal: wal,
dirtyMap: dirtyMap,
healthScore: NewHealthScore(),
Metrics: NewEngineMetrics(),
opsDrained: make(chan struct{}, 1),
}
v.nextLSN.Store(nextLSN)
v.epoch.Store(sb.Epoch)
@@ -207,6 +220,7 @@ func OpenBlockVol(path string, cfgs ...BlockVolConfig) (*BlockVol, error) {
LowWatermark: cfg.GroupCommitLowWatermark,
OnDegraded: func() { v.healthy.Store(false) },
PostSyncCheck: v.writeGate,
Metrics: v.Metrics,
})
go v.groupCommit.Run()
v.flusher = NewFlusher(FlusherConfig{
@@ -215,6 +229,7 @@ func OpenBlockVol(path string, cfgs ...BlockVolConfig) (*BlockVol, error) {
WAL: wal,
DirtyMap: dirtyMap,
Interval: cfg.FlushInterval,
Metrics: v.Metrics,
})
go v.flusher.Run()
@@ -292,9 +307,14 @@ func (v *BlockVol) appendWithRetry(entry *WALEntry) (uint64, error) {
return 0, ErrVolumeClosed
}
// Ship to replica if configured (fire-and-forget).
if v.shipper != nil {
v.shipper.Ship(entry)
// Ship to replicas if configured (fire-and-forget).
if v.shipperGroup != nil {
v.shipperGroup.ShipAll(entry)
}
// Notify scrubber of written LBAs for CRC invalidation.
if v.scrubber != nil && entry.Type == EntryTypeWrite {
v.scrubber.NotifyWrite(entry.LBA, entry.Length/v.super.BlockSize)
}
return walOff, nil
@@ -567,17 +587,33 @@ func (v *BlockVol) SyncCache() error {
return v.groupCommit.Submit()
}
// SetReplicaAddr configures the replica endpoint and creates a WAL shipper
// with distributed group commit. Must be called before any writes.
// ReplicaAddr holds the data and control addresses for one replica.
type ReplicaAddr struct {
DataAddr string
CtrlAddr string
}
// SetReplicaAddr configures a single replica endpoint. Backward-compatible wrapper
// around SetReplicaAddrs for RF=2 callers.
func (v *BlockVol) SetReplicaAddr(dataAddr, ctrlAddr string) {
v.shipper = NewWALShipper(dataAddr, ctrlAddr, func() uint64 {
return v.epoch.Load()
})
v.SetReplicaAddrs([]ReplicaAddr{{DataAddr: dataAddr, CtrlAddr: ctrlAddr}})
}
// SetReplicaAddrs configures N replica endpoints and creates a ShipperGroup
// with distributed group commit. Creates fresh shippers (old group is GC'd).
func (v *BlockVol) SetReplicaAddrs(addrs []ReplicaAddr) {
shippers := make([]*WALShipper, len(addrs))
for i, a := range addrs {
shippers[i] = NewWALShipper(a.DataAddr, a.CtrlAddr, func() uint64 {
return v.epoch.Load()
}, v.Metrics)
}
v.shipperGroup = NewShipperGroup(shippers)
// Replace the group committer's sync function with a distributed version.
v.groupCommit.Stop()
v.groupCommit = NewGroupCommitter(GroupCommitterConfig{
SyncFunc: MakeDistributedSync(v.fd.Sync, v.shipper, v),
SyncFunc: MakeDistributedSync(v.fd.Sync, v.shipperGroup, v),
MaxDelay: v.config.GroupCommitMaxDelay,
MaxBatch: v.config.GroupCommitMaxBatch,
LowWatermark: v.config.GroupCommitLowWatermark,
@@ -622,21 +658,21 @@ func (v *BlockVol) StopRebuildServer() {
}
}
// degradeReplica marks the shipper as degraded and logs a warning.
// degradeReplica logs a warning about a replica barrier failure.
// Individual shippers are already degraded by Barrier itself.
func (v *BlockVol) degradeReplica(err error) {
if v.shipper != nil {
v.shipper.degraded.Store(true)
}
log.Printf("blockvol: replica degraded: %v", err)
}
// BlockVolumeStatus contains block volume state for heartbeat reporting.
type BlockVolumeStatus struct {
Epoch uint64
WALHeadLSN uint64
Role Role
CheckpointLSN uint64
HasLease bool
Epoch uint64
WALHeadLSN uint64
Role Role
CheckpointLSN uint64
HasLease bool
HealthScore float64
ReplicaDegraded bool
}
// Status returns the current block volume status for heartbeat reporting.
@@ -645,13 +681,79 @@ func (v *BlockVol) Status() BlockVolumeStatus {
if v.flusher != nil {
cpLSN = v.flusher.CheckpointLSN()
}
return BlockVolumeStatus{
Epoch: v.epoch.Load(),
WALHeadLSN: v.nextLSN.Load() - 1,
Role: v.Role(),
CheckpointLSN: cpLSN,
HasLease: v.lease.IsValid(),
var hs float64 = 1.0
if v.healthScore != nil {
hs = v.healthScore.Score()
}
var degraded bool
if v.shipperGroup != nil {
degraded = v.shipperGroup.AnyDegraded()
}
return BlockVolumeStatus{
Epoch: v.epoch.Load(),
WALHeadLSN: v.nextLSN.Load() - 1,
Role: v.Role(),
CheckpointLSN: cpLSN,
HasLease: v.lease.IsValid(),
HealthScore: hs,
ReplicaDegraded: degraded,
}
}
// CheckpointLSN returns the last LSN flushed to the extent region.
func (v *BlockVol) CheckpointLSN() uint64 {
if v.flusher != nil {
return v.flusher.CheckpointLSN()
}
return 0
}
// HealthScore returns the current health score (0.0-1.0).
func (v *BlockVol) HealthScore() float64 {
if v.healthScore == nil {
return 1.0
}
return v.healthScore.Score()
}
// HealthStats returns detailed health statistics.
func (v *BlockVol) HealthStats() HealthStats {
if v.healthScore == nil {
return HealthStats{Score: 1.0}
}
return v.healthScore.Stats()
}
// StartScrub starts background scrubbing with the given interval.
func (v *BlockVol) StartScrub(interval time.Duration) {
if v.scrubber != nil {
return
}
v.scrubber = NewScrubber(v, interval)
v.scrubber.Start()
}
// StopScrub stops the background scrubber.
func (v *BlockVol) StopScrub() {
if v.scrubber != nil {
v.scrubber.Stop()
v.scrubber = nil
}
}
// TriggerScrub triggers an immediate scrub pass.
func (v *BlockVol) TriggerScrub() {
if v.scrubber != nil {
v.scrubber.TriggerNow()
}
}
// ScrubStats returns the current scrub statistics.
func (v *BlockVol) ScrubStats() ScrubStats {
if v.scrubber == nil {
return ScrubStats{}
}
return v.scrubber.Stats()
}
// CreateSnapshot creates a point-in-time snapshot. The snapshot captures the
@@ -945,13 +1047,16 @@ func (v *BlockVol) persistSuperblock() error {
}
// Close shuts down the block volume and closes the file.
// Shutdown order: shipper -> replica receiver -> rebuild server -> drain ops -> group committer -> flusher -> final flush -> close fd.
// Shutdown order: shippers -> replica receiver -> rebuild server -> drain ops -> group committer -> flusher -> final flush -> close fd.
func (v *BlockVol) Close() error {
v.closed.Store(true)
// Stop shipper first: no more Ship calls.
if v.shipper != nil {
v.shipper.Stop()
// Stop scrubber first (no more extent reads).
v.StopScrub()
// Stop shippers: no more Ship calls.
if v.shipperGroup != nil {
v.shipperGroup.StopAll()
}
// Stop replica receiver: no more barrier waits.
if v.replRecv != nil {
+13 -12
View File
@@ -2982,7 +2982,7 @@ func testDistCommitBothPass(t *testing.T) {
shipper := NewWALShipper(dataAddr, ctrlAddr, func() uint64 { return 0 })
defer shipper.Stop()
distSync := MakeDistributedSync(walSync, shipper, v)
distSync := MakeDistributedSync(walSync, NewShipperGroup([]*WALShipper{shipper}), v)
if err := distSync(); err != nil {
t.Fatalf("distSync: %v", err)
}
@@ -3005,7 +3005,7 @@ func testDistCommitLocalFail(t *testing.T) {
shipper := NewWALShipper(dataAddr, ctrlAddr, func() uint64 { return 0 })
defer shipper.Stop()
distSync := MakeDistributedSync(walSync, shipper, v)
distSync := MakeDistributedSync(walSync, NewShipperGroup([]*WALShipper{shipper}), v)
err := distSync()
if err == nil {
t.Fatal("expected error from local sync failure")
@@ -3025,10 +3025,10 @@ func testDistCommitRemoteFailDegrades(t *testing.T) {
shipper := NewWALShipper(dataAddr, ctrlAddr, func() uint64 { return 0 })
defer shipper.Stop()
// Set shipper on vol so degradeReplica can mark it degraded.
v.shipper = shipper
// Set shipper group on vol so degradeReplica can see it.
v.shipperGroup = NewShipperGroup([]*WALShipper{shipper})
distSync := MakeDistributedSync(walSync, shipper, v)
distSync := MakeDistributedSync(walSync, v.shipperGroup, v)
// Should NOT return error (local succeeded, remote degraded).
if err := distSync(); err != nil {
t.Fatalf("distSync should not fail on remote error: %v", err)
@@ -3054,7 +3054,7 @@ func testDistCommitNoReplica(t *testing.T) {
v := createTestVol(t)
defer v.Close()
// nil shipper --local-only mode.
// nil group --local-only mode.
distSync := MakeDistributedSync(walSync, nil, v)
if err := distSync(); err != nil {
t.Fatalf("distSync: %v", err)
@@ -3133,9 +3133,9 @@ func testBlockvolNoReplicaCompat(t *testing.T) {
t.Error("data mismatch in no-replica mode")
}
// Verify shipper is nil.
if v.shipper != nil {
t.Error("shipper should be nil without SetReplicaAddr")
// Verify shipperGroup is nil.
if v.shipperGroup != nil {
t.Error("shipperGroup should be nil without SetReplicaAddr")
}
}
@@ -3541,15 +3541,16 @@ func testDemoteStopsShipper(t *testing.T) {
v := setupPrimary(t)
defer v.Close()
// Create a shipper (won't connect but that's fine for this test).
v.shipper = NewWALShipper("127.0.0.1:0", "127.0.0.1:0", func() uint64 {
// Create a shipper group (won't connect but that's fine for this test).
shipper := NewWALShipper("127.0.0.1:0", "127.0.0.1:0", func() uint64 {
return v.epoch.Load()
})
v.shipperGroup = NewShipperGroup([]*WALShipper{shipper})
if err := v.HandleAssignment(2, RoleStale, 0); err != nil {
t.Fatalf("demote: %v", err)
}
if !v.shipper.stopped.Load() {
if !shipper.stopped.Load() {
t.Error("shipper should be stopped after demotion")
}
}
+199 -18
View File
@@ -3,10 +3,14 @@ package csi
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/container-storage-interface/spec/lib/go/csi"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
const (
@@ -32,7 +36,7 @@ func (s *controllerServer) CreateVolume(_ context.Context, req *csi.CreateVolume
if req.CapacityRange != nil && req.CapacityRange.RequiredBytes > 0 {
sizeBytes = req.CapacityRange.RequiredBytes
} else if req.CapacityRange != nil && req.CapacityRange.LimitBytes > 0 {
// No RequiredBytes set — use LimitBytes as the target size.
// No RequiredBytes set -- use LimitBytes as the target size.
sizeBytes = req.CapacityRange.LimitBytes
}
if req.CapacityRange != nil && req.CapacityRange.LimitBytes > 0 {
@@ -126,27 +130,201 @@ func (s *controllerServer) ControllerUnpublishVolume(_ context.Context, req *csi
return &csi.ControllerUnpublishVolumeResponse{}, nil
}
func (s *controllerServer) ControllerGetCapabilities(_ context.Context, _ *csi.ControllerGetCapabilitiesRequest) (*csi.ControllerGetCapabilitiesResponse, error) {
return &csi.ControllerGetCapabilitiesResponse{
Capabilities: []*csi.ControllerServiceCapability{
{
Type: &csi.ControllerServiceCapability_Rpc{
Rpc: &csi.ControllerServiceCapability_RPC{
Type: csi.ControllerServiceCapability_RPC_CREATE_DELETE_VOLUME,
},
},
},
{
Type: &csi.ControllerServiceCapability_Rpc{
Rpc: &csi.ControllerServiceCapability_RPC{
Type: csi.ControllerServiceCapability_RPC_PUBLISH_UNPUBLISH_VOLUME,
},
},
},
func (s *controllerServer) CreateSnapshot(_ context.Context, req *csi.CreateSnapshotRequest) (*csi.CreateSnapshotResponse, error) {
if req.SourceVolumeId == "" {
return nil, status.Error(codes.InvalidArgument, "source volume ID is required")
}
if req.Name == "" {
return nil, status.Error(codes.InvalidArgument, "snapshot name is required")
}
snapID := snapshotNameToID(req.Name)
info, err := s.backend.CreateSnapshot(context.Background(), req.SourceVolumeId, snapID)
if err != nil {
errMsg := err.Error()
// CSI idempotency: if the snapshot already exists on the same volume,
// return it as success. Only return AlreadyExists for true collisions
// (different source volume using the same FNV hash).
if strings.Contains(errMsg, "snapshot exists") || strings.Contains(errMsg, "already exists") {
snaps, lerr := s.backend.ListSnapshots(context.Background(), req.SourceVolumeId)
if lerr == nil {
for _, snap := range snaps {
if snap.SnapshotID == snapID {
// Same name retried on same volume -- idempotent success.
csiSnapID := FormatSnapshotID(req.SourceVolumeId, snapID)
return &csi.CreateSnapshotResponse{
Snapshot: &csi.Snapshot{
SnapshotId: csiSnapID,
SourceVolumeId: req.SourceVolumeId,
CreationTime: timestamppb.New(time.Unix(snap.CreatedAt, 0)),
ReadyToUse: true,
SizeBytes: int64(snap.SizeBytes),
},
}, nil
}
}
}
// Snapshot exists but not on this volume -- true FNV collision.
return nil, status.Errorf(codes.AlreadyExists,
"snapshot ID collision: FNV hash %d already used by a different snapshot", snapID)
}
if strings.Contains(errMsg, "not found") {
return nil, status.Errorf(codes.NotFound, "volume %q not found", req.SourceVolumeId)
}
return nil, status.Errorf(codes.Internal, "create snapshot: %v", err)
}
csiSnapID := FormatSnapshotID(req.SourceVolumeId, snapID)
return &csi.CreateSnapshotResponse{
Snapshot: &csi.Snapshot{
SnapshotId: csiSnapID,
SourceVolumeId: req.SourceVolumeId,
CreationTime: timestamppb.New(time.Unix(info.CreatedAt, 0)),
ReadyToUse: true,
SizeBytes: int64(info.SizeBytes),
},
}, nil
}
func (s *controllerServer) DeleteSnapshot(_ context.Context, req *csi.DeleteSnapshotRequest) (*csi.DeleteSnapshotResponse, error) {
if req.SnapshotId == "" {
return nil, status.Error(codes.InvalidArgument, "snapshot ID is required")
}
volumeID, snapID, err := ParseSnapshotID(req.SnapshotId)
if err != nil {
// Can't parse → treat as already deleted (idempotent).
return &csi.DeleteSnapshotResponse{}, nil
}
if berr := s.backend.DeleteSnapshot(context.Background(), volumeID, snapID); berr != nil {
// Volume not found → snapshot doesn't exist → success (idempotent).
if strings.Contains(berr.Error(), "not found") {
return &csi.DeleteSnapshotResponse{}, nil
}
return nil, status.Errorf(codes.Internal, "delete snapshot: %v", berr)
}
return &csi.DeleteSnapshotResponse{}, nil
}
func (s *controllerServer) ListSnapshots(_ context.Context, req *csi.ListSnapshotsRequest) (*csi.ListSnapshotsResponse, error) {
// If filtering by snapshot_id, parse it to get the volume.
if req.SnapshotId != "" {
volumeID, snapID, err := ParseSnapshotID(req.SnapshotId)
if err != nil {
return &csi.ListSnapshotsResponse{}, nil
}
snaps, lerr := s.backend.ListSnapshots(context.Background(), volumeID)
if lerr != nil {
if strings.Contains(lerr.Error(), "not found") {
return &csi.ListSnapshotsResponse{}, nil
}
return nil, status.Errorf(codes.Internal, "list snapshots: %v", lerr)
}
for _, snap := range snaps {
if snap.SnapshotID == snapID {
return &csi.ListSnapshotsResponse{
Entries: []*csi.ListSnapshotsResponse_Entry{{
Snapshot: &csi.Snapshot{
SnapshotId: req.SnapshotId,
SourceVolumeId: volumeID,
SizeBytes: int64(snap.SizeBytes),
ReadyToUse: true,
},
}},
}, nil
}
}
return &csi.ListSnapshotsResponse{}, nil
}
// If filtering by source_volume_id, list snapshots for that volume.
if req.SourceVolumeId != "" {
snaps, err := s.backend.ListSnapshots(context.Background(), req.SourceVolumeId)
if err != nil {
if strings.Contains(err.Error(), "not found") {
return &csi.ListSnapshotsResponse{}, nil
}
return nil, status.Errorf(codes.Internal, "list snapshots: %v", err)
}
resp := &csi.ListSnapshotsResponse{}
for _, snap := range snaps {
resp.Entries = append(resp.Entries, &csi.ListSnapshotsResponse_Entry{
Snapshot: &csi.Snapshot{
SnapshotId: FormatSnapshotID(req.SourceVolumeId, snap.SnapshotID),
SourceVolumeId: req.SourceVolumeId,
SizeBytes: int64(snap.SizeBytes),
ReadyToUse: true,
},
})
}
return resp, nil
}
// No filter: not supported for distributed mode (would need to enumerate all volumes).
return &csi.ListSnapshotsResponse{}, nil
}
func (s *controllerServer) ControllerExpandVolume(_ context.Context, req *csi.ControllerExpandVolumeRequest) (*csi.ControllerExpandVolumeResponse, error) {
if req.VolumeId == "" {
return nil, status.Error(codes.InvalidArgument, "volume ID is required")
}
if req.CapacityRange == nil || req.CapacityRange.RequiredBytes <= 0 {
return nil, status.Error(codes.InvalidArgument, "capacity_range.required_bytes must be > 0")
}
newSize := uint64(req.CapacityRange.RequiredBytes)
// Round up to block size.
if newSize%blockSize != 0 {
newSize = (newSize/blockSize + 1) * blockSize
}
capacity, err := s.backend.ExpandVolume(context.Background(), req.VolumeId, newSize)
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "shrink") {
return nil, status.Errorf(codes.InvalidArgument, "cannot shrink volume")
}
if strings.Contains(errMsg, "snapshots") {
return nil, status.Errorf(codes.FailedPrecondition,
"cannot resize with active snapshots: delete snapshots first")
}
if strings.Contains(errMsg, "not found") {
return nil, status.Errorf(codes.NotFound, "volume %q not found", req.VolumeId)
}
return nil, status.Errorf(codes.Internal, "expand volume: %v", err)
}
return &csi.ControllerExpandVolumeResponse{
CapacityBytes: int64(capacity),
NodeExpansionRequired: true,
}, nil
}
func (s *controllerServer) ControllerGetCapabilities(_ context.Context, _ *csi.ControllerGetCapabilitiesRequest) (*csi.ControllerGetCapabilitiesResponse, error) {
caps := []csi.ControllerServiceCapability_RPC_Type{
csi.ControllerServiceCapability_RPC_CREATE_DELETE_VOLUME,
csi.ControllerServiceCapability_RPC_PUBLISH_UNPUBLISH_VOLUME,
csi.ControllerServiceCapability_RPC_CREATE_DELETE_SNAPSHOT,
csi.ControllerServiceCapability_RPC_LIST_SNAPSHOTS,
csi.ControllerServiceCapability_RPC_EXPAND_VOLUME,
}
var result []*csi.ControllerServiceCapability
for _, c := range caps {
result = append(result, &csi.ControllerServiceCapability{
Type: &csi.ControllerServiceCapability_Rpc{
Rpc: &csi.ControllerServiceCapability_RPC{Type: c},
},
})
}
return &csi.ControllerGetCapabilitiesResponse{Capabilities: result}, nil
}
func (s *controllerServer) ValidateVolumeCapabilities(_ context.Context, req *csi.ValidateVolumeCapabilitiesRequest) (*csi.ValidateVolumeCapabilitiesResponse, error) {
if req.VolumeId == "" {
return nil, status.Error(codes.InvalidArgument, "volume ID is required")
@@ -164,3 +342,6 @@ func (s *controllerServer) ValidateVolumeCapabilities(_ context.Context, req *cs
},
}, nil
}
// errSnapshotsPreventResize is used by the mock backend for testing.
var errSnapshotsPreventResize = fmt.Errorf("blockvol: cannot resize with active snapshots")
+216 -15
View File
@@ -220,7 +220,7 @@ func TestControllerUnpublish_Success(t *testing.T) {
}
}
func TestController_Capabilities_IncludesPublish(t *testing.T) {
func TestController_Capabilities(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
@@ -230,24 +230,225 @@ func TestController_Capabilities_IncludesPublish(t *testing.T) {
t.Fatalf("ControllerGetCapabilities: %v", err)
}
hasCreate := false
hasPublish := false
want := map[csi.ControllerServiceCapability_RPC_Type]bool{
csi.ControllerServiceCapability_RPC_CREATE_DELETE_VOLUME: true,
csi.ControllerServiceCapability_RPC_PUBLISH_UNPUBLISH_VOLUME: true,
csi.ControllerServiceCapability_RPC_CREATE_DELETE_SNAPSHOT: true,
csi.ControllerServiceCapability_RPC_LIST_SNAPSHOTS: true,
csi.ControllerServiceCapability_RPC_EXPAND_VOLUME: true,
}
got := make(map[csi.ControllerServiceCapability_RPC_Type]bool)
for _, cap := range resp.Capabilities {
rpc := cap.GetRpc()
if rpc == nil {
continue
}
switch rpc.Type {
case csi.ControllerServiceCapability_RPC_CREATE_DELETE_VOLUME:
hasCreate = true
case csi.ControllerServiceCapability_RPC_PUBLISH_UNPUBLISH_VOLUME:
hasPublish = true
if rpc != nil {
got[rpc.Type] = true
}
}
if !hasCreate {
t.Fatal("expected CREATE_DELETE_VOLUME capability")
for capType := range want {
if !got[capType] {
t.Fatalf("missing capability: %v", capType)
}
}
if !hasPublish {
t.Fatal("expected PUBLISH_UNPUBLISH_VOLUME capability")
}
func TestController_CreateSnapshot(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
// Create volume first.
cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
Name: "snap-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
resp, err := cs.CreateSnapshot(context.Background(), &csi.CreateSnapshotRequest{
Name: "my-snapshot",
SourceVolumeId: "snap-vol",
})
if err != nil {
t.Fatalf("CreateSnapshot: %v", err)
}
if resp.Snapshot.SnapshotId == "" {
t.Fatal("snapshot ID should not be empty")
}
if resp.Snapshot.SourceVolumeId != "snap-vol" {
t.Fatalf("SourceVolumeId: got %q, want snap-vol", resp.Snapshot.SourceVolumeId)
}
if !resp.Snapshot.ReadyToUse {
t.Fatal("snapshot should be ready")
}
}
func TestController_CreateSnapshotIdempotent(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
Name: "snap-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
// First create.
resp1, err := cs.CreateSnapshot(context.Background(), &csi.CreateSnapshotRequest{
Name: "my-snapshot",
SourceVolumeId: "snap-vol",
})
if err != nil {
t.Fatalf("first CreateSnapshot: %v", err)
}
// Same name on same volume -- CSI idempotency: should return existing snapshot.
resp2, err := cs.CreateSnapshot(context.Background(), &csi.CreateSnapshotRequest{
Name: "my-snapshot",
SourceVolumeId: "snap-vol",
})
if err != nil {
t.Fatalf("idempotent CreateSnapshot: %v", err)
}
if resp2.Snapshot.SnapshotId != resp1.Snapshot.SnapshotId {
t.Fatalf("snapshot IDs differ: %q vs %q", resp2.Snapshot.SnapshotId, resp1.Snapshot.SnapshotId)
}
if resp2.Snapshot.SourceVolumeId != "snap-vol" {
t.Fatalf("source volume: got %q", resp2.Snapshot.SourceVolumeId)
}
}
func TestController_DeleteSnapshot(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
Name: "snap-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
resp, _ := cs.CreateSnapshot(context.Background(), &csi.CreateSnapshotRequest{
Name: "del-snap",
SourceVolumeId: "snap-vol",
})
_, err := cs.DeleteSnapshot(context.Background(), &csi.DeleteSnapshotRequest{
SnapshotId: resp.Snapshot.SnapshotId,
})
if err != nil {
t.Fatalf("DeleteSnapshot: %v", err)
}
}
func TestController_DeleteSnapshotNotFound(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
// Delete non-existent snapshot should succeed (idempotent).
_, err := cs.DeleteSnapshot(context.Background(), &csi.DeleteSnapshotRequest{
SnapshotId: "snap-nonexistent-vol-42",
})
if err != nil {
t.Fatalf("DeleteSnapshot non-existent: %v", err)
}
}
func TestController_ListSnapshotsByVolume(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
Name: "list-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
cs.CreateSnapshot(context.Background(), &csi.CreateSnapshotRequest{
Name: "snap-a", SourceVolumeId: "list-vol",
})
cs.CreateSnapshot(context.Background(), &csi.CreateSnapshotRequest{
Name: "snap-b", SourceVolumeId: "list-vol",
})
resp, err := cs.ListSnapshots(context.Background(), &csi.ListSnapshotsRequest{
SourceVolumeId: "list-vol",
})
if err != nil {
t.Fatalf("ListSnapshots: %v", err)
}
if len(resp.Entries) != 2 {
t.Fatalf("expected 2 snapshots, got %d", len(resp.Entries))
}
}
func TestController_ExpandVolume(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
Name: "expand-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
resp, err := cs.ControllerExpandVolume(context.Background(), &csi.ControllerExpandVolumeRequest{
VolumeId: "expand-vol",
CapacityRange: &csi.CapacityRange{RequiredBytes: 8 * 1024 * 1024},
})
if err != nil {
t.Fatalf("ControllerExpandVolume: %v", err)
}
if resp.CapacityBytes != 8*1024*1024 {
t.Fatalf("CapacityBytes: got %d, want %d", resp.CapacityBytes, 8*1024*1024)
}
if !resp.NodeExpansionRequired {
t.Fatal("NodeExpansionRequired should be true")
}
}
func TestController_ExpandNoShrink(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
Name: "noshrink-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 8 * 1024 * 1024},
})
_, err := cs.ControllerExpandVolume(context.Background(), &csi.ControllerExpandVolumeRequest{
VolumeId: "noshrink-vol",
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
if err == nil {
t.Fatal("expected error for shrink")
}
st, _ := status.FromError(err)
if st.Code() != codes.InvalidArgument {
t.Fatalf("expected InvalidArgument, got %v", st.Code())
}
}
func TestController_ExpandMissingVolumeID(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
_, err := cs.ControllerExpandVolume(context.Background(), &csi.ControllerExpandVolumeRequest{
CapacityRange: &csi.CapacityRange{RequiredBytes: 8 * 1024 * 1024},
})
if err == nil {
t.Fatal("expected error for missing volume ID")
}
st, _ := status.FromError(err)
if st.Code() != codes.InvalidArgument {
t.Fatalf("expected InvalidArgument, got %v", st.Code())
}
}
+43
View File
@@ -16,6 +16,7 @@ type ISCSIUtil interface {
Logout(ctx context.Context, iqn string) error
GetDeviceByIQN(ctx context.Context, iqn string) (string, error)
IsLoggedIn(ctx context.Context, iqn string) (bool, error)
RescanDevice(ctx context.Context, iqn string) error
}
// MountUtil provides filesystem mount operations.
@@ -26,6 +27,7 @@ type MountUtil interface {
Unmount(ctx context.Context, target string) error
IsFormatted(ctx context.Context, device string) (bool, error)
IsMounted(ctx context.Context, target string) (bool, error)
ResizeFS(ctx context.Context, devicePath, mountPath, fsType string) error
}
// realISCSIUtil uses iscsiadm CLI.
@@ -115,6 +117,15 @@ func (r *realISCSIUtil) IsLoggedIn(ctx context.Context, iqn string) (bool, error
return strings.Contains(string(out), iqn), nil
}
func (r *realISCSIUtil) RescanDevice(ctx context.Context, iqn string) error {
cmd := exec.CommandContext(ctx, "iscsiadm", "-m", "node", "-T", iqn, "--rescan")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("iscsiadm rescan: %s: %w", string(out), err)
}
return nil
}
// realMountUtil uses mount/umount/mkfs CLI.
type realMountUtil struct{}
@@ -200,11 +211,32 @@ func (r *realMountUtil) IsMounted(ctx context.Context, target string) (bool, err
return false, nil
}
func (r *realMountUtil) ResizeFS(ctx context.Context, devicePath, mountPath, fsType string) error {
switch fsType {
case "ext4", "ext3", "ext2":
cmd := exec.CommandContext(ctx, "resize2fs", devicePath)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("resize2fs: %s: %w", string(out), err)
}
case "xfs":
cmd := exec.CommandContext(ctx, "xfs_growfs", mountPath)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("xfs_growfs: %s: %w", string(out), err)
}
default:
return fmt.Errorf("unsupported filesystem type for resize: %s", fsType)
}
return nil
}
// mockISCSIUtil is a test double for ISCSIUtil.
type mockISCSIUtil struct {
discoveryErr error
loginErr error
logoutErr error
rescanErr error
getDeviceResult string
getDeviceErr error
loggedIn map[string]bool
@@ -247,11 +279,17 @@ func (m *mockISCSIUtil) IsLoggedIn(_ context.Context, iqn string) (bool, error)
return m.loggedIn[iqn], nil
}
func (m *mockISCSIUtil) RescanDevice(_ context.Context, iqn string) error {
m.calls = append(m.calls, "rescan:"+iqn)
return m.rescanErr
}
// mockMountUtil is a test double for MountUtil.
type mockMountUtil struct {
formatAndMountErr error
mountErr error
unmountErr error
resizeFSErr error
isFormattedResult bool
isMountedTargets map[string]bool
calls []string
@@ -304,3 +342,8 @@ func (m *mockMountUtil) IsFormatted(_ context.Context, device string) (bool, err
func (m *mockMountUtil) IsMounted(_ context.Context, target string) (bool, error) {
return m.isMountedTargets[target], nil
}
func (m *mockMountUtil) ResizeFS(_ context.Context, devicePath, mountPath, fsType string) error {
m.calls = append(m.calls, "resizefs:"+devicePath+":"+mountPath+":"+fsType)
return m.resizeFSErr
}
+84 -18
View File
@@ -13,11 +13,13 @@ import (
"google.golang.org/grpc/status"
)
// stagedVolumeInfo tracks info needed for NodeUnstageVolume.
// stagedVolumeInfo tracks info needed for NodeUnstageVolume and NodeExpandVolume.
type stagedVolumeInfo struct {
iqn string
iscsiAddr string
isLocal bool // true if volume is served by local VolumeManager
iqn string
iscsiAddr string
isLocal bool // true if volume is served by local VolumeManager
fsType string // filesystem type (ext4, xfs, etc.)
stagingPath string // staging mount path
}
type nodeServer struct {
@@ -140,9 +142,11 @@ func (s *nodeServer) NodeStageVolume(ctx context.Context, req *csi.NodeStageVolu
s.staged = make(map[string]*stagedVolumeInfo)
}
s.staged[volumeID] = &stagedVolumeInfo{
iqn: iqn,
iscsiAddr: portal,
isLocal: isLocal,
iqn: iqn,
iscsiAddr: portal,
isLocal: isLocal,
fsType: fsType,
stagingPath: stagingPath,
}
s.stagedMu.Unlock()
@@ -293,20 +297,82 @@ func (s *nodeServer) NodeUnpublishVolume(ctx context.Context, req *csi.NodeUnpub
return &csi.NodeUnpublishVolumeResponse{}, nil
}
func (s *nodeServer) NodeGetCapabilities(_ context.Context, _ *csi.NodeGetCapabilitiesRequest) (*csi.NodeGetCapabilitiesResponse, error) {
return &csi.NodeGetCapabilitiesResponse{
Capabilities: []*csi.NodeServiceCapability{
{
Type: &csi.NodeServiceCapability_Rpc{
Rpc: &csi.NodeServiceCapability_RPC{
Type: csi.NodeServiceCapability_RPC_STAGE_UNSTAGE_VOLUME,
},
},
},
},
func (s *nodeServer) NodeExpandVolume(ctx context.Context, req *csi.NodeExpandVolumeRequest) (*csi.NodeExpandVolumeResponse, error) {
if req.VolumeId == "" {
return nil, status.Error(codes.InvalidArgument, "volume ID is required")
}
// Look up staged volume info.
s.stagedMu.Lock()
info, ok := s.staged[req.VolumeId]
s.stagedMu.Unlock()
if !ok {
return nil, status.Errorf(codes.FailedPrecondition, "volume %q not staged", req.VolumeId)
}
// Check that iSCSI session is active.
loggedIn, err := s.iscsiUtil.IsLoggedIn(ctx, info.iqn)
if err != nil {
return nil, status.Errorf(codes.Internal, "check iSCSI session: %v", err)
}
if !loggedIn {
return nil, status.Errorf(codes.FailedPrecondition, "volume %q not staged: iSCSI session not active", req.VolumeId)
}
// Rescan device to pick up new size.
if err := s.iscsiUtil.RescanDevice(ctx, info.iqn); err != nil {
return nil, status.Errorf(codes.Internal, "rescan iSCSI device: %v", err)
}
// Find the device path.
device, err := s.iscsiUtil.GetDeviceByIQN(ctx, info.iqn)
if err != nil {
return nil, status.Errorf(codes.Internal, "find device: %v", err)
}
// Determine mount path and fsType.
mountPath := info.stagingPath
if req.VolumePath != "" {
mountPath = req.VolumePath
}
fsType := info.fsType
if fsType == "" {
fsType = "ext4"
}
// Resize the filesystem.
if err := s.mountUtil.ResizeFS(ctx, device, mountPath, fsType); err != nil {
return nil, status.Errorf(codes.Internal, "resize filesystem: %v", err)
}
s.logger.Printf("NodeExpandVolume: %s expanded (device=%s, fs=%s)", req.VolumeId, device, fsType)
capacity := int64(0)
if req.CapacityRange != nil {
capacity = req.CapacityRange.RequiredBytes
}
return &csi.NodeExpandVolumeResponse{
CapacityBytes: capacity,
}, nil
}
func (s *nodeServer) NodeGetCapabilities(_ context.Context, _ *csi.NodeGetCapabilitiesRequest) (*csi.NodeGetCapabilitiesResponse, error) {
caps := []csi.NodeServiceCapability_RPC_Type{
csi.NodeServiceCapability_RPC_STAGE_UNSTAGE_VOLUME,
csi.NodeServiceCapability_RPC_EXPAND_VOLUME,
}
var result []*csi.NodeServiceCapability
for _, c := range caps {
result = append(result, &csi.NodeServiceCapability{
Type: &csi.NodeServiceCapability_Rpc{
Rpc: &csi.NodeServiceCapability_RPC{Type: c},
},
})
}
return &csi.NodeGetCapabilitiesResponse{Capabilities: result}, nil
}
func (s *nodeServer) NodeGetInfo(_ context.Context, _ *csi.NodeGetInfoRequest) (*csi.NodeGetInfoResponse, error) {
return &csi.NodeGetInfoResponse{
NodeId: s.nodeID,
+234
View File
@@ -5,6 +5,7 @@ import (
"errors"
"log"
"os"
"strings"
"testing"
"github.com/container-storage-interface/spec/lib/go/csi"
@@ -511,6 +512,239 @@ func TestNode_UnstageRetryKeepsStagedEntry(t *testing.T) {
}
}
// TestNode_ExpandVolume verifies happy path for NodeExpandVolume.
func TestNode_ExpandVolume(t *testing.T) {
ns, mi, mm := newTestNodeServer(t)
stagingPath := t.TempDir()
// Stage the volume first.
_, err := ns.NodeStageVolume(context.Background(), &csi.NodeStageVolumeRequest{
VolumeId: "test-vol",
StagingTargetPath: stagingPath,
VolumeCapability: testVolCap(),
})
if err != nil {
t.Fatalf("NodeStageVolume: %v", err)
}
// Reset calls to track expand-specific calls.
mi.calls = nil
mm.calls = nil
_, err = ns.NodeExpandVolume(context.Background(), &csi.NodeExpandVolumeRequest{
VolumeId: "test-vol",
CapacityRange: &csi.CapacityRange{RequiredBytes: 8 * 1024 * 1024},
})
if err != nil {
t.Fatalf("NodeExpandVolume: %v", err)
}
// Verify rescan was called.
foundRescan := false
for _, c := range mi.calls {
if strings.HasPrefix(c, "rescan:") {
foundRescan = true
}
}
if !foundRescan {
t.Fatalf("expected rescan call, got: %v", mi.calls)
}
// Verify resize was called.
foundResize := false
for _, c := range mm.calls {
if strings.HasPrefix(c, "resizefs:") {
foundResize = true
}
}
if !foundResize {
t.Fatalf("expected resizefs call, got: %v", mm.calls)
}
}
// TestNode_ExpandNotStaged verifies error when volume is not staged.
func TestNode_ExpandNotStaged(t *testing.T) {
ns, _, _ := newTestNodeServer(t)
_, err := ns.NodeExpandVolume(context.Background(), &csi.NodeExpandVolumeRequest{
VolumeId: "not-staged",
CapacityRange: &csi.CapacityRange{RequiredBytes: 8 * 1024 * 1024},
})
if err == nil {
t.Fatal("expected error for not-staged volume")
}
st, _ := status.FromError(err)
if st.Code() != codes.FailedPrecondition {
t.Fatalf("expected FailedPrecondition, got %v", st.Code())
}
}
// TestNode_ExpandMissingVolumeID verifies error for missing volume ID.
func TestNode_ExpandMissingVolumeID(t *testing.T) {
ns, _, _ := newTestNodeServer(t)
_, err := ns.NodeExpandVolume(context.Background(), &csi.NodeExpandVolumeRequest{})
if err == nil {
t.Fatal("expected error for missing volume ID")
}
st, _ := status.FromError(err)
if st.Code() != codes.InvalidArgument {
t.Fatalf("expected InvalidArgument, got %v", st.Code())
}
}
// TestNode_ExpandXFSUsesMountPath verifies xfs_growfs uses mount path.
func TestNode_ExpandXFSUsesMountPath(t *testing.T) {
mi := newMockISCSIUtil()
mi.getDeviceResult = "/dev/sdb"
mm := newMockMountUtil()
ns := &nodeServer{
mgr: nil,
nodeID: "test-node-1",
iqnPrefix: "iqn.2024.com.seaweedfs",
iscsiUtil: mi,
mountUtil: mm,
logger: log.New(os.Stderr, "[test-node] ", log.LstdFlags),
staged: map[string]*stagedVolumeInfo{
"xfs-vol": {
iqn: "iqn.2024.com.seaweedfs:xfs-vol",
iscsiAddr: "10.0.0.5:3260",
isLocal: false,
fsType: "xfs",
stagingPath: "/staging/xfs-vol",
},
},
}
// Mark as logged in so the check passes.
mi.loggedIn["iqn.2024.com.seaweedfs:xfs-vol"] = true
_, err := ns.NodeExpandVolume(context.Background(), &csi.NodeExpandVolumeRequest{
VolumeId: "xfs-vol",
CapacityRange: &csi.CapacityRange{RequiredBytes: 16 * 1024 * 1024},
})
if err != nil {
t.Fatalf("NodeExpandVolume: %v", err)
}
// Verify resizefs was called with xfs type.
foundXFS := false
for _, c := range mm.calls {
if strings.Contains(c, "resizefs:") && strings.Contains(c, ":xfs") {
foundXFS = true
// Verify mount path is used (not device path).
if !strings.Contains(c, "/staging/xfs-vol") {
t.Fatalf("xfs_growfs should use mount path, got: %s", c)
}
}
}
if !foundXFS {
t.Fatalf("expected resizefs call with xfs, got: %v", mm.calls)
}
}
// TestNode_ExpandRejectsNoISCSISession verifies error when iSCSI not logged in.
func TestNode_ExpandRejectsNoISCSISession(t *testing.T) {
mi := newMockISCSIUtil()
mm := newMockMountUtil()
ns := &nodeServer{
mgr: nil,
nodeID: "test-node-1",
iqnPrefix: "iqn.2024.com.seaweedfs",
iscsiUtil: mi,
mountUtil: mm,
logger: log.New(os.Stderr, "[test-node] ", log.LstdFlags),
staged: map[string]*stagedVolumeInfo{
"no-session-vol": {
iqn: "iqn.2024.com.seaweedfs:no-session-vol",
iscsiAddr: "10.0.0.5:3260",
isLocal: false,
fsType: "ext4",
stagingPath: "/staging/no-session-vol",
},
},
}
// NOT logged in (loggedIn map is empty).
_, err := ns.NodeExpandVolume(context.Background(), &csi.NodeExpandVolumeRequest{
VolumeId: "no-session-vol",
})
if err == nil {
t.Fatal("expected error when iSCSI session not active")
}
st, _ := status.FromError(err)
if st.Code() != codes.FailedPrecondition {
t.Fatalf("expected FailedPrecondition, got %v", st.Code())
}
}
// TestNode_NodeGetCapabilities verifies EXPAND_VOLUME capability is present.
func TestNode_NodeGetCapabilities(t *testing.T) {
ns, _, _ := newTestNodeServer(t)
resp, err := ns.NodeGetCapabilities(context.Background(), &csi.NodeGetCapabilitiesRequest{})
if err != nil {
t.Fatalf("NodeGetCapabilities: %v", err)
}
hasStage := false
hasExpand := false
for _, cap := range resp.Capabilities {
rpc := cap.GetRpc()
if rpc == nil {
continue
}
switch rpc.Type {
case csi.NodeServiceCapability_RPC_STAGE_UNSTAGE_VOLUME:
hasStage = true
case csi.NodeServiceCapability_RPC_EXPAND_VOLUME:
hasExpand = true
}
}
if !hasStage {
t.Fatal("expected STAGE_UNSTAGE_VOLUME capability")
}
if !hasExpand {
t.Fatal("expected EXPAND_VOLUME capability")
}
}
// TestNode_StageCapturesFSType verifies that fsType is captured in staged info.
func TestNode_StageCapturesFSType(t *testing.T) {
ns, _, _ := newTestNodeServer(t)
stagingPath := t.TempDir()
_, err := ns.NodeStageVolume(context.Background(), &csi.NodeStageVolumeRequest{
VolumeId: "test-vol",
StagingTargetPath: stagingPath,
VolumeCapability: &csi.VolumeCapability{
AccessType: &csi.VolumeCapability_Mount{
Mount: &csi.VolumeCapability_MountVolume{FsType: "xfs"},
},
},
})
if err != nil {
t.Fatalf("NodeStageVolume: %v", err)
}
ns.stagedMu.Lock()
info := ns.staged["test-vol"]
ns.stagedMu.Unlock()
if info == nil {
t.Fatal("expected test-vol in staged map")
}
if info.fsType != "xfs" {
t.Fatalf("fsType: got %q, want xfs", info.fsType)
}
if info.stagingPath != stagingPath {
t.Fatalf("stagingPath: got %q, want %q", info.stagingPath, stagingPath)
}
}
// TestNode_StageFallbackLocal verifies local fallback when no volume_context is provided.
func TestNode_StageFallbackLocal(t *testing.T) {
ns, mi, _ := newTestNodeServer(t)
@@ -0,0 +1,690 @@
package csi
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"github.com/container-storage-interface/spec/lib/go/csi"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// ============================================================================
// QA Adversarial Tests for CP8-3: CSI Snapshots + Expansion
// Covers concurrency, edge cases, error injection, and lifecycle races.
// ============================================================================
// --- A. Concurrent Snapshot Operations ---
// TestQA_CP83_ConcurrentCreateSameSnapshot: 10 goroutines create the same
// snapshot name on the same volume. Exactly one should create; the rest
// should get idempotent success (same snapID on same volume).
func TestQA_CP83_ConcurrentCreateSameSnapshot(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
Name: "conc-snap-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
const N = 10
var wg sync.WaitGroup
errs := make([]error, N)
resps := make([]*csi.CreateSnapshotResponse, N)
wg.Add(N)
for i := 0; i < N; i++ {
go func(idx int) {
defer wg.Done()
resp, err := cs.CreateSnapshot(context.Background(), &csi.CreateSnapshotRequest{
Name: "same-snap-name",
SourceVolumeId: "conc-snap-vol",
})
errs[idx] = err
resps[idx] = resp
}(i)
}
wg.Wait()
// All should succeed (one creates, rest get idempotent).
var snapID string
for i := 0; i < N; i++ {
if errs[i] != nil {
t.Errorf("goroutine %d: unexpected error: %v", i, errs[i])
continue
}
if snapID == "" {
snapID = resps[i].Snapshot.SnapshotId
} else if resps[i].Snapshot.SnapshotId != snapID {
t.Errorf("goroutine %d: snapshot ID mismatch: %s vs %s", i, resps[i].Snapshot.SnapshotId, snapID)
}
}
}
// TestQA_CP83_ConcurrentCreateDifferentSnapshots: 5 goroutines each create
// a uniquely named snapshot on the same volume. All should succeed.
func TestQA_CP83_ConcurrentCreateDifferentSnapshots(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
Name: "multi-snap-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
const N = 5
var wg sync.WaitGroup
errs := make([]error, N)
wg.Add(N)
for i := 0; i < N; i++ {
go func(idx int) {
defer wg.Done()
_, errs[idx] = cs.CreateSnapshot(context.Background(), &csi.CreateSnapshotRequest{
Name: fmt.Sprintf("snap-%d", idx),
SourceVolumeId: "multi-snap-vol",
})
}(i)
}
wg.Wait()
for i := 0; i < N; i++ {
if errs[i] != nil {
t.Errorf("goroutine %d: unexpected error: %v", i, errs[i])
}
}
// Verify all snapshots exist.
listResp, err := cs.ListSnapshots(context.Background(), &csi.ListSnapshotsRequest{
SourceVolumeId: "multi-snap-vol",
})
if err != nil {
t.Fatal(err)
}
if len(listResp.Entries) != N {
t.Fatalf("expected %d snapshots, got %d", N, len(listResp.Entries))
}
}
// TestQA_CP83_ConcurrentSnapshotAndExpand: one goroutine creates a snapshot,
// another tries to expand. The expand should fail (FailedPrecondition) if
// the snapshot was created first, or the snapshot should succeed.
func TestQA_CP83_ConcurrentSnapshotAndExpand(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
Name: "race-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
var wg sync.WaitGroup
var snapErr, expandErr error
wg.Add(2)
go func() {
defer wg.Done()
_, snapErr = cs.CreateSnapshot(context.Background(), &csi.CreateSnapshotRequest{
Name: "race-snap",
SourceVolumeId: "race-vol",
})
}()
go func() {
defer wg.Done()
_, expandErr = cs.ControllerExpandVolume(context.Background(), &csi.ControllerExpandVolumeRequest{
VolumeId: "race-vol",
CapacityRange: &csi.CapacityRange{RequiredBytes: 8 * 1024 * 1024},
})
}()
wg.Wait()
// One of two outcomes:
// 1. Snapshot wins → expand gets FailedPrecondition
// 2. Expand wins → snapshot still succeeds (taken after expand)
if snapErr != nil {
t.Fatalf("snapshot should always succeed, got: %v", snapErr)
}
if expandErr != nil {
st, _ := status.FromError(expandErr)
if st.Code() != codes.FailedPrecondition {
t.Fatalf("expand error should be FailedPrecondition, got %v: %s", st.Code(), st.Message())
}
}
// No panic = pass.
}
// --- B. Expand Edge Cases ---
// TestQA_CP83_ExpandToSameSize: expand to current size should succeed (no-op).
func TestQA_CP83_ExpandToSameSize(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
Name: "same-size-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
resp, err := cs.ControllerExpandVolume(context.Background(), &csi.ControllerExpandVolumeRequest{
VolumeId: "same-size-vol",
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
// Engine may return success (no-op) or error (can't shrink to same).
// Either way, no panic.
if err != nil {
st, _ := status.FromError(err)
// InvalidArgument (shrink guard) is acceptable for same-size.
if st.Code() != codes.InvalidArgument {
t.Fatalf("unexpected error: %v", err)
}
} else if resp.CapacityBytes != 4*1024*1024 {
t.Fatalf("expected 4MB, got %d", resp.CapacityBytes)
}
}
// TestQA_CP83_ExpandToSmallerSize: shrink should be rejected.
func TestQA_CP83_ExpandToSmallerSize(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
Name: "shrink-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 8 * 1024 * 1024},
})
_, err := cs.ControllerExpandVolume(context.Background(), &csi.ControllerExpandVolumeRequest{
VolumeId: "shrink-vol",
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
if err == nil {
t.Fatal("expected error for shrink")
}
st, _ := status.FromError(err)
if st.Code() != codes.InvalidArgument {
t.Fatalf("expected InvalidArgument for shrink, got %v", st.Code())
}
}
// TestQA_CP83_ExpandWithZeroBytes: expand with 0 bytes should be rejected.
func TestQA_CP83_ExpandWithZeroBytes(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
Name: "zero-expand-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
_, err := cs.ControllerExpandVolume(context.Background(), &csi.ControllerExpandVolumeRequest{
VolumeId: "zero-expand-vol",
CapacityRange: &csi.CapacityRange{RequiredBytes: 0},
})
if err == nil {
t.Fatal("expected error for zero expand")
}
st, _ := status.FromError(err)
if st.Code() != codes.InvalidArgument {
t.Fatalf("expected InvalidArgument, got %v", st.Code())
}
}
// TestQA_CP83_ExpandNilCapacity: expand with nil CapacityRange should be rejected.
func TestQA_CP83_ExpandNilCapacity(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
_, err := cs.ControllerExpandVolume(context.Background(), &csi.ControllerExpandVolumeRequest{
VolumeId: "nil-cap-vol",
CapacityRange: nil,
})
if err == nil {
t.Fatal("expected error for nil capacity range")
}
st, _ := status.FromError(err)
if st.Code() != codes.InvalidArgument {
t.Fatalf("expected InvalidArgument, got %v", st.Code())
}
}
// TestQA_CP83_ExpandNonAligned: expand to a non-block-aligned size should
// round up and succeed.
func TestQA_CP83_ExpandNonAligned(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
Name: "align-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
// Request non-aligned size (5MB + 1 byte).
requested := int64(5*1024*1024 + 1)
resp, err := cs.ControllerExpandVolume(context.Background(), &csi.ControllerExpandVolumeRequest{
VolumeId: "align-vol",
CapacityRange: &csi.CapacityRange{RequiredBytes: requested},
})
if err != nil {
t.Fatalf("non-aligned expand should succeed: %v", err)
}
// Result should be rounded up to next 4096 boundary.
expected := int64((5*1024*1024+1)/4096+1) * 4096
if resp.CapacityBytes != expected {
t.Fatalf("expected rounded %d, got %d", expected, resp.CapacityBytes)
}
}
// TestQA_CP83_ExpandMissingVolume: expand on nonexistent volume.
func TestQA_CP83_ExpandMissingVolume(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
_, err := cs.ControllerExpandVolume(context.Background(), &csi.ControllerExpandVolumeRequest{
VolumeId: "no-such-vol",
CapacityRange: &csi.CapacityRange{RequiredBytes: 8 * 1024 * 1024},
})
if err == nil {
t.Fatal("expected error for missing volume")
}
st, _ := status.FromError(err)
if st.Code() != codes.NotFound {
t.Fatalf("expected NotFound, got %v: %s", st.Code(), st.Message())
}
}
// --- C. Snapshot Lifecycle ---
// TestQA_CP83_DoubleSnapshot: create two snapshots, delete first, verify
// second still exists, delete second, verify empty.
func TestQA_CP83_DoubleSnapshot(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
ctx := context.Background()
cs.CreateVolume(ctx, &csi.CreateVolumeRequest{
Name: "double-snap-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
snap1, err := cs.CreateSnapshot(ctx, &csi.CreateSnapshotRequest{
Name: "snap-alpha", SourceVolumeId: "double-snap-vol",
})
if err != nil {
t.Fatal(err)
}
snap2, err := cs.CreateSnapshot(ctx, &csi.CreateSnapshotRequest{
Name: "snap-beta", SourceVolumeId: "double-snap-vol",
})
if err != nil {
t.Fatal(err)
}
// Delete first.
cs.DeleteSnapshot(ctx, &csi.DeleteSnapshotRequest{SnapshotId: snap1.Snapshot.SnapshotId})
// List should show 1.
list, _ := cs.ListSnapshots(ctx, &csi.ListSnapshotsRequest{SourceVolumeId: "double-snap-vol"})
if len(list.Entries) != 1 {
t.Fatalf("expected 1 snapshot, got %d", len(list.Entries))
}
if list.Entries[0].Snapshot.SnapshotId != snap2.Snapshot.SnapshotId {
t.Fatalf("wrong snapshot survived")
}
// Delete second.
cs.DeleteSnapshot(ctx, &csi.DeleteSnapshotRequest{SnapshotId: snap2.Snapshot.SnapshotId})
// List should be empty.
list, _ = cs.ListSnapshots(ctx, &csi.ListSnapshotsRequest{SourceVolumeId: "double-snap-vol"})
if len(list.Entries) != 0 {
t.Fatalf("expected 0 snapshots, got %d", len(list.Entries))
}
}
// TestQA_CP83_SnapshotOnDeletedVolume: delete volume, then try snapshot.
func TestQA_CP83_SnapshotOnDeletedVolume(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
ctx := context.Background()
cs.CreateVolume(ctx, &csi.CreateVolumeRequest{
Name: "doomed-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
cs.DeleteVolume(ctx, &csi.DeleteVolumeRequest{VolumeId: "doomed-vol"})
_, err := cs.CreateSnapshot(ctx, &csi.CreateSnapshotRequest{
Name: "orphan", SourceVolumeId: "doomed-vol",
})
if err == nil {
t.Fatal("expected error for snapshot on deleted volume")
}
st, _ := status.FromError(err)
if st.Code() != codes.NotFound {
t.Fatalf("expected NotFound, got %v", st.Code())
}
}
// TestQA_CP83_DeleteVolumeWithActiveSnapshots: volume with snapshots can be
// deleted (engine cleans up snap files).
func TestQA_CP83_DeleteVolumeWithActiveSnapshots(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
ctx := context.Background()
cs.CreateVolume(ctx, &csi.CreateVolumeRequest{
Name: "snap-then-delete-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
cs.CreateSnapshot(ctx, &csi.CreateSnapshotRequest{
Name: "pre-delete-snap", SourceVolumeId: "snap-then-delete-vol",
})
_, err := cs.DeleteVolume(ctx, &csi.DeleteVolumeRequest{VolumeId: "snap-then-delete-vol"})
if err != nil {
t.Fatalf("delete volume with snapshots should succeed: %v", err)
}
// Verify volume is gone.
_, err = cs.CreateSnapshot(ctx, &csi.CreateSnapshotRequest{
Name: "after-delete-snap", SourceVolumeId: "snap-then-delete-vol",
})
if err == nil {
t.Fatal("expected error for snapshot on deleted volume")
}
}
// TestQA_CP83_FullCycleSnapExpandSnap: create → snap → delete snap → expand →
// snap at new size → verify snapshot reports new size.
func TestQA_CP83_FullCycleSnapExpandSnap(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
ctx := context.Background()
cs.CreateVolume(ctx, &csi.CreateVolumeRequest{
Name: "cycle-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
// Snapshot at 4MB.
snap1, _ := cs.CreateSnapshot(ctx, &csi.CreateSnapshotRequest{
Name: "pre-expand-snap", SourceVolumeId: "cycle-vol",
})
if snap1.Snapshot.SizeBytes != 4*1024*1024 {
t.Fatalf("snap1 size: got %d, want %d", snap1.Snapshot.SizeBytes, 4*1024*1024)
}
// Delete snap so we can expand.
cs.DeleteSnapshot(ctx, &csi.DeleteSnapshotRequest{SnapshotId: snap1.Snapshot.SnapshotId})
// Expand to 8MB.
_, err := cs.ControllerExpandVolume(ctx, &csi.ControllerExpandVolumeRequest{
VolumeId: "cycle-vol",
CapacityRange: &csi.CapacityRange{RequiredBytes: 8 * 1024 * 1024},
})
if err != nil {
t.Fatalf("expand: %v", err)
}
// Snapshot at 8MB.
snap2, _ := cs.CreateSnapshot(ctx, &csi.CreateSnapshotRequest{
Name: "post-expand-snap", SourceVolumeId: "cycle-vol",
})
if snap2.Snapshot.SizeBytes != 8*1024*1024 {
t.Fatalf("snap2 size: got %d, want %d", snap2.Snapshot.SizeBytes, 8*1024*1024)
}
}
// --- D. Error Injection ---
// TestQA_CP83_ExpandBackendFailure: backend returns error on expand.
func TestQA_CP83_ExpandBackendFailure(t *testing.T) {
mock := &expandFailBackend{expandErr: fmt.Errorf("disk full")}
cs := &controllerServer{backend: mock}
_, err := cs.ControllerExpandVolume(context.Background(), &csi.ControllerExpandVolumeRequest{
VolumeId: "fail-vol",
CapacityRange: &csi.CapacityRange{RequiredBytes: 8 * 1024 * 1024},
})
if err == nil {
t.Fatal("expected error from backend")
}
st, _ := status.FromError(err)
if st.Code() != codes.Internal {
t.Fatalf("expected Internal, got %v", st.Code())
}
}
// TestQA_CP83_SnapshotBackendFailure: backend returns error on create snapshot.
func TestQA_CP83_SnapshotBackendFailure(t *testing.T) {
mock := &expandFailBackend{snapErr: fmt.Errorf("I/O error")}
cs := &controllerServer{backend: mock}
_, err := cs.CreateSnapshot(context.Background(), &csi.CreateSnapshotRequest{
Name: "fail-snap",
SourceVolumeId: "fail-vol",
})
if err == nil {
t.Fatal("expected error from backend")
}
st, _ := status.FromError(err)
if st.Code() != codes.Internal {
t.Fatalf("expected Internal, got %v", st.Code())
}
}
// --- E. ListSnapshots Edge Cases ---
// TestQA_CP83_ListSnapshotsBySnapshotID: filter by snapshot ID returns exactly 1.
func TestQA_CP83_ListSnapshotsBySnapshotID(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
ctx := context.Background()
cs.CreateVolume(ctx, &csi.CreateVolumeRequest{
Name: "list-by-id-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
snapResp, _ := cs.CreateSnapshot(ctx, &csi.CreateSnapshotRequest{
Name: "list-snap", SourceVolumeId: "list-by-id-vol",
})
list, err := cs.ListSnapshots(ctx, &csi.ListSnapshotsRequest{
SnapshotId: snapResp.Snapshot.SnapshotId,
})
if err != nil {
t.Fatal(err)
}
if len(list.Entries) != 1 {
t.Fatalf("expected 1, got %d", len(list.Entries))
}
if list.Entries[0].Snapshot.SnapshotId != snapResp.Snapshot.SnapshotId {
t.Fatalf("wrong snapshot")
}
}
// TestQA_CP83_ListSnapshotsBySnapshotID_NotFound: filter by non-existent
// snapshot ID returns empty.
func TestQA_CP83_ListSnapshotsBySnapshotID_NotFound(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
ctx := context.Background()
cs.CreateVolume(ctx, &csi.CreateVolumeRequest{
Name: "list-miss-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
list, err := cs.ListSnapshots(ctx, &csi.ListSnapshotsRequest{
SnapshotId: FormatSnapshotID("list-miss-vol", 99999),
})
if err != nil {
t.Fatal(err)
}
if len(list.Entries) != 0 {
t.Fatalf("expected 0, got %d", len(list.Entries))
}
}
// TestQA_CP83_ListSnapshotsNoFilter: no filter returns empty (documented behavior).
func TestQA_CP83_ListSnapshotsNoFilter(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
ctx := context.Background()
cs.CreateVolume(ctx, &csi.CreateVolumeRequest{
Name: "no-filter-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
cs.CreateSnapshot(ctx, &csi.CreateSnapshotRequest{
Name: "no-filter-snap", SourceVolumeId: "no-filter-vol",
})
list, err := cs.ListSnapshots(ctx, &csi.ListSnapshotsRequest{})
if err != nil {
t.Fatal(err)
}
if len(list.Entries) != 0 {
t.Fatalf("expected 0 (no filter not supported), got %d", len(list.Entries))
}
}
// --- F. Concurrent Expand ---
// TestQA_CP83_ConcurrentExpand: 5 goroutines expand the same volume to
// increasing sizes. No panics, final size should be the largest requested.
func TestQA_CP83_ConcurrentExpand(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
Name: "conc-expand-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
const N = 5
var wg sync.WaitGroup
var maxCapacity atomic.Int64
wg.Add(N)
for i := 0; i < N; i++ {
go func(idx int) {
defer wg.Done()
newSize := int64((idx + 2) * 4 * 1024 * 1024) // 8MB, 12MB, 16MB, 20MB, 24MB
resp, err := cs.ControllerExpandVolume(context.Background(), &csi.ControllerExpandVolumeRequest{
VolumeId: "conc-expand-vol",
CapacityRange: &csi.CapacityRange{RequiredBytes: newSize},
})
if err != nil {
// Some may fail with "shrink" if a larger expand completed first.
return
}
for {
old := maxCapacity.Load()
if resp.CapacityBytes <= old {
break
}
if maxCapacity.CompareAndSwap(old, resp.CapacityBytes) {
break
}
}
}(i)
}
wg.Wait()
// Final capacity should be at least 24MB.
if maxCapacity.Load() < 24*1024*1024 {
t.Fatalf("expected max capacity >= 24MB, got %d", maxCapacity.Load())
}
}
// --- G. Snapshot ID Helpers ---
// TestQA_CP83_FormatParseRoundTrip: verify format/parse round-trip for various names.
func TestQA_CP83_FormatParseRoundTrip(t *testing.T) {
cases := []struct {
vol string
snapID uint32
}{
{"simple", 42},
{"pvc-12345", 99},
{"vol-with-many-dashes", 1},
{"vol", 0}, // ID 0
{"vol", 0xFFFFFFFF}, // max uint32
}
for _, tc := range cases {
formatted := FormatSnapshotID(tc.vol, tc.snapID)
gotVol, gotSnap, err := ParseSnapshotID(formatted)
if err != nil {
t.Errorf("parse(%q): %v", formatted, err)
continue
}
if gotVol != tc.vol || gotSnap != tc.snapID {
t.Errorf("round-trip failed: vol=%q snap=%d → %q → vol=%q snap=%d",
tc.vol, tc.snapID, formatted, gotVol, gotSnap)
}
}
}
// --- Mock backends for error injection ---
type expandFailBackend struct {
expandErr error
snapErr error
}
func (m *expandFailBackend) CreateVolume(_ context.Context, name string, sizeBytes uint64) (*VolumeInfo, error) {
return &VolumeInfo{VolumeID: name, CapacityBytes: sizeBytes}, nil
}
func (m *expandFailBackend) DeleteVolume(_ context.Context, _ string) error { return nil }
func (m *expandFailBackend) LookupVolume(_ context.Context, name string) (*VolumeInfo, error) {
return &VolumeInfo{VolumeID: name}, nil
}
func (m *expandFailBackend) CreateSnapshot(_ context.Context, volumeID string, snapID uint32) (*SnapshotInfo, error) {
if m.snapErr != nil {
return nil, m.snapErr
}
return &SnapshotInfo{SnapshotID: snapID, VolumeID: volumeID}, nil
}
func (m *expandFailBackend) DeleteSnapshot(_ context.Context, _ string, _ uint32) error { return nil }
func (m *expandFailBackend) ListSnapshots(_ context.Context, _ string) ([]*SnapshotInfo, error) {
return nil, nil
}
func (m *expandFailBackend) ExpandVolume(_ context.Context, _ string, _ uint64) (uint64, error) {
return 0, m.expandErr
}
+446
View File
@@ -0,0 +1,446 @@
package csi
import (
"context"
"errors"
"fmt"
"log"
"os"
"strings"
"testing"
"github.com/container-storage-interface/spec/lib/go/csi"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// TestQA_Snap_CreateOnNonExistentVolume verifies NotFound when creating snapshot
// on a volume that doesn't exist.
func TestQA_Snap_CreateOnNonExistentVolume(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
_, err := cs.CreateSnapshot(context.Background(), &csi.CreateSnapshotRequest{
Name: "orphan-snap",
SourceVolumeId: "nonexistent-volume",
})
if err == nil {
t.Fatal("expected error for snapshot on nonexistent volume")
}
st, _ := status.FromError(err)
if st.Code() != codes.NotFound {
t.Fatalf("expected NotFound, got %v: %s", st.Code(), st.Message())
}
}
// TestQA_Snap_DeleteNonExistentSnapshot verifies idempotent success when deleting
// a snapshot that doesn't exist (CSI spec: delete should succeed).
func TestQA_Snap_DeleteNonExistentSnapshot(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
// Create volume but no snapshot.
cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
Name: "del-snap-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
// Delete a well-formed snapshot ID for an existing volume, but snapshot doesn't exist.
snapID := FormatSnapshotID("del-snap-vol", 99999)
_, err := cs.DeleteSnapshot(context.Background(), &csi.DeleteSnapshotRequest{
SnapshotId: snapID,
})
if err != nil {
t.Fatalf("delete nonexistent snapshot should succeed (idempotent), got: %v", err)
}
}
// TestQA_Snap_SnapshotIDParsing exercises ParseSnapshotID with weird, empty,
// and unicode strings. Delete should treat unparseable IDs as idempotent success.
func TestQA_Snap_SnapshotIDParsing(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
cases := []struct {
name string
id string
}{
{"empty", ""},
{"no prefix", "garbage-123"},
{"only prefix", "snap-"},
{"no numeric suffix", "snap-vol-abc"},
{"unicode volume", "snap-日本語ボリューム-42"},
{"missing separator", "snap-noseparator"},
{"double dash", "snap--42"},
{"huge number", "snap-vol-99999999999999999999"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.id == "" {
// Empty ID → InvalidArgument from DeleteSnapshot.
_, err := cs.DeleteSnapshot(context.Background(), &csi.DeleteSnapshotRequest{
SnapshotId: tc.id,
})
if err == nil {
t.Fatal("expected error for empty snapshot ID")
}
st, _ := status.FromError(err)
if st.Code() != codes.InvalidArgument {
t.Fatalf("expected InvalidArgument for empty ID, got %v", st.Code())
}
return
}
// Non-empty but unparseable → idempotent success.
_, err := cs.DeleteSnapshot(context.Background(), &csi.DeleteSnapshotRequest{
SnapshotId: tc.id,
})
if err != nil {
t.Fatalf("delete unparseable snapshot ID should succeed (idempotent), got: %v", err)
}
})
}
}
// TestQA_Snap_ExpandWithActiveSnapshots verifies FailedPrecondition when expanding
// a volume that has active snapshots.
func TestQA_Snap_ExpandWithActiveSnapshots(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
// Create volume.
cs.CreateVolume(context.Background(), &csi.CreateVolumeRequest{
Name: "snap-expand-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
// Create snapshot.
_, err := cs.CreateSnapshot(context.Background(), &csi.CreateSnapshotRequest{
Name: "blocking-snap",
SourceVolumeId: "snap-expand-vol",
})
if err != nil {
t.Fatalf("CreateSnapshot: %v", err)
}
// Now try to expand --engine should reject due to active snapshots.
_, err = cs.ControllerExpandVolume(context.Background(), &csi.ControllerExpandVolumeRequest{
VolumeId: "snap-expand-vol",
CapacityRange: &csi.CapacityRange{RequiredBytes: 8 * 1024 * 1024},
})
if err == nil {
t.Fatal("expected error for expand with active snapshots")
}
st, _ := status.FromError(err)
if st.Code() != codes.FailedPrecondition {
t.Fatalf("expected FailedPrecondition, got %v: %s", st.Code(), st.Message())
}
if !strings.Contains(st.Message(), "snapshots") {
t.Fatalf("error message should mention snapshots: %s", st.Message())
}
}
// TestQA_Snap_SnapshotCollisionHandled tests that two different names hashing to
// the same FNV-32a ID are handled with AlreadyExists.
func TestQA_Snap_SnapshotCollisionHandled(t *testing.T) {
// Use a mock backend that always returns "already exists" after the first create,
// simulating what happens when two names produce the same FNV-32a hash.
mock := &collisionMockBackend{
createCount: 0,
}
cs := &controllerServer{backend: mock}
// First snapshot succeeds.
_, err := cs.CreateSnapshot(context.Background(), &csi.CreateSnapshotRequest{
Name: "first-name",
SourceVolumeId: "collision-vol",
})
if err != nil {
t.Fatalf("first CreateSnapshot: %v", err)
}
// Second snapshot: mock simulates FNV collision by returning "already exists".
_, err = cs.CreateSnapshot(context.Background(), &csi.CreateSnapshotRequest{
Name: "second-name",
SourceVolumeId: "collision-vol",
})
if err == nil {
t.Fatal("expected error for collision")
}
st, _ := status.FromError(err)
if st.Code() != codes.AlreadyExists {
t.Fatalf("expected AlreadyExists, got %v: %s", st.Code(), st.Message())
}
if !strings.Contains(st.Message(), "collision") {
t.Fatalf("error message should mention collision: %s", st.Message())
}
}
// TestQA_Expand_RescanFailure verifies that NodeExpandVolume handles rescan failure
// by returning Internal error.
func TestQA_Expand_RescanFailure(t *testing.T) {
mi := newMockISCSIUtil()
mi.getDeviceResult = "/dev/sdb"
mi.rescanErr = errors.New("iscsiadm rescan: device not found")
mm := newMockMountUtil()
ns := &nodeServer{
mgr: nil,
nodeID: "test-node-1",
iqnPrefix: "iqn.2024.com.seaweedfs",
iscsiUtil: mi,
mountUtil: mm,
logger: log.New(os.Stderr, "[test-node] ", log.LstdFlags),
staged: map[string]*stagedVolumeInfo{
"rescan-fail-vol": {
iqn: "iqn.2024.com.seaweedfs:rescan-fail-vol",
iscsiAddr: "10.0.0.5:3260",
isLocal: false,
fsType: "ext4",
stagingPath: "/staging/rescan-fail-vol",
},
},
}
// Mark as logged in so session check passes.
mi.loggedIn["iqn.2024.com.seaweedfs:rescan-fail-vol"] = true
_, err := ns.NodeExpandVolume(context.Background(), &csi.NodeExpandVolumeRequest{
VolumeId: "rescan-fail-vol",
CapacityRange: &csi.CapacityRange{RequiredBytes: 16 * 1024 * 1024},
})
if err == nil {
t.Fatal("expected error from rescan failure")
}
st, _ := status.FromError(err)
if st.Code() != codes.Internal {
t.Fatalf("expected Internal, got %v: %s", st.Code(), st.Message())
}
}
// TestQA_FullLifecycleWithSnapshotExpand tests the full lifecycle:
// create volume → snapshot → delete snapshot → expand → verify.
func TestQA_FullLifecycleWithSnapshotExpand(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
ctx := context.Background()
// 1. Create volume.
volResp, err := cs.CreateVolume(ctx, &csi.CreateVolumeRequest{
Name: "lifecycle-vol",
VolumeCapabilities: testVolCaps(),
CapacityRange: &csi.CapacityRange{RequiredBytes: 4 * 1024 * 1024},
})
if err != nil {
t.Fatalf("CreateVolume: %v", err)
}
if volResp.Volume.CapacityBytes != 4*1024*1024 {
t.Fatalf("initial capacity: got %d", volResp.Volume.CapacityBytes)
}
// 2. Create snapshot.
snapResp, err := cs.CreateSnapshot(ctx, &csi.CreateSnapshotRequest{
Name: "lifecycle-snap",
SourceVolumeId: "lifecycle-vol",
})
if err != nil {
t.Fatalf("CreateSnapshot: %v", err)
}
if !snapResp.Snapshot.ReadyToUse {
t.Fatal("snapshot should be ready")
}
snapID := snapResp.Snapshot.SnapshotId
// 3. List snapshots --should find it.
listResp, err := cs.ListSnapshots(ctx, &csi.ListSnapshotsRequest{
SourceVolumeId: "lifecycle-vol",
})
if err != nil {
t.Fatalf("ListSnapshots: %v", err)
}
if len(listResp.Entries) != 1 {
t.Fatalf("expected 1 snapshot, got %d", len(listResp.Entries))
}
// 4. Expand should fail with active snapshot.
_, err = cs.ControllerExpandVolume(ctx, &csi.ControllerExpandVolumeRequest{
VolumeId: "lifecycle-vol",
CapacityRange: &csi.CapacityRange{RequiredBytes: 8 * 1024 * 1024},
})
if err == nil {
t.Fatal("expected expand to fail with active snapshot")
}
st, _ := status.FromError(err)
if st.Code() != codes.FailedPrecondition {
t.Fatalf("expected FailedPrecondition, got %v", st.Code())
}
// 5. Delete snapshot.
_, err = cs.DeleteSnapshot(ctx, &csi.DeleteSnapshotRequest{
SnapshotId: snapID,
})
if err != nil {
t.Fatalf("DeleteSnapshot: %v", err)
}
// 6. Now expand should succeed.
expandResp, err := cs.ControllerExpandVolume(ctx, &csi.ControllerExpandVolumeRequest{
VolumeId: "lifecycle-vol",
CapacityRange: &csi.CapacityRange{RequiredBytes: 8 * 1024 * 1024},
})
if err != nil {
t.Fatalf("ControllerExpandVolume: %v", err)
}
if expandResp.CapacityBytes != 8*1024*1024 {
t.Fatalf("expanded capacity: got %d, want %d", expandResp.CapacityBytes, 8*1024*1024)
}
if !expandResp.NodeExpansionRequired {
t.Fatal("NodeExpansionRequired should be true")
}
// 7. List snapshots --should be empty now.
listResp, err = cs.ListSnapshots(ctx, &csi.ListSnapshotsRequest{
SourceVolumeId: "lifecycle-vol",
})
if err != nil {
t.Fatalf("ListSnapshots after delete: %v", err)
}
if len(listResp.Entries) != 0 {
t.Fatalf("expected 0 snapshots after delete, got %d", len(listResp.Entries))
}
}
// TestQA_Snap_ListSnapshotsBackendError verifies that ListSnapshots propagates
// backend errors as Internal instead of silently returning empty list.
func TestQA_Snap_ListSnapshotsBackendError(t *testing.T) {
mock := &errorBackend{
listErr: fmt.Errorf("connection refused"),
}
cs := &controllerServer{backend: mock}
// By snapshot_id.
_, err := cs.ListSnapshots(context.Background(), &csi.ListSnapshotsRequest{
SnapshotId: FormatSnapshotID("vol1", 42),
})
if err == nil {
t.Fatal("expected error from backend failure")
}
st, _ := status.FromError(err)
if st.Code() != codes.Internal {
t.Fatalf("expected Internal, got %v: %s", st.Code(), st.Message())
}
// By source_volume_id.
_, err = cs.ListSnapshots(context.Background(), &csi.ListSnapshotsRequest{
SourceVolumeId: "vol1",
})
if err == nil {
t.Fatal("expected error from backend failure")
}
st, _ = status.FromError(err)
if st.Code() != codes.Internal {
t.Fatalf("expected Internal, got %v: %s", st.Code(), st.Message())
}
}
// TestQA_Snap_ListSnapshotsNotFoundVolume verifies that ListSnapshots returns
// empty list (not error) when volume is not found.
func TestQA_Snap_ListSnapshotsNotFoundVolume(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
cs := &controllerServer{backend: backend}
resp, err := cs.ListSnapshots(context.Background(), &csi.ListSnapshotsRequest{
SourceVolumeId: "nonexistent-vol",
})
if err != nil {
t.Fatalf("ListSnapshots on missing volume should succeed, got: %v", err)
}
if len(resp.Entries) != 0 {
t.Fatalf("expected 0 entries, got %d", len(resp.Entries))
}
}
// --- collisionMockBackend ---
// collisionMockBackend is a VolumeBackend that simulates FNV-32a hash collision
// on CreateSnapshot. The first CreateSnapshot succeeds; all subsequent ones
// return "snapshot already exists" to simulate what happens when two different
// names produce the same FNV-32a hash.
type collisionMockBackend struct {
createCount int
}
func (m *collisionMockBackend) CreateVolume(_ context.Context, name string, sizeBytes uint64) (*VolumeInfo, error) {
return &VolumeInfo{VolumeID: name, CapacityBytes: sizeBytes}, nil
}
func (m *collisionMockBackend) DeleteVolume(_ context.Context, name string) error {
return nil
}
func (m *collisionMockBackend) LookupVolume(_ context.Context, name string) (*VolumeInfo, error) {
return &VolumeInfo{VolumeID: name}, nil
}
func (m *collisionMockBackend) CreateSnapshot(_ context.Context, volumeID string, snapID uint32) (*SnapshotInfo, error) {
m.createCount++
if m.createCount > 1 {
return nil, fmt.Errorf("snapshot already exists")
}
return &SnapshotInfo{SnapshotID: snapID, VolumeID: volumeID, SizeBytes: 4 * 1024 * 1024}, nil
}
func (m *collisionMockBackend) DeleteSnapshot(_ context.Context, volumeID string, snapID uint32) error {
return nil
}
func (m *collisionMockBackend) ListSnapshots(_ context.Context, volumeID string) ([]*SnapshotInfo, error) {
return nil, nil
}
func (m *collisionMockBackend) ExpandVolume(_ context.Context, volumeID string, newSizeBytes uint64) (uint64, error) {
return newSizeBytes, nil
}
// --- errorBackend ---
// errorBackend is a VolumeBackend that returns configurable errors for testing
// error propagation paths.
type errorBackend struct {
listErr error
}
func (m *errorBackend) CreateVolume(_ context.Context, name string, sizeBytes uint64) (*VolumeInfo, error) {
return &VolumeInfo{VolumeID: name, CapacityBytes: sizeBytes}, nil
}
func (m *errorBackend) DeleteVolume(_ context.Context, name string) error { return nil }
func (m *errorBackend) LookupVolume(_ context.Context, name string) (*VolumeInfo, error) {
return &VolumeInfo{VolumeID: name}, nil
}
func (m *errorBackend) CreateSnapshot(_ context.Context, volumeID string, snapID uint32) (*SnapshotInfo, error) {
return &SnapshotInfo{SnapshotID: snapID, VolumeID: volumeID}, nil
}
func (m *errorBackend) DeleteSnapshot(_ context.Context, volumeID string, snapID uint32) error {
return nil
}
func (m *errorBackend) ListSnapshots(_ context.Context, volumeID string) ([]*SnapshotInfo, error) {
return nil, m.listErr
}
func (m *errorBackend) ExpandVolume(_ context.Context, volumeID string, newSizeBytes uint64) (uint64, error) {
return newSizeBytes, nil
}
+44
View File
@@ -0,0 +1,44 @@
package csi
import (
"fmt"
"hash/fnv"
"strconv"
"strings"
)
// FormatSnapshotID creates a CSI-standard snapshot ID: "snap-{volumeID}-{snapID}".
func FormatSnapshotID(volumeID string, snapID uint32) string {
return fmt.Sprintf("snap-%s-%d", volumeID, snapID)
}
// ParseSnapshotID reverses FormatSnapshotID, returning volumeID and snapID.
// The volumeID may contain dashes, so we split on the last dash.
func ParseSnapshotID(csiSnapID string) (string, uint32, error) {
if !strings.HasPrefix(csiSnapID, "snap-") {
return "", 0, fmt.Errorf("invalid snapshot ID format: missing snap- prefix")
}
rest := csiSnapID[len("snap-"):]
lastDash := strings.LastIndex(rest, "-")
if lastDash < 0 || lastDash == 0 {
return "", 0, fmt.Errorf("invalid snapshot ID format: no volume/snap separator")
}
volumeID := rest[:lastDash]
numStr := rest[lastDash+1:]
id, err := strconv.ParseUint(numStr, 10, 32)
if err != nil {
return "", 0, fmt.Errorf("invalid snapshot ID format: %w", err)
}
return volumeID, uint32(id), nil
}
// snapshotNameToID converts a snapshot name to a deterministic uint32 via FNV-32a hash.
func snapshotNameToID(name string) uint32 {
h := fnv.New32a()
h.Write([]byte(name))
return h.Sum32()
}
@@ -0,0 +1,84 @@
package csi
import (
"testing"
)
func TestSnapshotID_FormatParseRoundTrip(t *testing.T) {
tests := []struct {
volumeID string
snapID uint32
}{
{"pvc-abc123", 42},
{"vol-with-dashes-in-name", 999},
{"simple", 0},
{"a", 4294967295}, // max uint32
}
for _, tt := range tests {
csiID := FormatSnapshotID(tt.volumeID, tt.snapID)
gotVol, gotSnap, err := ParseSnapshotID(csiID)
if err != nil {
t.Errorf("ParseSnapshotID(%q): %v", csiID, err)
continue
}
if gotVol != tt.volumeID {
t.Errorf("volumeID: got %q, want %q", gotVol, tt.volumeID)
}
if gotSnap != tt.snapID {
t.Errorf("snapID: got %d, want %d", gotSnap, tt.snapID)
}
}
}
func TestSnapshotID_ParseErrors(t *testing.T) {
bad := []string{
"",
"not-a-snap",
"snap-", // no volume or snap
"snap-vol", // no dash separator for snap ID
"snap-vol-abc", // non-numeric snap ID
}
for _, s := range bad {
_, _, err := ParseSnapshotID(s)
if err == nil {
t.Errorf("ParseSnapshotID(%q): expected error", s)
}
}
}
func TestSnapshotNameToID_Deterministic(t *testing.T) {
name := "my-snapshot-name"
id1 := snapshotNameToID(name)
id2 := snapshotNameToID(name)
if id1 != id2 {
t.Fatalf("FNV hash not deterministic: %d vs %d", id1, id2)
}
if id1 == 0 {
t.Fatal("expected non-zero hash")
}
}
func TestSnapshotNameToID_DifferentNames(t *testing.T) {
id1 := snapshotNameToID("snap-alpha")
id2 := snapshotNameToID("snap-beta")
if id1 == id2 {
t.Fatalf("different names produced same hash: %d", id1)
}
}
func TestSnapshotID_EmptyVolumeID(t *testing.T) {
// snap--42 should parse: volumeID="" but we reject it
_, _, err := ParseSnapshotID("snap--42")
if err != nil {
// We accept this is an error since lastDash==0 means empty volumeID
return
}
// If it doesn't error, that's also fine as long as parse is correct
}
func TestSnapshotID_FormatContainsComponents(t *testing.T) {
id := FormatSnapshotID("pvc-123", 7)
if id != "snap-pvc-123-7" {
t.Fatalf("got %q, want snap-pvc-123-7", id)
}
}
+102
View File
@@ -17,11 +17,24 @@ type VolumeInfo struct {
CapacityBytes uint64
}
// SnapshotInfo holds snapshot metadata returned by the backend.
type SnapshotInfo struct {
SnapshotID uint32
VolumeID string
CreatedAt int64 // Unix timestamp in seconds
SizeBytes uint64
}
// VolumeBackend abstracts volume lifecycle for the CSI controller.
type VolumeBackend interface {
CreateVolume(ctx context.Context, name string, sizeBytes uint64) (*VolumeInfo, error)
DeleteVolume(ctx context.Context, name string) error
LookupVolume(ctx context.Context, name string) (*VolumeInfo, error)
CreateSnapshot(ctx context.Context, volumeID string, snapID uint32) (*SnapshotInfo, error)
DeleteSnapshot(ctx context.Context, volumeID string, snapID uint32) error
ListSnapshots(ctx context.Context, volumeID string) ([]*SnapshotInfo, error)
ExpandVolume(ctx context.Context, volumeID string, newSizeBytes uint64) (uint64, error)
}
// LocalVolumeBackend wraps VolumeManager for standalone/local mode (CP6-1).
@@ -66,6 +79,26 @@ func (b *LocalVolumeBackend) LookupVolume(ctx context.Context, name string) (*Vo
}, nil
}
func (b *LocalVolumeBackend) CreateSnapshot(ctx context.Context, volumeID string, snapID uint32) (*SnapshotInfo, error) {
info, err := b.mgr.CreateSnapshot(volumeID, snapID)
if err != nil {
return nil, err
}
return info, nil
}
func (b *LocalVolumeBackend) DeleteSnapshot(ctx context.Context, volumeID string, snapID uint32) error {
return b.mgr.DeleteSnapshot(volumeID, snapID)
}
func (b *LocalVolumeBackend) ListSnapshots(ctx context.Context, volumeID string) ([]*SnapshotInfo, error) {
return b.mgr.ListSnapshots(volumeID)
}
func (b *LocalVolumeBackend) ExpandVolume(ctx context.Context, volumeID string, newSizeBytes uint64) (uint64, error) {
return b.mgr.ExpandVolume(volumeID, newSizeBytes)
}
// MasterVolumeClient calls master gRPC for volume operations.
type MasterVolumeClient struct {
masterAddr string
@@ -129,3 +162,72 @@ func (c *MasterVolumeClient) LookupVolume(ctx context.Context, name string) (*Vo
})
return info, err
}
func (c *MasterVolumeClient) CreateSnapshot(ctx context.Context, volumeID string, snapID uint32) (*SnapshotInfo, error) {
var info *SnapshotInfo
err := pb.WithMasterClient(false, pb.ServerAddress(c.masterAddr), c.dialOpt, false, func(client master_pb.SeaweedClient) error {
resp, err := client.CreateBlockSnapshot(ctx, &master_pb.CreateBlockSnapshotRequest{
VolumeName: volumeID,
SnapshotId: snapID,
})
if err != nil {
return err
}
info = &SnapshotInfo{
SnapshotID: resp.SnapshotId,
VolumeID: volumeID,
CreatedAt: resp.CreatedAt,
SizeBytes: resp.SizeBytes,
}
return nil
})
return info, err
}
func (c *MasterVolumeClient) DeleteSnapshot(ctx context.Context, volumeID string, snapID uint32) error {
return pb.WithMasterClient(false, pb.ServerAddress(c.masterAddr), c.dialOpt, false, func(client master_pb.SeaweedClient) error {
_, err := client.DeleteBlockSnapshot(ctx, &master_pb.DeleteBlockSnapshotRequest{
VolumeName: volumeID,
SnapshotId: snapID,
})
return err
})
}
func (c *MasterVolumeClient) ListSnapshots(ctx context.Context, volumeID string) ([]*SnapshotInfo, error) {
var infos []*SnapshotInfo
err := pb.WithMasterClient(false, pb.ServerAddress(c.masterAddr), c.dialOpt, false, func(client master_pb.SeaweedClient) error {
resp, err := client.ListBlockSnapshots(ctx, &master_pb.ListBlockSnapshotsRequest{
VolumeName: volumeID,
})
if err != nil {
return err
}
for _, s := range resp.Snapshots {
infos = append(infos, &SnapshotInfo{
SnapshotID: s.SnapshotId,
VolumeID: volumeID,
CreatedAt: s.CreatedAt,
SizeBytes: s.VolumeSizeBytes,
})
}
return nil
})
return infos, err
}
func (c *MasterVolumeClient) ExpandVolume(ctx context.Context, volumeID string, newSizeBytes uint64) (uint64, error) {
var capacity uint64
err := pb.WithMasterClient(false, pb.ServerAddress(c.masterAddr), c.dialOpt, false, func(client master_pb.SeaweedClient) error {
resp, err := client.ExpandBlockVolume(ctx, &master_pb.ExpandBlockVolumeRequest{
Name: volumeID,
NewSizeBytes: newSizeBytes,
})
if err != nil {
return err
}
capacity = resp.CapacityBytes
return nil
})
return capacity, err
}
@@ -90,3 +90,107 @@ func TestBackend_LocalLookupNotFound(t *testing.T) {
t.Fatal("lookup missing should return error")
}
}
func TestBackend_LocalCreateDeleteListSnapshots(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
ctx := context.Background()
// Create volume.
if _, err := backend.CreateVolume(ctx, "snap-vol", 4*1024*1024); err != nil {
t.Fatalf("create: %v", err)
}
// Create snapshot.
snap, err := backend.CreateSnapshot(ctx, "snap-vol", 1)
if err != nil {
t.Fatalf("CreateSnapshot: %v", err)
}
if snap.SnapshotID != 1 {
t.Fatalf("SnapshotID: got %d, want 1", snap.SnapshotID)
}
if snap.CreatedAt == 0 {
t.Fatal("CreatedAt should not be zero")
}
// List snapshots.
snaps, err := backend.ListSnapshots(ctx, "snap-vol")
if err != nil {
t.Fatalf("ListSnapshots: %v", err)
}
if len(snaps) != 1 {
t.Fatalf("expected 1 snapshot, got %d", len(snaps))
}
// Delete snapshot.
if err := backend.DeleteSnapshot(ctx, "snap-vol", 1); err != nil {
t.Fatalf("DeleteSnapshot: %v", err)
}
// Idempotent delete.
if err := backend.DeleteSnapshot(ctx, "snap-vol", 1); err != nil {
t.Fatalf("idempotent delete: %v", err)
}
// List should be empty now.
snaps, err = backend.ListSnapshots(ctx, "snap-vol")
if err != nil {
t.Fatalf("ListSnapshots after delete: %v", err)
}
if len(snaps) != 0 {
t.Fatalf("expected 0 snapshots after delete, got %d", len(snaps))
}
}
func TestBackend_LocalExpandVolume(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
ctx := context.Background()
if _, err := backend.CreateVolume(ctx, "expand-vol", 4*1024*1024); err != nil {
t.Fatalf("create: %v", err)
}
newSize, err := backend.ExpandVolume(ctx, "expand-vol", 8*1024*1024)
if err != nil {
t.Fatalf("ExpandVolume: %v", err)
}
if newSize != 8*1024*1024 {
t.Fatalf("new size: got %d, want %d", newSize, 8*1024*1024)
}
// Verify via lookup.
info, err := backend.LookupVolume(ctx, "expand-vol")
if err != nil {
t.Fatalf("lookup: %v", err)
}
if info.CapacityBytes != 8*1024*1024 {
t.Fatalf("capacity after expand: got %d, want %d", info.CapacityBytes, 8*1024*1024)
}
}
func TestBackend_LocalExpandNoShrink(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
ctx := context.Background()
if _, err := backend.CreateVolume(ctx, "noshrink-vol", 8*1024*1024); err != nil {
t.Fatalf("create: %v", err)
}
_, err := backend.ExpandVolume(ctx, "noshrink-vol", 4*1024*1024)
if err == nil {
t.Fatal("expected error when shrinking")
}
}
func TestBackend_LocalSnapshotVolumeNotFound(t *testing.T) {
mgr := newTestManager(t)
backend := NewLocalVolumeBackend(mgr)
ctx := context.Background()
_, err := backend.CreateSnapshot(ctx, "missing", 1)
if err == nil {
t.Fatal("expected error for missing volume")
}
}
@@ -325,6 +325,93 @@ func (m *VolumeManager) ListenAddr() string {
return ""
}
// WithVolume runs fn while holding the manager lock with a reference to the volume.
func (m *VolumeManager) WithVolume(name string, fn func(*blockvol.BlockVol) error) error {
m.mu.RLock()
defer m.mu.RUnlock()
mv, ok := m.volumes[name]
if !ok {
return ErrVolumeNotFound
}
return fn(mv.vol)
}
// CreateSnapshot creates a snapshot on the named volume.
func (m *VolumeManager) CreateSnapshot(name string, snapID uint32) (*SnapshotInfo, error) {
m.mu.RLock()
defer m.mu.RUnlock()
mv, ok := m.volumes[name]
if !ok {
return nil, ErrVolumeNotFound
}
if err := mv.vol.CreateSnapshot(snapID); err != nil {
return nil, err
}
// Find the snapshot we just created.
for _, s := range mv.vol.ListSnapshots() {
if s.ID == snapID {
return &SnapshotInfo{
SnapshotID: snapID,
VolumeID: name,
CreatedAt: s.CreatedAt.Unix(),
SizeBytes: mv.sizeBytes,
}, nil
}
}
return nil, fmt.Errorf("snapshot %d created but not found", snapID)
}
// DeleteSnapshot deletes a snapshot. Idempotent: returns nil if not found.
func (m *VolumeManager) DeleteSnapshot(name string, snapID uint32) error {
m.mu.RLock()
defer m.mu.RUnlock()
mv, ok := m.volumes[name]
if !ok {
return ErrVolumeNotFound
}
err := mv.vol.DeleteSnapshot(snapID)
if err != nil && err.Error() == "blockvol: snapshot not found" {
return nil
}
return err
}
// ListSnapshots returns all snapshots for the named volume.
func (m *VolumeManager) ListSnapshots(name string) ([]*SnapshotInfo, error) {
m.mu.RLock()
defer m.mu.RUnlock()
mv, ok := m.volumes[name]
if !ok {
return nil, ErrVolumeNotFound
}
engineInfos := mv.vol.ListSnapshots()
result := make([]*SnapshotInfo, 0, len(engineInfos))
for _, s := range engineInfos {
result = append(result, &SnapshotInfo{
SnapshotID: s.ID,
VolumeID: name,
CreatedAt: s.CreatedAt.Unix(),
SizeBytes: mv.sizeBytes,
})
}
return result, nil
}
// ExpandVolume expands the named volume. Returns the new size.
func (m *VolumeManager) ExpandVolume(name string, newSizeBytes uint64) (uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
mv, ok := m.volumes[name]
if !ok {
return 0, ErrVolumeNotFound
}
if err := mv.vol.Expand(newSizeBytes); err != nil {
return 0, err
}
mv.sizeBytes = mv.vol.Info().VolumeSize
return mv.sizeBytes, nil
}
func (m *VolumeManager) volumePath(name string) string {
return filepath.Join(m.dataDir, sanitizeFilename(name)+".blk")
}
+16 -13
View File
@@ -5,22 +5,24 @@ import (
)
// MakeDistributedSync creates a sync function that runs local WAL fsync and
// replica barrier in parallel. If no replica is configured or the replica is
// degraded, it falls back to local-only sync (Phase 3 behavior).
// replica barriers in parallel. Supports N replicas via ShipperGroup.
//
// walSync: the local fsync function (typically fd.Sync)
// shipper: the WAL shipper to the replica (may be nil)
// vol: the BlockVol (used to read nextLSN and trigger degradation)
func MakeDistributedSync(walSync func() error, shipper *WALShipper, vol *BlockVol) func() error {
// If no replicas are configured (group is nil or empty) or all replicas are
// degraded, it falls back to local-only sync.
//
// Local fsync is the durability point. Replica barrier failures degrade
// individual shippers but never fail the write.
func MakeDistributedSync(walSync func() error, group *ShipperGroup, vol *BlockVol) func() error {
return func() error {
if shipper == nil || shipper.IsDegraded() {
if group == nil || group.Len() == 0 || group.AllDegraded() {
return walSync()
}
// The highest LSN that needs to be durable is nextLSN-1.
lsnMax := vol.nextLSN.Load() - 1
var localErr, remoteErr error
var localErr error
var barrierErrs []error
var wg sync.WaitGroup
wg.Add(2)
go func() {
@@ -29,17 +31,18 @@ func MakeDistributedSync(walSync func() error, shipper *WALShipper, vol *BlockVo
}()
go func() {
defer wg.Done()
remoteErr = shipper.Barrier(lsnMax)
barrierErrs = group.BarrierAll(lsnMax)
}()
wg.Wait()
if localErr != nil {
return localErr
}
if remoteErr != nil {
// Local succeeded, replica failed --degrade but don't fail the client.
vol.degradeReplica(remoteErr)
return nil
// Remote failures: degrade individual shippers (already done by Barrier).
for _, err := range barrierErrs {
if err != nil {
vol.degradeReplica(err)
}
}
return nil
}
+119
View File
@@ -0,0 +1,119 @@
package blockvol
import (
"sync/atomic"
"time"
)
// EngineMetrics collects subsystem-level counters for Prometheus export.
// All fields are safe for concurrent access.
type EngineMetrics struct {
// Flusher
FlusherBytesTotal atomic.Uint64
FlusherFlushesTotal atomic.Uint64
FlusherErrorsTotal atomic.Uint64
flusherLatencyNs atomicHistogram // flush cycle duration
// Group Commit
GroupCommitBatchTotal atomic.Uint64 // alias for GroupCommitter.syncCount
groupCommitBatchSize atomicHistogram
groupCommitWaitNs atomicHistogram
// WAL Shipper
WALShippedEntriesTotal atomic.Uint64
WALBarrierRequestsTotal atomic.Uint64
WALFailedBarriersTotal atomic.Uint64
walBarrierLatencyNs atomicHistogram
// Scrub
ScrubPassesTotal atomic.Uint64
ScrubErrorsTotal atomic.Uint64
scrubDurationNs atomicHistogram
}
// NewEngineMetrics creates an EngineMetrics instance.
func NewEngineMetrics() *EngineMetrics {
return &EngineMetrics{}
}
// RecordFlusherFlush records a completed flush cycle.
func (m *EngineMetrics) RecordFlusherFlush(bytesWritten uint64, dur time.Duration) {
m.FlusherBytesTotal.Add(bytesWritten)
m.FlusherFlushesTotal.Add(1)
m.flusherLatencyNs.record(dur.Nanoseconds())
}
// RecordFlusherError records a flush error.
func (m *EngineMetrics) RecordFlusherError() {
m.FlusherErrorsTotal.Add(1)
}
// RecordGroupCommitBatch records a group commit batch.
func (m *EngineMetrics) RecordGroupCommitBatch(batchSize int, waitDur time.Duration) {
m.GroupCommitBatchTotal.Add(1)
m.groupCommitBatchSize.record(int64(batchSize))
m.groupCommitWaitNs.record(waitDur.Nanoseconds())
}
// RecordWALShipped records a shipped WAL entry.
func (m *EngineMetrics) RecordWALShipped() {
m.WALShippedEntriesTotal.Add(1)
}
// RecordWALBarrier records a barrier request with its result.
func (m *EngineMetrics) RecordWALBarrier(dur time.Duration, failed bool) {
m.WALBarrierRequestsTotal.Add(1)
m.walBarrierLatencyNs.record(dur.Nanoseconds())
if failed {
m.WALFailedBarriersTotal.Add(1)
}
}
// RecordScrubPass records a completed scrub pass.
func (m *EngineMetrics) RecordScrubPass(dur time.Duration, errors int64) {
m.ScrubPassesTotal.Add(1)
m.ScrubErrorsTotal.Add(uint64(errors))
m.scrubDurationNs.record(dur.Nanoseconds())
}
// FlusherLatencySnapshot returns flusher latency stats (count, sum_ns).
func (m *EngineMetrics) FlusherLatencySnapshot() (count uint64, sumNs int64) {
return m.flusherLatencyNs.snapshot()
}
// GroupCommitBatchSizeSnapshot returns batch size stats.
func (m *EngineMetrics) GroupCommitBatchSizeSnapshot() (count uint64, sum int64) {
return m.groupCommitBatchSize.snapshot()
}
// GroupCommitWaitSnapshot returns group commit wait time stats.
func (m *EngineMetrics) GroupCommitWaitSnapshot() (count uint64, sumNs int64) {
return m.groupCommitWaitNs.snapshot()
}
// WALBarrierLatencySnapshot returns barrier latency stats.
func (m *EngineMetrics) WALBarrierLatencySnapshot() (count uint64, sumNs int64) {
return m.walBarrierLatencyNs.snapshot()
}
// ScrubDurationSnapshot returns scrub duration stats.
func (m *EngineMetrics) ScrubDurationSnapshot() (count uint64, sumNs int64) {
return m.scrubDurationNs.snapshot()
}
// atomicHistogram is a minimal lock-free histogram that tracks count and sum.
// Prometheus histograms are registered in the iscsi-target metrics layer;
// this struct provides the raw observations for GaugeFunc export.
type atomicHistogram struct {
count atomic.Uint64
sum atomic.Int64
}
func (h *atomicHistogram) record(val int64) {
h.count.Add(1)
h.sum.Add(val)
}
func (h *atomicHistogram) snapshot() (uint64, int64) {
return h.count.Load(), h.sum.Load()
}
+18 -2
View File
@@ -37,6 +37,7 @@ type Flusher struct {
logger *log.Logger
lastErr bool // true if last FlushOnce returned error
metrics *EngineMetrics
interval time.Duration
notifyCh chan struct{}
@@ -51,8 +52,9 @@ type FlusherConfig struct {
Super *Superblock
WAL *WALWriter
DirtyMap *DirtyMap
Interval time.Duration // default 100ms
Logger *log.Logger // optional; defaults to log.Default()
Interval time.Duration // default 100ms
Logger *log.Logger // optional; defaults to log.Default()
Metrics *EngineMetrics // optional; if nil, no metrics recorded
}
// NewFlusher creates a flusher. Call Run() in a goroutine.
@@ -73,6 +75,7 @@ func NewFlusher(cfg FlusherConfig) *Flusher {
blockSize: cfg.Super.BlockSize,
extentStart: cfg.Super.WALOffset + cfg.Super.WALSize,
logger: cfg.Logger,
metrics: cfg.Metrics,
checkpointLSN: cfg.Super.WALCheckpointLSN,
checkpointTail: 0,
interval: cfg.Interval,
@@ -98,6 +101,9 @@ func (f *Flusher) Run() {
f.logger.Printf("flusher error: %v", err)
}
f.lastErr = true
if f.metrics != nil {
f.metrics.RecordFlusherError()
}
} else {
f.lastErr = false
}
@@ -107,6 +113,9 @@ func (f *Flusher) Run() {
f.logger.Printf("flusher error: %v", err)
}
f.lastErr = true
if f.metrics != nil {
f.metrics.RecordFlusherError()
}
} else {
f.lastErr = false
}
@@ -185,6 +194,7 @@ func (f *Flusher) FlushOnce() error {
// flushOnceLocked is the inner FlushOnce. Caller must hold flushMu.
func (f *Flusher) flushOnceLocked() error {
flushStart := time.Now()
entries := f.dirtyMap.Snapshot()
if len(entries) == 0 {
return nil
@@ -333,6 +343,12 @@ func (f *Flusher) flushOnceLocked() error {
// Update superblock checkpoint.
f.updateSuperblockCheckpoint(maxLSN, f.wal.Tail())
// Record metrics.
if f.metrics != nil {
bytesWritten := uint64(len(entries)) * uint64(f.blockSize)
f.metrics.RecordFlusherFlush(bytesWritten, time.Since(flushStart))
}
return nil
}
+15 -6
View File
@@ -22,6 +22,7 @@ type GroupCommitter struct {
lowWatermark int // skip delay if fewer pending (0 = always wait)
onDegraded func() // called when fsync fails
postSyncCheck func() error // called after sync; error fails waiters
metrics *EngineMetrics
mu sync.Mutex
pending []chan error
@@ -36,12 +37,13 @@ type GroupCommitter struct {
// GroupCommitterConfig configures the group committer.
type GroupCommitterConfig struct {
SyncFunc func() error // required: the fsync function
MaxDelay time.Duration // default 1ms
MaxBatch int // default 64
LowWatermark int // skip delay if fewer pending (0 = always wait)
OnDegraded func() // optional: called on fsync error
PostSyncCheck func() error // optional: called after successful sync; error fails all waiters
SyncFunc func() error // required: the fsync function
MaxDelay time.Duration // default 1ms
MaxBatch int // default 64
LowWatermark int // skip delay if fewer pending (0 = always wait)
OnDegraded func() // optional: called on fsync error
PostSyncCheck func() error // optional: called after successful sync; error fails all waiters
Metrics *EngineMetrics // optional; if nil, no metrics recorded
}
// NewGroupCommitter creates a new group committer. Call Run() to start it.
@@ -62,6 +64,7 @@ func NewGroupCommitter(cfg GroupCommitterConfig) *GroupCommitter {
lowWatermark: cfg.LowWatermark,
onDegraded: cfg.OnDegraded,
postSyncCheck: cfg.PostSyncCheck,
metrics: cfg.Metrics,
notifyCh: make(chan struct{}, 1),
stopCh: make(chan struct{}),
done: make(chan struct{}),
@@ -130,6 +133,7 @@ func (gc *GroupCommitter) Run() {
// Perform fsync with panic recovery. A panic in syncFunc must
// not leave waiters hung --notify them and drain stragglers.
syncStart := time.Now()
err := gc.callSyncFunc()
gc.syncCount.Add(1)
if err == nil && gc.postSyncCheck != nil {
@@ -139,6 +143,11 @@ func (gc *GroupCommitter) Run() {
gc.onDegraded()
}
// Record metrics.
if gc.metrics != nil {
gc.metrics.RecordGroupCommitBatch(len(batch), time.Since(syncStart))
}
// Wake all waiters.
for _, ch := range batch {
ch <- err
+77
View File
@@ -0,0 +1,77 @@
package blockvol
import (
"sync/atomic"
"time"
)
// HealthStats contains health score details for reporting.
type HealthStats struct {
Score float64
ScrubErrors int64
LastScrubError int64 // unix timestamp, 0 = never
LastScrubTime int64 // unix timestamp, 0 = never
}
// HealthScore tracks the health of a block volume based on scrub results.
// WAL lag is NOT part of engine health — it is tracked at the registry level only.
type HealthScore struct {
scrubErrors atomic.Int64
lastScrubError atomic.Int64 // unix seconds
lastScrubTime atomic.Int64 // unix seconds
}
// NewHealthScore creates a new HealthScore with perfect health (1.0).
func NewHealthScore() *HealthScore {
return &HealthScore{}
}
// RecordScrubError increments the scrub error counter and records timestamp.
func (h *HealthScore) RecordScrubError() {
h.scrubErrors.Add(1)
h.lastScrubError.Store(time.Now().Unix())
}
// RecordScrubComplete records when a scrub pass finishes.
func (h *HealthScore) RecordScrubComplete() {
h.lastScrubTime.Store(time.Now().Unix())
}
// Score returns the current health score in [0.0, 1.0].
// Penalties:
// - Scrub errors > 0: penalty = min(0.5, errors × 0.1)
// - Last scrub > 48h ago: penalty = 0.1
func (h *HealthScore) Score() float64 {
score := 1.0
// Scrub error penalty.
errs := h.scrubErrors.Load()
if errs > 0 {
penalty := float64(errs) * 0.1
if penalty > 0.5 {
penalty = 0.5
}
score -= penalty
}
// Stale scrub penalty.
lastScrub := h.lastScrubTime.Load()
if lastScrub > 0 && time.Since(time.Unix(lastScrub, 0)) > 48*time.Hour {
score -= 0.1
}
if score < 0.0 {
score = 0.0
}
return score
}
// Stats returns detailed health statistics.
func (h *HealthScore) Stats() HealthStats {
return HealthStats{
Score: h.Score(),
ScrubErrors: h.scrubErrors.Load(),
LastScrubError: h.lastScrubError.Load(),
LastScrubTime: h.lastScrubTime.Load(),
}
}
@@ -0,0 +1,58 @@
package blockvol
import (
"testing"
"time"
)
func TestHealthScore_DefaultPerfect(t *testing.T) {
h := NewHealthScore()
if got := h.Score(); got != 1.0 {
t.Fatalf("default score: got %f, want 1.0", got)
}
}
func TestHealthScore_ScrubErrorsLowerScore(t *testing.T) {
h := NewHealthScore()
h.RecordScrubError()
if got := h.Score(); got != 0.9 {
t.Fatalf("1 error: got %f, want 0.9", got)
}
h.RecordScrubError()
if got := h.Score(); got != 0.8 {
t.Fatalf("2 errors: got %f, want 0.8", got)
}
}
func TestHealthScore_CappedPenalty(t *testing.T) {
h := NewHealthScore()
for i := 0; i < 10; i++ {
h.RecordScrubError()
}
// 10 errors × 0.1 = 1.0, but capped at 0.5 penalty → score 0.5
if got := h.Score(); got != 0.5 {
t.Fatalf("10 errors: got %f, want 0.5", got)
}
}
func TestHealthScore_StaleScrubPenalty(t *testing.T) {
h := NewHealthScore()
// Set last scrub to 49 hours ago.
h.lastScrubTime.Store(time.Now().Add(-49 * time.Hour).Unix())
got := h.Score()
if got < 0.89 || got > 0.91 {
t.Fatalf("stale scrub: got %f, want ~0.9", got)
}
}
func TestHealthScore_RecordScrubComplete(t *testing.T) {
h := NewHealthScore()
h.RecordScrubComplete()
stats := h.Stats()
if stats.LastScrubTime == 0 {
t.Fatal("LastScrubTime should be set after RecordScrubComplete")
}
if stats.Score != 1.0 {
t.Fatalf("score after complete: got %f, want 1.0", stats.Score)
}
}
@@ -101,6 +101,53 @@ func newMetricsAdapter(inner iscsi.BlockDevice, vol *blockvol.BlockVol, reg prom
Name: "snapshot_count", Help: "Number of active snapshots",
}, gs.snapshotCount))
// --- Engine subsystem metrics (CP8-4) ---
em := vol.Metrics
// Flusher
reg.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
Namespace: "seaweedfs", Subsystem: "blockvol",
Name: "flusher_bytes_total", Help: "Total bytes flushed to extent",
}, func() float64 { return float64(em.FlusherBytesTotal.Load()) }))
reg.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
Namespace: "seaweedfs", Subsystem: "blockvol",
Name: "flusher_flushes_total", Help: "Total flush cycles completed",
}, func() float64 { return float64(em.FlusherFlushesTotal.Load()) }))
reg.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
Namespace: "seaweedfs", Subsystem: "blockvol",
Name: "flusher_errors_total", Help: "Total flusher errors",
}, func() float64 { return float64(em.FlusherErrorsTotal.Load()) }))
reg.MustRegister(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Namespace: "seaweedfs", Subsystem: "blockvol",
Name: "flusher_checkpoint_lsn", Help: "Last flushed checkpoint LSN",
}, gs.checkpointLSN))
// Group Commit
reg.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
Namespace: "seaweedfs", Subsystem: "blockvol",
Name: "group_commit_flushes_total", Help: "Total group commit fsyncs",
}, func() float64 { return float64(em.GroupCommitBatchTotal.Load()) }))
// WAL Shipper
reg.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
Namespace: "seaweedfs", Subsystem: "blockvol",
Name: "wal_shipped_entries_total", Help: "Total WAL entries shipped to replicas",
}, func() float64 { return float64(em.WALShippedEntriesTotal.Load()) }))
reg.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
Namespace: "seaweedfs", Subsystem: "blockvol",
Name: "replica_failed_barriers_total", Help: "Total failed barrier requests",
}, func() float64 { return float64(em.WALFailedBarriersTotal.Load()) }))
// Scrub
reg.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
Namespace: "seaweedfs", Subsystem: "blockvol",
Name: "scrub_passes_total", Help: "Total scrub passes completed",
}, func() float64 { return float64(em.ScrubPassesTotal.Load()) }))
reg.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
Namespace: "seaweedfs", Subsystem: "blockvol",
Name: "scrub_errors_total", Help: "Total scrub errors (CRC mismatches)",
}, func() float64 { return float64(em.ScrubErrorsTotal.Load()) }))
return &metricsAdapter{
inner: inner,
writeOps: writeOps,
@@ -192,3 +239,7 @@ func (gs *gaugeSource) role() float64 {
func (gs *gaugeSource) snapshotCount() float64 {
return float64(len(gs.vol.ListSnapshots()))
}
func (gs *gaugeSource) checkpointLSN() float64 {
return float64(gs.vol.CheckpointLSN())
}
+3 -3
View File
@@ -115,9 +115,9 @@ func demote(vol *BlockVol, newEpoch uint64) error {
}
}
// Stop shipper if present.
if vol.shipper != nil {
vol.shipper.Stop()
// Stop shippers if present.
if vol.shipperGroup != nil {
vol.shipperGroup.StopAll()
}
// Transition Draining -> Stale.
+9 -9
View File
@@ -922,12 +922,12 @@ func testQADSyncRemoteFailDegrades(t *testing.T) {
replica.replRecv.Stop()
// Force shipper to reconnect (old ctrl conn may still be open).
primary.shipper.ctrlMu.Lock()
if primary.shipper.ctrlConn != nil {
primary.shipper.ctrlConn.Close()
primary.shipper.ctrlConn = nil
primary.shipperGroup.Shipper(0).ctrlMu.Lock()
if primary.shipperGroup.Shipper(0).ctrlConn != nil {
primary.shipperGroup.Shipper(0).ctrlConn.Close()
primary.shipperGroup.Shipper(0).ctrlConn = nil
}
primary.shipper.ctrlMu.Unlock()
primary.shipperGroup.Shipper(0).ctrlMu.Unlock()
// SyncCache triggers distributed sync -> barrier fails -> degrade.
err := primary.SyncCache()
@@ -937,7 +937,7 @@ func testQADSyncRemoteFailDegrades(t *testing.T) {
}
// Shipper should be degraded.
if !primary.shipper.IsDegraded() {
if !primary.shipperGroup.Shipper(0).IsDegraded() {
t.Error("shipper should be degraded after replica receiver stopped")
}
}
@@ -953,7 +953,7 @@ func testQADSyncBothFail(t *testing.T) {
syncFn := MakeDistributedSync(
func() error { return localErr },
shipper,
NewShipperGroup([]*WALShipper{shipper}),
vol,
)
@@ -992,7 +992,7 @@ func testQADSyncParallelExecution(t *testing.T) {
return nil
}
syncFn := MakeDistributedSync(slowLocalSync, primary.shipper, primary)
syncFn := MakeDistributedSync(slowLocalSync, primary.shipperGroup, primary)
start := time.Now()
err := syncFn()
@@ -1051,7 +1051,7 @@ func testQAE2EReplicaDataMatchesPrimary(t *testing.T) {
}
// Verify receivedLSN on replica matches what primary shipped.
primaryLSN := primary.shipper.ShippedLSN()
primaryLSN := primary.shipperGroup.Shipper(0).ShippedLSN()
replicaLSN := replica.replRecv.ReceivedLSN()
if replicaLSN < primaryLSN {
t.Errorf("replica receivedLSN = %d < primary shippedLSN = %d", replicaLSN, primaryLSN)
+4 -3
View File
@@ -335,13 +335,14 @@ func testAssignDemoteStopsShipper(t *testing.T) {
primary.HandleAssignment(2, RoleStale, 0)
// After demote, shipper should be stopped -- Ship is a no-op.
if primary.shipper != nil {
if primary.shipperGroup != nil && primary.shipperGroup.Len() > 0 {
shipper := primary.shipperGroup.Shipper(0)
entry := &WALEntry{LSN: 999, Epoch: 2, Type: EntryTypeWrite, LBA: 0, Length: 4096, Data: makeBlock('X')}
err := primary.shipper.Ship(entry)
err := shipper.Ship(entry)
if err != nil {
t.Errorf("Ship after stop: %v (should silently drop)", err)
}
if primary.shipper.ShippedLSN() == 999 {
if shipper.ShippedLSN() == 999 {
t.Error("shipper advanced LSN after demote -- should be stopped")
}
}
@@ -146,15 +146,16 @@ func testQA4b4ShipDataOkCtrlDown(t *testing.T) {
replica.replRecv.ctrlListener.Close()
// Force primary shipper to reconnect ctrl.
primary.shipper.ctrlMu.Lock()
if primary.shipper.ctrlConn != nil {
primary.shipper.ctrlConn.Close()
primary.shipper.ctrlConn = nil
shipper := primary.shipperGroup.Shipper(0)
shipper.ctrlMu.Lock()
if shipper.ctrlConn != nil {
shipper.ctrlConn.Close()
shipper.ctrlConn = nil
}
primary.shipper.ctrlMu.Unlock()
shipper.ctrlMu.Unlock()
// Barrier must fail (ctrl down).
err := primary.shipper.Barrier(replicaLSN)
err := shipper.Barrier(replicaLSN)
if err == nil {
t.Error("Barrier should fail when ctrl listener is closed")
}
+266
View File
@@ -0,0 +1,266 @@
package blockvol
import (
"hash/crc32"
"log"
"sync"
"sync/atomic"
"time"
)
// ScrubStats contains scrub pass statistics.
type ScrubStats struct {
PassCount int64
ErrorCount int64
LastPassTime int64 // unix seconds
SegmentsTotal int64
SegmentsClean int64
SegmentsDirty int64 // skipped (recently written)
SegmentsErrors int64
}
// Scrubber performs periodic patrol-reads to detect silent data corruption.
// Uses segment-level CRC (1 MiB segments = 256 LBAs with 4K blocks) to reduce
// memory usage. Written LBAs are tracked via NotifyWrite to avoid false positives.
type Scrubber struct {
vol *BlockVol
interval time.Duration
throttle time.Duration // delay between segments
stopCh chan struct{}
triggerCh chan struct{}
stopped atomic.Bool
// Segment CRC map: segment_index → CRC32 of concatenated block data.
mu sync.Mutex
segmentSize uint64 // LBAs per segment (default 256 = 1 MiB)
segmentCRCs map[uint64]uint32 // segment_index → CRC32
stats ScrubStats
// Write tracking: LBAs written since last scrub pass.
writtenMu sync.Mutex
writtenSegs map[uint64]struct{} // segment indices with writes since last pass
}
// NewScrubber creates a scrubber for the given volume. metrics is optional.
func NewScrubber(vol *BlockVol, interval time.Duration) *Scrubber {
blockSize := uint64(vol.super.BlockSize)
segmentSize := uint64(256) // default: 256 blocks per segment (1 MiB with 4K blocks)
if blockSize == 0 {
blockSize = 4096
}
_ = blockSize // used to document the 256 choice
return &Scrubber{
vol: vol,
interval: interval,
throttle: 100 * time.Microsecond,
stopCh: make(chan struct{}),
triggerCh: make(chan struct{}, 1),
segmentSize: segmentSize,
segmentCRCs: make(map[uint64]uint32),
writtenSegs: make(map[uint64]struct{}),
}
}
// Start begins the background scrub loop.
func (s *Scrubber) Start() {
go s.loop()
}
// Stop stops the scrub loop.
func (s *Scrubber) Stop() {
if s.stopped.Swap(true) {
return
}
close(s.stopCh)
}
// TriggerNow requests an immediate scrub pass.
func (s *Scrubber) TriggerNow() {
select {
case s.triggerCh <- struct{}{}:
default:
}
}
// Stats returns the current scrub statistics.
func (s *Scrubber) Stats() ScrubStats {
s.mu.Lock()
defer s.mu.Unlock()
return s.stats
}
// NotifyWrite records that LBAs were written, invalidating their segment CRCs.
// Called from appendWithRetry after WAL append.
func (s *Scrubber) NotifyWrite(lba uint64, blockCount uint32) {
s.writtenMu.Lock()
defer s.writtenMu.Unlock()
for i := uint32(0); i < blockCount; i++ {
segIdx := (lba + uint64(i)) / s.segmentSize
s.writtenSegs[segIdx] = struct{}{}
}
}
func (s *Scrubber) loop() {
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-s.stopCh:
return
case <-ticker.C:
s.runPass()
case <-s.triggerCh:
s.runPass()
}
}
}
func (s *Scrubber) runPass() {
if s.stopped.Load() {
return
}
passStart := time.Now()
blockSize := uint64(s.vol.super.BlockSize)
if blockSize == 0 {
return
}
totalBlocks := s.vol.super.VolumeSize / blockSize
totalSegments := (totalBlocks + s.segmentSize - 1) / s.segmentSize
var segClean, segDirty, segErrors int64
for segIdx := uint64(0); segIdx < totalSegments; segIdx++ {
if s.stopped.Load() {
return
}
// Check written segments under lock — live check, not snapshot.
// This avoids false positives from writes that arrive during the pass.
s.writtenMu.Lock()
_, wasWritten := s.writtenSegs[segIdx]
if wasWritten {
delete(s.writtenSegs, segIdx) // consumed: clear this segment entry
}
s.writtenMu.Unlock()
if wasWritten {
// Invalidate old CRC for this segment.
s.mu.Lock()
delete(s.segmentCRCs, segIdx)
s.mu.Unlock()
segDirty++
continue
}
// Skip segments with dirty map entries.
startLBA := segIdx * s.segmentSize
if s.segmentHasDirtyBlocks(startLBA) {
s.mu.Lock()
delete(s.segmentCRCs, segIdx)
s.mu.Unlock()
segDirty++
continue
}
// Read and checksum the segment.
segCRC, err := s.checksumSegment(segIdx, totalBlocks)
if err != nil {
log.Printf("scrub: error reading segment %d: %v", segIdx, err)
segErrors++
s.vol.healthScore.RecordScrubError()
continue
}
// Post-read double-check: if a write arrived during the read, skip comparison.
// This closes the race between the pre-read writtenSegs check and the actual I/O.
s.writtenMu.Lock()
_, writtenDuringRead := s.writtenSegs[segIdx]
if writtenDuringRead {
delete(s.writtenSegs, segIdx)
}
s.writtenMu.Unlock()
if writtenDuringRead {
s.mu.Lock()
delete(s.segmentCRCs, segIdx)
s.mu.Unlock()
segDirty++
continue
}
// Compare with previous CRC.
s.mu.Lock()
prevCRC, hasPrev := s.segmentCRCs[segIdx]
s.segmentCRCs[segIdx] = segCRC
s.mu.Unlock()
if hasPrev && prevCRC != segCRC {
log.Printf("scrub: CORRUPTION detected in segment %d (LBAs %d-%d): CRC %08x != %08x",
segIdx, startLBA, startLBA+s.segmentSize-1, segCRC, prevCRC)
segErrors++
s.vol.healthScore.RecordScrubError()
} else {
segClean++
}
// Throttle I/O.
if s.throttle > 0 {
time.Sleep(s.throttle)
}
}
s.vol.healthScore.RecordScrubComplete()
s.mu.Lock()
s.stats.PassCount++
s.stats.ErrorCount += segErrors
s.stats.LastPassTime = time.Now().Unix()
s.stats.SegmentsTotal = int64(totalSegments)
s.stats.SegmentsClean = segClean
s.stats.SegmentsDirty = segDirty
s.stats.SegmentsErrors = segErrors
s.mu.Unlock()
// Record metrics.
if s.vol.Metrics != nil {
s.vol.Metrics.RecordScrubPass(time.Since(passStart), segErrors)
}
}
// segmentHasDirtyBlocks checks if any LBA in the segment has a dirty map entry.
func (s *Scrubber) segmentHasDirtyBlocks(startLBA uint64) bool {
for i := uint64(0); i < s.segmentSize; i++ {
lba := startLBA + i
if lba*uint64(s.vol.super.BlockSize) >= s.vol.super.VolumeSize {
break
}
if _, _, _, ok := s.vol.dirtyMap.Get(lba); ok {
return true
}
}
return false
}
// checksumSegment reads all blocks in a segment from the extent and computes CRC32.
func (s *Scrubber) checksumSegment(segIdx, totalBlocks uint64) (uint32, error) {
startLBA := segIdx * s.segmentSize
blockSize := uint64(s.vol.super.BlockSize)
extentStart := s.vol.super.WALOffset + s.vol.super.WALSize
h := crc32.NewIEEE()
for i := uint64(0); i < s.segmentSize; i++ {
lba := startLBA + i
if lba >= totalBlocks {
break
}
buf := make([]byte, blockSize)
off := int64(extentStart + lba*blockSize)
if _, err := s.vol.fd.ReadAt(buf, off); err != nil {
return 0, err
}
h.Write(buf)
}
return h.Sum32(), nil
}
+297
View File
@@ -0,0 +1,297 @@
package blockvol
import (
"os"
"path/filepath"
"testing"
"time"
)
func createScrubVol(t *testing.T) *BlockVol {
t.Helper()
dir := t.TempDir()
cfg := DefaultConfig()
cfg.FlushInterval = 5 * time.Millisecond
v, err := CreateBlockVol(filepath.Join(dir, "scrub.blk"), CreateOptions{
VolumeSize: 64 * 1024, // 64 KiB = 16 blocks
BlockSize: 4096,
WALSize: 64 * 1024,
}, cfg)
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
return v
}
func TestScrub_CleanVolume(t *testing.T) {
v := createScrubVol(t)
defer v.Close()
scrubber := NewScrubber(v, 24*time.Hour)
scrubber.throttle = 0
// First pass: establishes baseline CRCs.
scrubber.runPass()
stats := scrubber.Stats()
if stats.PassCount != 1 {
t.Fatalf("PassCount: got %d, want 1", stats.PassCount)
}
if stats.ErrorCount != 0 {
t.Fatalf("ErrorCount: got %d, want 0", stats.ErrorCount)
}
// Second pass: same data, no errors.
scrubber.runPass()
stats = scrubber.Stats()
if stats.PassCount != 2 {
t.Fatalf("PassCount: got %d, want 2", stats.PassCount)
}
if stats.ErrorCount != 0 {
t.Fatalf("ErrorCount: got %d, want 0", stats.ErrorCount)
}
}
func TestScrub_DetectCorruption(t *testing.T) {
v := createScrubVol(t)
defer v.Close()
// Write some data and flush it to extent.
data := make([]byte, 4096)
for i := range data {
data[i] = 0xAA
}
if err := v.WriteLBA(0, data); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
if err := v.SyncCache(); err != nil {
t.Fatalf("SyncCache: %v", err)
}
// Wait for flusher to flush.
time.Sleep(50 * time.Millisecond)
scrubber := NewScrubber(v, 24*time.Hour)
scrubber.throttle = 0
// First pass: establish baseline.
scrubber.runPass()
if scrubber.Stats().ErrorCount != 0 {
t.Fatalf("first pass errors: %d", scrubber.Stats().ErrorCount)
}
// Corrupt the extent data directly.
extentStart := v.super.WALOffset + v.super.WALSize
corruptBuf := []byte{0xFF, 0xFF, 0xFF, 0xFF}
v.fd.WriteAt(corruptBuf, int64(extentStart))
v.fd.Sync()
// Second pass: should detect corruption.
scrubber.runPass()
stats := scrubber.Stats()
if stats.ErrorCount == 0 {
t.Fatal("expected corruption error but got none")
}
}
func TestScrub_SkipDirtyBlocks(t *testing.T) {
v := createScrubVol(t)
defer v.Close()
scrubber := NewScrubber(v, 24*time.Hour)
scrubber.throttle = 0
// First pass: baseline.
scrubber.runPass()
// Write data (stays in dirty map since flusher interval is slow relative to us).
data := make([]byte, 4096)
for i := range data {
data[i] = 0xBB
}
if err := v.WriteLBA(0, data); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
// Second pass: segment 0 has dirty blocks, should be skipped.
scrubber.runPass()
stats := scrubber.Stats()
if stats.ErrorCount != 0 {
t.Fatalf("dirty block should be skipped, not flagged as error: errors=%d", stats.ErrorCount)
}
}
func TestScrub_SkipRecentlyWritten(t *testing.T) {
v := createScrubVol(t)
defer v.Close()
scrubber := NewScrubber(v, 24*time.Hour)
scrubber.throttle = 0
// First pass: baseline.
scrubber.runPass()
// Write, sync, flush (data in extent, dirty map cleared).
data := make([]byte, 4096)
for i := range data {
data[i] = 0xCC
}
if err := v.WriteLBA(0, data); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
v.SyncCache()
time.Sleep(50 * time.Millisecond) // let flusher clear dirty map
// Manually notify scrubber of write (simulates hook in appendWithRetry).
scrubber.NotifyWrite(0, 1)
// Second pass: segment 0 was recently written, skip CRC comparison.
scrubber.runPass()
stats := scrubber.Stats()
if stats.ErrorCount != 0 {
t.Fatalf("recently written segment should be skipped: errors=%d", stats.ErrorCount)
}
}
func TestScrub_StatsUpdated(t *testing.T) {
v := createScrubVol(t)
defer v.Close()
scrubber := NewScrubber(v, 24*time.Hour)
scrubber.throttle = 0
scrubber.runPass()
stats := scrubber.Stats()
if stats.LastPassTime == 0 {
t.Fatal("LastPassTime should be set")
}
if stats.SegmentsTotal == 0 {
t.Fatal("SegmentsTotal should be > 0")
}
}
func TestScrub_TriggerNow(t *testing.T) {
v := createScrubVol(t)
defer v.Close()
scrubber := NewScrubber(v, 1*time.Hour) // long interval
scrubber.throttle = 0
scrubber.Start()
defer scrubber.Stop()
scrubber.TriggerNow()
// Wait for the triggered pass to complete.
time.Sleep(100 * time.Millisecond)
stats := scrubber.Stats()
if stats.PassCount < 1 {
t.Fatalf("TriggerNow did not cause a pass: PassCount=%d", stats.PassCount)
}
}
func TestScrub_StopIdempotent(t *testing.T) {
v := createScrubVol(t)
defer v.Close()
scrubber := NewScrubber(v, 24*time.Hour)
scrubber.Start()
scrubber.Stop()
scrubber.Stop() // second stop should be no-op
}
func TestScrub_HealthScoreImpact(t *testing.T) {
dir := t.TempDir()
cfg := DefaultConfig()
cfg.FlushInterval = 5 * time.Millisecond
path := filepath.Join(dir, "health.blk")
v, err := CreateBlockVol(path, CreateOptions{
VolumeSize: 64 * 1024,
BlockSize: 4096,
WALSize: 64 * 1024,
}, cfg)
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
defer v.Close()
if v.HealthScore() != 1.0 {
t.Fatalf("initial health: got %f, want 1.0", v.HealthScore())
}
// Write + flush to extent.
data := make([]byte, 4096)
for i := range data {
data[i] = 0xDD
}
v.WriteLBA(0, data)
v.SyncCache()
time.Sleep(50 * time.Millisecond)
scrubber := NewScrubber(v, 24*time.Hour)
scrubber.throttle = 0
scrubber.runPass()
// Corrupt extent.
extentStart := v.super.WALOffset + v.super.WALSize
corruptBuf := []byte{0xFF}
v.fd.WriteAt(corruptBuf, int64(extentStart))
v.fd.Sync()
scrubber.runPass()
if v.HealthScore() >= 1.0 {
t.Fatalf("health should drop after corruption: got %f", v.HealthScore())
}
// Cleanup temp files on Windows: remove delta files if any.
_ = os.RemoveAll(dir)
}
// Fix #5: Write arriving during a scrub pass must not cause a false corruption detection.
func TestScrub_InPassWrite_NoFalsePositive(t *testing.T) {
v := createScrubVol(t)
defer v.Close()
// Write initial data and flush to extent.
data := make([]byte, 4096)
for i := range data {
data[i] = 0xAA
}
if err := v.WriteLBA(0, data); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
v.SyncCache()
time.Sleep(50 * time.Millisecond) // let flusher clear dirty map
scrubber := NewScrubber(v, 24*time.Hour)
scrubber.throttle = 0
// First pass: establish baseline CRC for segment 0.
scrubber.runPass()
if scrubber.Stats().ErrorCount != 0 {
t.Fatalf("baseline pass should have no errors: %d", scrubber.Stats().ErrorCount)
}
// Now write different data and flush — this changes the extent.
for i := range data {
data[i] = 0xBB
}
if err := v.WriteLBA(0, data); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
v.SyncCache()
time.Sleep(50 * time.Millisecond) // flushed to extent, dirty map cleared
// Simulate in-pass write: NotifyWrite arrives AFTER pass would have
// snapshotted writtenSegs (old bug), but BEFORE segment 0 is read.
// With the fix, writtenSegs is checked live during the pass.
scrubber.NotifyWrite(0, 1)
// Second pass: segment 0 extent data changed, but NotifyWrite should
// prevent false corruption — segment should be skipped.
scrubber.runPass()
stats := scrubber.Stats()
if stats.ErrorCount != 0 {
t.Fatalf("in-pass write caused false corruption: errors=%d", stats.ErrorCount)
}
if stats.SegmentsDirty == 0 {
t.Fatal("segment 0 should be counted as dirty (recently written)")
}
}
+114
View File
@@ -0,0 +1,114 @@
package blockvol
import (
"sync"
)
// ShipperGroup wraps multiple WALShippers for fan-out replication to N replicas.
// Len()==0 means standalone (no replicas), Len()==1 is RF=2, Len()==2 is RF=3.
type ShipperGroup struct {
mu sync.RWMutex
shippers []*WALShipper
}
// NewShipperGroup creates a ShipperGroup from the given shippers.
func NewShipperGroup(shippers []*WALShipper) *ShipperGroup {
return &ShipperGroup{shippers: shippers}
}
// ShipAll sends a WAL entry to every non-degraded shipper (fire-and-forget).
func (sg *ShipperGroup) ShipAll(entry *WALEntry) {
sg.mu.RLock()
defer sg.mu.RUnlock()
for _, s := range sg.shippers {
s.Ship(entry)
}
}
// BarrierAll sends barriers to all shippers in parallel.
// Returns a per-shipper error slice (nil entry = success).
func (sg *ShipperGroup) BarrierAll(lsnMax uint64) []error {
sg.mu.RLock()
n := len(sg.shippers)
shippers := sg.shippers
sg.mu.RUnlock()
errs := make([]error, n)
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func(idx int) {
defer wg.Done()
errs[idx] = shippers[idx].Barrier(lsnMax)
}(i)
}
wg.Wait()
return errs
}
// AllDegraded returns true only if every shipper is degraded.
// Returns false when there are no shippers (standalone).
func (sg *ShipperGroup) AllDegraded() bool {
sg.mu.RLock()
defer sg.mu.RUnlock()
if len(sg.shippers) == 0 {
return false
}
for _, s := range sg.shippers {
if !s.IsDegraded() {
return false
}
}
return true
}
// AnyDegraded returns true if at least one shipper is degraded.
func (sg *ShipperGroup) AnyDegraded() bool {
sg.mu.RLock()
defer sg.mu.RUnlock()
for _, s := range sg.shippers {
if s.IsDegraded() {
return true
}
}
return false
}
// DegradedCount returns the number of degraded shippers.
func (sg *ShipperGroup) DegradedCount() int {
sg.mu.RLock()
defer sg.mu.RUnlock()
count := 0
for _, s := range sg.shippers {
if s.IsDegraded() {
count++
}
}
return count
}
// StopAll stops all shippers and closes their connections.
func (sg *ShipperGroup) StopAll() {
sg.mu.Lock()
defer sg.mu.Unlock()
for _, s := range sg.shippers {
s.Stop()
}
}
// Len returns the number of shippers. 0=standalone, 1=RF2, 2=RF3.
func (sg *ShipperGroup) Len() int {
sg.mu.RLock()
defer sg.mu.RUnlock()
return len(sg.shippers)
}
// Shipper returns the shipper at index i. For internal/test use.
func (sg *ShipperGroup) Shipper(i int) *WALShipper {
sg.mu.RLock()
defer sg.mu.RUnlock()
if i < 0 || i >= len(sg.shippers) {
return nil
}
return sg.shippers[i]
}
+116
View File
@@ -0,0 +1,116 @@
package blockvol
import (
"sync/atomic"
"testing"
)
func TestShipperGroup_ShipAll_Single(t *testing.T) {
s := newTestShipper()
sg := NewShipperGroup([]*WALShipper{s})
entry := &WALEntry{LSN: 1, Epoch: 1, Type: EntryTypeWrite}
sg.ShipAll(entry)
// Ship on a non-connected shipper degrades it but doesn't panic.
if sg.Len() != 1 {
t.Fatalf("Len: got %d, want 1", sg.Len())
}
}
func TestShipperGroup_ShipAll_Two(t *testing.T) {
s1 := newTestShipper()
s2 := newTestShipper()
sg := NewShipperGroup([]*WALShipper{s1, s2})
entry := &WALEntry{LSN: 1, Epoch: 1, Type: EntryTypeWrite}
sg.ShipAll(entry)
if sg.Len() != 2 {
t.Fatalf("Len: got %d, want 2", sg.Len())
}
}
func TestShipperGroup_BarrierAll_AllSucceed(t *testing.T) {
// Create shippers that are stopped (Barrier returns ErrShipperStopped).
// We test the fan-out logic: all return errors, which is fine.
s1 := newTestShipper()
s2 := newTestShipper()
sg := NewShipperGroup([]*WALShipper{s1, s2})
errs := sg.BarrierAll(10)
if len(errs) != 2 {
t.Fatalf("BarrierAll: got %d errors, want 2", len(errs))
}
}
func TestShipperGroup_BarrierAll_OneFail(t *testing.T) {
s1 := newTestShipper()
s1.degraded.Store(true)
s2 := newTestShipper()
sg := NewShipperGroup([]*WALShipper{s1, s2})
errs := sg.BarrierAll(10)
if errs[0] == nil {
t.Fatalf("expected error for degraded shipper[0]")
}
}
func TestShipperGroup_BarrierAll_AllFail(t *testing.T) {
s1 := newTestShipper()
s1.degraded.Store(true)
s2 := newTestShipper()
s2.degraded.Store(true)
sg := NewShipperGroup([]*WALShipper{s1, s2})
errs := sg.BarrierAll(10)
for i, e := range errs {
if e == nil {
t.Fatalf("expected error for shipper[%d]", i)
}
}
}
func TestShipperGroup_AllDegraded_Empty(t *testing.T) {
sg := NewShipperGroup(nil)
if sg.AllDegraded() {
t.Fatal("empty group should not be AllDegraded")
}
}
func TestShipperGroup_AllDegraded_Mixed(t *testing.T) {
s1 := newTestShipper()
s1.degraded.Store(true)
s2 := newTestShipper()
sg := NewShipperGroup([]*WALShipper{s1, s2})
if sg.AllDegraded() {
t.Fatal("mixed group should not be AllDegraded")
}
if !sg.AnyDegraded() {
t.Fatal("mixed group should be AnyDegraded")
}
}
func TestShipperGroup_StopAll(t *testing.T) {
s1 := newTestShipper()
s2 := newTestShipper()
sg := NewShipperGroup([]*WALShipper{s1, s2})
sg.StopAll()
if !s1.stopped.Load() || !s2.stopped.Load() {
t.Fatal("StopAll should stop all shippers")
}
}
func TestShipperGroup_DegradedCount(t *testing.T) {
s1 := newTestShipper()
s2 := newTestShipper()
s1.degraded.Store(true)
sg := NewShipperGroup([]*WALShipper{s1, s2})
if got := sg.DegradedCount(); got != 1 {
t.Fatalf("DegradedCount: got %d, want 1", got)
}
}
// newTestShipper creates a WALShipper with a fixed epoch, not connected to anything.
func newTestShipper() *WALShipper {
var epoch atomic.Uint64
epoch.Store(1)
return &WALShipper{
dataAddr: "127.0.0.1:0",
controlAddr: "127.0.0.1:0",
epochFn: func() uint64 { return epoch.Load() },
}
}
+32 -1
View File
@@ -23,6 +23,7 @@ type WALShipper struct {
dataAddr string
controlAddr string
epochFn func() uint64
metrics *EngineMetrics
mu sync.Mutex // protects dataConn
dataConn net.Conn
@@ -37,11 +38,17 @@ type WALShipper struct {
// NewWALShipper creates a WAL shipper. Connections are established lazily on
// first Ship/Barrier call. epochFn returns the current epoch for validation.
func NewWALShipper(dataAddr, controlAddr string, epochFn func() uint64) *WALShipper {
// metrics is optional; if nil, no metrics are recorded.
func NewWALShipper(dataAddr, controlAddr string, epochFn func() uint64, metrics ...*EngineMetrics) *WALShipper {
var m *EngineMetrics
if len(metrics) > 0 {
m = metrics[0]
}
return &WALShipper{
dataAddr: dataAddr,
controlAddr: controlAddr,
epochFn: epochFn,
metrics: m,
}
}
@@ -83,6 +90,9 @@ func (s *WALShipper) Ship(entry *WALEntry) error {
s.dataConn.SetWriteDeadline(time.Time{}) // clear deadline
s.shippedLSN.Store(entry.LSN)
if s.metrics != nil {
s.metrics.RecordWALShipped()
}
return nil
}
@@ -97,6 +107,8 @@ func (s *WALShipper) Barrier(lsnMax uint64) error {
return ErrReplicaDegraded
}
barrierStart := time.Now()
req := EncodeBarrierRequest(BarrierRequest{
LSN: lsnMax,
Epoch: s.epochFn(),
@@ -107,6 +119,7 @@ func (s *WALShipper) Barrier(lsnMax uint64) error {
if err := s.ensureCtrlConn(); err != nil {
s.markDegraded()
s.recordBarrierMetric(barrierStart, true)
return ErrReplicaDegraded
}
@@ -114,34 +127,52 @@ func (s *WALShipper) Barrier(lsnMax uint64) error {
if err := WriteFrame(s.ctrlConn, MsgBarrierReq, req); err != nil {
s.markDegraded()
s.recordBarrierMetric(barrierStart, true)
return ErrReplicaDegraded
}
msgType, payload, err := ReadFrame(s.ctrlConn)
if err != nil {
s.markDegraded()
s.recordBarrierMetric(barrierStart, true)
return ErrReplicaDegraded
}
if msgType != MsgBarrierResp || len(payload) < 1 {
s.markDegraded()
s.recordBarrierMetric(barrierStart, true)
return ErrReplicaDegraded
}
switch payload[0] {
case BarrierOK:
s.recordBarrierMetric(barrierStart, false)
return nil
case BarrierEpochMismatch:
s.markDegraded()
s.recordBarrierMetric(barrierStart, true)
return fmt.Errorf("wal_shipper: barrier epoch mismatch")
case BarrierTimeout:
s.markDegraded()
s.recordBarrierMetric(barrierStart, true)
return fmt.Errorf("wal_shipper: barrier timeout on replica")
case BarrierFsyncFailed:
s.markDegraded()
s.recordBarrierMetric(barrierStart, true)
return fmt.Errorf("wal_shipper: barrier fsync failed on replica")
default:
s.markDegraded()
s.recordBarrierMetric(barrierStart, true)
return fmt.Errorf("wal_shipper: unknown barrier status %d", payload[0])
}
}
func (s *WALShipper) recordBarrierMetric(start time.Time, failed bool) {
if s.metrics != nil {
s.metrics.RecordWALBarrier(time.Since(start), failed)
}
}
// ShippedLSN returns the highest LSN successfully sent to the replica.
func (s *WALShipper) ShippedLSN() uint64 {
return s.shippedLSN.Load()