mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-12 23:09:33 +01:00
* 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>
298 lines
9.4 KiB
Go
298 lines
9.4 KiB
Go
package s3api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"github.com/gorilla/mux"
|
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/s3_pb"
|
|
"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 {
|
|
sync.RWMutex
|
|
Enabled bool
|
|
counters map[string]*int64
|
|
limitations map[string]int64
|
|
s3a *S3ApiServer
|
|
|
|
// Interceptor, if set, wraps the per-route handler ahead of ALL
|
|
// circuit-breaker logic (upload limiting and the breaker checks) and runs
|
|
// even when the breaker is disabled. It is a general per-request
|
|
// interceptor seam: an implementation may reject the request (write its own
|
|
// response and not call next) or wrap next to observe/shape it. Nil by
|
|
// default, so it is a no-op unless explicitly set.
|
|
Interceptor func(next http.HandlerFunc, action string) http.HandlerFunc
|
|
}
|
|
|
|
func NewCircuitBreaker(option *S3ApiServerOption) *CircuitBreaker {
|
|
cb := &CircuitBreaker{
|
|
counters: make(map[string]*int64),
|
|
limitations: make(map[string]int64),
|
|
}
|
|
|
|
// Use WithOneOfGrpcFilerClients to support multiple filers with failover
|
|
err := pb.WithOneOfGrpcFilerClients(false, option.Filers, option.GrpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
|
content, err := filer.ReadInsideFiler(context.Background(), client, s3_constants.CircuitBreakerConfigDir, s3_constants.CircuitBreakerConfigFile)
|
|
if errors.Is(err, filer_pb.ErrNotFound) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("read S3 circuit breaker config: %w", err)
|
|
}
|
|
return cb.LoadS3ApiConfigurationFromBytes(content)
|
|
})
|
|
|
|
if err != nil {
|
|
glog.Warningf("S3 circuit breaker disabled; failed to load config from any filer: %v", err)
|
|
}
|
|
|
|
return cb
|
|
}
|
|
|
|
func (cb *CircuitBreaker) LoadS3ApiConfigurationFromBytes(content []byte) error {
|
|
cbCfg := &s3_pb.S3CircuitBreakerConfig{}
|
|
if err := filer.ParseS3ConfigurationFromBytes(content, cbCfg); err != nil {
|
|
glog.Warningf("unmarshal error: %v", err)
|
|
return fmt.Errorf("unmarshal error: %w", err)
|
|
}
|
|
if err := cb.loadCircuitBreakerConfig(cbCfg); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (cb *CircuitBreaker) loadCircuitBreakerConfig(cfg *s3_pb.S3CircuitBreakerConfig) error {
|
|
|
|
//global
|
|
globalEnabled := false
|
|
globalOptions := cfg.Global
|
|
limitations := make(map[string]int64)
|
|
if globalOptions != nil && globalOptions.Enabled && len(globalOptions.Actions) > 0 {
|
|
globalEnabled = globalOptions.Enabled
|
|
for action, limit := range globalOptions.Actions {
|
|
limitations[action] = limit
|
|
}
|
|
}
|
|
cb.Enabled = globalEnabled
|
|
|
|
//buckets
|
|
for bucket, cbOptions := range cfg.Buckets {
|
|
if cbOptions.Enabled {
|
|
for action, limit := range cbOptions.Actions {
|
|
limitations[s3_constants.Concat(bucket, action)] = limit
|
|
}
|
|
}
|
|
}
|
|
|
|
cb.limitations = limitations
|
|
return nil
|
|
}
|
|
|
|
func (cb *CircuitBreaker) Limit(f func(w http.ResponseWriter, r *http.Request), action string) (http.HandlerFunc, Action) {
|
|
inner := func(w http.ResponseWriter, r *http.Request) {
|
|
// Apply upload limiting for write actions if configured
|
|
if cb.s3a != nil && (action == s3_constants.ACTION_WRITE) &&
|
|
(cb.s3a.option.ConcurrentUploadLimit != 0 || cb.s3a.option.ConcurrentFileUploadLimit != 0) {
|
|
|
|
// Get content length, default to 0 if not provided
|
|
contentLength := r.ContentLength
|
|
if contentLength < 0 {
|
|
contentLength = 0
|
|
}
|
|
|
|
if !cb.waitForUploadSlot(w, r, contentLength) {
|
|
return
|
|
}
|
|
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.Broadcast()
|
|
}()
|
|
}
|
|
|
|
// Apply circuit breaker logic
|
|
if !cb.Enabled {
|
|
f(w, r)
|
|
return
|
|
}
|
|
|
|
vars := mux.Vars(r)
|
|
bucket := vars["bucket"]
|
|
|
|
rollback, errCode := cb.limit(r, bucket, action)
|
|
defer func() {
|
|
for _, rf := range rollback {
|
|
rf()
|
|
}
|
|
}()
|
|
|
|
if errCode == s3err.ErrNone {
|
|
f(w, r)
|
|
return
|
|
}
|
|
s3err.WriteErrorResponse(w, r, errCode)
|
|
}
|
|
|
|
// The interceptor is consulted per request rather than captured here, so it
|
|
// can be installed after the routes are registered (e.g. once the server and
|
|
// its dependencies are constructed) and so a nil CircuitBreaker is never
|
|
// dereferenced at registration time. When unset this is just a nil check.
|
|
// It runs outermost: before upload limiting and the breaker checks, and
|
|
// regardless of cb.Enabled.
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if cb.Interceptor != nil {
|
|
cb.Interceptor(inner, action)(w, r)
|
|
return
|
|
}
|
|
inner(w, r)
|
|
}, 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
|
|
bucketCountRollBack, errCode := cb.loadCounterAndCompare(s3_constants.Concat(bucket, action, s3_constants.LimitTypeCount), 1, s3err.ErrTooManyRequest)
|
|
if bucketCountRollBack != nil {
|
|
rollback = append(rollback, bucketCountRollBack)
|
|
}
|
|
if errCode != s3err.ErrNone {
|
|
return
|
|
}
|
|
|
|
//bucket simultaneous request content bytes
|
|
bucketContentLengthRollBack, errCode := cb.loadCounterAndCompare(s3_constants.Concat(bucket, action, s3_constants.LimitTypeBytes), r.ContentLength, s3err.ErrRequestBytesExceed)
|
|
if bucketContentLengthRollBack != nil {
|
|
rollback = append(rollback, bucketContentLengthRollBack)
|
|
}
|
|
if errCode != s3err.ErrNone {
|
|
return
|
|
}
|
|
|
|
//global simultaneous request count
|
|
globalCountRollBack, errCode := cb.loadCounterAndCompare(s3_constants.Concat(action, s3_constants.LimitTypeCount), 1, s3err.ErrTooManyRequest)
|
|
if globalCountRollBack != nil {
|
|
rollback = append(rollback, globalCountRollBack)
|
|
}
|
|
if errCode != s3err.ErrNone {
|
|
return
|
|
}
|
|
|
|
//global simultaneous request content bytes
|
|
globalContentLengthRollBack, errCode := cb.loadCounterAndCompare(s3_constants.Concat(action, s3_constants.LimitTypeBytes), r.ContentLength, s3err.ErrRequestBytesExceed)
|
|
if globalContentLengthRollBack != nil {
|
|
rollback = append(rollback, globalContentLengthRollBack)
|
|
}
|
|
if errCode != s3err.ErrNone {
|
|
return
|
|
}
|
|
return
|
|
}
|
|
|
|
func (cb *CircuitBreaker) loadCounterAndCompare(key string, inc int64, errCode s3err.ErrorCode) (f func(), e s3err.ErrorCode) {
|
|
e = s3err.ErrNone
|
|
if max, ok := cb.limitations[key]; ok {
|
|
cb.RLock()
|
|
counter, exists := cb.counters[key]
|
|
cb.RUnlock()
|
|
|
|
if !exists {
|
|
cb.Lock()
|
|
counter, exists = cb.counters[key]
|
|
if !exists {
|
|
var newCounter int64
|
|
counter = &newCounter
|
|
cb.counters[key] = counter
|
|
}
|
|
cb.Unlock()
|
|
}
|
|
current := atomic.LoadInt64(counter)
|
|
if current+inc > max {
|
|
e = errCode
|
|
return
|
|
} else {
|
|
current := atomic.AddInt64(counter, inc)
|
|
f = func() {
|
|
atomic.AddInt64(counter, -inc)
|
|
}
|
|
if current > max {
|
|
e = errCode
|
|
return
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func (s3a *S3ApiServer) Limit(handler http.HandlerFunc, action string) http.HandlerFunc {
|
|
if s3a.cb == nil {
|
|
return handler
|
|
}
|
|
limited, _ := s3a.cb.Limit(handler, action)
|
|
return limited
|
|
}
|