Files
seaweedfs/weed/command/filer_shutdown_test.go
T
ea65746947 fix(filer): end metadata subscriptions before gRPC GracefulStop on shutdown (#11663)
* fix(filer): end metadata subscriptions before gRPC GracefulStop on shutdown

GracefulStop waits for every open stream. The filer's own MetaAggregator
subscription and, under -s3, the S3 gateway's IAM subscription live in
the same process and only end when the filer shuts down, which happens
after GracefulStop - so every shutdown sat out the full 15s timeout.

StopSubscriptions ends them (and any started later) right after leaving
the lock ring; the handlers derive their context from the stream and
this signal. Measured on weed server -s3: stop 25.6s -> 10.7s, and
0.6s with -volume.preStopSeconds=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FTq6bDpgdQfqQagQsUUvdw

* fix(filer): end stopped subscriptions with Unavailable, not cleanly

A subscription that StopSubscriptions cut short returned nil. The client
reads that as io.EOF, "caught up, done", and util.RetryUntil - which the
S3 gateway and mount follow with - stops on nil. A separate S3 gateway
therefore never resubscribed after its filer restarted (measured: 0
subscriptions in 25s after the restart; upstream master and this fix: it
is back within a second).

endOfSubscription now turns that clean end into codes.Unavailable, which
is what a dropped connection looks like, so followers reconnect. Errors
pass through, and so does the end of a stream the client closed.

A subscriber that has stopped reading is still left to GracefulStop's
timeout: its handler sits in stream.Send, which only the end of the
stream releases, and grpc-go does not allow Send after the handler
returns. Documented at StopSubscriptions. Shutdown under weed server -s3
is unchanged at 10.7s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FTq6bDpgdQfqQagQsUUvdw

* fix(filer): interrupt in-flight subscription reads and end them Unavailable

StopSubscriptions only reached the wait points: a handler replaying the
ring or the persisted log kept streaming until the pass ended, and a pass
cut short at a ctx.Err() boundary (the replay semaphore, chunk ref
collection, ref sends) returned a wrapped context.Canceled, which reads as
codes.Canceled on the wire - a code transient-error classifiers treat as
non-retryable.

eachLogEntryFn and sendRefsBatched now check the subscription context per
entry/batch, and endOfSubscription reports a context.Canceled cut short by
the stop signal as codes.Unavailable, same as a clean end it interrupted.

* filer: honor the subscription context in the pipelined sender

A subscriber that stops reading wedges sendLoop in stream.Send; Send and
Close then blocked past StopSubscriptions, so GracefulStop still waited out
its timeout. Both now return when the subscription context ends, letting the
handler return and gRPC tear the stream down.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* filer: stop sendLoop from starting sends after the subscription ends

With Send and Close released by the subscription context, sendLoop could
still issue a stream.Send after the handler returned. Gate every send on
the context so at most the in-flight Send overlaps teardown - and the
stream's own teardown is what unblocks it.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* filer: assert sendLoop exits in the cancel test

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 10:59:00 +08:00

227 lines
6.1 KiB
Go

package command
import (
"io"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestFilerShutdownJoinsServeDuringParallelDrain(t *testing.T) {
var servers []*http.Server
var releases []func()
var completed []chan struct{}
var listenersClosing []chan struct{}
for range 2 {
entered := make(chan struct{})
release := make(chan struct{})
finish := sync.OnceFunc(func() { close(release) })
done := make(chan struct{})
closing := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
close(entered)
<-release
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
t.Cleanup(finish)
server.Config.RegisterOnShutdown(func() { close(closing) })
servers = append(servers, server.Config)
releases = append(releases, finish)
completed = append(completed, done)
listenersClosing = append(listenersClosing, closing)
go func() {
defer close(done)
response, err := server.Client().Get(server.URL)
if err != nil {
t.Error(err)
return
}
defer response.Body.Close()
io.Copy(io.Discard, response.Body)
if response.StatusCode != http.StatusNoContent {
t.Errorf("active request returned %s", response.Status)
}
}()
<-entered
}
grpcStarted := make(chan struct{})
grpcRelease := make(chan struct{})
releaseGrpc := sync.OnceFunc(func() { close(grpcRelease) })
t.Cleanup(releaseGrpc)
grpcStopped := make(chan struct{})
filerClosed := make(chan struct{})
var closes atomic.Int32
shutdown := newFilerShutdown(func() {}, func() {}, func() {
close(grpcStarted)
<-grpcRelease
close(grpcStopped)
}, func() {
closes.Add(1)
close(filerClosed)
}, servers...)
joined := make(chan struct{})
go func() { shutdown(); close(joined) }()
deadline := time.After(5 * time.Second)
select {
case <-grpcStarted:
case <-deadline:
t.Fatal("gRPC shutdown did not start")
}
for _, closing := range listenersClosing {
select {
case <-closing:
case <-deadline:
t.Fatal("HTTP shutdown did not start while gRPC was draining")
}
}
// Model the main Serve path joining shutdown as soon as listeners close.
serveReturned := make(chan struct{})
go func() { shutdown(); close(serveReturned) }()
releases[0]()
<-completed[0]
select {
case <-filerClosed:
t.Error("filer closed while another server still had an active request")
default:
}
select {
case <-serveReturned:
t.Error("Serve exit path returned before request draining completed")
default:
}
releases[1]()
<-completed[1]
select {
case <-filerClosed:
t.Error("filer closed while gRPC was still draining")
default:
}
releaseGrpc()
<-grpcStopped
<-joined
<-serveReturned
if closes.Load() != 1 {
t.Errorf("filer closed %d times, want once", closes.Load())
}
}
func TestFilerShutdownWaitsForHTTPAfterGrpcStops(t *testing.T) {
entered := make(chan struct{})
release := make(chan struct{})
finish := sync.OnceFunc(func() { close(release) })
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
close(entered)
<-release
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
t.Cleanup(finish)
requestDone := make(chan struct{})
go func() {
defer close(requestDone)
response, err := server.Client().Get(server.URL)
if err != nil {
t.Error(err)
return
}
response.Body.Close()
}()
<-entered
httpClosing := make(chan struct{})
server.Config.RegisterOnShutdown(func() { close(httpClosing) })
grpcStopped := make(chan struct{})
filerClosed := make(chan struct{})
shutdown := newFilerShutdown(func() {}, func() {}, func() { close(grpcStopped) }, func() { close(filerClosed) }, server.Config)
joined := make(chan struct{})
go func() { shutdown(); close(joined) }()
<-httpClosing
<-grpcStopped
select {
case <-filerClosed:
t.Error("filer closed while HTTP request was active")
case <-time.After(100 * time.Millisecond):
}
finish()
<-requestDone
<-joined
select {
case <-filerClosed:
default:
t.Error("filer was not closed after HTTP request completed")
}
}
func TestFilerShutdownLeavesLockRingBeforeDraining(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}))
t.Cleanup(server.Close)
httpClosing := make(chan struct{})
server.Config.RegisterOnShutdown(func() { close(httpClosing) })
leaving := make(chan struct{})
releaseLeave := make(chan struct{})
finishLeave := sync.OnceFunc(func() { close(releaseLeave) })
t.Cleanup(finishLeave)
grpcStopping := make(chan struct{})
shutdown := newFilerShutdown(func() {
close(leaving)
<-releaseLeave
}, func() {}, func() { close(grpcStopping) }, func() {}, server.Config)
joined := make(chan struct{})
go func() { shutdown(); close(joined) }()
select {
case <-leaving:
case <-time.After(5 * time.Second):
t.Fatal("shutdown did not leave the lock ring")
}
select {
case <-grpcStopping:
t.Fatal("gRPC stopped accepting while the filer was still leaving the lock ring")
case <-httpClosing:
t.Fatal("HTTP stopped accepting while the filer was still leaving the lock ring")
case <-time.After(100 * time.Millisecond):
}
finishLeave()
<-joined
select {
case <-grpcStopping:
default:
t.Error("gRPC was not stopped after leaving the lock ring")
}
}
// The filer's own MetaAggregator and an in-process S3 gateway keep metadata
// subscription streams open until the filer is shut down, which happens after
// gRPC GracefulStop - so the subscriptions have to end before it, or every
// shutdown sits out the full graceful-stop timeout.
func TestFilerShutdownStopsSubscriptionsBeforeGrpc(t *testing.T) {
var order []string
var mu sync.Mutex
record := func(step string) func() {
return func() {
mu.Lock()
defer mu.Unlock()
order = append(order, step)
}
}
shutdown := newFilerShutdown(record("leave"), record("subscriptions"), record("grpc"), record("filer"))
shutdown()
want := []string{"leave", "subscriptions", "grpc", "filer"}
if len(order) != len(want) {
t.Fatalf("shutdown steps %v, want %v", order, want)
}
for i := range want {
if order[i] != want[i] {
t.Fatalf("shutdown steps %v, want %v", order, want)
}
}
}