Files
seaweedfs/weed/s3api/s3api_handlers_failover_test.go
T
Chris Lu 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

190 lines
6.5 KiB
Go

package s3api
import (
"context"
"io"
"math"
"reflect"
"sync/atomic"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
)
// fakeListFiler serves ListEntries from names; the first failCalls calls die
// with Unavailable after streaming sendBeforeFail entries.
type fakeListFiler struct {
filer_pb.UnimplementedSeaweedFilerServer
names []string
failCalls int32
sendBeforeFail int
calls int32
}
func (f *fakeListFiler) ListEntries(req *filer_pb.ListEntriesRequest, stream filer_pb.SeaweedFiler_ListEntriesServer) error {
names := f.names
failing := atomic.AddInt32(&f.calls, 1) <= f.failCalls
if failing {
names = names[:f.sendBeforeFail]
}
for _, name := range names {
if err := stream.Send(&filer_pb.ListEntriesResponse{Entry: &filer_pb.Entry{Name: name}}); err != nil {
return err
}
}
if failing {
return status.Error(codes.Unavailable, "filer restarting")
}
return nil
}
func newFailoverTestServer(t *testing.T, filers ...pb.ServerAddress) *S3ApiServer {
t.Helper()
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
return &S3ApiServer{
option: &S3ApiServerOption{Filers: filers, GrpcDialOption: dialOption},
filerClient: wdclient.NewFilerClient(filers, dialOption, ""),
}
}
// accumulateListing is the callback shape the failover contract has to protect:
// entries collect into a variable that survives a replay of the callback.
func accumulateListing(got *[]string) func(filer_pb.SeaweedFilerClient) error {
return func(client filer_pb.SeaweedFilerClient) error {
stream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{Directory: "/d"})
if err != nil {
return err
}
for {
resp, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
*got = append(*got, resp.Entry.Name)
}
}
}
// A filer that dies mid-stream must surface the error, not fail over: the
// callback has already consumed part of the response, and replaying it against
// the next filer would append a second copy of what it accumulated.
func TestFailoverStopsAfterPartialResponse(t *testing.T) {
names := []string{"e1", "e2", "e3", "e4"}
flaky := &fakeListFiler{names: names, failCalls: math.MaxInt32, sendBeforeFail: 2}
healthy := &fakeListFiler{names: names}
s3a := newFailoverTestServer(t, startFakeFiler(t, flaky), startFakeFiler(t, healthy))
var got []string
err := s3a.WithFilerClient(true, accumulateListing(&got))
if err == nil {
t.Fatalf("want the mid-stream failure surfaced, got success with %v", got)
}
if calls := atomic.LoadInt32(&healthy.calls); calls != 0 {
t.Fatalf("callback was replayed on the second filer %d time(s)", calls)
}
}
// A filer that fails before delivering anything is still failed over, so the
// partial-response guard does not cost the healthy-peer retry that failover exists for.
func TestFailoverBeforeFirstResponse(t *testing.T) {
names := []string{"e1", "e2", "e3", "e4"}
flaky := &fakeListFiler{names: names, failCalls: math.MaxInt32, sendBeforeFail: 0}
healthy := &fakeListFiler{names: names}
s3a := newFailoverTestServer(t, startFakeFiler(t, flaky), startFakeFiler(t, healthy))
var got []string
err := s3a.WithFilerClient(true, accumulateListing(&got))
if err != nil {
t.Fatalf("want failover success, got %v", err)
}
if !reflect.DeepEqual(got, names) {
t.Fatalf("entries = %v, want %v", got, names)
}
}
// End to end through the real listing path: a mid-stream failure surfaces to
// listWithRetry, whose replay starts from a fresh accumulator, so the caller
// sees each entry exactly once instead of a silently duplicated prefix.
func TestListAfterMidStreamFailureHasNoDuplicates(t *testing.T) {
names := []string{"e1", "e2", "e3", "e4"}
flaky := &fakeListFiler{names: names, failCalls: 1, sendBeforeFail: 2}
healthy := &fakeListFiler{names: names}
s3a := newFailoverTestServer(t, startFakeFiler(t, flaky), startFakeFiler(t, healthy))
entries, isLast, err := s3a.list("/d", "", "", false, 10)
if err != nil {
t.Fatalf("list: %v", err)
}
var got []string
for _, entry := range entries {
got = append(got, entry.Name)
}
if !reflect.DeepEqual(got, names) {
t.Fatalf("entries = %v, want %v", got, names)
}
if !isLast {
t.Fatal("want isLast")
}
}
// hangingCollectionFiler answers CollectionList only when the caller gives up,
// standing in for a filer waiting on a master that has stopped answering.
type hangingCollectionFiler struct {
filer_pb.UnimplementedSeaweedFilerServer
calls atomic.Int32
}
func (f *hangingCollectionFiler) CollectionList(ctx context.Context, _ *filer_pb.CollectionListRequest) (*filer_pb.CollectionListResponse, error) {
f.calls.Add(1)
<-ctx.Done()
return nil, ctx.Err()
}
// The caller's own budget expiring says nothing about the filer that was
// answering it. Counted as a filer failure it would, after the three that open
// the circuit, mark every filer in the walk unhealthy -- and LookupVolumeIds
// skips unhealthy filers, so unrelated object reads would start failing over a
// slow master they never touched.
func TestFailoverDoesNotBlameFilersForTheCallersExpiredBudget(t *testing.T) {
first := &hangingCollectionFiler{}
second := &hangingCollectionFiler{}
firstAddr := startFakeFiler(t, first)
secondAddr := startFakeFiler(t, second)
s3a := newFailoverTestServer(t, firstAddr, secondAddr)
// More attempts than the three failures it takes to open the circuit.
for attempt := 0; attempt < 5; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
err := s3a.withFilerClient(ctx, false, func(client filer_pb.SeaweedFilerClient) error {
_, listErr := client.CollectionList(ctx, &filer_pb.CollectionListRequest{})
return listErr
})
cancel()
if err == nil {
t.Fatalf("attempt %d: want the expired budget surfaced as an error", attempt)
}
}
for _, addr := range []pb.ServerAddress{firstAddr, secondAddr} {
if s3a.filerClient.ShouldSkipUnhealthyFiler(addr) {
t.Errorf("filer %s was flagged unhealthy for the caller's own timeout; unrelated reads would now skip it", addr)
}
}
// And the walk stops rather than spending an already-spent budget on the next
// filer, which cannot answer any faster.
if n := second.calls.Load(); n != 0 {
t.Errorf("the second filer was tried %d times with no budget left to answer in", n)
}
}