s3, filer: bound the wait for an upload slot (#12559)

* util: add WaitUntil, a cond wait bounded by a context and a timeout

The caller holds the cond lock. Cancellation and the timeout wake the
waiter through a broadcast taken under the same lock, so no wakeup is lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3: bound the wait for an upload slot

A PUT that arrived while the concurrent upload limits were exhausted waited
on the condition variable with no bound. The handler reads nothing from the
body while it waits, so a client that gave up and reset its connection went
unnoticed: the socket and goroutine stayed pinned until a slot freed, and a
stalled volume server grew the gateway's fd count by one per aborted PUT.

The wait now ends on InflightUploadDataTimeout with 503 SlowDown, which S3
SDKs retry, and on request cancellation. An admitted upload takes its slot
under the lock, and a finished upload broadcasts, so freed bytes admit every
waiter that fits instead of one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* filer: bound the wait for an upload slot

The filer's PUT/POST upload limits queued requests the same unbounded way.
Reuse the bounded wait: reject with 429 after InflightUploadDataTimeout, as
the volume server does, and stop on request cancellation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* command: add -inflightUploadDataTimeout for the S3 gateway and the filer

weed s3 -inflightUploadDataTimeout, weed filer -inflightUploadDataTimeout and
-s3.inflightUploadDataTimeout, weed server and weed mini
-filer.inflightUploadDataTimeout and -s3.inflightUploadDataTimeout. The
default of 60s matches the volume server's flag of the same name; 0 keeps
the old unbounded wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3: reject a queued upload without reading its body

WriteErrorResponse drains the request body before answering. Behind the
stats recorder the drain has no read deadline, and reading the body sends an
Expect: 100-continue client the go-ahead to upload what is about to be
discarded. Answer the rejection with an empty-body copy of the request so
the drain is skipped; net/http closes the connection when a large unread
body remains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3, filer: release an upload slot under the cond lock

A waiter checks the counters under the cond lock and then waits. A release
that decremented and broadcast without the lock could land between the two,
so the waiter slept with a slot free until the next release or its timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3, filer: close the HTTP/1 connection when rejecting a queued upload

net/http reads up to 256 KiB of an unread request body before sending a
keep-alive reply, so a client that stalls its body held back the 503 or
429 until the connection idle timeout. Connection: close skips that read.
HTTP/2 is left alone: there it would shut down the whole connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chris LuandClaude Opus 5.5 authored and GitHub committed 2026-11-11 10:17:18 -08:00
1 parent d659228338
commit fcfca3fee3
12 files changed
+470 -50

No files matched your search

+53 -26
View File
@@ -17,6 +17,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/seaweedfs/seaweedfs/weed/stats"
"github.com/seaweedfs/seaweedfs/weed/util"
)
type CircuitBreaker struct {
@@ -111,40 +112,19 @@ func (cb *CircuitBreaker) Limit(f func(w http.ResponseWriter, r *http.Request),
contentLength = 0
}
// Wait until in flight data is less than the limit
cb.s3a.inFlightDataLimitCond.L.Lock()
inFlightDataSize := atomic.LoadInt64(&cb.s3a.inFlightDataSize)
inFlightUploads := atomic.LoadInt64(&cb.s3a.inFlightUploads)
// Wait if either data size limit or file count limit is exceeded
for (cb.s3a.option.ConcurrentUploadLimit != 0 && inFlightDataSize > cb.s3a.option.ConcurrentUploadLimit) ||
(cb.s3a.option.ConcurrentFileUploadLimit != 0 && inFlightUploads >= cb.s3a.option.ConcurrentFileUploadLimit) {
if cb.s3a.option.ConcurrentUploadLimit != 0 && inFlightDataSize > cb.s3a.option.ConcurrentUploadLimit {
glog.V(4).Infof("wait because inflight data %d > %d", inFlightDataSize, cb.s3a.option.ConcurrentUploadLimit)
}
if cb.s3a.option.ConcurrentFileUploadLimit != 0 && inFlightUploads >= cb.s3a.option.ConcurrentFileUploadLimit {
glog.V(4).Infof("wait because inflight uploads %d >= %d", inFlightUploads, cb.s3a.option.ConcurrentFileUploadLimit)
}
cb.s3a.inFlightDataLimitCond.Wait()
inFlightDataSize = atomic.LoadInt64(&cb.s3a.inFlightDataSize)
inFlightUploads = atomic.LoadInt64(&cb.s3a.inFlightUploads)
if !cb.waitForUploadSlot(w, r, contentLength) {
return
}
cb.s3a.inFlightDataLimitCond.L.Unlock()
// Increment counters
newUploads := atomic.AddInt64(&cb.s3a.inFlightUploads, 1)
newSize := atomic.AddInt64(&cb.s3a.inFlightDataSize, contentLength)
// Update metrics
stats.S3InFlightUploadCountGauge.Set(float64(newUploads))
stats.S3InFlightUploadBytesGauge.Set(float64(newSize))
defer func() {
cb.s3a.inFlightDataLimitCond.L.Lock()
defer cb.s3a.inFlightDataLimitCond.L.Unlock()
// Decrement counters
newUploads := atomic.AddInt64(&cb.s3a.inFlightUploads, -1)
newSize := atomic.AddInt64(&cb.s3a.inFlightDataSize, -contentLength)
// Update metrics
stats.S3InFlightUploadCountGauge.Set(float64(newUploads))
stats.S3InFlightUploadBytesGauge.Set(float64(newSize))
cb.s3a.inFlightDataLimitCond.Signal()
cb.s3a.inFlightDataLimitCond.Broadcast()
}()
}
@@ -186,6 +166,53 @@ func (cb *CircuitBreaker) Limit(f func(w http.ResponseWriter, r *http.Request),
}, Action(action)
}
func (cb *CircuitBreaker) waitForUploadSlot(w http.ResponseWriter, r *http.Request, contentLength int64) bool {
s3a := cb.s3a
s3a.inFlightDataLimitCond.L.Lock()
err := util.WaitUntil(r.Context(), s3a.inFlightDataLimitCond, s3a.option.InflightUploadDataTimeout, func() bool {
inFlightDataSize := atomic.LoadInt64(&s3a.inFlightDataSize)
if s3a.option.ConcurrentUploadLimit != 0 && inFlightDataSize > s3a.option.ConcurrentUploadLimit {
glog.V(4).Infof("wait because inflight data %d > %d", inFlightDataSize, s3a.option.ConcurrentUploadLimit)
return false
}
inFlightUploads := atomic.LoadInt64(&s3a.inFlightUploads)
if s3a.option.ConcurrentFileUploadLimit != 0 && inFlightUploads >= s3a.option.ConcurrentFileUploadLimit {
glog.V(4).Infof("wait because inflight uploads %d >= %d", inFlightUploads, s3a.option.ConcurrentFileUploadLimit)
return false
}
return true
})
if err == nil {
// Increment counters
newUploads := atomic.AddInt64(&s3a.inFlightUploads, 1)
newSize := atomic.AddInt64(&s3a.inFlightDataSize, contentLength)
// Update metrics
stats.S3InFlightUploadCountGauge.Set(float64(newUploads))
stats.S3InFlightUploadBytesGauge.Set(float64(newSize))
}
s3a.inFlightDataLimitCond.L.Unlock()
if err == nil {
return true
}
// Reject without reading the body: draining it would send an
// Expect: 100-continue client the go-ahead, and closing an HTTP/1
// connection stops net/http from reading it before the reply.
if r.ProtoMajor == 1 {
w.Header().Set("Connection", "close")
}
rejected := r.WithContext(r.Context())
rejected.Body = http.NoBody
if r.Context().Err() != nil {
glog.V(1).Infof("upload from %s cancelled while waiting for an upload slot: %v", r.RemoteAddr, err)
s3err.WriteErrorResponse(w, rejected, s3err.ErrClientDisconnected)
return false
}
glog.V(1).Infof("reject upload from %s: no upload slot within %v", r.RemoteAddr, s3a.option.InflightUploadDataTimeout)
s3err.WriteErrorResponse(w, rejected, s3err.ErrTooManyRequest)
return false
}
func (cb *CircuitBreaker) limit(r *http.Request, bucket string, action string) (rollback []func(), errCode s3err.ErrorCode) {
//bucket simultaneous request count
+175
View File
@@ -1,11 +1,17 @@
package s3api
import (
"bufio"
"context"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/s3_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
@@ -203,3 +209,172 @@ func TestLimitInterceptor(t *testing.T) {
}
})
}
func newUploadLimitedCircuitBreaker(option *S3ApiServerOption) *CircuitBreaker {
return &CircuitBreaker{
counters: make(map[string]*int64),
limitations: make(map[string]int64),
s3a: &S3ApiServer{option: option, inFlightDataLimitCond: sync.NewCond(new(sync.Mutex))},
}
}
func putRequest(ctx context.Context, size int) *http.Request {
return httptest.NewRequest(http.MethodPut, "/bucket/object", strings.NewReader(strings.Repeat("x", size))).WithContext(ctx)
}
type readRecorder struct {
r io.Reader
read bool
}
func (b *readRecorder) Read(p []byte) (int, error) {
b.read = true
return b.r.Read(p)
}
func (b *readRecorder) Close() error { return nil }
func holdUploadSlot(t *testing.T, cb *CircuitBreaker, size int) (release func()) {
entered, done := make(chan struct{}), make(chan struct{})
finished := make(chan struct{})
h, _ := cb.Limit(func(w http.ResponseWriter, r *http.Request) {
close(entered)
<-done
}, s3_constants.ACTION_WRITE)
go func() {
defer close(finished)
h(httptest.NewRecorder(), putRequest(context.Background(), size))
}()
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("first upload was not admitted")
}
return func() {
close(done)
<-finished
}
}
func TestLimitUploadSlotWait(t *testing.T) {
t.Run("wait for a slot times out with SlowDown", func(t *testing.T) {
cb := newUploadLimitedCircuitBreaker(&S3ApiServerOption{ConcurrentFileUploadLimit: 1, InflightUploadDataTimeout: 100 * time.Millisecond})
release := holdUploadSlot(t, cb, 10)
defer release()
ran := false
h, _ := cb.Limit(func(w http.ResponseWriter, r *http.Request) { ran = true }, s3_constants.ACTION_WRITE)
rec := httptest.NewRecorder()
body := &readRecorder{r: strings.NewReader("0123456789")}
req := putRequest(context.Background(), 10)
req.Body = body
start := time.Now()
h(rec, req)
if ran {
t.Fatal("upload ran without a free slot")
}
if body.read {
t.Fatal("rejected upload body was read")
}
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "SlowDown") {
t.Fatalf("got %d %q, want 503 SlowDown", rec.Code, rec.Body.String())
}
if elapsed := time.Since(start); elapsed < 100*time.Millisecond {
t.Fatalf("rejected after %v, before the wait timeout", elapsed)
}
if n := atomic.LoadInt64(&cb.s3a.inFlightUploads); n != 1 {
t.Fatalf("in-flight uploads %d, want 1", n)
}
})
t.Run("cancelled request stops waiting", func(t *testing.T) {
cb := newUploadLimitedCircuitBreaker(&S3ApiServerOption{ConcurrentFileUploadLimit: 1})
release := holdUploadSlot(t, cb, 10)
defer release()
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(50*time.Millisecond, cancel)
h, _ := cb.Limit(func(w http.ResponseWriter, r *http.Request) { t.Error("cancelled upload ran") }, s3_constants.ACTION_WRITE)
done := make(chan int)
go func() {
rec := httptest.NewRecorder()
h(rec, putRequest(ctx, 10))
done <- rec.Code
}()
select {
case code := <-done:
if code != 499 {
t.Fatalf("got %d, want 499", code)
}
case <-time.After(5 * time.Second):
t.Fatal("cancelled upload kept waiting for a slot")
}
})
t.Run("freed bytes admit every waiter that fits", func(t *testing.T) {
cb := newUploadLimitedCircuitBreaker(&S3ApiServerOption{ConcurrentUploadLimit: 100, InflightUploadDataTimeout: time.Minute})
release := holdUploadSlot(t, cb, 1000)
var admitted sync.WaitGroup
admitted.Add(2)
proceed := make(chan struct{})
h, _ := cb.Limit(func(w http.ResponseWriter, r *http.Request) {
admitted.Done()
<-proceed
}, s3_constants.ACTION_WRITE)
var finished sync.WaitGroup
for i := 0; i < 2; i++ {
finished.Add(1)
go func() {
defer finished.Done()
h(httptest.NewRecorder(), putRequest(context.Background(), 10))
}()
}
time.Sleep(50 * time.Millisecond)
release()
bothIn := make(chan struct{})
go func() {
admitted.Wait()
close(bothIn)
}()
select {
case <-bothIn:
case <-time.After(5 * time.Second):
t.Error("only some waiters were admitted after the large upload finished")
}
close(proceed)
finished.Wait()
})
}
func stalledPut(t *testing.T, h http.HandlerFunc) *http.Response {
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
conn, err := net.Dial("tcp", srv.Listener.Addr().String())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { conn.Close() })
conn.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := io.WriteString(conn, "PUT /bucket/object HTTP/1.1\r\nHost: s3\r\nContent-Length: 10\r\n\r\n"); err != nil {
t.Fatal(err)
}
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("no reply while the body is unsent: %v", err)
}
return resp
}
func TestLimitUploadSlotRejectsStalledBody(t *testing.T) {
cb := newUploadLimitedCircuitBreaker(&S3ApiServerOption{ConcurrentFileUploadLimit: 1, InflightUploadDataTimeout: 100 * time.Millisecond})
release := holdUploadSlot(t, cb, 10)
defer release()
h, _ := cb.Limit(func(w http.ResponseWriter, r *http.Request) { t.Error("upload ran without a free slot") }, s3_constants.ACTION_WRITE)
resp := stalledPut(t, h)
if resp.StatusCode != http.StatusServiceUnavailable || !resp.Close {
t.Fatalf("got %d close=%v, want 503 with Connection: close", resp.StatusCode, resp.Close)
}
}
+1
View File
@@ -64,6 +64,7 @@ type S3ApiServerOption struct {
IamConfig string // Advanced IAM configuration file path
ConcurrentUploadLimit int64
ConcurrentFileUploadLimit int64
InflightUploadDataTimeout time.Duration
EnableIam bool // Enable embedded IAM API on the same port
IamReadOnly bool // Disable IAM write operations on this server
Cipher bool // encrypt data on volume servers