mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-02 14:17:19 +00:00
* filer: store TUS sub-chunks through the regular chunk writer A TUS sub-chunk was written with one assigned file id, retried up to three times against that same id, and abandoned on failure: an attempt that had landed on some replicas left a needle no session record and no entry ever references, unreclaimable by vacuum. dataToChunkWithSSE, which the regular write path uses per chunk, assigns a fresh file id per attempt and hands back the file ids of failed attempts, which are now freed the way the regular write path frees them. * filer: retry a chunk write on a fresh volume when the server 5xxs The filer's chunk writer assigns a fresh file id per attempt but only retried transient network errors, so a volume filling up and turning read-only mid-write failed the whole request even though the very next assignment would have landed elsewhere. Every other write client already routes this through ShouldReassignUpload; the filer's own write path now does the same, for regular uploads and TUS sub-chunks alike. * filer: export the chunk deletion queue The filer test harness in weed/server builds filer.Filer as a struct literal, so any code path reaching DeleteChunks dereferenced a nil queue. Exported like the neighboring DeletionRetryQueue so the harness can arm it. * filer: complete a TUS upload whose chunk records overlap A PATCH retried while its predecessor was still storing a sub-chunk - a proxy timeout with an immediate retry is enough - records the same range twice. HEAD computes Upload-Offset as the covered watermark and reported the upload fully received, but completion demanded exactly adjacent records and failed every attempt: the client concluded success from offset == length, no entry was created, and the session eventually expired, turning the entire upload into deleted needles for the vacuum to chew through. Completion now validates gapless coverage with the same watermark HEAD uses. A record extending coverage joins the entry - the read path resolves partial overlaps by ModifiedTsNs, and the raced copies carry identical bytes - while a fully covered duplicate is freed once the entry lands. * filer: allow one mutating TUS request per session at a time Nothing stopped two PATCHes from writing the same range concurrently: both loaded the same offset, both passed the conflict check, and both recorded their sub-chunks. A client whose request timed out in a proxy retries immediately while the server side is still storing the buffered sub-chunk, which is exactly that race. A session now accepts one PATCH or DELETE at a time, the way tusd locks uploads; a concurrent one is refused with 423 Locked, which TUS clients retry, and HEAD keeps answering so progress polling is unaffected. The chunk state is loaded under the claim, so a retried PATCH sees every record its predecessor left and conflicts cleanly instead of duplicating data. * test: cover a TUS PATCH raced by its own retry Stalls a PATCH mid-body over a raw connection, retries the same range while it is in flight, and expects the retry refused with 423 Locked; the upload then resumes from the reported offset and the final content must be intact. * filer: never free a TUS duplicate the entry still references Coverage is computed from ranges, so a record fully covered by another is treated as a duplicate no matter which needle it names. A malformed record naming a file id the entry keeps would have had that needle freed right after the entry landed - the corruption this change set exists to stop. The duplicates are now freed in one batch, skipping any file id the entry references; their records go with the session directory. * test: bound the raw TUS connection reads http.ReadResponse on the stalled PATCH's connection blocked until the whole go test timeout if the filer never answered. * filer: free the needles of chunk write attempts a retry replaced A volume server stores the needle locally and only then fans out to the replicas, so a replication failure 5xxs with the data already written. Each attempt assigns its own file id, so once a later attempt lands elsewhere nothing references the earlier ones: the caller only sees the chunk that succeeded, and the failed ids were dropped. They are now freed the way the caller frees them when the whole write fails. Retrying on a 5xx makes this reachable on every read-only or full volume, which is exactly the condition that filled the reporter's volumes.
583 lines
20 KiB
Go
583 lines
20 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"
|
|
"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 fmt.Sprintf("http://%s/?proxyChunkId=%s", 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}"
|
|
}
|
|
|
|
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
|
|
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
|
|
}
|
|
|
|
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, err error), uploadOption *UploadOption, data []byte) (fileId string, uploadResult *UploadResult, err error) {
|
|
doUploadFunc := func() error {
|
|
var host string
|
|
var auth security.EncodedJwt
|
|
fileId, host, auth, 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)
|
|
uploadOption.Jwt = auth
|
|
|
|
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). Never under cipher: the server sees only ciphertext.
|
|
if uploadOption.WantMd5 && uploadOption.Md5 == "" && !uploadOption.Cipher {
|
|
digest := md5.Sum(data)
|
|
uploadOption.Md5 = base64.StdEncoding.EncodeToString(digest[:])
|
|
}
|
|
|
|
fileId, uploadResult, err = uploader.uploadWithRetryData(func() (fileId string, host string, auth security.EncodedJwt, 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)
|
|
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) {
|
|
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()
|
|
}
|
|
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,
|
|
})
|
|
if uploadResult == nil {
|
|
return
|
|
}
|
|
uploadResult.Name = option.Filename
|
|
uploadResult.Mime = option.MimeType
|
|
uploadResult.CipherKey = cipherKey
|
|
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,
|
|
})
|
|
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())
|
|
}
|
|
req, postErr := http.NewRequestWithContext(ctx, 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)
|
|
|
|
// print("+")
|
|
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 {
|
|
req.Body = newBody
|
|
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, err: fmt.Errorf("upload %s %d bytes to %v: %v", option.Filename, originalDataSize, option.UploadUrl, post_err)}
|
|
}
|
|
// print("-")
|
|
|
|
var ret UploadResult
|
|
etag := getEtag(resp)
|
|
if resp.StatusCode == http.StatusNoContent {
|
|
ret.ETag = etag
|
|
ret.ContentMd5 = resp.Header.Get("Content-MD5")
|
|
return &ret, nil
|
|
}
|
|
|
|
resp_body, ra_err := io.ReadAll(resp.Body)
|
|
if ra_err != nil {
|
|
stats.UploadErrorCounter.WithLabelValues(strconv.Itoa(resp.StatusCode)).Inc()
|
|
return nil, &uploadStatusError{StatusCode: resp.StatusCode, 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)}
|
|
}
|
|
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
|
|
}
|