Files
seaweedfs/weed/server/master_grpc_server_collection_test.go
T
Chris LuandGitHub ba5b14b457 master, filer, s3api: bound the collection deletes that strand a caller (#11026)
* master: bound each volume server DeleteCollection, and finish the fan-out

A collection delete fanned out to every volume server holding it with
context.Background(), so a server that accepted the connection and then
went quiet held the whole delete open with nothing to end it. Each RPC is
bounded now, on the same budget allocateVolumeTimeout gives the other
master-to-volume-server admin RPC. The volume server runs the delete to
completion regardless of the request context, so giving up costs the
confirmation and not the deletion.

The walk itself is the caller's, not a per-server one:

- It outlives the caller. A cancelled request must not abandon a
  destructive fan-out part-done, with volumes left behind and no request
  still running to come back for them.
- It no longer stops at the first server that refuses, which left the
  collection on every server after it in the list. The first failure is
  still what is reported, and the collection stays in the topology so a
  later delete comes back for the rest.
- It sends one RPC per server rather than one per replica.
  ListVolumeServers reports a node once for every replica it holds, while
  DeleteCollection removes the whole collection from the server it
  reaches, so a collection with thousands of volumes repeated the same
  whole-collection delete thousands of times over.

Both passes run too. Returning after a failed normal pass left the
collection's EC shards in place with nothing left to retry them.

Claude-Session: https://claude.ai/code/session_01EnB1fbryyKc2LetRZxQPTP

* master: delete the EC shards behind /col/delete too

The HTTP handler carried its own copy of the volume-server walk and only
ever ran the normal pass, so a collection deleted through it kept its EC
shards. It shares the gRPC path now, which also gets it the bounded RPCs
and the one-per-server fan-out.

Claude-Session: https://claude.ai/code/session_01EnB1fbryyKc2LetRZxQPTP

* filer: bound the collection delete a bucket delete leaves behind

Deleting a bucket entry deletes its collection afterwards, deliberately
detached from the request so a client that hangs up cannot strand the
bucket's volumes. Detached meant unbounded, though: with the master down
or mid-election the wait for a leader has nothing to end it, so the
handler parks, and the client retrying behind it parks another.

It keeps outliving the request and now carries a deadline of its own. The
budget bounds the wait, not the work: the master keeps deleting on its own
fan-out once asked, so giving up costs the confirmation.

Claude-Session: https://claude.ai/code/session_01EnB1fbryyKc2LetRZxQPTP

* s3api: bound the collection RPCs a bucket creation and deletion issue

Neither carried a deadline, so a transient failure anywhere down the chain
held the S3 request open until the client gave up on it. Both budgets are
taken outside the filer failover walk, so one budget covers the whole walk
rather than granting each filer a fresh one.

The walk itself stops when that budget is spent, and stops without blaming
anyone: the caller's own expiry is not evidence against the filer that was
answering, and the next filer has no time left to answer in either.
Recorded as a filer failure, a slow master upstream would flag every filer
in the walk, and the three failures that open the circuit take unrelated
object reads down with them.

Claude-Session: https://claude.ai/code/session_01EnB1fbryyKc2LetRZxQPTP

* s3api: a failed collection listing no longer fails a bucket creation

PutBucket lists collections to notice a leftover one it is about to reuse.
The result feeds a warning and nothing else -- s3a.exists is what decides
whether the bucket already exists -- yet a transient failure of that
listing returned 500 and refused the creation. It is advisory now, so a
failure is logged and the creation continues, exactly as it does when the
listing returns false.

Claude-Session: https://claude.ai/code/session_01EnB1fbryyKc2LetRZxQPTP
2026-08-28 16:32:30 -07:00

288 lines
10 KiB
Go

package weed_server
import (
"context"
"errors"
"sync"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/topology"
)
// recordingVolumeServer reports what each DeleteCollection arrived carrying.
// failWith answers an error at once; hang holds the call until the caller gives
// up or the test releases it, the way a server that is still reachable but
// wedged behaves.
type recordingVolumeServer struct {
volume_server_pb.UnimplementedVolumeServerServer
hang bool
failWith error
calls chan deleteCollectionCall
release chan struct{}
releaseOnce sync.Once
}
type deleteCollectionCall struct {
collection string
// budget is the time the RPC arrived with, or 0 when it carried no deadline.
budget time.Duration
}
func newRecordingVolumeServer() *recordingVolumeServer {
return &recordingVolumeServer{
calls: make(chan deleteCollectionCall, 8),
release: make(chan struct{}),
}
}
func (s *recordingVolumeServer) DeleteCollection(ctx context.Context, req *volume_server_pb.DeleteCollectionRequest) (*volume_server_pb.DeleteCollectionResponse, error) {
call := deleteCollectionCall{collection: req.Collection}
if deadline, ok := ctx.Deadline(); ok {
call.budget = time.Until(deadline)
}
s.calls <- call
if s.failWith != nil {
return nil, s.failWith
}
if s.hang {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-s.release:
}
}
return &volume_server_pb.DeleteCollectionResponse{}, nil
}
// Release unblocks every held DeleteCollection. Safe to call more than once.
func (s *recordingVolumeServer) Release() {
s.releaseOnce.Do(func() { close(s.release) })
}
// addVolumeServer publishes stub on a fresh listener and registers it as a data
// node of topo. Only the grpc port is dialed; the http port just has to stay
// distinct per server so the topology does not treat two nodes as one address.
func addVolumeServer(t *testing.T, topo *topology.Topology, id string, stub *recordingVolumeServer) *topology.DataNode {
t.Helper()
t.Cleanup(stub.Release)
grpcPort := serveGrpc(t, func(s *grpc.Server) {
volume_server_pb.RegisterVolumeServerServer(s, stub)
})
return topo.GetOrCreateDataCenter("dc1").GetOrCreateRack("rack1").
GetOrCreateDataNode("127.0.0.1", grpcPort-10000, grpcPort, "", id, map[string]uint32{"": 10})
}
func newCollectionTestMaster(topo *topology.Topology) *MasterServer {
return &MasterServer{
Topo: topo,
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
}
}
// awaitCall waits for one DeleteCollection to reach the stub.
func awaitCall(t *testing.T, stub *recordingVolumeServer, what string) deleteCollectionCall {
t.Helper()
select {
case call := <-stub.calls:
return call
case <-time.After(15 * time.Second):
t.Fatalf("%s: the volume server never received DeleteCollection", what)
return deleteCollectionCall{}
}
}
// A caller with no deadline of its own must still not wait forever: the master
// has to bound each RPC, or one wedged volume server holds the fan-out open with
// nothing to end it.
func TestDoDeleteNormalCollectionBoundsEachVolumeServerRPC(t *testing.T) {
const collection = "bucket-a"
stub := newRecordingVolumeServer()
stub.hang = true
topo := topology.NewTopology("test", nil, 32*1024, 5, false)
dn := addVolumeServer(t, topo, "vs1", stub)
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{
{Id: 1, Collection: collection, Version: 3},
}, dn)
ms := newCollectionTestMaster(topo)
done := make(chan error, 1)
go func() { done <- ms.doDeleteNormalCollection(context.Background(), collection) }()
call := awaitCall(t, stub, "bounded RPC")
if call.budget <= 0 {
t.Fatal("DeleteCollection reached the volume server with no deadline: a wedged server holds the fan-out open with nothing to end it")
}
if call.budget > deleteCollectionTimeout {
t.Errorf("DeleteCollection budget = %v, want at most deleteCollectionTimeout %v", call.budget, deleteCollectionTimeout)
}
stub.Release()
select {
case err := <-done:
if err != nil {
t.Errorf("doDeleteNormalCollection returned %v, want nil once the volume server answers", err)
}
case <-time.After(15 * time.Second):
t.Fatal("doDeleteNormalCollection did not return after the volume server answered")
}
}
// The fan-out must outlive the caller. A collection can span many servers and
// the deletes are sequential, so letting an inbound cancellation end the loop
// would abandon a large delete part-done, with volumes left behind and no
// request still running to come back for them.
func TestDeleteCollectionOutlivesTheCallersCancellation(t *testing.T) {
const collection = "bucket-b"
normal := newRecordingVolumeServer()
ec := newRecordingVolumeServer()
topo := topology.NewTopology("test", nil, 32*1024, 5, false)
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{
{Id: 1, Collection: collection, Version: 3},
}, addVolumeServer(t, topo, "vs-normal", normal))
topo.SyncDataNodeEcShards([]*master_pb.VolumeEcShardInformationMessage{
{Id: 9, Collection: collection, EcIndexBits: 0x1f},
}, addVolumeServer(t, topo, "vs-ec", ec))
ms := newCollectionTestMaster(topo)
// The caller is already gone by the time the delete starts.
expired, cancel := context.WithCancel(context.Background())
cancel()
if err := ms.deleteCollection(expired, collection); err != nil {
t.Fatalf("deleteCollection returned %v; a cancelled caller must not fail the fan-out", err)
}
// The EC pass runs second, so it is the one most likely to find the caller
// already gone.
for _, tc := range []struct {
stub *recordingVolumeServer
what string
}{{normal, "normal pass"}, {ec, "ec pass"}} {
call := awaitCall(t, tc.stub, "cancelled caller, "+tc.what)
if call.collection != collection {
t.Errorf("%s: server was told to delete %q, want %q", tc.what, call.collection, collection)
}
if call.budget <= 0 {
t.Errorf("%s: DeleteCollection arrived with no deadline; each RPC should still be bounded", tc.what)
}
}
if _, found := topo.FindCollection(collection); found {
t.Error("the collection was left in the topology; the delete did not run to completion")
}
}
// ListVolumeServers reports a node once per replica it holds. DeleteCollection
// removes the whole collection from the server it reaches, so the master must
// send it once per server, not once per replica.
func TestDoDeleteNormalCollectionSendsOneRPCPerVolumeServer(t *testing.T) {
const collection = "bucket-c"
stub := newRecordingVolumeServer()
topo := topology.NewTopology("test", nil, 32*1024, 5, false)
dn := addVolumeServer(t, topo, "vs1", stub)
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{
{Id: 1, Collection: collection, Version: 3},
{Id: 2, Collection: collection, Version: 3},
{Id: 3, Collection: collection, Version: 3},
}, dn)
collectionInTopology, found := topo.FindCollection(collection)
if !found {
t.Fatalf("test setup: collection %s was not registered", collection)
}
if listed := len(collectionInTopology.ListVolumeServers()); listed < 2 {
t.Fatalf("test setup: the one node is listed %d times, expected once per replica", listed)
}
ms := newCollectionTestMaster(topo)
if err := ms.doDeleteNormalCollection(context.Background(), collection); err != nil {
t.Fatalf("doDeleteNormalCollection: %v", err)
}
awaitCall(t, stub, "one RPC per server")
select {
case extra := <-stub.calls:
t.Errorf("the same server was told to delete %q more than once", extra.collection)
default:
}
}
// One server refusing must not leave the collection on every server after it in
// the list: returning at the first failure meant a single node that was down
// stranded the rest, and the failure is reported either way.
func TestDoDeleteNormalCollectionKeepsGoingPastAFailingServer(t *testing.T) {
const collection = "bucket-d"
failing := newRecordingVolumeServer()
failing.failWith = errors.New("volume server is out of disk")
healthy := newRecordingVolumeServer()
topo := topology.NewTopology("test", nil, 32*1024, 5, false)
failingNode := addVolumeServer(t, topo, "vs-failing", failing)
healthyNode := addVolumeServer(t, topo, "vs-healthy", healthy)
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{
{Id: 1, Collection: collection, Version: 3},
}, failingNode)
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{
{Id: 1, Collection: collection, Version: 3},
}, healthyNode)
ms := newCollectionTestMaster(topo)
if err := ms.doDeleteNormalCollection(context.Background(), collection); err == nil {
t.Fatal("doDeleteNormalCollection reported success while a server refused")
}
awaitCall(t, failing, "failing server")
awaitCall(t, healthy, "server behind the failing one")
if _, found := topo.FindCollection(collection); !found {
t.Error("the collection was dropped from the topology despite failing; nothing would come back for the rest")
}
}
// A collection can hold normal volumes and EC shards at once. Returning after a
// failed normal pass left the EC shards in place with no request left to come
// back for them, so both passes have to run.
func TestDeleteCollectionRunsTheEcPassWhenTheNormalPassFails(t *testing.T) {
const collection = "bucket-e"
normal := newRecordingVolumeServer()
normal.failWith = errors.New("volume server is out of disk")
ec := newRecordingVolumeServer()
topo := topology.NewTopology("test", nil, 32*1024, 5, false)
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{
{Id: 1, Collection: collection, Version: 3},
}, addVolumeServer(t, topo, "vs-normal", normal))
topo.SyncDataNodeEcShards([]*master_pb.VolumeEcShardInformationMessage{
{Id: 9, Collection: collection, EcIndexBits: 0x1f},
}, addVolumeServer(t, topo, "vs-ec", ec))
ms := newCollectionTestMaster(topo)
if err := ms.deleteCollection(context.Background(), collection); err == nil {
t.Fatal("deleteCollection reported success while the normal pass failed")
}
// The failure is reported, and the EC shards are cleaned up anyway.
awaitCall(t, ec, "ec pass after a failed normal pass")
if _, found := topo.FindCollection(collection); !found {
t.Error("the normal collection was dropped from the topology despite failing; nothing would retry it")
}
}