Files
seaweedfs/weed/operation/upload_chunked.go
T
ab79d1f680 operation: re-assign chunk upload when replica volume is full (#10588)
* operation: re-assign chunk upload when replica volume is full

When a volume reaches MaxPossibleVolumeSize, needle writes return
'Volume Size Exceeded' and the fan-out in uploadChunkToHolders fails.
Previously the error propagated immediately, killing the entire chunked
upload even though the master has other writable volumes.

Fix: detect 'Volume Size' errors on the fan-out path, call AssignFunc
again to get a fresh volume, and retry (up to 3 attempts). This avoids
backup failures while a single replica volume is full and waiting for GC.

Also add 'volume size exceeded' to the transient error messages so any
retry path that checks IsTransientError recognises it.

* util: fix transient error pattern for volume size errors

The actual error message is 'Volume Size 34361499680 Exceeded 34359738368'
where the numeric size separates 'Volume Size' from 'Exceeded'. The
previous pattern 'volume size exceeded' would never match. Change to
'volume size' which correctly matches any capacity-full error.

* operation: fix reassignment loop nits

- Case-insensitive volume size match (strings.ToLower)
- Propagate AssignFunc error so caller sees reassignment failure
- Reset JWT fallback before each reassignment to avoid retaining stale auth

* util: stop classifying a full volume as a transient error

A volume at capacity does not become writable on the next attempt, so the
entry only bought a retry loop's worth of sleeping before the same failure.
It also reached four consumers that all retry the same target — the deletion
queue, the replication sink, volume lookups, and Retry/MultiRetry — none of
which reassign, and the widened "volume size" substring swallowed the
replica-receive rejection from WriteNeedleBlob too.

The chunked upload path recovers by asking for a different volume instead.

* operation: reassign a chunk with the shared upload gate

shouldReassignUpload already answers this question for the non-chunked path,
keyed off the status the volume server returned rather than its message text.
Reusing it covers a lost replica peer and an unreachable target as well as a
full volume, and it stops a 4xx from being retried on a second volume that
would reject it identically.

Pulling the single attempt out into uploadChunk keeps the retry loop readable
now that it wraps both the fan-out and the relay path.

* test: cover chunk reassignment across volumes

Pins the three outcomes the gate decides: a full volume moves the chunk to a
fresh assignment, a 4xx stays put, and the loop gives up after
chunkAssignAttempts volumes.

* operation: roll back the fid a reassigned chunk abandons

ReplicatedWrite commits the needle locally before it replicates, so a 5xx can
leave a copy behind on a volume the chunk is about to walk away from. Nothing
will ever reference that fid, and an unreferenced needle is not garbage vacuum
can find — it is dead space until the volume is destroyed.

uploadChunkToHolders rolls back only the holders that reported success, which
misses the one whose write landed but whose response did not, and the relay
path had no rollback at all. Delete from every holder of the abandoned
assignment instead; deleting a needle that never landed is a no-op.

* operation: stop the reassignment loop from multiplying work

retriedUploadData already retries a chunk three times against the same URL, so
wrapping it in three assignments made nine POSTs for one chunk. On the relay
path that inner retry is redundant — the loop retries everything it would, and
on a different volume — so cap it at one attempt per assignment and leave the
budget where it was. The fan-out path keeps its inner retries: absorbing a blip
on one holder beats cancelling the rest and re-uploading the whole chunk.

Nothing bounded any of it by time. weed/s3api passes context.Background() so a
chunk survives client disconnect, which also means no deadline cuts the loop
short, and a chunk goroutine holds one of four buffer slots while it spins.
Break out once another chunk has already failed the object.

* operation: keep the reassignment gate's inputs deterministic

uploadChunkToHolders reported whichever holder error won the channel race.
That was cosmetic while the value was only logged; now it decides whether the
chunk moves to another volume, so a 400 and a 500 arriving in either order
made the retry behavior depend on scheduling. Prefer an error the caller can
act on, and the same failure always retries the same way.

A failed reassignment also overwrote the upload error that prompted it, which
buried a full volume behind whatever the filer happened to say. Keep both in
the chain.

The tests grew a JWT per assignment, since the loop re-derives one and nothing
covered it, and the bound is now spelled out rather than compared against the
constant that defines it.

* operation: roll back the last abandoned fid too

The rollback ran only on the path that goes on to reassign, so the attempt
that exhausts the budget — or stops because another chunk already failed the
object, or because the error is not one a different volume fixes — left its
fid behind. That is the case that matters most: no chunk names it, the caller
gets no fid to clean up, and a 5xx can still mean the needle was committed.

Roll back on every failed attempt instead, before deciding whether to retry.

---------

Co-authored-by: timolow <timolow@users.noreply.github.com>
Co-authored-by: timolow <tim@timolow.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-08-05 17:43:37 -07:00

425 lines
14 KiB
Go

package operation
import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"errors"
"fmt"
"hash"
"io"
"sort"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
)
// ErrTruncatedBody tags a source read that ended before the expected bytes
// arrived, so callers can tell a truncated input (client abort, reverse-proxy
// timeout) apart from a volume-server upload fault.
var ErrTruncatedBody = errors.New("truncated request body")
// ChunkedUploadResult contains the result of a chunked upload
type ChunkedUploadResult struct {
FileChunks []*filer_pb.FileChunk
Md5Hash hash.Hash
TotalSize int64
SmallContent []byte // For files smaller than threshold
}
// ChunkedUploadOption contains options for chunked uploads
type ChunkedUploadOption struct {
ChunkSize int32
SmallFileLimit int64
Collection string
Replication string
DataCenter string
SaveSmallInline bool
Jwt security.EncodedJwt
MimeType string
Cipher bool // encrypt data on volume servers
AssignFunc func(ctx context.Context, count int, expectedDataSize uint64) (*VolumeAssignRequest, *AssignResult, error)
UploadFunc func(ctx context.Context, data []byte, option *UploadOption) (*UploadResult, error) // Optional: for testing
}
var chunkBufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
// UploadReaderInChunks reads from reader and uploads in chunks to volume servers
// This prevents OOM by processing the stream in fixed-size chunks
// Returns file chunks, MD5 hash, total size, and any small content stored inline
func UploadReaderInChunks(ctx context.Context, reader io.Reader, opt *ChunkedUploadOption) (*ChunkedUploadResult, error) {
md5Hash := md5.New()
var partReader = io.TeeReader(reader, md5Hash)
var fileChunks []*filer_pb.FileChunk
var fileChunksLock sync.Mutex
var uploadErr error
var uploadErrLock sync.Mutex
var chunkOffset int64 = 0
var wg sync.WaitGroup
const bytesBufferCounter = 4
bytesBufferLimitChan := make(chan struct{}, bytesBufferCounter)
// objectFailed reports whether another chunk has already doomed the upload,
// so an in-flight chunk stops spending its retry budget on bytes nobody
// will reference.
objectFailed := func() bool {
uploadErrLock.Lock()
defer uploadErrLock.Unlock()
return uploadErr != nil
}
uploadLoop:
for {
// Throttle buffer usage
bytesBufferLimitChan <- struct{}{}
// Check for errors from parallel uploads
uploadErrLock.Lock()
if uploadErr != nil {
<-bytesBufferLimitChan
uploadErrLock.Unlock()
break
}
uploadErrLock.Unlock()
// Check for context cancellation
select {
case <-ctx.Done():
<-bytesBufferLimitChan
uploadErrLock.Lock()
if uploadErr == nil {
uploadErr = ctx.Err()
}
uploadErrLock.Unlock()
break uploadLoop
default:
}
// Get buffer from pool
bytesBuffer := chunkBufferPool.Get().(*bytes.Buffer)
limitedReader := io.LimitReader(partReader, int64(opt.ChunkSize))
bytesBuffer.Reset()
// Read one chunk
dataSize, err := bytesBuffer.ReadFrom(limitedReader)
if err != nil {
// Attach offset + bytes-read to distinguish client disconnect
// before any data (offset=0,got=0) from mid-stream truncation.
// A bare io.ErrUnexpectedEOF is not actionable on its own (see #9149).
wrapped := fmt.Errorf("read chunk at offset %d (got %d bytes): %w", chunkOffset, dataSize, err)
if errors.Is(err, io.ErrUnexpectedEOF) {
wrapped = fmt.Errorf("%w: %w", ErrTruncatedBody, wrapped)
}
glog.V(2).Infof("UploadReaderInChunks: %v", wrapped)
chunkBufferPool.Put(bytesBuffer)
<-bytesBufferLimitChan
uploadErrLock.Lock()
if uploadErr == nil {
uploadErr = wrapped
}
uploadErrLock.Unlock()
break
}
// If no data was read, we've reached EOF
// Only break if we've already read some data (chunkOffset > 0) or if this is truly EOF
if dataSize == 0 {
if chunkOffset == 0 {
// Empty objects are valid for S3/HTTP uploads (e.g. zero-byte files).
// Keep this at verbose level to avoid warning noise in normal operation.
glog.V(4).Infof("UploadReaderInChunks: received 0 bytes on first read - creating empty file")
}
chunkBufferPool.Put(bytesBuffer)
<-bytesBufferLimitChan
// If we've already read some chunks, this is normal EOF
// If we haven't read anything yet (chunkOffset == 0), this could be an empty file
// which is valid (e.g., touch command creates 0-byte files)
break
}
// For small files at offset 0, store inline instead of uploading
if chunkOffset == 0 && opt.SaveSmallInline && dataSize < opt.SmallFileLimit {
smallContent := make([]byte, dataSize)
n, readErr := io.ReadFull(bytesBuffer, smallContent)
chunkBufferPool.Put(bytesBuffer)
<-bytesBufferLimitChan
if readErr != nil {
return nil, fmt.Errorf("failed to read small content: read %d of %d bytes: %w", n, dataSize, readErr)
}
return &ChunkedUploadResult{
FileChunks: nil,
Md5Hash: md5Hash,
TotalSize: dataSize,
SmallContent: smallContent,
}, nil
}
// Upload chunk in parallel goroutine
wg.Add(1)
go func(offset int64, buf *bytes.Buffer, size int64) {
defer func() {
chunkBufferPool.Put(buf)
<-bytesBufferLimitChan
wg.Done()
}()
// Assign volume for this chunk
_, assignResult, assignErr := opt.AssignFunc(ctx, 1, uint64(size))
if assignErr != nil {
uploadErrLock.Lock()
if uploadErr == nil {
uploadErr = fmt.Errorf("assign volume: %w", assignErr)
}
uploadErrLock.Unlock()
return
}
// Use per-assignment JWT if present, otherwise fall back to the original JWT
// This is critical for secured clusters where each volume assignment has its own JWT
jwt := opt.Jwt
if assignResult.Auth != "" {
jwt = assignResult.Auth
}
// Calculate MD5 for the chunk
chunkMd5 := md5.Sum(buf.Bytes())
chunkMd5B64 := base64.StdEncoding.EncodeToString(chunkMd5[:])
var uploadResult *UploadResult
var uploadResultErr error
// A target that fills up, loses its replica peer, or goes away fails
// every attempt against the same fid, so ask for a fresh assignment and
// retry there rather than losing the whole object to one bad volume.
for attempt := 1; ; attempt++ {
uploadResult, uploadResultErr = uploadChunk(ctx, assignResult, buf.Bytes(), jwt, chunkMd5B64, opt)
if uploadResultErr == nil {
break
}
// The volume server commits the needle before replicating, so a
// failed write can still leave a copy behind. No chunk will name
// this fid whether we retry or give up here, and an unreferenced
// needle is not garbage vacuum can find, so drop it either way.
deleteChunkFromHolders(chunkHolders(assignResult), assignResult.Fid, jwt)
if attempt == chunkAssignAttempts || !shouldReassignUpload(uploadResultErr) || objectFailed() {
break
}
glog.V(2).Infof("re-assigning chunk at offset %d after attempt %d/%d: %v", offset, attempt, chunkAssignAttempts, uploadResultErr)
_, assignResult, assignErr = opt.AssignFunc(ctx, 1, uint64(size))
if assignErr != nil {
uploadResultErr = fmt.Errorf("reassign volume after %w: %w", uploadResultErr, assignErr)
break
}
jwt = opt.Jwt
if assignResult.Auth != "" {
jwt = assignResult.Auth
}
}
if uploadResultErr != nil {
uploadErrLock.Lock()
if uploadErr == nil {
uploadErr = fmt.Errorf("upload chunk: %w", uploadResultErr)
}
uploadErrLock.Unlock()
return
}
// Create chunk entry
// Set ModifiedTsNs to current time (nanoseconds) to track when upload completed
// This is critical for multipart uploads where the same part may be uploaded multiple times
// The part with the latest ModifiedTsNs is selected as the authoritative version
fid, _ := filer_pb.ToFileIdObject(assignResult.Fid)
chunk := &filer_pb.FileChunk{
FileId: assignResult.Fid,
Offset: offset,
Size: uint64(uploadResult.Size),
ModifiedTsNs: time.Now().UnixNano(),
ETag: uploadResult.ContentMd5,
Fid: fid,
CipherKey: uploadResult.CipherKey,
IsCompressed: uploadResult.Gzip > 0,
}
fileChunksLock.Lock()
fileChunks = append(fileChunks, chunk)
fileChunksLock.Unlock()
}(chunkOffset, bytesBuffer, dataSize)
// Update offset for next chunk
chunkOffset += dataSize
// If this was a partial chunk, we're done
if dataSize < int64(opt.ChunkSize) {
break
}
}
// Wait for all uploads to complete
wg.Wait()
// Sort chunks by offset (do this even if there's an error, for cleanup purposes)
sort.Slice(fileChunks, func(i, j int) bool {
return fileChunks[i].Offset < fileChunks[j].Offset
})
// Check for errors - return partial results for cleanup
if uploadErr != nil {
glog.Errorf("chunked upload failed: %v (returning %d partial chunks for cleanup)", uploadErr, len(fileChunks))
// IMPORTANT: Return partial results even on error so caller can cleanup orphaned chunks
return &ChunkedUploadResult{
FileChunks: fileChunks,
Md5Hash: md5Hash,
TotalSize: chunkOffset,
SmallContent: nil,
}, uploadErr
}
return &ChunkedUploadResult{
FileChunks: fileChunks,
Md5Hash: md5Hash,
TotalSize: chunkOffset,
SmallContent: nil,
}, nil
}
// chunkAssignAttempts bounds how many volumes one chunk may be offered to
// before the upload gives up.
const chunkAssignAttempts = 3
// uploadChunk writes one chunk to its assigned volume. It fans out to every
// holder, except for cipher: per-call encryption would give each replica
// different bytes, so keep its relay path.
func uploadChunk(ctx context.Context, assignResult *AssignResult, data []byte, jwt security.EncodedJwt, md5b64 string, opt *ChunkedUploadOption) (*UploadResult, error) {
holders := chunkHolders(assignResult)
if opt.UploadFunc == nil && !opt.Cipher && len(holders) > 1 {
return uploadChunkToHolders(ctx, holders, assignResult.Fid, data, jwt, md5b64, opt)
}
uploadOption := &UploadOption{
UploadUrl: fmt.Sprintf("http://%s/%s", assignResult.Url, assignResult.Fid),
Cipher: opt.Cipher,
IsInputCompressed: false,
MimeType: opt.MimeType,
PairMap: nil,
Jwt: jwt,
Md5: md5b64,
// One upload call per assignment: the caller retries everything a
// same-URL retry would, and a different volume is the better second try.
// This bounds retriedUploadData only — doUploadData still reissues once
// on a connection reset, with a rewound body, which is a transport
// stutter rather than a fresh attempt at the volume. The fan-out path
// keeps its retries, where absorbing a blip locally beats cancelling
// every holder and re-uploading the chunk.
MaxAttempts: 1,
}
// Use mock upload function if provided (for testing), otherwise use real uploader
if opt.UploadFunc != nil {
return opt.UploadFunc(ctx, data, uploadOption)
}
uploader, err := NewUploader()
if err != nil {
return nil, fmt.Errorf("create uploader: %w", err)
}
return uploader.UploadData(ctx, data, uploadOption)
}
// chunkHolders returns the assigned volume plus its replica holders.
func chunkHolders(assignResult *AssignResult) []string {
hosts := []string{assignResult.Url}
for _, replica := range assignResult.Replicas {
if replica.Url != "" && replica.Url != assignResult.Url {
hosts = append(hosts, replica.Url)
}
}
return hosts
}
// uploadChunkToHolders writes the chunk to every holder concurrently (each with
// type=replicate). On the first failure it cancels the remaining uploads and
// deletes any copies that already landed, so a partial fan-out leaves no
// orphaned needle the caller cannot see.
func uploadChunkToHolders(ctx context.Context, hosts []string, fid string, data []byte, jwt security.EncodedJwt, md5b64 string, opt *ChunkedUploadOption) (*UploadResult, error) {
uploader, err := NewUploader()
if err != nil {
return nil, fmt.Errorf("create uploader: %w", err)
}
glog.V(4).Infof("replica fan-out: writing chunk %s to %d holders %v", fid, len(hosts), hosts)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
type outcome struct {
host string
result *UploadResult
err error
}
outcomes := make(chan outcome, len(hosts))
for _, host := range hosts {
go func(host string) {
uploadOption := &UploadOption{
UploadUrl: fmt.Sprintf("http://%s/%s?type=replicate", host, fid),
Cipher: false,
IsInputCompressed: false,
MimeType: opt.MimeType,
PairMap: nil,
Jwt: jwt,
Md5: md5b64,
}
r, e := uploader.UploadData(ctx, data, uploadOption)
outcomes <- outcome{host, r, e}
}(host)
}
var first *UploadResult
var firstErr error
var succeeded []string
for range hosts {
o := <-outcomes
if o.err != nil {
// Once one holder fails the rest are cancelled, so errors arrive in
// no fixed order. Prefer one the caller can act on, or the choice of
// which host to report — and whether to retry elsewhere — turns on
// goroutine scheduling.
if firstErr == nil {
firstErr = o.err
cancel()
} else if !shouldReassignUpload(firstErr) && shouldReassignUpload(o.err) {
firstErr = o.err
}
} else {
succeeded = append(succeeded, o.host)
if first == nil {
first = o.result
}
}
}
if firstErr != nil {
// A failed fan-out records no chunk, so roll back the copies that landed
// before the cancel rather than leaking them as orphans.
deleteChunkFromHolders(succeeded, fid, jwt)
return nil, firstErr
}
return first, nil
}
// deleteChunkFromHolders best-effort removes a needle from each holder it landed
// on, using type=replicate so the volume drops only its local copy. A failed
// delete falls back to vacuum reclaiming the orphan.
func deleteChunkFromHolders(hosts []string, fid string, jwt security.EncodedJwt) {
for _, host := range hosts {
if err := util_http.Delete(fmt.Sprintf("http://%s/%s?type=replicate", host, fid), string(jwt)); err != nil {
glog.Warningf("replica fan-out cleanup: delete %s from %s: %v", fid, host, err)
}
}
}