mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-24 23:10:41 +02:00
* 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>
764 lines
27 KiB
Go
764 lines
27 KiB
Go
package operation
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/textproto"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/util/request_id"
|
|
"github.com/valyala/bytebufferpool"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/security"
|
|
"github.com/seaweedfs/seaweedfs/weed/stats"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
|
|
util_http_client "github.com/seaweedfs/seaweedfs/weed/util/http/client"
|
|
)
|
|
|
|
// GenUploadUrlProxy returns a function that builds a chunk upload URL via the
|
|
// filer proxy. Identical to the inline logic used by weed mount -filerProxy
|
|
// and weed filer gateway:
|
|
//
|
|
// - without filerProxy: "http://{host}/{fileId}"
|
|
// - with filerProxy: "http://{filerAddress}/?proxyChunkId={fileId}"
|
|
func GenUploadUrlProxy(filerAddress string) func(host, fileId string) string {
|
|
return func(host, fileId string) string {
|
|
if filerAddress == "" {
|
|
return fmt.Sprintf("http://%s/%s", host, fileId)
|
|
}
|
|
return util_http.ProxyChunkUrl(filerAddress, fileId)
|
|
}
|
|
}
|
|
|
|
type UploadOption struct {
|
|
UploadUrl string
|
|
Filename string
|
|
Cipher bool
|
|
IsInputCompressed bool
|
|
IsReplication bool // preserve the source needle's compression state
|
|
MimeType string
|
|
PairMap map[string]string
|
|
Jwt security.EncodedJwt
|
|
RetryForever bool
|
|
Md5 string
|
|
WantMd5 bool // compute Content-MD5 from the data when Md5 is unset and the upload is not ciphered
|
|
BytesBuffer *bytes.Buffer
|
|
SourceUrl string // optional: for logging when reading from a remote source
|
|
MaxAttempts int // <=0 uses the default
|
|
GenUploadUrl func(host, fileId string) string // if nil → fallback "http://{host}/{fileId}"
|
|
FilerJwt security.FilerJwtProvider // credential for proxy chunk URLs; nil → process-wide jwt.filer_signing
|
|
|
|
// stallWatchdogDisarmed turns the watchdog off for this upload's later
|
|
// attempts once it fired: a re-arm could burn every retry.
|
|
stallWatchdogDisarmed bool
|
|
}
|
|
|
|
type UploadResult struct {
|
|
Name string `json:"name,omitempty"`
|
|
Size uint32 `json:"size,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
ETag string `json:"eTag,omitempty"`
|
|
CipherKey []byte `json:"cipherKey,omitempty"`
|
|
Mime string `json:"mime,omitempty"`
|
|
Gzip uint32 `json:"gzip,omitempty"`
|
|
ContentMd5 string `json:"contentMd5,omitempty"`
|
|
RetryCount int `json:"-"`
|
|
}
|
|
|
|
func (uploadResult *UploadResult) ToPbFileChunk(fileId string, offset int64, tsNs int64) *filer_pb.FileChunk {
|
|
fid, _ := filer_pb.ToFileIdObject(fileId)
|
|
return &filer_pb.FileChunk{
|
|
FileId: fileId,
|
|
Offset: offset,
|
|
Size: uint64(uploadResult.Size),
|
|
ModifiedTsNs: tsNs,
|
|
ETag: uploadResult.ContentMd5,
|
|
CipherKey: uploadResult.CipherKey,
|
|
IsCompressed: uploadResult.Gzip > 0,
|
|
Fid: fid,
|
|
}
|
|
}
|
|
|
|
// ToPbFileChunkWithSSE creates a FileChunk with SSE metadata
|
|
func (uploadResult *UploadResult) ToPbFileChunkWithSSE(fileId string, offset int64, tsNs int64, sseType filer_pb.SSEType, sseMetadata []byte) *filer_pb.FileChunk {
|
|
fid, _ := filer_pb.ToFileIdObject(fileId)
|
|
chunk := &filer_pb.FileChunk{
|
|
FileId: fileId,
|
|
Offset: offset,
|
|
Size: uint64(uploadResult.Size),
|
|
ModifiedTsNs: tsNs,
|
|
ETag: uploadResult.ContentMd5,
|
|
CipherKey: uploadResult.CipherKey,
|
|
IsCompressed: uploadResult.Gzip > 0,
|
|
Fid: fid,
|
|
}
|
|
|
|
// Add SSE metadata if provided
|
|
chunk.SseType = sseType
|
|
if len(sseMetadata) > 0 {
|
|
chunk.SseMetadata = sseMetadata
|
|
}
|
|
|
|
return chunk
|
|
}
|
|
|
|
var (
|
|
uploader *Uploader
|
|
uploaderErr error
|
|
once sync.Once
|
|
)
|
|
|
|
// uploadStatusError carries the volume-server HTTP status of a failed upload so
|
|
// the retry gate can decide on the status code instead of the message text.
|
|
// StatusCode 0 means the request never got a response — a transport failure.
|
|
type uploadStatusError struct {
|
|
StatusCode int
|
|
stalled bool // the stall watchdog cancelled this attempt's request
|
|
err error
|
|
}
|
|
|
|
func (e *uploadStatusError) Error() string { return e.err.Error() }
|
|
func (e *uploadStatusError) Unwrap() error { return e.err }
|
|
|
|
// ShouldReassignUpload reports whether an upload error means the client should
|
|
// ask for a fresh volume assignment and retry on another volume.
|
|
//
|
|
// On the write path a volume server only 5xxs on a ReplicatedWrite failure
|
|
// (local disk, replica peer down, or under-replication) — all of which a
|
|
// different volume dodges — so any 5xx is reassignable. A status of 0 means the
|
|
// assigned target never answered (down/unreachable), also reassignable. A 4xx
|
|
// is a genuine client error and is surfaced. Errors without a status come from
|
|
// the AssignVolume RPC or request setup; retry only transient transport ones.
|
|
func ShouldReassignUpload(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
var se *uploadStatusError
|
|
if errors.As(err, &se) {
|
|
return se.StatusCode == 0 || se.StatusCode >= 500
|
|
}
|
|
return strings.Contains(err.Error(), "transport")
|
|
}
|
|
|
|
// assignVolumeTimeout bounds a single AssignVolume RPC so an overwhelmed filer
|
|
// can't block the caller forever. Overridable in tests.
|
|
var assignVolumeTimeout = 30 * time.Second
|
|
|
|
// HTTPClient interface for testing
|
|
type HTTPClient interface {
|
|
Do(req *http.Request) (*http.Response, error)
|
|
}
|
|
|
|
// Uploader
|
|
type Uploader struct {
|
|
httpClient HTTPClient
|
|
// fastUploadHosts records the wire rate of the last sizeable upload to
|
|
// each host, so a later upload stalling far below it can be blamed on
|
|
// that one connection rather than the link and get cancelled for a retry
|
|
// on a fresh conn.
|
|
fastUploadHosts sync.Map // host:port → fastHostMark
|
|
}
|
|
|
|
func NewUploader() (*Uploader, error) {
|
|
once.Do(func() {
|
|
// With Dial context
|
|
var httpClient *util_http_client.HTTPClient
|
|
httpClient, uploaderErr = util_http.NewGlobalHttpClient(util_http_client.AddDialContext)
|
|
if uploaderErr != nil {
|
|
uploaderErr = fmt.Errorf("error initializing the loader: %s", uploaderErr)
|
|
}
|
|
if httpClient != nil {
|
|
uploader = newUploader(httpClient)
|
|
}
|
|
})
|
|
return uploader, uploaderErr
|
|
}
|
|
|
|
func newUploader(httpClient HTTPClient) *Uploader {
|
|
return &Uploader{
|
|
httpClient: httpClient,
|
|
}
|
|
}
|
|
|
|
// NewUploaderWithHttpClient creates an Uploader that uses the provided HTTP
|
|
// client instead of the global one. This is used by filer.sync to upload to
|
|
// remote clusters that use different TLS certificates.
|
|
func NewUploaderWithHttpClient(httpClient HTTPClient) *Uploader {
|
|
return &Uploader{
|
|
httpClient: httpClient,
|
|
}
|
|
}
|
|
|
|
func (uploader *Uploader) uploadWithRetryData(assignFn func() (fileId string, host string, auth security.EncodedJwt, fsync bool, err error), uploadOption *UploadOption, data []byte) (fileId string, uploadResult *UploadResult, err error) {
|
|
doUploadFunc := func() error {
|
|
var host string
|
|
var auth security.EncodedJwt
|
|
var fsync bool
|
|
fileId, host, auth, fsync, err = assignFn()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
genUrl := uploadOption.GenUploadUrl
|
|
if genUrl == nil {
|
|
genUrl = func(host, fileId string) string { return fmt.Sprintf("http://%s/%s", host, fileId) }
|
|
}
|
|
uploadOption.UploadUrl = genUrl(host, fileId)
|
|
if fsync {
|
|
uploadOption.UploadUrl = util_http.AppendQueryParameter(uploadOption.UploadUrl, "fsync", "true")
|
|
}
|
|
uploadOption.Jwt = auth
|
|
if util_http.IsProxyChunkUrl(uploadOption.UploadUrl) {
|
|
// The request addresses the filer, which authorizes it and mints the
|
|
// volume credential itself. The AssignVolume token is not a filer
|
|
// credential and gets the caller nowhere here.
|
|
if uploadOption.FilerJwt != nil {
|
|
uploadOption.Jwt = uploadOption.FilerJwt(true)
|
|
} else {
|
|
uploadOption.Jwt = security.EncodedJwt(util_http.JwtForFilerServer(true))
|
|
}
|
|
}
|
|
|
|
uploadResult, err = uploader.retriedUploadData(context.Background(), data, uploadOption)
|
|
return err
|
|
}
|
|
|
|
if uploadOption.RetryForever {
|
|
util.RetryUntil("uploadWithRetryForever", doUploadFunc, func(err error) (shouldContinue bool) {
|
|
glog.V(0).Infof("upload content: %v", err)
|
|
return true
|
|
})
|
|
} else {
|
|
err = util.RetryOnError("uploadWithRetry", ShouldReassignUpload, doUploadFunc)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// UploadWithRetry will retry both assigning volume request and uploading content
|
|
// The option parameter does not need to specify UploadUrl and Jwt, which will come from assigning volume.
|
|
func (uploader *Uploader) UploadWithRetry(filerClient filer_pb.FilerClient, assignRequest *filer_pb.AssignVolumeRequest, uploadOption *UploadOption, reader io.Reader) (fileId string, uploadResult *UploadResult, err error, data []byte) {
|
|
bytesReader, ok := reader.(*util.BytesReader)
|
|
if ok {
|
|
data = bytesReader.Bytes
|
|
} else {
|
|
data, err = io.ReadAll(reader)
|
|
if err != nil {
|
|
glog.V(0).Infof("upload read input %s: %v", uploadOption.SourceUrl, err)
|
|
err = fmt.Errorf("read input: %w", err)
|
|
return
|
|
}
|
|
glog.V(4).Infof("upload read %d bytes from %s", len(data), uploadOption.SourceUrl)
|
|
}
|
|
|
|
// Tell the master the real chunk size so its effectiveSize accounting
|
|
// doesn't fall back to the 1 MB DefaultNeedleSizeEstimate per fid.
|
|
if assignRequest.ExpectedDataSize == 0 {
|
|
assignRequest.ExpectedDataSize = uint64(len(data))
|
|
}
|
|
|
|
// Hash the buffer we already hold so the server echoes Content-MD5 back as
|
|
// the chunk ETag (std-base64 of the raw digest, the form ParseUpload
|
|
// verifies). Under cipher the server never sees the header, but the digest
|
|
// still becomes the chunk ETag.
|
|
if uploadOption.WantMd5 && uploadOption.Md5 == "" {
|
|
digest := md5.Sum(data)
|
|
uploadOption.Md5 = base64.StdEncoding.EncodeToString(digest[:])
|
|
}
|
|
|
|
fileId, uploadResult, err = uploader.uploadWithRetryData(func() (fileId string, host string, auth security.EncodedJwt, fsync bool, err error) {
|
|
// grpc assign volume
|
|
if grpcAssignErr := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
assignCtx, assignCancel := context.WithTimeout(context.Background(), assignVolumeTimeout)
|
|
defer assignCancel()
|
|
resp, assignErr := client.AssignVolume(assignCtx, assignRequest)
|
|
if assignErr != nil {
|
|
glog.V(0).Infof("assign volume failure %v: %v", assignRequest, assignErr)
|
|
return assignErr
|
|
}
|
|
if resp.Error != "" {
|
|
return fmt.Errorf("assign volume failure %v: %v", assignRequest, resp.Error)
|
|
}
|
|
|
|
fileId, auth = resp.FileId, security.EncodedJwt(resp.Auth)
|
|
fsync = resp.Fsync
|
|
loc := resp.Location
|
|
host = filerClient.AdjustedUrl(loc)
|
|
|
|
return nil
|
|
}); grpcAssignErr != nil {
|
|
err = fmt.Errorf("filerGrpcAddress assign volume: %w", grpcAssignErr)
|
|
}
|
|
return
|
|
}, uploadOption, data)
|
|
return
|
|
}
|
|
|
|
// Upload sends a POST request to a volume server to upload the content with adjustable compression level
|
|
func (uploader *Uploader) UploadData(ctx context.Context, data []byte, option *UploadOption) (uploadResult *UploadResult, err error) {
|
|
uploadResult, err = uploader.retriedUploadData(ctx, data, option)
|
|
return
|
|
}
|
|
|
|
// Upload sends a POST request to a volume server to upload the content with fast compression
|
|
func (uploader *Uploader) Upload(ctx context.Context, reader io.Reader, option *UploadOption) (uploadResult *UploadResult, err error, data []byte) {
|
|
uploadResult, err, data = uploader.doUpload(ctx, reader, option)
|
|
return
|
|
}
|
|
|
|
func (uploader *Uploader) doUpload(ctx context.Context, reader io.Reader, option *UploadOption) (uploadResult *UploadResult, err error, data []byte) {
|
|
bytesReader, ok := reader.(*util.BytesReader)
|
|
if ok {
|
|
data = bytesReader.Bytes
|
|
} else {
|
|
data, err = io.ReadAll(reader)
|
|
if err != nil {
|
|
err = fmt.Errorf("read input: %w", err)
|
|
return
|
|
}
|
|
}
|
|
uploadResult, uploadErr := uploader.retriedUploadData(ctx, data, option)
|
|
return uploadResult, uploadErr, data
|
|
}
|
|
|
|
func (uploader *Uploader) retriedUploadData(ctx context.Context, data []byte, option *UploadOption) (uploadResult *UploadResult, err error) {
|
|
// A stall disarms the watchdog for this upload's remaining attempts only;
|
|
// keep that on a per-call option so a caller reusing UploadOption starts
|
|
// the next upload armed.
|
|
localOption := *option
|
|
option = &localOption
|
|
maxAttempts := option.MaxAttempts
|
|
if maxAttempts <= 0 {
|
|
maxAttempts = 3
|
|
}
|
|
for i := 0; i < maxAttempts; i++ {
|
|
if i > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(time.Millisecond * time.Duration(237*(i+1))):
|
|
}
|
|
}
|
|
uploadResult, err = uploader.doUploadData(ctx, data, option)
|
|
if err == nil {
|
|
uploadResult.RetryCount = i
|
|
return
|
|
}
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
var statusErr *uploadStatusError
|
|
if errors.As(err, &statusErr) && statusErr.stalled {
|
|
option.stallWatchdogDisarmed = true
|
|
}
|
|
glog.WarningfCtx(ctx, "uploading %d to %s: %v", i, option.UploadUrl, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
func (uploader *Uploader) doUploadData(ctx context.Context, data []byte, option *UploadOption) (uploadResult *UploadResult, err error) {
|
|
contentIsGzipped := option.IsInputCompressed
|
|
shouldGzipNow := false
|
|
if !option.IsInputCompressed && !option.IsReplication {
|
|
if option.MimeType == "" {
|
|
option.MimeType = http.DetectContentType(data)
|
|
// println("detect1 mimetype to", MimeType)
|
|
if option.MimeType == "application/octet-stream" {
|
|
option.MimeType = ""
|
|
}
|
|
}
|
|
if shouldBeCompressed, iAmSure := util.IsCompressableFileType(filepath.Base(option.Filename), option.MimeType); iAmSure && shouldBeCompressed {
|
|
shouldGzipNow = true
|
|
} else if !iAmSure && option.MimeType == "" && len(data) > 16*1024 {
|
|
var compressed []byte
|
|
compressed, err = util.GzipData(data[0:128])
|
|
if err != nil {
|
|
return
|
|
}
|
|
shouldGzipNow = len(compressed)*10 < 128*9 // can not compress to less than 90%
|
|
}
|
|
}
|
|
|
|
var clearDataLen int
|
|
|
|
// gzip if possible
|
|
// this could be double copying
|
|
clearDataLen = len(data)
|
|
clearData := data
|
|
if shouldGzipNow {
|
|
compressed, compressErr := util.GzipData(data)
|
|
// fmt.Printf("data is compressed from %d ==> %d\n", len(data), len(compressed))
|
|
if compressErr == nil {
|
|
if len(compressed) < len(data) {
|
|
data = compressed
|
|
contentIsGzipped = true
|
|
}
|
|
}
|
|
} else if option.IsInputCompressed && !option.IsReplication {
|
|
// decompress only to report the clear data length; replication discards the result, so skip it
|
|
clearData, err = util.DecompressData(data)
|
|
if err == nil {
|
|
clearDataLen = len(clearData)
|
|
}
|
|
}
|
|
|
|
if option.Cipher {
|
|
// encrypt(gzip(data))
|
|
|
|
// encrypt
|
|
cipherKey := util.GenCipherKey()
|
|
encryptedData, encryptionErr := util.Encrypt(data, cipherKey)
|
|
if encryptionErr != nil {
|
|
err = fmt.Errorf("encrypt input: %w", encryptionErr)
|
|
return
|
|
}
|
|
|
|
// upload data
|
|
uploadResult, err = uploader.upload_content(ctx, func(w io.Writer) (err error) {
|
|
_, err = w.Write(encryptedData)
|
|
return
|
|
}, len(encryptedData), &UploadOption{
|
|
UploadUrl: option.UploadUrl,
|
|
Filename: "",
|
|
Cipher: false,
|
|
IsInputCompressed: false,
|
|
MimeType: "",
|
|
PairMap: nil,
|
|
Jwt: option.Jwt,
|
|
BytesBuffer: option.BytesBuffer,
|
|
stallWatchdogDisarmed: option.stallWatchdogDisarmed,
|
|
})
|
|
if uploadResult == nil {
|
|
return
|
|
}
|
|
uploadResult.Name = option.Filename
|
|
uploadResult.Mime = option.MimeType
|
|
uploadResult.CipherKey = cipherKey
|
|
// The volume server only ever hashes the ciphertext it stored, so the
|
|
// chunk ETag has to come from the caller's plaintext digest.
|
|
uploadResult.ContentMd5 = option.Md5
|
|
uploadResult.Size = uint32(clearDataLen)
|
|
if contentIsGzipped {
|
|
uploadResult.Gzip = 1
|
|
}
|
|
} else {
|
|
// upload data
|
|
uploadResult, err = uploader.upload_content(ctx, func(w io.Writer) (err error) {
|
|
_, err = w.Write(data)
|
|
return
|
|
}, len(data), &UploadOption{
|
|
UploadUrl: option.UploadUrl,
|
|
Filename: option.Filename,
|
|
Cipher: false,
|
|
IsInputCompressed: contentIsGzipped,
|
|
MimeType: option.MimeType,
|
|
PairMap: option.PairMap,
|
|
Jwt: option.Jwt,
|
|
Md5: option.Md5,
|
|
BytesBuffer: option.BytesBuffer,
|
|
stallWatchdogDisarmed: option.stallWatchdogDisarmed,
|
|
})
|
|
if uploadResult == nil {
|
|
return
|
|
}
|
|
uploadResult.Size = uint32(clearDataLen)
|
|
if contentIsGzipped {
|
|
uploadResult.Gzip = 1
|
|
}
|
|
}
|
|
|
|
return uploadResult, err
|
|
}
|
|
|
|
func (uploader *Uploader) upload_content(ctx context.Context, fillBufferFunction func(w io.Writer) error, originalDataSize int, option *UploadOption) (*UploadResult, error) {
|
|
var body_writer *multipart.Writer
|
|
var reqReader *bytes.Reader
|
|
var buf *bytebufferpool.ByteBuffer
|
|
if option.BytesBuffer == nil {
|
|
buf = GetBuffer()
|
|
defer PutBuffer(buf)
|
|
body_writer = multipart.NewWriter(buf)
|
|
} else {
|
|
option.BytesBuffer.Reset()
|
|
body_writer = multipart.NewWriter(option.BytesBuffer)
|
|
}
|
|
h := make(textproto.MIMEHeader)
|
|
// Use mime.FormatMediaType for RFC 6266 compliant Content-Disposition,
|
|
// properly handling non-ASCII characters and special characters
|
|
h.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{"name": "file", "filename": option.Filename}))
|
|
h.Set("Idempotency-Key", option.UploadUrl)
|
|
if option.MimeType == "" {
|
|
option.MimeType = mime.TypeByExtension(strings.ToLower(filepath.Ext(option.Filename)))
|
|
}
|
|
if option.MimeType != "" {
|
|
h.Set("Content-Type", option.MimeType)
|
|
}
|
|
if option.IsInputCompressed {
|
|
h.Set("Content-Encoding", "gzip")
|
|
}
|
|
if option.Md5 != "" {
|
|
h.Set("Content-MD5", option.Md5)
|
|
}
|
|
|
|
file_writer, cp_err := body_writer.CreatePart(h)
|
|
if cp_err != nil {
|
|
glog.V(0).InfolnCtx(ctx, "error creating form file", cp_err.Error())
|
|
return nil, cp_err
|
|
}
|
|
if err := fillBufferFunction(file_writer); err != nil {
|
|
glog.V(0).InfolnCtx(ctx, "error copying data", err)
|
|
return nil, err
|
|
}
|
|
content_type := body_writer.FormDataContentType()
|
|
if err := body_writer.Close(); err != nil {
|
|
glog.V(0).InfolnCtx(ctx, "error closing body", err)
|
|
return nil, err
|
|
}
|
|
if option.BytesBuffer == nil {
|
|
reqReader = bytes.NewReader(buf.Bytes())
|
|
} else {
|
|
reqReader = bytes.NewReader(option.BytesBuffer.Bytes())
|
|
}
|
|
// bodySize is what the conn must move: originalDataSize counts bytes
|
|
// before compression, which would inflate both the recorded rate and the
|
|
// stall budget for a pre-compressed body.
|
|
bodySize := int64(reqReader.Len())
|
|
reqCtx, cancelUpload := context.WithCancel(ctx)
|
|
defer cancelUpload()
|
|
|
|
req, postErr := http.NewRequestWithContext(reqCtx, http.MethodPost, option.UploadUrl, reqReader)
|
|
if postErr != nil {
|
|
glog.V(1).InfofCtx(ctx, "create upload request %s: %v", option.UploadUrl, postErr)
|
|
return nil, fmt.Errorf("create upload request %s: %v", option.UploadUrl, postErr)
|
|
}
|
|
req.Header.Set("Content-Type", content_type)
|
|
for k, v := range option.PairMap {
|
|
req.Header.Set(k, v)
|
|
}
|
|
if option.Jwt != "" {
|
|
req.Header.Set("Authorization", security.BearerPrefix+string(option.Jwt))
|
|
}
|
|
|
|
request_id.InjectToRequest(ctx, req)
|
|
|
|
var uploadDone atomic.Bool
|
|
defer uploadDone.Store(true)
|
|
// bodySentAt marks when the transport finished draining the request body;
|
|
// past it the wait is server-side commit, which the transport's response
|
|
// header timeout already bounds — the stall watchdog only blames a conn
|
|
// that is still trickling the body.
|
|
var curBodySentAt atomic.Pointer[atomic.Int64]
|
|
bodySentAt := &atomic.Int64{}
|
|
curBodySentAt.Store(bodySentAt)
|
|
req.Body = &watchdogBody{ReadCloser: req.Body, sentAt: bodySentAt}
|
|
mark, hostWasFast := uploader.fastUploadHosts.Load(req.URL.Host)
|
|
hostWasFast = hostWasFast && !option.stallWatchdogDisarmed && time.Since(time.Unix(0, mark.(fastHostMark).at)) < fastUploadHostMemory
|
|
stallFired := func() {
|
|
if !uploadDone.CompareAndSwap(false, true) {
|
|
return
|
|
}
|
|
// The host was moving bodies at full rate a moment ago, so an
|
|
// upload still trickling far below that rate rides a wedged pooled
|
|
// conn; cancel drops the conn so the retry dials a fresh one.
|
|
// CompareAndDelete leaves a newer mark a concurrent success stored.
|
|
uploader.fastUploadHosts.CompareAndDelete(req.URL.Host, mark)
|
|
glog.WarningfCtx(ctx, "upload %d bytes to %s stalled on a pooled connection, retrying on a fresh one", originalDataSize, option.UploadUrl)
|
|
cancelUpload()
|
|
}
|
|
var stallTimer *time.Timer
|
|
if hostWasFast {
|
|
defer func() {
|
|
if stallTimer != nil {
|
|
stallTimer.Stop()
|
|
}
|
|
}()
|
|
stallTimer = time.AfterFunc(uploadStallTimeout(bodySize, mark.(fastHostMark).bps), func() {
|
|
if curBodySentAt.Load().Load() == 0 {
|
|
stallFired()
|
|
}
|
|
})
|
|
}
|
|
|
|
// print("+")
|
|
started := time.Now()
|
|
resp, post_err := uploader.httpClient.Do(req)
|
|
defer util_http.CloseResponse(resp)
|
|
if post_err != nil {
|
|
if strings.Contains(post_err.Error(), "connection reset by peer") ||
|
|
strings.Contains(post_err.Error(), "use of closed network connection") {
|
|
glog.V(1).InfofCtx(ctx, "repeat error upload request %s: %v", option.UploadUrl, post_err)
|
|
stats.FilerHandlerCounter.WithLabelValues(stats.RepeatErrorUploadContent).Inc()
|
|
// The first attempt already consumed (or partially consumed) the
|
|
// body, so retrying with the same *http.Request would send 0 bytes
|
|
// and Go's transport would surface "ContentLength=N with Body
|
|
// length 0". http.NewRequestWithContext sets GetBody for
|
|
// *bytes.Reader bodies; use it to attach a fresh body for retry.
|
|
// If we can't rewind, skip the inner retry and let the outer
|
|
// retriedUploadData loop reissue the request with a fresh body —
|
|
// retrying here with a consumed body would mask the original
|
|
// "connection reset" error with a misleading "Body length 0".
|
|
if req.GetBody != nil {
|
|
if newBody, gbErr := req.GetBody(); gbErr == nil {
|
|
bodySentAt = &atomic.Int64{}
|
|
curBodySentAt.Store(bodySentAt)
|
|
started = time.Now()
|
|
if stallTimer != nil && !uploadDone.Load() {
|
|
stallTimer.Reset(uploadStallTimeout(bodySize, mark.(fastHostMark).bps))
|
|
}
|
|
req.Body = &watchdogBody{ReadCloser: newBody, sentAt: bodySentAt}
|
|
resp, post_err = uploader.httpClient.Do(req)
|
|
defer util_http.CloseResponse(resp)
|
|
} else {
|
|
glog.V(1).InfofCtx(ctx, "skip inner retry for %s: GetBody returned %v", option.UploadUrl, gbErr)
|
|
}
|
|
} else {
|
|
glog.V(1).InfofCtx(ctx, "skip inner retry for %s: req.GetBody is nil", option.UploadUrl)
|
|
}
|
|
}
|
|
}
|
|
if post_err != nil {
|
|
stats.UploadErrorCounter.WithLabelValues("0").Inc()
|
|
return nil, &uploadStatusError{StatusCode: 0, stalled: reqCtx.Err() != nil && ctx.Err() == nil, err: fmt.Errorf("upload %s %d bytes to %v: %v", option.Filename, originalDataSize, option.UploadUrl, post_err)}
|
|
}
|
|
// Rate is bounded by elapsed-to-response: the server cannot answer before
|
|
// the body lands, so this can only understate link speed — a drain-time
|
|
// reading mistakes kernel-buffer absorption for wire speed and would mark
|
|
// a slow link fast. The mark still only lands on a verified upload: a
|
|
// fast rejection says nothing about how the conn drains.
|
|
movedIn := time.Since(started)
|
|
|
|
// print("-")
|
|
|
|
var ret UploadResult
|
|
etag := getEtag(resp)
|
|
if resp.StatusCode == http.StatusNoContent {
|
|
if bps := uploadObservedBps(bodySize, movedIn); bps > 0 {
|
|
uploader.fastUploadHosts.Store(req.URL.Host, fastHostMark{at: time.Now().UnixNano(), bps: bps})
|
|
}
|
|
ret.ETag = etag
|
|
ret.ContentMd5 = resp.Header.Get("Content-MD5")
|
|
return &ret, nil
|
|
}
|
|
|
|
if stallTimer != nil {
|
|
// The header timeout does not cover the response body: keep the
|
|
// watchdog armed for the read so a conn that wedges mid-reply gets
|
|
// cancelled for a fresh retry.
|
|
respStallTimer := time.AfterFunc(uploadStallMinWait, stallFired)
|
|
defer respStallTimer.Stop()
|
|
}
|
|
resp_body, ra_err := io.ReadAll(resp.Body)
|
|
uploadDone.Store(true)
|
|
if ra_err != nil {
|
|
stats.UploadErrorCounter.WithLabelValues(strconv.Itoa(resp.StatusCode)).Inc()
|
|
return nil, &uploadStatusError{StatusCode: resp.StatusCode, stalled: reqCtx.Err() != nil && ctx.Err() == nil, err: fmt.Errorf("read response body %v: %w", option.UploadUrl, ra_err)}
|
|
}
|
|
|
|
unmarshal_err := json.Unmarshal(resp_body, &ret)
|
|
if unmarshal_err != nil {
|
|
stats.UploadErrorCounter.WithLabelValues(strconv.Itoa(resp.StatusCode)).Inc()
|
|
glog.ErrorfCtx(ctx, "unmarshal %s: %v", option.UploadUrl, string(resp_body))
|
|
return nil, &uploadStatusError{StatusCode: resp.StatusCode, err: fmt.Errorf("unmarshal %v: %w", option.UploadUrl, unmarshal_err)}
|
|
}
|
|
if ret.Error != "" {
|
|
stats.UploadErrorCounter.WithLabelValues(strconv.Itoa(resp.StatusCode)).Inc()
|
|
return nil, &uploadStatusError{StatusCode: resp.StatusCode, err: fmt.Errorf("unmarshalled error %v: %v", option.UploadUrl, ret.Error)}
|
|
}
|
|
if bps := uploadObservedBps(bodySize, movedIn); bps > 0 {
|
|
uploader.fastUploadHosts.Store(req.URL.Host, fastHostMark{at: time.Now().UnixNano(), bps: bps})
|
|
}
|
|
ret.ETag = etag
|
|
ret.ContentMd5 = resp.Header.Get("Content-MD5")
|
|
return &ret, nil
|
|
}
|
|
|
|
func getEtag(r *http.Response) (etag string) {
|
|
etag = r.Header.Get("ETag")
|
|
if strings.HasPrefix(etag, "\"") && strings.HasSuffix(etag, "\"") {
|
|
etag = etag[1 : len(etag)-1]
|
|
}
|
|
return
|
|
}
|
|
|
|
// uploadRateMinBytes is the smallest body whose measured rate can mark a
|
|
// host: smaller uploads are dominated by round-trip and commit overhead, not
|
|
// wire speed.
|
|
const uploadRateMinBytes = int64(256 << 10)
|
|
|
|
var (
|
|
// uploadStallSlowFactor is how far below the host's last observed rate an
|
|
// upload may run before its conn is presumed wedged: at 8, a host that
|
|
// just moved a body at 8MiB/s tolerates down to 1MiB/s.
|
|
uploadStallSlowFactor = int64(8)
|
|
uploadStallMinWait = 10 * time.Second
|
|
fastUploadHostMemory = time.Minute
|
|
)
|
|
|
|
// fastHostMark remembers the wire rate of a host's last sizeable upload, so a
|
|
// later upload running far below it blames the pooled conn rather than the
|
|
// link — no fixed rate fits every deployment's links.
|
|
type fastHostMark struct {
|
|
at int64 // UnixNano
|
|
bps int64 // wire bytes/sec measured elapsed-to-response
|
|
}
|
|
|
|
// uploadStallTimeout is the most an upload may take on a conn whose host last
|
|
// proved bps: the time the body needs at an uploadStallSlowFactor of that
|
|
// rate, with a floor so a fast conn is not raced on small bodies.
|
|
func uploadStallTimeout(size, bps int64) time.Duration {
|
|
if bps > 0 {
|
|
if d := time.Duration(size/bps) * time.Duration(uploadStallSlowFactor) * time.Second; d > uploadStallMinWait {
|
|
return d
|
|
}
|
|
}
|
|
return uploadStallMinWait
|
|
}
|
|
|
|
// uploadObservedBps returns the wire rate a completed upload proved on its
|
|
// host, or 0 when the body was too small to measure. Elapsed-to-response can
|
|
// only understate the link's speed, which keeps the mark — and the stall
|
|
// budget derived from it — conservative.
|
|
func uploadObservedBps(size int64, elapsed time.Duration) int64 {
|
|
if size < uploadRateMinBytes || elapsed <= 0 {
|
|
return 0
|
|
}
|
|
return int64(float64(size) / elapsed.Seconds())
|
|
}
|
|
|
|
// watchdogBody records when the transport drains the request body: before that
|
|
// a stuck send blames the conn; after it the wait is the server's.
|
|
type watchdogBody struct {
|
|
io.ReadCloser
|
|
sentAt *atomic.Int64
|
|
}
|
|
|
|
func (b *watchdogBody) Read(p []byte) (int, error) {
|
|
n, err := b.ReadCloser.Read(p)
|
|
if err == io.EOF {
|
|
b.sentAt.Store(time.Now().UnixNano())
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
func (b *watchdogBody) Close() error {
|
|
b.sentAt.Store(time.Now().UnixNano())
|
|
return b.ReadCloser.Close()
|
|
}
|