Files
seaweedfs/weed/server/filer_server_tus_handlers.go
T
Chris Lu 44115c1051 filer: stop TUS uploads from turning into garbage (#10945)
* 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.
2026-08-25 09:24:51 -07:00

742 lines
26 KiB
Go

package weed_server
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"github.com/google/uuid"
"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"
)
// tusHandler is the main entry point for TUS protocol requests
func (fs *FilerServer) tusHandler(w http.ResponseWriter, r *http.Request) {
// Set common TUS response headers
w.Header().Set("Tus-Resumable", TusVersion)
// OPTIONS is capability discovery only and carries no data, so it is left
// unauthenticated like the main filer OPTIONS handler. Every other TUS method
// authenticates the credential first, then authorizes the server-stored
// TargetPath below, once routing has resolved which resource it acts on.
var claims *security.SeaweedFilerClaims
if r.Method != http.MethodOptions {
tusVersion := r.Header.Get("Tus-Resumable")
if tusVersion != TusVersion {
http.Error(w, "Unsupported TUS version", http.StatusPreconditionFailed)
return
}
var authenticated bool
if claims, authenticated = fs.authenticateFilerJwt(r, r.Method != http.MethodHead); !authenticated {
writeJsonError(w, r, http.StatusUnauthorized, errors.New("wrong jwt"))
return
}
}
// Route based on method and path
reqPath := r.URL.Path
// TusBasePath is pre-normalized in filer_server.go (leading slash, no trailing slash)
tusPrefix := fs.option.TusBasePath
// Check if this is an upload location (contains upload ID after {tusPrefix}/.uploads/)
uploadsPrefix := tusPrefix + "/.uploads/"
if strings.HasPrefix(reqPath, uploadsPrefix) {
// Session ids this server mints are canonical UUIDs. Rejecting aliases
// (a trailing path or any non-canonical spelling) keeps one URL bound to
// one stored authorization resource.
uploadID := strings.TrimPrefix(reqPath, uploadsPrefix)
if !isCanonicalTusUploadID(uploadID) {
writeTusSessionNotFound(w, r.Method)
return
}
switch r.Method {
case http.MethodHead, http.MethodPatch, http.MethodDelete:
default:
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
ctx := r.Context()
if r.Method == http.MethodPatch {
ctx = context.WithoutCancel(ctx)
}
session, err := fs.readTusSessionInfo(ctx, uploadID)
if err != nil {
// A transient filer error resolves to "not found"; log it so it is
// distinguishable from a genuinely missing session.
glog.V(1).Infof("TUS session %s not resolved: %v", uploadID, err)
writeTusSessionNotFound(w, r.Method)
return
}
if !authorizeFilerJwtPaths(r, claims, []string{session.TargetPath}) {
writeJsonError(w, r, http.StatusUnauthorized, errors.New("wrong jwt"))
return
}
// One mutating request per session at a time, like tusd: a PATCH retried
// while its predecessor is still storing a sub-chunk would otherwise
// record the same range twice, and a DELETE would race the writer. The
// chunk state is loaded under this claim so the offset check sees every
// record the previous request left behind. 423 tells the client to retry.
if r.Method != http.MethodHead {
if !fs.lockTusUpload(uploadID) {
http.Error(w, "Upload is locked by another request", http.StatusLocked)
return
}
defer fs.unlockTusUpload(uploadID)
}
if err := fs.loadTusSessionChunks(ctx, session); err != nil {
glog.Errorf("Failed to load TUS session %s chunks: %v", uploadID, err)
writeTusSessionNotFound(w, r.Method)
return
}
switch r.Method {
case http.MethodHead:
fs.tusHeadHandler(w, session)
case http.MethodPatch:
fs.tusPatchHandler(w, r, session)
case http.MethodDelete:
fs.tusDeleteHandler(w, r, session)
}
return
}
// Handle creation endpoints (POST to /.tus/{path})
switch r.Method {
case http.MethodOptions:
fs.tusOptionsHandler(w, r)
case http.MethodPost:
if !authorizeFilerJwtPaths(r, claims, []string{fs.tusTargetPath(r)}) {
writeJsonError(w, r, http.StatusUnauthorized, errors.New("wrong jwt"))
return
}
fs.tusCreateHandler(w, r, claims)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// lockTusUpload claims a session for one mutating request; it reports false
// while another request holds the claim.
func (fs *FilerServer) lockTusUpload(uploadID string) bool {
_, loaded := fs.tusActiveUploads.LoadOrStore(uploadID, struct{}{})
return !loaded
}
func (fs *FilerServer) unlockTusUpload(uploadID string) {
fs.tusActiveUploads.Delete(uploadID)
}
// writeTusSessionNotFound answers a request whose session cannot be resolved.
// DELETE is idempotent and returns 204 for a missing session; other verbs 404.
func writeTusSessionNotFound(w http.ResponseWriter, method string) {
if method == http.MethodDelete {
w.WriteHeader(http.StatusNoContent)
return
}
http.Error(w, "Upload not found", http.StatusNotFound)
}
// isCanonicalTusUploadID reports whether uploadID is a canonical UUID, the only
// form this server mints, so an aliased or crafted id cannot address a session.
func isCanonicalTusUploadID(uploadID string) bool {
id, err := uuid.Parse(uploadID)
return err == nil && id.String() == uploadID
}
// tusTargetPath resolves the filer path a TUS create request targets from the
// request URL. It guarantees a leading slash so the result matches stored
// absolute paths and JWT AllowedPrefixes even if TusBasePath were misconfigured
// with a trailing slash.
func (fs *FilerServer) tusTargetPath(r *http.Request) string {
target := strings.TrimPrefix(r.URL.Path, fs.option.TusBasePath)
if target != "" && !strings.HasPrefix(target, "/") {
target = "/" + target
}
return canonicalTusTargetPath(target)
}
// canonicalTusTargetPath normalises an absolute filer target, or returns "" when
// the input is empty or not absolute, so authorization and the final write agree
// on one path.
func canonicalTusTargetPath(target string) string {
if target == "" || !strings.HasPrefix(target, "/") {
return ""
}
return path.Clean(target)
}
// writeTusCompleteError maps a completeTusUpload failure to the same HTTP status
// the normal write path uses: a read-only prefix returns 507 and a WORM-protected
// target returns 403, rather than a generic 500.
func writeTusCompleteError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrReadOnly):
http.Error(w, err.Error(), http.StatusInsufficientStorage)
case errors.Is(err, ErrWormEnforced):
http.Error(w, err.Error(), http.StatusForbidden)
default:
http.Error(w, "Failed to complete upload", http.StatusInternalServerError)
}
}
// tusOptionsHandler handles OPTIONS requests for capability discovery
func (fs *FilerServer) tusOptionsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Tus-Version", TusVersion)
w.Header().Set("Tus-Extension", TusExtensions)
w.Header().Set("Tus-Max-Size", strconv.FormatInt(fs.option.TusMaxSize, 10))
w.WriteHeader(http.StatusOK)
}
// tusCreateHandler handles POST requests to create new uploads
func (fs *FilerServer) tusCreateHandler(w http.ResponseWriter, r *http.Request, claims *security.SeaweedFilerClaims) {
// Use a context that ignores cancellation from the request context.
// Internal operations (creating TUS session, writing data, completing uploads)
// may exceed the filer's client connection inactivity timeout.
ctx := context.WithoutCancel(r.Context())
concat := r.Header.Get("Upload-Concat")
if strings.HasPrefix(concat, TusConcatFinalPrefix) {
fs.tusConcatFinalHandler(w, r, claims, concat)
return
}
if concat != "" && concat != TusConcatPartial {
http.Error(w, "Invalid Upload-Concat", http.StatusBadRequest)
return
}
// Parse Upload-Length header (required)
uploadLengthStr := r.Header.Get("Upload-Length")
if uploadLengthStr == "" {
http.Error(w, "Upload-Length header required", http.StatusBadRequest)
return
}
uploadLength, err := strconv.ParseInt(uploadLengthStr, 10, 64)
if err != nil || uploadLength < 0 {
http.Error(w, "Invalid Upload-Length", http.StatusBadRequest)
return
}
if uploadLength > fs.option.TusMaxSize {
http.Error(w, "Upload-Length exceeds maximum", http.StatusRequestEntityTooLarge)
return
}
// Parse Upload-Metadata header (optional)
metadata := parseTusMetadata(r.Header.Get("Upload-Metadata"))
// Determine target path from request URL (leading slash guaranteed)
targetPath := fs.tusTargetPath(r)
if targetPath == "" || targetPath == "/" {
http.Error(w, "Target path required", http.StatusBadRequest)
return
}
// Reject writes to a read-only prefix up front, before creating a session or
// uploading any chunks, matching the normal write path.
if fs.filer.FilerConf.MatchStorageRule(targetPath).ReadOnly {
http.Error(w, ErrReadOnly.Error(), http.StatusInsufficientStorage)
return
}
// Generate upload ID
uploadID := uuid.New().String()
// Create upload session
session, err := fs.createTusSession(ctx, uploadID, targetPath, uploadLength, metadata, concat)
if err != nil {
glog.Errorf("Failed to create TUS session: %v", err)
http.Error(w, "Failed to create upload", http.StatusInternalServerError)
return
}
uploadLocation := fs.tusUploadLocation(uploadID)
// Handle creation-with-upload extension
// TUS requires Content-Length for uploads; reject chunked encoding
if r.Header.Get("Content-Type") == "application/offset+octet-stream" {
if r.ContentLength < 0 {
fs.deleteTusSession(ctx, uploadID)
http.Error(w, "Content-Length header required for creation-with-upload", http.StatusBadRequest)
return
}
if r.ContentLength > 0 {
// Upload data in the creation request
bytesWritten, uploadErr := fs.tusWriteData(ctx, r, session, 0, r.Body, r.ContentLength)
if uploadErr != nil {
// Cleanup session on failure
fs.deleteTusSession(ctx, uploadID)
if errors.Is(uploadErr, ErrContentTooLarge) {
http.Error(w, "Content-Length exceeds declared upload size", http.StatusRequestEntityTooLarge)
return
}
glog.Errorf("Failed to write initial TUS data: %v", uploadErr)
http.Error(w, "Failed to write data", http.StatusInternalServerError)
return
}
// Update offset in response header
w.Header().Set("Upload-Offset", strconv.FormatInt(bytesWritten, 10))
// Check if upload is complete; a partial upload keeps its chunks for
// a later concatenation instead of landing at the target path.
if bytesWritten == session.Size && !session.isPartial() {
// Ensure the pinned session still exists, then refresh its chunks.
if err = fs.refreshTusSessionChunks(ctx, session); err != nil {
glog.Errorf("Failed to get updated TUS session: %v", err)
http.Error(w, "Failed to complete upload", http.StatusInternalServerError)
return
}
if err := fs.completeTusUpload(ctx, session); err != nil {
glog.Errorf("Failed to complete TUS upload: %v", err)
writeTusCompleteError(w, err)
return
}
}
}
// ContentLength == 0 is allowed, just proceed to respond
}
w.Header().Set("Location", uploadLocation)
w.WriteHeader(http.StatusCreated)
}
// tusUploadLocation builds the upload URL path for a session id (single leading /)
func (fs *FilerServer) tusUploadLocation(uploadID string) string {
// TusBasePath is pre-normalized in filer_server.go (leading slash, no trailing slash)
uploadLocation := path.Clean(fmt.Sprintf("%s/.uploads/%s", fs.option.TusBasePath, uploadID))
if !strings.HasPrefix(uploadLocation, "/") {
uploadLocation = "/" + uploadLocation
}
return uploadLocation
}
// parseTusConcatFinal extracts the partial upload ids from an Upload-Concat
// final header. Each reference may be an absolute URL or a path, must point at
// this server's upload location, and may appear only once since concatenation
// consumes the partial.
func (fs *FilerServer) parseTusConcatFinal(concat string) ([]string, error) {
uploadsPrefix := fs.option.TusBasePath + "/.uploads/"
var partialIDs []string
seen := make(map[string]bool)
for _, ref := range strings.Fields(strings.TrimPrefix(concat, TusConcatFinalPrefix)) {
refURL, err := url.Parse(ref)
if err != nil {
return nil, fmt.Errorf("invalid partial upload reference %q", ref)
}
partialID := strings.TrimPrefix(refURL.Path, uploadsPrefix)
if partialID == refURL.Path || !isCanonicalTusUploadID(partialID) {
return nil, fmt.Errorf("invalid partial upload reference %q", ref)
}
if seen[partialID] {
return nil, fmt.Errorf("duplicate partial upload reference %q", ref)
}
seen[partialID] = true
partialIDs = append(partialIDs, partialID)
}
if len(partialIDs) == 0 {
return nil, errors.New("no partial uploads listed")
}
return partialIDs, nil
}
// tusConcatFinalHandler handles POST requests carrying Upload-Concat final. It
// stitches the listed completed partial uploads, in order, into one entry at
// the request's target path.
func (fs *FilerServer) tusConcatFinalHandler(w http.ResponseWriter, r *http.Request, claims *security.SeaweedFilerClaims, concat string) {
ctx := context.WithoutCancel(r.Context())
// The final upload's length is the sum of the partial lengths.
if r.Header.Get("Upload-Length") != "" {
http.Error(w, "Upload-Length not allowed for a final upload", http.StatusBadRequest)
return
}
if r.ContentLength > 0 {
http.Error(w, "Cannot upload data to a final upload", http.StatusForbidden)
return
}
partialIDs, err := fs.parseTusConcatFinal(concat)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
targetPath := fs.tusTargetPath(r)
if targetPath == "" || targetPath == "/" {
http.Error(w, "Target path required", http.StatusBadRequest)
return
}
if fs.filer.FilerConf.MatchStorageRule(targetPath).ReadOnly {
http.Error(w, ErrReadOnly.Error(), http.StatusInsufficientStorage)
return
}
// Every partial must exist, be authorized for this credential, and be
// complete; concatenation-unfinished is not offered. Each one is claimed
// with an exclusive consumed marker before its chunks are read, so a
// concurrent final cannot consume the same partial and a concurrent DELETE
// or expiry cleanup cannot free chunks that move to the final entry.
var partials []*TusSession
var claimedIDs []string
var totalSize int64
releaseClaims := func() {
for _, claimedID := range claimedIDs {
fs.rollbackTusSessionConsumed(ctx, claimedID)
}
}
for _, partialID := range partialIDs {
partial, err := fs.readTusSessionInfo(ctx, partialID)
if err != nil {
glog.V(1).Infof("TUS partial %s not resolved: %v", partialID, err)
releaseClaims()
http.Error(w, "Partial upload not found", http.StatusNotFound)
return
}
if !authorizeFilerJwtPaths(r, claims, []string{partial.TargetPath}) {
releaseClaims()
writeJsonError(w, r, http.StatusUnauthorized, errors.New("wrong jwt"))
return
}
if !partial.isPartial() {
releaseClaims()
http.Error(w, "Not a partial upload: "+partialID, http.StatusBadRequest)
return
}
totalSize += partial.Size
if totalSize > fs.option.TusMaxSize {
releaseClaims()
http.Error(w, "Combined upload size exceeds maximum", http.StatusRequestEntityTooLarge)
return
}
if err := fs.claimTusPartial(ctx, partial); err != nil {
releaseClaims()
switch {
case errors.Is(err, filer_pb.ErrEntryAlreadyExists):
http.Error(w, "Partial upload already consumed: "+partialID, http.StatusConflict)
case errors.Is(err, filer_pb.ErrNotFound):
glog.V(1).Infof("TUS partial %s changed before concatenation: %v", partialID, err)
http.Error(w, "Partial upload not found", http.StatusNotFound)
default:
glog.Errorf("Failed to claim TUS partial %s: %v", partialID, err)
http.Error(w, "Failed to create upload", http.StatusInternalServerError)
}
return
}
claimedIDs = append(claimedIDs, partialID)
if partial.Offset != partial.Size {
releaseClaims()
http.Error(w, "Partial upload not finished: "+partialID, http.StatusBadRequest)
return
}
partials = append(partials, partial)
}
metadata := parseTusMetadata(r.Header.Get("Upload-Metadata"))
uploadID := uuid.New().String()
session, err := fs.createTusSession(ctx, uploadID, targetPath, totalSize, metadata, concat)
if err != nil {
glog.Errorf("Failed to create TUS session: %v", err)
releaseClaims()
http.Error(w, "Failed to create upload", http.StatusInternalServerError)
return
}
// Re-base each partial's chunks onto the final upload's offset space.
for _, partial := range partials {
for _, chunk := range partial.Chunks {
session.Chunks = append(session.Chunks, &TusChunkInfo{
Offset: session.Offset + chunk.Offset,
Size: chunk.Size,
FileId: chunk.FileId,
UploadAt: chunk.UploadAt,
})
}
session.Offset += partial.Size
}
if err := fs.completeTusUpload(ctx, session); err != nil {
fs.deleteTusSession(ctx, uploadID)
releaseClaims()
glog.Errorf("Failed to complete TUS concatenation: %v", err)
writeTusCompleteError(w, err)
return
}
// The chunks now belong to the final entry, so remove only the partials'
// session metadata.
for _, partial := range partials {
if err := fs.filer.DeleteEntryMetaAndData(ctx, util.FullPath(fs.tusSessionPath(partial.ID)), true, false, false, false, nil, 0); err != nil {
glog.V(1).Infof("Failed to cleanup TUS partial session %s: %v", partial.ID, err)
}
}
w.Header().Set("Location", fs.tusUploadLocation(uploadID))
w.WriteHeader(http.StatusCreated)
}
// tusHeadHandler handles HEAD requests to get current upload offset
func (fs *FilerServer) tusHeadHandler(w http.ResponseWriter, session *TusSession) {
if session.Concat != "" {
w.Header().Set("Upload-Concat", session.Concat)
}
w.Header().Set("Upload-Offset", strconv.FormatInt(session.Offset, 10))
w.Header().Set("Upload-Length", strconv.FormatInt(session.Size, 10))
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
}
// tusPatchHandler handles PATCH requests to upload data
func (fs *FilerServer) tusPatchHandler(w http.ResponseWriter, r *http.Request, session *TusSession) {
if session.isFinal() {
http.Error(w, "Cannot PATCH a final upload", http.StatusForbidden)
return
}
// Use a context that ignores cancellation from the request context.
// The filer's connection has an inactivity timeout: after the request body is fully read,
// internal operations (assigning file IDs, uploading to volume servers, completing uploads)
// may exceed the timeout, causing the request context to be canceled.
ctx := context.WithoutCancel(r.Context())
// Validate Content-Type
contentType := r.Header.Get("Content-Type")
if contentType != "application/offset+octet-stream" {
http.Error(w, "Content-Type must be application/offset+octet-stream", http.StatusUnsupportedMediaType)
return
}
// Validate Upload-Offset header
uploadOffsetStr := r.Header.Get("Upload-Offset")
if uploadOffsetStr == "" {
http.Error(w, "Upload-Offset header required", http.StatusBadRequest)
return
}
uploadOffset, err := strconv.ParseInt(uploadOffsetStr, 10, 64)
if err != nil || uploadOffset < 0 {
http.Error(w, "Invalid Upload-Offset", http.StatusBadRequest)
return
}
// Check offset matches current position
if uploadOffset != session.Offset {
http.Error(w, fmt.Sprintf("Offset mismatch: expected %d, got %d", session.Offset, uploadOffset), http.StatusConflict)
return
}
// TUS requires Content-Length header for PATCH requests
if r.ContentLength < 0 {
http.Error(w, "Content-Length header required", http.StatusBadRequest)
return
}
// Write data
bytesWritten, err := fs.tusWriteData(ctx, r, session, uploadOffset, r.Body, r.ContentLength)
if err != nil {
if errors.Is(err, ErrContentTooLarge) {
http.Error(w, "Content-Length exceeds remaining upload size", http.StatusRequestEntityTooLarge)
return
}
glog.Errorf("Failed to write TUS data: %v", err)
http.Error(w, "Failed to write data", http.StatusInternalServerError)
return
}
newOffset := uploadOffset + bytesWritten
// Check if upload is complete; a partial upload keeps its chunks for a later
// concatenation instead of landing at the target path.
if newOffset == session.Size && !session.isPartial() {
// Ensure the authorized session still exists, then refresh its chunks.
if err = fs.refreshTusSessionChunks(ctx, session); err != nil {
glog.Errorf("Failed to get updated TUS session: %v", err)
http.Error(w, "Failed to complete upload", http.StatusInternalServerError)
return
}
if err := fs.completeTusUpload(ctx, session); err != nil {
glog.Errorf("Failed to complete TUS upload: %v", err)
writeTusCompleteError(w, err)
return
}
}
w.Header().Set("Upload-Offset", strconv.FormatInt(newOffset, 10))
w.WriteHeader(http.StatusNoContent)
}
// tusDeleteHandler handles DELETE requests to cancel uploads
func (fs *FilerServer) tusDeleteHandler(w http.ResponseWriter, r *http.Request, session *TusSession) {
ctx := r.Context()
if err := fs.deleteTusSession(ctx, session.ID); err != nil {
glog.Errorf("Failed to delete TUS session: %v", err)
http.Error(w, "Failed to delete upload", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// tusChunkSize is the size of sub-chunks used when streaming uploads to volume servers.
// 4MB balances memory usage (avoiding buffering large TUS chunks) with upload efficiency
// (minimizing the number of volume server requests). Smaller values reduce memory but
// increase request overhead; larger values do the opposite.
const tusChunkSize = 4 * 1024 * 1024 // 4MB
// ErrContentTooLarge is returned when Content-Length exceeds remaining upload space
var ErrContentTooLarge = fmt.Errorf("content length exceeds remaining upload size")
// tusWriteData uploads data to volume servers in streaming chunks and updates session
// It reads data in fixed-size sub-chunks to avoid buffering large TUS chunks entirely in memory
func (fs *FilerServer) tusWriteData(ctx context.Context, r *http.Request, session *TusSession, offset int64, reader io.Reader, contentLength int64) (int64, error) {
if contentLength == 0 {
return 0, nil
}
// Check if content length exceeds remaining size - return error instead of silently truncating
remaining := session.Size - offset
if contentLength > remaining {
return 0, ErrContentTooLarge
}
if remaining <= 0 {
return 0, nil
}
// Determine storage options based on target path
so, err := fs.detectStorageOption0(ctx, session.TargetPath, "", "", "", "", "", "", "", "", "")
if err != nil {
return 0, fmt.Errorf("detect storage option: %w", err)
}
// When DiskType is empty, use filer's -disk
if so.DiskType == "" {
so.DiskType = fs.option.DiskType
}
// Read first bytes for MIME type detection
sniffSize := int64(512)
if contentLength < sniffSize {
sniffSize = contentLength
}
sniffBuf := make([]byte, sniffSize)
sniffN, sniffErr := io.ReadFull(reader, sniffBuf)
if sniffErr != nil && sniffErr != io.EOF && sniffErr != io.ErrUnexpectedEOF {
return 0, fmt.Errorf("read data for mime detection: %w", sniffErr)
}
if sniffN == 0 {
return 0, nil
}
sniffBuf = sniffBuf[:sniffN]
mimeType := http.DetectContentType(sniffBuf)
// Create a combined reader with sniffed bytes prepended
var dataReader io.Reader
if int64(sniffN) >= contentLength {
dataReader = bytes.NewReader(sniffBuf)
} else {
dataReader = io.MultiReader(bytes.NewReader(sniffBuf), io.LimitReader(reader, contentLength-int64(sniffN)))
}
// Upload in streaming chunks to avoid buffering entire content in memory
var totalWritten int64
var uploadErr error
chunkBuf := make([]byte, tusChunkSize)
currentOffset := offset
for totalWritten < contentLength {
// Read up to tusChunkSize bytes
readSize := int64(tusChunkSize)
if contentLength-totalWritten < readSize {
readSize = contentLength - totalWritten
}
n, readErr := io.ReadFull(dataReader, chunkBuf[:readSize])
if readErr != nil && readErr != io.EOF && readErr != io.ErrUnexpectedEOF {
uploadErr = fmt.Errorf("read chunk data: %w", readErr)
break
}
if n == 0 {
break
}
// Store the sub-chunk through the regular write path's chunk writer,
// which assigns a fresh file id per attempt; a failed attempt's needle
// is returned so it can be freed instead of lingering unreferenced.
chunks, chunkErr := fs.dataToChunkWithSSE(ctx, r, "", mimeType, chunkBuf[:n], currentOffset, so)
if chunkErr != nil {
fs.filer.DeleteUncommittedChunks(ctx, chunks)
uploadErr = fmt.Errorf("upload data: %w", chunkErr)
break
}
if len(chunks) == 0 {
uploadErr = fmt.Errorf("no chunk stored at offset %d", currentOffset)
break
}
stored := chunks[0]
chunk := &TusChunkInfo{
Offset: stored.Offset,
Size: int64(stored.Size),
FileId: stored.FileId,
UploadAt: stored.ModifiedTsNs,
}
if saveErr := fs.saveTusChunk(ctx, session.ID, chunk); saveErr != nil {
fs.deleteTusChunk(ctx, session, chunk)
uploadErr = fmt.Errorf("update session: %w", saveErr)
break
}
totalWritten += chunk.Size
currentOffset += chunk.Size
stats.FilerHandlerCounter.WithLabelValues("tusUploadChunk").Inc()
}
// Sub-chunks already recorded stay: the session offset a resuming client
// reads back covers them, and the completed entry is assembled from them.
return totalWritten, uploadErr
}
// parseTusMetadata parses the Upload-Metadata header
// Format: key1 base64value1,key2 base64value2,...
func parseTusMetadata(header string) map[string]string {
metadata := make(map[string]string)
if header == "" {
return metadata
}
pairs := strings.Split(header, ",")
for _, pair := range pairs {
pair = strings.TrimSpace(pair)
parts := strings.SplitN(pair, " ", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
encodedValue := strings.TrimSpace(parts[1])
value, err := base64.StdEncoding.DecodeString(encodedValue)
if err != nil {
glog.V(1).Infof("Failed to decode TUS metadata value for key %s: %v", key, err)
continue
}
metadata[key] = string(value)
}
return metadata
}