mirror of
https://github.com/rclone/rclone.git
synced 2026-09-27 08:16:56 -04:00
The multipart upload complete endpoint returns 200 with an empty body,
but rclone tried to decode that body as JSON, failed with EOF and
retried until the retries ran out, so every upload failed even though
the server had actually completed it.
Fixed by not attempting to decode the response body.
This was a regression introduced in
a4972de505 shade: retry server errors instead of failing the transfer
which started treating the JSON decode error as fatal where previously
it was accidentally ignored.
314 lines
7.9 KiB
Go
314 lines
7.9 KiB
Go
//multipart upload for shade
|
|
|
|
package shade
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"sort"
|
|
"sync"
|
|
|
|
"github.com/rclone/rclone/backend/shade/api"
|
|
"github.com/rclone/rclone/fs"
|
|
"github.com/rclone/rclone/fs/chunksize"
|
|
"github.com/rclone/rclone/lib/multipart"
|
|
"github.com/rclone/rclone/lib/rest"
|
|
)
|
|
|
|
var warnStreamUpload sync.Once
|
|
|
|
type shadeChunkWriter struct {
|
|
initToken string
|
|
chunkSize int64
|
|
size int64
|
|
f *Fs
|
|
o *Object
|
|
completedParts []api.CompletedPart
|
|
completedPartsMu sync.Mutex
|
|
}
|
|
|
|
// uploadMultipart handles multipart upload for larger files
|
|
func (o *Object) uploadMultipart(ctx context.Context, src fs.ObjectInfo, in io.Reader, options ...fs.OpenOption) error {
|
|
|
|
chunkWriter, err := multipart.UploadMultipart(ctx, src, in, multipart.UploadMultipartOptions{
|
|
Open: o.fs,
|
|
OpenOptions: options,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var shadeWriter = chunkWriter.(*shadeChunkWriter)
|
|
o.size = shadeWriter.size
|
|
return nil
|
|
}
|
|
|
|
// OpenChunkWriter returns the chunk size and a ChunkWriter
|
|
//
|
|
// Pass in the remote and the src object
|
|
// You can also use options to hint at the desired chunk size
|
|
func (f *Fs) OpenChunkWriter(ctx context.Context, remote string, src fs.ObjectInfo, options ...fs.OpenOption) (info fs.ChunkWriterInfo, writer fs.ChunkWriter, err error) {
|
|
// Temporary Object under construction
|
|
o := &Object{
|
|
fs: f,
|
|
remote: remote,
|
|
}
|
|
|
|
uploadParts := f.opt.MaxUploadParts
|
|
if uploadParts < 1 {
|
|
uploadParts = 1
|
|
} else if uploadParts > maxUploadParts {
|
|
uploadParts = maxUploadParts
|
|
}
|
|
size := src.Size()
|
|
fs.FixRangeOption(options, size)
|
|
|
|
// calculate size of parts
|
|
chunkSize := f.opt.ChunkSize
|
|
|
|
// size can be -1 here meaning we don't know the size of the incoming file. We use ChunkSize
|
|
// buffers here (default 64 MB). With a maximum number of parts (10,000) this will be a file of
|
|
// 640 GB.
|
|
if size == -1 {
|
|
warnStreamUpload.Do(func() {
|
|
fs.Logf(f, "Streaming uploads using chunk size %v will have maximum file size of %v",
|
|
chunkSize, fs.SizeSuffix(int64(chunkSize)*int64(uploadParts)))
|
|
})
|
|
} else {
|
|
chunkSize = chunksize.Calculator(src, size, uploadParts, chunkSize)
|
|
}
|
|
|
|
token, err := o.fs.refreshJWTToken(ctx)
|
|
if err != nil {
|
|
return info, nil, fmt.Errorf("failed to get token: %w", err)
|
|
}
|
|
|
|
err = f.ensureParentDirectories(ctx, remote)
|
|
if err != nil {
|
|
return info, nil, fmt.Errorf("failed to ensure parent directories: %w", err)
|
|
}
|
|
|
|
fullPath := remote
|
|
if f.root != "" {
|
|
fullPath = path.Join(f.root, remote)
|
|
}
|
|
|
|
// Initiate multipart upload
|
|
type initRequest struct {
|
|
Path string `json:"path"`
|
|
PartSize int64 `json:"partSize"`
|
|
}
|
|
reqBody := initRequest{
|
|
Path: fullPath,
|
|
PartSize: int64(chunkSize),
|
|
}
|
|
|
|
var initResp struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: fmt.Sprintf("/%s/upload/multipart", o.fs.drive),
|
|
RootURL: o.fs.endpoint,
|
|
ExtraHeaders: map[string]string{
|
|
"Authorization": "Bearer " + token,
|
|
},
|
|
Options: options,
|
|
}
|
|
|
|
err = o.fs.pacer.Call(func() (bool, error) {
|
|
res, err := o.fs.srv.CallJSON(ctx, &opts, reqBody, &initResp)
|
|
return shouldRetry(ctx, res, err)
|
|
})
|
|
|
|
if err != nil {
|
|
return info, nil, fmt.Errorf("failed to initiate multipart upload: %w", err)
|
|
}
|
|
|
|
chunkWriter := &shadeChunkWriter{
|
|
initToken: initResp.Token,
|
|
chunkSize: int64(chunkSize),
|
|
size: size,
|
|
f: f,
|
|
o: o,
|
|
}
|
|
info = fs.ChunkWriterInfo{
|
|
ChunkSize: int64(chunkSize),
|
|
Concurrency: f.opt.Concurrency,
|
|
LeavePartsOnError: false,
|
|
}
|
|
return info, chunkWriter, err
|
|
}
|
|
|
|
// WriteChunk will write chunk number with reader bytes, where chunk number >= 0
|
|
func (s *shadeChunkWriter) WriteChunk(ctx context.Context, chunkNumber int, reader io.ReadSeeker) (bytesWritten int64, err error) {
|
|
|
|
token, err := s.f.refreshJWTToken(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Read chunk
|
|
var chunk bytes.Buffer
|
|
n, err := io.Copy(&chunk, reader)
|
|
|
|
if n == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to read chunk: %w", err)
|
|
}
|
|
// Get presigned URL for this part
|
|
var partURL api.PartURL
|
|
|
|
partOpts := rest.Opts{
|
|
Method: "POST",
|
|
Path: fmt.Sprintf("/%s/upload/multipart/part/%d?token=%s", s.f.drive, chunkNumber+1, url.QueryEscape(s.initToken)),
|
|
RootURL: s.f.endpoint,
|
|
ExtraHeaders: map[string]string{
|
|
"Authorization": "Bearer " + token,
|
|
},
|
|
}
|
|
|
|
err = s.f.pacer.Call(func() (bool, error) {
|
|
res, err := s.f.srv.CallJSON(ctx, &partOpts, nil, &partURL)
|
|
return shouldRetry(ctx, res, err)
|
|
})
|
|
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to get part URL: %w", err)
|
|
}
|
|
opts := rest.Opts{
|
|
Method: "PUT",
|
|
RootURL: partURL.URL,
|
|
ContentType: "",
|
|
ContentLength: &n,
|
|
}
|
|
|
|
// Add headers
|
|
var uploadRes *http.Response
|
|
if len(partURL.Headers) > 0 {
|
|
opts.ExtraHeaders = make(map[string]string)
|
|
maps.Copy(opts.ExtraHeaders, partURL.Headers)
|
|
}
|
|
|
|
err = s.f.pacer.Call(func() (bool, error) {
|
|
// Use a fresh reader for each attempt so retries resend the whole chunk
|
|
opts.Body = bytes.NewReader(chunk.Bytes())
|
|
uploadRes, err = s.f.srv.Call(ctx, &opts)
|
|
return shouldRetry(ctx, uploadRes, err)
|
|
})
|
|
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to upload part %d: %w", chunk, err)
|
|
}
|
|
|
|
if uploadRes.StatusCode != http.StatusOK && uploadRes.StatusCode != http.StatusCreated {
|
|
body, _ := io.ReadAll(uploadRes.Body)
|
|
fs.CheckClose(uploadRes.Body, &err)
|
|
return 0, fmt.Errorf("part upload failed with status %d: %s", uploadRes.StatusCode, string(body))
|
|
}
|
|
|
|
// Get ETag from response
|
|
etag := uploadRes.Header.Get("ETag")
|
|
fs.CheckClose(uploadRes.Body, &err)
|
|
|
|
s.completedPartsMu.Lock()
|
|
defer s.completedPartsMu.Unlock()
|
|
s.completedParts = append(s.completedParts, api.CompletedPart{
|
|
PartNumber: int32(chunkNumber + 1),
|
|
ETag: etag,
|
|
})
|
|
return n, nil
|
|
}
|
|
|
|
// Close complete chunked writer finalising the file.
|
|
func (s *shadeChunkWriter) Close(ctx context.Context) error {
|
|
|
|
// Complete multipart upload
|
|
sort.Slice(s.completedParts, func(i, j int) bool {
|
|
return s.completedParts[i].PartNumber < s.completedParts[j].PartNumber
|
|
})
|
|
|
|
type completeRequest struct {
|
|
Parts []api.CompletedPart `json:"parts"`
|
|
}
|
|
var completeBody completeRequest
|
|
|
|
if s.completedParts == nil {
|
|
completeBody = completeRequest{Parts: []api.CompletedPart{}}
|
|
} else {
|
|
completeBody = completeRequest{Parts: s.completedParts}
|
|
}
|
|
|
|
token, err := s.f.refreshJWTToken(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// The complete response has an empty body so don't attempt to decode it
|
|
completeOpts := rest.Opts{
|
|
Method: "POST",
|
|
Path: fmt.Sprintf("/%s/upload/multipart/complete?token=%s", s.f.drive, url.QueryEscape(s.initToken)),
|
|
RootURL: s.f.endpoint,
|
|
NoResponse: true,
|
|
ExtraHeaders: map[string]string{
|
|
"Authorization": "Bearer " + token,
|
|
},
|
|
}
|
|
|
|
err = s.f.pacer.Call(func() (bool, error) {
|
|
res, err := s.f.srv.CallJSON(ctx, &completeOpts, completeBody, nil)
|
|
return shouldRetry(ctx, res, err)
|
|
})
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to complete multipart upload: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Abort chunk write
|
|
//
|
|
// You can and should call Abort without calling Close.
|
|
func (s *shadeChunkWriter) Abort(ctx context.Context) error {
|
|
token, err := s.f.refreshJWTToken(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
opts := rest.Opts{
|
|
Method: "POST",
|
|
Path: fmt.Sprintf("/%s/upload/abort/multipart?token=%s", s.f.drive, url.QueryEscape(s.initToken)),
|
|
RootURL: s.f.endpoint,
|
|
ExtraHeaders: map[string]string{
|
|
"Authorization": "Bearer " + token,
|
|
},
|
|
}
|
|
|
|
err = s.f.pacer.Call(func() (bool, error) {
|
|
res, err := s.f.srv.Call(ctx, &opts)
|
|
if err != nil {
|
|
fs.Debugf(s.f, "Failed to abort multipart upload: %v", err)
|
|
return false, nil // Don't retry abort
|
|
}
|
|
if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated {
|
|
fs.Debugf(s.f, "Abort returned status %d", res.StatusCode)
|
|
}
|
|
return false, nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to abort multipart upload: %w", err)
|
|
}
|
|
return nil
|
|
}
|