mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-08 15:41:15 +02: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.
432 lines
14 KiB
Go
432 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, assignResult.Fsync, 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,
|
|
}
|
|
if assignResult.Fsync {
|
|
uploadOption.UploadUrl += "?fsync=true"
|
|
}
|
|
// 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, fsync bool, 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) {
|
|
uploadUrl := fmt.Sprintf("http://%s/%s?type=replicate", host, fid)
|
|
if fsync {
|
|
uploadUrl += "&fsync=true"
|
|
}
|
|
uploadOption := &UploadOption{
|
|
UploadUrl: uploadUrl,
|
|
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)
|
|
}
|
|
}
|
|
}
|