Files
seaweedfs/weed/operation/upload_content_test.go
T
Chris LuandDevin 673d1a02e0 operation: euthanize pooled upload conns that stall far below a fast host (#12003)
* operation: euthanize pooled upload conns that stall far below a fast host

A pooled keep-alive conn can wedge and drain the request body at a trickle
while fresh conns to the same volume run at full rate; every later upload
routed onto it stalls for tens of seconds, indefinitely. Track per-host
healthy-rate uploads (>=1MiB/s on a >=256KiB body) and, on a host that was
fast within the last minute, cancel an upload still in flight past
max(10s, size/256KiB/s): the transport discards the wedged conn and the
existing retry dials a fresh one. A watchdog kill also clears the fast mark
so the retried attempt runs unbounded, and hosts that never prove fast keep
today's unbounded behavior - a chronically slow link is never penalized.

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

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

* operation: keep the stall watchdog armed through the response read

uploadDone was set as soon as Do() returned, so a conn that accepted the
request body then wedged on the reply escaped the watchdog and could
park the upload for hours — the same failure this watchdog exists to
kill. Deferring the done-store keeps the timer live until the function
returns, so a stalled io.ReadAll is cancelled and retried like a
stalled write. The redundant post_err recheck goes with it.

* operation: pin watchdog coverage of a stalled response read

* operation: disarm the stall watchdog for an upload's remaining attempts

Once the watchdog has cancelled an attempt, a concurrent healthy upload
to the same host can restore the fast mark during the retry backoff.
The re-armed watchdog would then cancel the retry too, and a large
upload that merely exceeds the floor could burn every attempt. Track
the watchdog kill on uploadStatusError and carry a
stallWatchdogDisarmed flag through retriedUploadData so the rest of
that upload's attempts run unbounded; other uploads to the host keep
their watchdog.

* operation: pin watchdog disarm on retry, fresh-dial, and slow-upload cases

* operation: drop the send-stall watchdog once the request body drains

A wedged pooled conn is only blameworthy while it is still trickling the
request body; once the body is on the wire the wait is server-side commit,
which the transport's response header timeout already bounds. Cancelling
then would fail a healthy write whose replication or fsync is simply slow.
Track body EOF through a wrapping reader, gate the send-side timer on it,
and re-arm the watchdog only around the response-body read, which the
header timeout does not cover.

* operation: prove a host fast only on a verified upload

A quick rejection moves bytes no faster than the wire did, so mark the
host from the body-send duration and only after the response checks out.

* operation: count an abandoned request body as sent

The transport closes a request body it never finished draining when the
server answers early, so EOF alone under-reports the send boundary and
the stall watchdog could still blame the conn during the reply wait.

* operation: scope the body-sent mark to the current attempt

A lagging Close or EOF from the first attempt's body could stamp the
shared mark after the inner retry reset it, disarming the watchdog for a
send that never happened; each attempt now points the watchdog at its
own sent timestamp.

* operation: drop only the fast mark the stall timer observed

A concurrent upload can re-prove the host while a stalled attempt is in
flight; deleting the key outright would erase that newer mark, so the
watchdog now removes only the value it armed against.

* operation: keep the stall disarm on a per-call upload option

retriedUploadData marked the caller's UploadOption, so a caller reusing it
across UploadData calls would start the next upload with the watchdog off
and lose the cancellation this adds.

* test: stall watchdog retry must fail if the conn gets cancelled

The fake retry slept through any cancellation, so a re-armed watchdog
firing mid-retry still let the upload succeed — the guard was untested.
Park on the request context like a real wedged conn so a re-armed
watchdog cancels the attempt and fails the test.

* operation: prove the fast-host mark with elapsed-to-response

Body-drain time only measures when the transport consumed the reader:
kernel socket buffers can swallow a small body instantly on a slow link,
inflating the rate and marking the host fast. The next upload then gets
its conn cancelled mid-send despite making progress. Elapsed-to-response
bounds remote speed from below — the server cannot answer before the
body lands — so the mark can only under-mark, never over-mark.

* operation: scale the stall budget off the host's measured rate

Fixed bps limits fit no link well: 256KiB/s never fires on a slow link
and waits far too long on a fast one. Record the wire rate of each
sizeable upload and let a later upload run for slowFactor times the
body's span at that rate — a wedged conn shows as an order-of-magnitude
drop on any link speed. The recorded rate uses the wire body size so a
pre-compressed payload no longer inflates the mark.

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-23 20:34:31 -07:00

944 lines
32 KiB
Go

package operation
import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc"
)
type scriptedHTTPResponse struct {
status int
body string
}
type scriptedHTTPClient struct {
mu sync.Mutex
responses map[string][]scriptedHTTPResponse
calls []string
}
func (c *scriptedHTTPClient) Do(req *http.Request) (*http.Response, error) {
c.mu.Lock()
defer c.mu.Unlock()
url := req.URL.String()
c.calls = append(c.calls, url)
plans := c.responses[url]
if len(plans) == 0 {
return nil, fmt.Errorf("unexpected request to %s", url)
}
plan := plans[0]
c.responses[url] = plans[1:]
return &http.Response{
StatusCode: plan.status,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(plan.body)),
}, nil
}
func TestShouldReassignUpload(t *testing.T) {
testCases := []struct {
name string
err error
want bool
}{
{name: "nil", err: nil, want: false},
{name: "no response (transport)", err: &uploadStatusError{StatusCode: 0, err: fmt.Errorf("dial tcp: no such host")}, want: true},
{name: "500 replica write failed", err: &uploadStatusError{StatusCode: 500, err: fmt.Errorf("failed to write to replicas")}, want: true},
{name: "503 service unavailable", err: &uploadStatusError{StatusCode: 503, err: fmt.Errorf("busy")}, want: true},
{name: "400 bad request", err: &uploadStatusError{StatusCode: 400, err: fmt.Errorf("bad needle")}, want: false},
{name: "401 unauthorized", err: &uploadStatusError{StatusCode: 401, err: fmt.Errorf("wrong jwt")}, want: false},
{name: "assign rpc transport", err: fmt.Errorf("assign volume: transport is closing"), want: true},
{name: "other permanent", err: fmt.Errorf("mismatching cookie"), want: false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if got := ShouldReassignUpload(tc.err); got != tc.want {
t.Fatalf("ShouldReassignUpload(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}
func TestUploadWithRetryDataReassignsOnVolumeSizeExceeded(t *testing.T) {
httpClient := &scriptedHTTPClient{
responses: map[string][]scriptedHTTPResponse{
"http://volume-a/1,first": {
{status: http.StatusInternalServerError, body: `{"error":"failed to write to local disk: append to volume 1 size 0 actualSize 0: Volume Size 33555976 Exceeded 33554432"}`},
{status: http.StatusInternalServerError, body: `{"error":"failed to write to local disk: append to volume 1 size 0 actualSize 0: Volume Size 33555976 Exceeded 33554432"}`},
{status: http.StatusInternalServerError, body: `{"error":"failed to write to local disk: append to volume 1 size 0 actualSize 0: Volume Size 33555976 Exceeded 33554432"}`},
},
"http://volume-b/2,second": {
{status: http.StatusCreated, body: `{"name":"test.bin","size":3}`},
},
},
}
uploader := newUploader(httpClient)
assignCalls := 0
fileID, uploadResult, err := uploader.uploadWithRetryData(func() (string, string, security.EncodedJwt, bool, error) {
assignCalls++
if assignCalls == 1 {
return "1,first", "volume-a", "", false, nil
}
return "2,second", "volume-b", "", false, nil
}, &UploadOption{Filename: "test.bin"}, []byte("abc"))
if err != nil {
t.Fatalf("expected success after reassignment, got %v", err)
}
if fileID != "2,second" {
t.Fatalf("expected second file id, got %s", fileID)
}
if assignCalls != 2 {
t.Fatalf("expected 2 assign attempts, got %d", assignCalls)
}
if uploadResult == nil || uploadResult.Name != "test.bin" {
t.Fatalf("expected successful upload result, got %#v", uploadResult)
}
if len(httpClient.calls) != 4 {
t.Fatalf("expected 4 upload attempts (3 same-url retries + 1 reassigned upload), got %d", len(httpClient.calls))
}
if httpClient.calls[0] != "http://volume-a/1,first" || httpClient.calls[3] != "http://volume-b/2,second" {
t.Fatalf("unexpected upload call sequence: %#v", httpClient.calls)
}
}
// A volume whose replica peer is down reports "failed to write to replicas":
// the primary write lands but replication fails. The client must re-assign to a
// volume on live nodes and retry rather than losing the write.
func TestUploadWithRetryDataReassignsOnReplicaWriteFailure(t *testing.T) {
replicaErr := `{"error":"failed to write to replicas for volume 1: [volume-down:8080]: dial tcp: lookup volume-down: no such host"}`
httpClient := &scriptedHTTPClient{
responses: map[string][]scriptedHTTPResponse{
"http://volume-a/1,first": {
{status: http.StatusInternalServerError, body: replicaErr},
{status: http.StatusInternalServerError, body: replicaErr},
{status: http.StatusInternalServerError, body: replicaErr},
},
"http://volume-b/2,second": {
{status: http.StatusCreated, body: `{"name":"test.bin","size":3}`},
},
},
}
uploader := newUploader(httpClient)
assignCalls := 0
fileID, uploadResult, err := uploader.uploadWithRetryData(func() (string, string, security.EncodedJwt, bool, error) {
assignCalls++
if assignCalls == 1 {
return "1,first", "volume-a", "", false, nil
}
return "2,second", "volume-b", "", false, nil
}, &UploadOption{Filename: "test.bin"}, []byte("abc"))
if err != nil {
t.Fatalf("expected success after reassignment, got %v", err)
}
if fileID != "2,second" {
t.Fatalf("expected reassigned file id, got %s", fileID)
}
if assignCalls != 2 {
t.Fatalf("expected 2 assign attempts, got %d", assignCalls)
}
if uploadResult == nil || uploadResult.Name != "test.bin" {
t.Fatalf("expected successful upload result, got %#v", uploadResult)
}
}
// bodyCapturingHTTPClient drains req.Body on every Do, optionally failing the
// first attempt with a transport error so we can verify upload_content rewinds
// the body before retrying.
type bodyCapturingHTTPClient struct {
mu sync.Mutex
bodies [][]byte
failFirst string
successJSON string
}
func (c *bodyCapturingHTTPClient) Do(req *http.Request) (*http.Response, error) {
c.mu.Lock()
defer c.mu.Unlock()
var captured []byte
if req.Body != nil {
b, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
captured = b
}
c.bodies = append(c.bodies, captured)
if len(c.bodies) == 1 && c.failFirst != "" {
return nil, errors.New(c.failFirst)
}
return &http.Response{
StatusCode: http.StatusCreated,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(c.successJSON)),
}, nil
}
// stallingHTTPClient parks the first stalls calls without draining the request
// body (a conn that never drains) until the request ctx is cancelled, and
// delays the rest by slowFor after draining.
type stallingHTTPClient struct {
mu sync.Mutex
stalls int
slowFor time.Duration
requestCtxs []context.Context
}
func (c *stallingHTTPClient) Do(req *http.Request) (*http.Response, error) {
c.mu.Lock()
c.requestCtxs = append(c.requestCtxs, req.Context())
stall := c.stalls > 0
if stall {
c.stalls--
}
c.mu.Unlock()
if stall {
<-req.Context().Done()
return nil, req.Context().Err()
}
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
if c.slowFor > 0 {
time.Sleep(c.slowFor)
}
return &http.Response{
StatusCode: http.StatusCreated,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"size":1}`)),
}, nil
}
// markHostFast arms the stall watchdog the way a recent fast upload would:
// the recorded rate is high enough that the adaptive budget lands on
// uploadStallMinWait for test-sized bodies.
func markHostFast(u *Uploader, host string) {
u.fastUploadHosts.Store(host, fastHostMark{at: time.Now().UnixNano(), bps: 1 << 30})
}
// A host that just served a fast upload arms the stall watchdog: a request
// parked well past the floor is riding a wedged conn, so it is cancelled and
// the retry runs on a fresh conn without the watchdog.
func TestUploadStallWatchdogRetriesOnFreshConn(t *testing.T) {
defer func(orig time.Duration) { uploadStallMinWait = orig }(uploadStallMinWait)
uploadStallMinWait = 50 * time.Millisecond
client := &stallingHTTPClient{stalls: 1}
uploader := newUploader(client)
markHostFast(uploader, "test-host:8080")
result, err := uploader.UploadData(context.Background(), make([]byte, 300*1024), &UploadOption{
UploadUrl: "http://test-host:8080/1,deadbeef",
MaxAttempts: 2,
})
if err != nil {
t.Fatalf("upload should have retried on a fresh conn: %v", err)
}
if result.RetryCount != 1 || len(client.requestCtxs) != 2 {
t.Fatalf("got retries=%d calls=%d, want one retry on a fresh conn", result.RetryCount, len(client.requestCtxs))
}
if err := client.requestCtxs[0].Err(); err == nil {
t.Fatalf("first attempt context was not cancelled")
}
}
// readStallHTTPClient answers with a response body that never delivers until
// the request ctx is cancelled — the conn that took the request then wedged on
// the read side.
type readStallHTTPClient struct {
mu sync.Mutex
stall bool
}
func (c *readStallHTTPClient) Do(req *http.Request) (*http.Response, error) {
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
c.mu.Lock()
stall := c.stall
c.stall = false
c.mu.Unlock()
var body io.ReadCloser = io.NopCloser(strings.NewReader(`{"size":1}`))
if stall {
body = &ctxBlockedBody{ctx: req.Context()}
}
return &http.Response{
StatusCode: http.StatusCreated,
Header: make(http.Header),
Body: body,
}, nil
}
type ctxBlockedBody struct{ ctx context.Context }
func (b *ctxBlockedBody) Read([]byte) (int, error) {
<-b.ctx.Done()
return 0, b.ctx.Err()
}
func (b *ctxBlockedBody) Close() error { return nil }
// The watchdog stays armed through the response read: a conn that accepts the
// body then wedges on the reply is as wedged as one that never drains.
func TestUploadStallWatchdogCoversResponseRead(t *testing.T) {
defer func(orig time.Duration) { uploadStallMinWait = orig }(uploadStallMinWait)
uploadStallMinWait = 50 * time.Millisecond
client := &readStallHTTPClient{stall: true}
uploader := newUploader(client)
markHostFast(uploader, "test-host:8080")
result, err := uploader.UploadData(context.Background(), make([]byte, 300*1024), &UploadOption{
UploadUrl: "http://test-host:8080/1,deadbeef",
MaxAttempts: 2,
})
if err != nil {
t.Fatalf("upload should have retried past the stalled read: %v", err)
}
if result.RetryCount != 1 {
t.Fatalf("got retries=%d, want one retry on a fresh conn", result.RetryCount)
}
}
// rearmHTTPClient parks the first attempt on a conn that never drains and
// re-marks the host fast as it fails — a concurrent healthy upload can restore
// the mark while this upload is between attempts. The retry drains slowly, so
// a re-armed watchdog would cancel it too.
type rearmHTTPClient struct {
uploader *Uploader
calls int32
slowFor time.Duration
}
func (c *rearmHTTPClient) Do(req *http.Request) (*http.Response, error) {
if atomic.AddInt32(&c.calls, 1) == 1 {
<-req.Context().Done()
markHostFast(c.uploader, req.URL.Host)
return nil, req.Context().Err()
}
// Park past the watchdog floor like a real wedged conn: a re-armed
// watchdog must cancel this attempt instead of the sleep masking it.
select {
case <-req.Context().Done():
return nil, req.Context().Err()
case <-time.After(c.slowFor):
}
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
return &http.Response{
StatusCode: http.StatusCreated,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"size":1}`)),
}, nil
}
// A re-proven host must not re-arm the watchdog for the retry: the conn it
// cancelled says nothing about the next conn, and re-arming could burn every
// remaining attempt.
func TestUploadStallWatchdogStaysDisarmedOnRetry(t *testing.T) {
defer func(orig time.Duration) { uploadStallMinWait = orig }(uploadStallMinWait)
uploadStallMinWait = 40 * time.Millisecond
uploader := newUploader(nil)
uploader.httpClient = &rearmHTTPClient{uploader: uploader, slowFor: 60 * time.Millisecond}
markHostFast(uploader, "test-host:8080")
result, err := uploader.UploadData(context.Background(), make([]byte, 300*1024), &UploadOption{
UploadUrl: "http://test-host:8080/1,deadbeef",
MaxAttempts: 2,
})
if err != nil {
t.Fatalf("retry should run unbounded once the watchdog has fired: %v", err)
}
if result.RetryCount != 1 {
t.Fatalf("got retries=%d, want one retry", result.RetryCount)
}
}
// The disarm is per upload: a caller reusing its UploadOption still gets an
// armed watchdog on the next call — a second stalled conn must cancel too.
func TestUploadStallWatchdogRearmsForNextUpload(t *testing.T) {
defer func(orig time.Duration) { uploadStallMinWait = orig }(uploadStallMinWait)
uploadStallMinWait = 40 * time.Millisecond
client := &stallingHTTPClient{}
uploader := newUploader(client)
markHostFast(uploader, "test-host:8080")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// IsInputCompressed keeps the body large enough to re-prove the host:
// compressed near-zeros would send too few bytes to re-mark it.
option := &UploadOption{UploadUrl: "http://test-host:8080/1,deadbeef", MaxAttempts: 2, IsInputCompressed: true}
for i := 0; i < 2; i++ {
client.mu.Lock()
client.stalls = 1
client.mu.Unlock()
result, err := uploader.UploadData(ctx, make([]byte, 300*1024), option)
if err != nil {
t.Fatalf("upload %d should have retried on a fresh conn: %v", i, err)
}
if result.RetryCount != 1 {
t.Fatalf("upload %d got retries=%d, want its own stall caught", i, result.RetryCount)
}
}
if option.stallWatchdogDisarmed {
t.Fatalf("a stall must not disarm the watchdog on the caller's option")
}
}
// A real transport must drop the conn a cancelled request was riding: the
// retry has to dial again rather than land back on the parked conn. The first
// conn here wedges mid-reply — headers flush, then the body never arrives.
func TestUploadStallWatchdogDialsFreshConn(t *testing.T) {
defer func(orig time.Duration) { uploadStallMinWait = orig }(uploadStallMinWait)
uploadStallMinWait = 50 * time.Millisecond
var requests, dials int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
if atomic.AddInt32(&requests, 1) == 1 {
w.WriteHeader(http.StatusCreated)
w.(http.Flusher).Flush()
<-r.Context().Done()
return
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"size":1}`))
}))
defer srv.Close()
tr := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
atomic.AddInt32(&dials, 1)
return new(net.Dialer).DialContext(ctx, network, addr)
},
}
defer tr.CloseIdleConnections()
uploader := newUploader(&http.Client{Transport: tr})
host := strings.TrimPrefix(srv.URL, "http://")
markHostFast(uploader, host)
result, err := uploader.UploadData(context.Background(), make([]byte, 300*1024), &UploadOption{
UploadUrl: srv.URL + "/1,deadbeef",
MaxAttempts: 2,
})
if err != nil {
t.Fatalf("upload should have retried on a fresh conn: %v", err)
}
if result.RetryCount != 1 || atomic.LoadInt32(&dials) != 2 {
t.Fatalf("got retries=%d dials=%d, want one retry on a fresh conn", result.RetryCount, dials)
}
}
// Once the body drains the wait is the server's commit: a write that takes
// longer than the stall floor to acknowledge must not be cancelled — the conn
// is healthy, the server is just busy.
func TestUploadStallWatchdogAllowsSlowCommit(t *testing.T) {
defer func(orig time.Duration) { uploadStallMinWait = orig }(uploadStallMinWait)
uploadStallMinWait = 30 * time.Millisecond
client := &stallingHTTPClient{slowFor: 80 * time.Millisecond}
uploader := newUploader(client)
markHostFast(uploader, "test-host:8080")
result, err := uploader.UploadData(context.Background(), make([]byte, 300*1024), &UploadOption{
UploadUrl: "http://test-host:8080/1,deadbeef",
MaxAttempts: 1,
})
if err != nil || result.RetryCount != 0 {
t.Fatalf("a slow commit must not be cancelled: result=%+v err=%v", result, err)
}
}
// A fast rejection proves nothing about how the conn drains, so it must not
// mark the host fast and arm the watchdog for the next upload.
func TestUploadStallWatchdogIgnoresFailedWrites(t *testing.T) {
uploader := newUploader(&rejectHTTPClient{})
_, err := uploader.UploadData(context.Background(), make([]byte, 300*1024), &UploadOption{
UploadUrl: "http://test-host:8080/1,deadbeef",
MaxAttempts: 1,
})
if err == nil {
t.Fatalf("rejected upload should fail")
}
if _, ok := uploader.fastUploadHosts.Load("test-host:8080"); ok {
t.Fatalf("a failed upload must not mark the host fast")
}
}
type rejectHTTPClient struct{}
func (c *rejectHTTPClient) Do(req *http.Request) (*http.Response, error) {
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
return &http.Response{
StatusCode: http.StatusInternalServerError,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"error":"volume is read only"}`)),
}, nil
}
// Without a recent fast upload the watchdog stays off: a chronically slow link
// is the link, not a conn, and must not get its uploads cancelled.
func TestUploadStallWatchdogKeepsUnprovenHost(t *testing.T) {
defer func(orig time.Duration) { uploadStallMinWait = orig }(uploadStallMinWait)
uploadStallMinWait = 20 * time.Millisecond
client := &stallingHTTPClient{slowFor: 60 * time.Millisecond}
uploader := newUploader(client)
result, err := uploader.UploadData(context.Background(), make([]byte, 300*1024), &UploadOption{
UploadUrl: "http://slow-host:8080/1,deadbeef",
MaxAttempts: 1,
})
if err != nil {
t.Fatalf("unproven host upload should not be cancelled: %v", err)
}
if result.RetryCount != 0 || len(client.requestCtxs) != 1 {
t.Fatalf("got retries=%d calls=%d, want a single unbounded attempt", result.RetryCount, len(client.requestCtxs))
}
}
// Any sizeable completed upload marks its host at the rate it proved — slow
// or fast — because the stall budget scales off that rate. Only a too-small
// body fails to measure.
func TestUploadObservedBps(t *testing.T) {
if bps := uploadObservedBps(4<<20, 4*time.Second); bps != 1<<20 {
t.Fatalf("4MiB in 4s observed %d B/s, want 1MiB/s", bps)
}
if bps := uploadObservedBps(4<<20, 3*time.Hour); bps <= 0 {
t.Fatalf("a slow upload still proves the rate it moved at")
}
if bps := uploadObservedBps(64*1024, time.Millisecond); bps != 0 {
t.Fatalf("a small upload says nothing about host speed")
}
if bps := uploadObservedBps(4<<20, 0); bps != 0 {
t.Fatalf("a zero-duration sample is not a measurement")
}
}
// The stall budget scales with the rate the host last proved, so the same
// body gets little slack on a fast link and plenty on a slow one — no fixed
// floor fits both.
func TestUploadStallTimeoutScalesWithMeasuredRate(t *testing.T) {
// 8MiB/s observed → a 64MiB body gets 8x the time it needs at that rate.
if d := uploadStallTimeout(64<<20, 8<<20); d != 64*time.Second {
t.Fatalf("stall budget = %v, want 64s", d)
}
// The same body on a 64MiB/s host gets 8s — under the floor, so clamped.
if d := uploadStallTimeout(64<<20, 64<<20); d != uploadStallMinWait {
t.Fatalf("stall budget = %v, want the min wait %v", d, uploadStallMinWait)
}
// A tiny body on a fast host cannot race its own conn.
if d := uploadStallTimeout(1, 1<<30); d != uploadStallMinWait {
t.Fatalf("stall budget = %v, want the min wait %v", d, uploadStallMinWait)
}
}
type fsyncAssignClient struct {
filer_pb.SeaweedFilerClient
response *filer_pb.AssignVolumeResponse
}
func (c *fsyncAssignClient) AssignVolume(context.Context, *filer_pb.AssignVolumeRequest, ...grpc.CallOption) (*filer_pb.AssignVolumeResponse, error) {
return c.response, nil
}
func (c *fsyncAssignClient) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
return fn(c)
}
func (c *fsyncAssignClient) AdjustedUrl(loc *filer_pb.Location) string { return loc.GetUrl() }
func (c *fsyncAssignClient) GetDataCenter() string { return "" }
func TestUploadWithRetryAppendsFsyncWhenAssigned(t *testing.T) {
for _, tc := range []struct {
name string
proxy bool
}{
{name: "direct"},
{name: "filer proxy", proxy: true},
} {
t.Run(tc.name, func(t *testing.T) {
const fileID = "1,0123456789"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if gotFsync := r.URL.Query().Get("fsync"); gotFsync != "true" {
t.Errorf("upload fsync = %q, want true when assignment requires fsync", gotFsync)
}
if tc.proxy {
if got := r.URL.Query().Get("proxyChunkId"); got != fileID {
t.Errorf("proxyChunkId = %q, want %q", got, fileID)
}
}
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"size":3}`)
}))
defer server.Close()
host := strings.TrimPrefix(server.URL, "http://")
client := &fsyncAssignClient{response: &filer_pb.AssignVolumeResponse{
FileId: fileID,
Location: &filer_pb.Location{Url: host},
Fsync: true,
}}
option := &UploadOption{}
if tc.proxy {
option.GenUploadUrl = GenUploadUrlProxy(host)
}
_, _, err, _ := newUploader(server.Client()).UploadWithRetry(client,
&filer_pb.AssignVolumeRequest{Path: "/bucket/object"}, option, strings.NewReader("abc"))
if err != nil {
t.Fatalf("upload failed: %v", err)
}
})
}
}
// hangingAssignSeaweedClient is a SeaweedFilerClient whose AssignVolume blocks
// until its context is done, so a test can prove UploadWithRetry bounds the RPC.
type hangingAssignSeaweedClient struct {
filer_pb.SeaweedFilerClient
sawDeadline chan bool
}
func (c *hangingAssignSeaweedClient) AssignVolume(ctx context.Context, in *filer_pb.AssignVolumeRequest, opts ...grpc.CallOption) (*filer_pb.AssignVolumeResponse, error) {
_, ok := ctx.Deadline()
// Non-blocking so a retry that calls this more than once can't wedge here.
select {
case c.sawDeadline <- ok:
default:
}
<-ctx.Done() // simulate an overwhelmed filer that never answers
return nil, ctx.Err()
}
type hangingAssignFilerClient struct {
inner *hangingAssignSeaweedClient
}
func (c *hangingAssignFilerClient) WithFilerClient(streamingMode bool, fn func(filer_pb.SeaweedFilerClient) error) error {
return fn(c.inner)
}
func (c *hangingAssignFilerClient) AdjustedUrl(loc *filer_pb.Location) string { return loc.GetUrl() }
func (c *hangingAssignFilerClient) GetDataCenter() string { return "" }
// TestUploadWithRetryBoundsAssignVolume covers the case where an AssignVolume
// against an overwhelmed filer carried no deadline, so the upload (and the FUSE
// flush driving it) blocked forever. UploadWithRetry must give the RPC a
// deadline and return once it expires instead of hanging.
func TestUploadWithRetryBoundsAssignVolume(t *testing.T) {
original := assignVolumeTimeout
assignVolumeTimeout = 200 * time.Millisecond
t.Cleanup(func() { assignVolumeTimeout = original })
client := &hangingAssignFilerClient{inner: &hangingAssignSeaweedClient{sawDeadline: make(chan bool, 1)}}
uploader := newUploader(&scriptedHTTPClient{responses: map[string][]scriptedHTTPResponse{}})
done := make(chan error, 1)
go func() {
_, _, err, _ := uploader.UploadWithRetry(client,
&filer_pb.AssignVolumeRequest{Count: 1},
&UploadOption{Filename: "test.bin"},
bytes.NewReader([]byte("abc")),
)
done <- err
}()
select {
case sawDeadline := <-client.inner.sawDeadline:
if !sawDeadline {
t.Fatal("AssignVolume received a context with no deadline")
}
case <-time.After(10 * time.Second):
t.Fatal("AssignVolume was never called")
}
select {
case err := <-done:
if err == nil {
t.Fatal("expected UploadWithRetry to fail once the assign deadline expired")
}
case <-time.After(10 * time.Second):
t.Fatal("UploadWithRetry hung well past the assign timeout")
}
}
// TestUploadRewindsBodyOnConnectionReset reproduces issue #9139 follow-up:
// when the inner Do retry fires on a "connection reset" / "closed network"
// error, the *bytes.Reader body has already been consumed, so without an
// explicit rewind the second attempt sends 0 bytes and Go's transport surfaces
// "ContentLength=N with Body length 0".
func TestUploadRewindsBodyOnConnectionReset(t *testing.T) {
for _, transient := range []string{
"connection reset by peer",
"use of closed network connection",
} {
t.Run(transient, func(t *testing.T) {
client := &bodyCapturingHTTPClient{
failFirst: transient,
successJSON: `{"name":"test.bin","size":42}`,
}
uploader := newUploader(client)
payload := bytes.Repeat([]byte("payload-"), 256) // 2048 bytes
_, err := uploader.UploadData(context.Background(), payload, &UploadOption{
UploadUrl: "http://volume/1,abc",
Filename: "test.bin",
})
if err != nil {
t.Fatalf("upload should succeed after inner retry, got %v", err)
}
if len(client.bodies) != 2 {
t.Fatalf("expected 2 Do attempts, got %d", len(client.bodies))
}
if len(client.bodies[0]) == 0 {
t.Fatalf("first attempt sent an empty body; test setup wrong")
}
if !bytes.Equal(client.bodies[0], client.bodies[1]) {
t.Fatalf("retry body length=%d differs from first attempt length=%d (body was not rewound)",
len(client.bodies[1]), len(client.bodies[0]))
}
})
}
}
func TestReplicationUploadPreservesUncompressedNeedle(t *testing.T) {
testCases := []struct {
name string
payload []byte
}{
{
name: "unknown binary sampling",
payload: bytes.Repeat([]byte{0}, 32*1024),
},
{
name: "receiver MIME detection",
payload: bytes.Repeat([]byte("plain text content\n"), 2048),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var replicatedNeedle *needle.Needle
var parseErr error
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
replicatedNeedle, _, _, parseErr = needle.CreateNeedleFromRequest(r, false, 1024*1024, &bytes.Buffer{})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"name":"payload.custom","size":32768}`)
}))
defer server.Close()
uploader := newUploader(server.Client())
_, err := uploader.UploadData(context.Background(), tc.payload, &UploadOption{
UploadUrl: server.URL + "/3,01637037d6?type=replicate",
Filename: "payload.custom",
IsReplication: true,
MaxAttempts: 1,
})
if err != nil {
t.Fatalf("replication upload failed: %v", err)
}
if parseErr != nil {
t.Fatalf("parse replicated upload: %v", parseErr)
}
if replicatedNeedle == nil {
t.Fatal("replica did not receive a needle")
}
if replicatedNeedle.IsCompressed() {
t.Fatal("replication changed an uncompressed needle to compressed")
}
if !bytes.Equal(replicatedNeedle.Data, tc.payload) {
t.Fatalf("replicated data differs: got %d bytes, want %d", len(replicatedNeedle.Data), len(tc.payload))
}
})
}
}
// deadReplicaClient simulates an unreachable replica: every request blocks for
// dialDelay (standing in for the TCP dial timeout) and then fails, unless the
// request context is cancelled first.
type deadReplicaClient struct {
mu sync.Mutex
calls int
dialDelay time.Duration
}
func (c *deadReplicaClient) attempts() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.calls
}
func (c *deadReplicaClient) Do(req *http.Request) (*http.Response, error) {
c.mu.Lock()
c.calls++
c.mu.Unlock()
select {
case <-time.After(c.dialDelay):
return nil, fmt.Errorf("dial tcp %s: i/o timeout", req.URL.Host)
case <-req.Context().Done():
return nil, req.Context().Err()
}
}
// TestUploadToDeadReplicaRetriesThreeTimes reproduces the dead-replica upload
// stall: a synchronous replica write to an unreachable host pays the dial
// timeout three times over, so one dead replica stalls the caller for ~3x the
// dial timeout. In production dialDelay is the 10s dialer timeout, so the
// caller blocks ~30s before the failure surfaces.
func TestUploadToDeadReplicaRetriesThreeTimes(t *testing.T) {
client := &deadReplicaClient{dialDelay: 100 * time.Millisecond}
uploader := newUploader(client)
start := time.Now()
_, err := uploader.UploadData(context.Background(), []byte("hello"), &UploadOption{
UploadUrl: "http://dead-replica:8080/3,01",
Filename: "test.bin",
})
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected an error uploading to a dead replica")
}
if got := client.attempts(); got != 3 {
t.Fatalf("dial attempts = %d, want 3 (each attempt pays the full dial timeout)", got)
}
t.Logf("dead replica stalled the caller for %v across %d attempts", elapsed, client.attempts())
}
// TestUploadToDeadReplicaSingleAttempt verifies the fix: a synchronous replica
// write makes a single attempt (MaxAttempts=1), so a dead replica fails after
// one dial timeout instead of three. The outer client write still retries.
func TestUploadToDeadReplicaSingleAttempt(t *testing.T) {
client := &deadReplicaClient{dialDelay: 100 * time.Millisecond}
uploader := newUploader(client)
_, err := uploader.UploadData(context.Background(), []byte("hello"), &UploadOption{
UploadUrl: "http://dead-replica:8080/3,01",
Filename: "test.bin",
MaxAttempts: 1,
})
if err == nil {
t.Fatal("expected an error uploading to a dead replica")
}
if got := client.attempts(); got != 1 {
t.Fatalf("dial attempts = %d, want 1", got)
}
}
// TestUploadRetryStopsOnContextCancel verifies the retry loop honors context
// cancellation so DistributedOperation can abandon a slow replica once the
// write outcome is already decided by another replica.
func TestUploadRetryStopsOnContextCancel(t *testing.T) {
client := &deadReplicaClient{dialDelay: time.Hour} // would block ~forever per attempt
uploader := newUploader(client)
ctx, cancel := context.WithCancel(context.Background())
cancel() // already decided elsewhere
start := time.Now()
_, err := uploader.UploadData(ctx, []byte("hello"), &UploadOption{
UploadUrl: "http://dead-replica:8080/3,01",
Filename: "test.bin",
})
if err == nil {
t.Fatal("expected a context error")
}
if elapsed := time.Since(start); elapsed > 10*time.Second {
t.Fatalf("upload did not abort on cancellation: took %v", elapsed)
}
if got := client.attempts(); got > 1 {
t.Fatalf("dial attempts = %d, want at most 1 after cancellation", got)
}
}
// A ciphered upload hands the volume server ciphertext, so it cannot echo a
// Content-MD5 back; without the caller's plaintext digest the chunk lands with
// no ETag and every ETag computed from those chunks comes out empty
// (issue #10968).
func TestCipherUploadKeepsPlaintextETag(t *testing.T) {
payload := bytes.Repeat([]byte("encrypt me\n"), 4096)
digest := md5.Sum(payload)
wantMd5 := base64.StdEncoding.EncodeToString(digest[:])
var storedNeedle *needle.Needle
var sentContentMd5 string
var parseErr error
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sentContentMd5 = r.Header.Get("Content-MD5")
storedNeedle, _, _, parseErr = needle.CreateNeedleFromRequest(r, false, 1024*1024, &bytes.Buffer{})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"name":"payload","size":45056}`)
}))
defer server.Close()
uploader := newUploader(server.Client())
result, err := uploader.UploadData(context.Background(), payload, &UploadOption{
UploadUrl: server.URL + "/3,01637037d6",
Cipher: true,
Md5: wantMd5,
MaxAttempts: 1,
})
if err != nil {
t.Fatalf("cipher upload failed: %v", err)
}
if parseErr != nil {
t.Fatalf("parse ciphered upload: %v", parseErr)
}
if sentContentMd5 != "" {
t.Fatalf("Content-MD5 %q sent to the volume server, which only sees ciphertext", sentContentMd5)
}
if result.ContentMd5 != wantMd5 {
t.Fatalf("chunk ETag = %q, want the plaintext digest %q", result.ContentMd5, wantMd5)
}
if bytes.Equal(storedNeedle.Data, payload) {
t.Fatal("volume server stored plaintext for a ciphered upload")
}
decrypted, err := util.Decrypt(storedNeedle.Data, util.CipherKey(result.CipherKey))
if err != nil {
t.Fatalf("decrypt stored needle: %v", err)
}
if result.Gzip > 0 {
if decrypted, err = util.DecompressData(decrypted); err != nil {
t.Fatalf("decompress stored needle: %v", err)
}
}
if !bytes.Equal(decrypted, payload) {
t.Fatalf("decrypted %d bytes, want %d", len(decrypted), len(payload))
}
}