mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-08 15:41:15 +02:00
* s3: stop retrying a delete the filer refused for a non-empty folder The filer looked and the children are there, so the answer will not change. retryFilerOp spent six attempts and up to 3.1s of backoff on it before the caller could act on the condition it was already holding. Claude-Session: https://claude.ai/code/session_01XqaJrwgXQ5GSUpyzRbe5nD * s3: thread the request context through the unversioned delete path doDeleteEntry issued every DeleteEntry on context.Background(), so an S3 client that hung up left the gateway working on its behalf, out of reach of both cancellation and the per-request retry allowance that DeleteMultipleObjectsHandler installs. Claude-Session: https://claude.ai/code/session_01XqaJrwgXQ5GSUpyzRbe5nD * s3: treat a cancelled filer RPC as terminal, not transient isRetryableFilerErr matched context.Canceled and DeadlineExceeded by sentinel, which only holds while the error is still local. Once it has crossed gRPC it is a status, so an abandoned request was retried six times on behalf of a caller that had already gone. Claude-Session: https://claude.ai/code/session_01XqaJrwgXQ5GSUpyzRbe5nD * s3: replay a delete whose reply the transport dropped A delete is idempotent at the filer, which answers an entry that is already gone with an empty resp.Error, so a reply lost in transit can be reissued rather than surfaced. Surfaced, it becomes a 500 on the bucket delete, which boto3 resends and is then answered NoSuchBucket, or a per-key InternalError inside the 200 of a multi-object delete, which no SDK retries at all. The replay runs through retryFilerOp, so it draws on the allowance the request already installs rather than paying a backoff per key, and stops for a caller that has gone. rm and rmObject re-enter WithFilerClient per attempt, so each one walks the failover list again on a connection the failed attempt had invalidated; the multi-object loop holds one client for the batch, so there the replay reuses it. Classification stays structural. The filer reports its own refusals in resp.Error, which carries no status and has the deleted path - and, for a recursive delete, the children it stopped on - formatted into it, so no key name can steer the decision either way. rm and rmObject now take the caller's context. Cleanup and rollback paths pass context.Background() deliberately: they have to run whether or not the caller is still waiting. Claude-Session: https://claude.ai/code/session_01XqaJrwgXQ5GSUpyzRbe5nD * s3: share one retry allowance across multipart completion cleanup The unused-entry loop deletes once per entry, and each delete now retries, so a filer that stays unavailable held the response for 3.1s per entry after the object was already committed. Claude-Session: https://claude.ai/code/session_01XqaJrwgXQ5GSUpyzRbe5nD
298 lines
12 KiB
Go
298 lines
12 KiB
Go
package s3api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
|
stats_collect "github.com/seaweedfs/seaweedfs/weed/stats"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
var renameSourceConditionalHeaders = sourceConditionalHeaderNames{
|
|
ifMatch: s3_constants.AmzRenameSourceIfMatch,
|
|
ifNoneMatch: s3_constants.AmzRenameSourceIfNoneMatch,
|
|
ifModifiedSince: s3_constants.AmzRenameSourceIfModifiedSince,
|
|
ifUnmodifiedSince: s3_constants.AmzRenameSourceIfUnmodifiedSince,
|
|
}
|
|
|
|
// RenameObjectHandler implements RenameObject:
|
|
//
|
|
// PUT /{bucket}/{destination key}?renameObject
|
|
// x-amz-rename-source: /{bucket}/{source key}
|
|
//
|
|
// The object is moved by the filer's AtomicRenameEntry, so its bytes are never
|
|
// read or rewritten and its metadata (ETag, tags, SSE keys) travels unchanged.
|
|
// Versioned buckets are rejected: the move would have to rebuild the .versions
|
|
// chain, and AWS itself only offers RenameObject on directory buckets, which
|
|
// cannot be versioned.
|
|
func (s3a *S3ApiServer) RenameObjectHandler(w http.ResponseWriter, r *http.Request) {
|
|
bucket, dstObject := s3_constants.GetBucketAndObject(r)
|
|
|
|
candidates, errCode := renameSourceCandidates(r, bucket)
|
|
if errCode != s3err.ErrNone {
|
|
s3err.WriteErrorResponse(w, r, errCode)
|
|
return
|
|
}
|
|
srcObject := s3a.pickRenameSource(bucket, candidates)
|
|
|
|
glog.V(3).Infof("RenameObjectHandler %s: %s => %s", bucket, srcObject, dstObject)
|
|
|
|
if len(dstObject) > s3_constants.MaxS3ObjectKeyLength {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrKeyTooLongError)
|
|
return
|
|
}
|
|
if err := s3a.validateTableBucketObjectPath(bucket, dstObject); err != nil {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
|
|
return
|
|
}
|
|
// A trailing slash names a directory, and renaming one would move a whole
|
|
// subtree rather than an object.
|
|
if strings.HasSuffix(dstObject, "/") {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
|
|
return
|
|
}
|
|
if strings.HasSuffix(srcObject, "/") {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
|
|
return
|
|
}
|
|
if srcObject == dstObject {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrRenameDestinationSameAsSource)
|
|
return
|
|
}
|
|
|
|
// The route's Auth middleware only authorized the destination, because that
|
|
// is what the request URL names. The source arrives in a header and loses
|
|
// its key, so it needs both read and delete permission checked here.
|
|
if errCode := s3a.authorizeRenameSource(r, bucket, srcObject); errCode != s3err.ErrNone {
|
|
s3err.WriteErrorResponse(w, r, errCode)
|
|
return
|
|
}
|
|
|
|
versioningState, err := s3a.getVersioningState(bucket)
|
|
if err != nil {
|
|
if errors.Is(err, filer_pb.ErrNotFound) {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
|
|
return
|
|
}
|
|
glog.Errorf("RenameObjectHandler: versioning state for bucket %s: %v", bucket, err)
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
|
|
return
|
|
}
|
|
if versioningState != "" {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrNotImplemented)
|
|
return
|
|
}
|
|
|
|
errCode = s3a.withRenameWriteLocks(bucket, srcObject, dstObject, func() s3err.ErrorCode {
|
|
entry, err := s3a.resolveCopySourceEntry(bucket, srcObject, "", "")
|
|
srcIsPrefixObject := entry.IsPrefixObject()
|
|
entry = prefixObjectSource(entry)
|
|
if errCode := classifyCopySourceError(entry, err); errCode != s3err.ErrNone {
|
|
return errCode
|
|
}
|
|
if errCode := validateSourceConditionalHeaders(r, entry, renameSourceConditionalHeaders); errCode != s3err.ErrNone {
|
|
return errCode
|
|
}
|
|
if errCode := s3a.checkConditionalHeaders(r, bucket, dstObject); errCode != s3err.ErrNone {
|
|
return errCode
|
|
}
|
|
return s3a.renameObjectEntry(r.Context(), bucket, srcObject, dstObject, entry, srcIsPrefixObject)
|
|
})
|
|
if errCode != s3err.ErrNone {
|
|
s3err.WriteErrorResponse(w, r, errCode)
|
|
return
|
|
}
|
|
|
|
stats_collect.RecordBucketActiveTime(bucket)
|
|
writeSuccessResponseEmpty(w, r)
|
|
}
|
|
|
|
// renameSourceCandidates reads x-amz-rename-source into the source keys it may
|
|
// mean, best guess first.
|
|
//
|
|
// AWS spells the source both ways: its CLI, Java and Rust examples pass a bare
|
|
// key, while a second CLI example and the boto3 conditional example pass
|
|
// bucket/key. A value is therefore read as a literal key first — that is the
|
|
// form AWS leads with, and it is the only reading that can never name the wrong
|
|
// object — and, when it is prefixed with the request's own bucket, as that
|
|
// bucket-qualified form second. There is no cross-bucket reading: RenameObject
|
|
// moves within one bucket, and the filer refuses to move an entry between two.
|
|
func renameSourceCandidates(r *http.Request, bucket string) ([]string, s3err.ErrorCode) {
|
|
rawSource := r.Header.Get(s3_constants.AmzRenameSource)
|
|
if rawSource == "" {
|
|
return nil, s3err.ErrInvalidRenameSource
|
|
}
|
|
// PathUnescape, not QueryUnescape: the value is a path, where '+' is a
|
|
// literal plus and not a space.
|
|
source, err := url.PathUnescape(rawSource)
|
|
if err != nil {
|
|
source = rawSource
|
|
}
|
|
|
|
// NormalizeObjectKey drops the leading slash both forms may carry.
|
|
source = s3_constants.NormalizeObjectKey(source)
|
|
if source == "" {
|
|
return nil, s3err.ErrInvalidRenameSource
|
|
}
|
|
|
|
candidates := []string{source}
|
|
if qualified := strings.TrimPrefix(source, bucket+"/"); qualified != source && qualified != "" {
|
|
candidates = append(candidates, qualified)
|
|
}
|
|
// `.`/`..` segments are collapsed by the filer's path join, so reject them
|
|
// here as the request URL's own key already is.
|
|
for _, candidate := range candidates {
|
|
if !s3_constants.IsValidObjectKey(candidate) {
|
|
return nil, s3err.ErrInvalidRenameSource
|
|
}
|
|
}
|
|
return candidates, s3err.ErrNone
|
|
}
|
|
|
|
// pickRenameSource resolves which reading of the source header the bucket
|
|
// actually holds. A single candidate is returned unprobed, so the common bare
|
|
// key costs no extra lookup; when both readings are possible the one the bucket
|
|
// holds wins, and when neither does the last is reported missing.
|
|
//
|
|
// Only a proven absence moves on to the next reading. A path that holds
|
|
// something the rename cannot move — a directory, say — is still the path the
|
|
// caller named, and answering for it beats renaming a different object under
|
|
// the other reading; so is a path whose lookup merely failed, since a blip must
|
|
// not be able to redirect a rename.
|
|
func (s3a *S3ApiServer) pickRenameSource(bucket string, candidates []string) string {
|
|
for _, candidate := range candidates[:len(candidates)-1] {
|
|
// A trailing slash never names an object, and never reaches a usable
|
|
// directory/name split either.
|
|
if strings.HasSuffix(candidate, "/") {
|
|
continue
|
|
}
|
|
if !renameSourceAbsent(s3a.resolveCopySourceEntry(bucket, candidate, "", "")) {
|
|
return candidate
|
|
}
|
|
}
|
|
return candidates[len(candidates)-1]
|
|
}
|
|
|
|
// renameSourceAbsent reports whether a lookup proved the candidate absent. Only
|
|
// the filer saying so counts; a lookup that failed for any other reason is not
|
|
// a proof of absence.
|
|
func renameSourceAbsent(entry *filer_pb.Entry, err error) bool {
|
|
if entry != nil {
|
|
return false
|
|
}
|
|
return err == nil || errors.Is(err, filer_pb.ErrNotFound) || status.Code(err) == codes.NotFound
|
|
}
|
|
|
|
func (s3a *S3ApiServer) authorizeRenameSource(r *http.Request, bucket, srcObject string) s3err.ErrorCode {
|
|
if s3a.iam == nil || !s3a.iam.isEnabled() {
|
|
return s3err.ErrNone
|
|
}
|
|
var identity *Identity
|
|
if id, ok := s3_constants.GetIdentityFromContext(r).(*Identity); ok {
|
|
identity = id
|
|
}
|
|
// The rename both reads the source object and removes it from its key.
|
|
if errCode := s3a.iam.AuthorizeCopySource(r, identity, bucket, srcObject, ""); errCode != s3err.ErrNone {
|
|
return errCode
|
|
}
|
|
return s3a.iam.AuthorizeObjectDelete(r, identity, bucket, srcObject, "")
|
|
}
|
|
|
|
// withRenameWriteLocks holds the object write lock of both keys across the
|
|
// precondition checks and the move. The keys are locked in a fixed order so a
|
|
// rename in the opposite direction cannot deadlock against this one.
|
|
func (s3a *S3ApiServer) withRenameWriteLocks(bucket, srcObject, dstObject string, fn func() s3err.ErrorCode) s3err.ErrorCode {
|
|
first, second := srcObject, dstObject
|
|
if second < first {
|
|
first, second = second, first
|
|
}
|
|
return s3a.withObjectWriteLock(bucket, first, nil, func() s3err.ErrorCode {
|
|
return s3a.withObjectWriteLock(bucket, second, nil, fn)
|
|
})
|
|
}
|
|
|
|
func (s3a *S3ApiServer) renameObjectEntry(ctx context.Context, bucket, srcObject, dstObject string, srcEntry *filer_pb.Entry, srcIsPrefixObject bool) s3err.ErrorCode {
|
|
srcDir, srcName := util.FullPath(s3a.toFilerPath(bucket, srcObject)).DirAndName()
|
|
dstDir, dstName := util.FullPath(s3a.toFilerPath(bucket, dstObject)).DirAndName()
|
|
|
|
// The move overwrites an existing destination object. A directory there is not a
|
|
// conflict: it means other keys are nested under the destination key, and the
|
|
// object goes onto the directory they live in, the way a PutObject of that key
|
|
// would put it there.
|
|
dstHoldsNestedKeys := false
|
|
if existing, err := s3a.getEntry(dstDir, dstName); err == nil {
|
|
dstHoldsNestedKeys = existing.IsDirectory
|
|
} else if !errors.Is(err, filer_pb.ErrNotFound) {
|
|
glog.Errorf("RenameObject %s: destination %s: %v", bucket, dstObject, err)
|
|
return s3err.ErrInternalError
|
|
}
|
|
|
|
// AtomicRenameEntry moves a directory by moving everything under it, and the keys
|
|
// nested under either end of this rename are not part of what is being renamed.
|
|
if srcIsPrefixObject || dstHoldsNestedKeys {
|
|
return s3a.renameKeyHoldingNestedKeys(bucket, srcObject, dstObject, srcEntry)
|
|
}
|
|
|
|
err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
_, err := client.AtomicRenameEntry(ctx, &filer_pb.AtomicRenameEntryRequest{
|
|
OldDirectory: srcDir,
|
|
OldName: srcName,
|
|
NewDirectory: dstDir,
|
|
NewName: dstName,
|
|
})
|
|
return err
|
|
})
|
|
if err != nil {
|
|
glog.Errorf("RenameObject %s: %s => %s: %v", bucket, srcObject, dstObject, err)
|
|
if isTransientFilerError(err) {
|
|
return s3err.ErrServiceUnavailable
|
|
}
|
|
return s3err.ErrInternalError
|
|
}
|
|
return s3err.ErrNone
|
|
}
|
|
|
|
// renameKeyHoldingNestedKeys moves an object when either key of the rename is one
|
|
// other keys are nested under. Such a key is stored on the directory those keys live
|
|
// in, which has to stay where it is, so the object's own data is written at the
|
|
// destination and then stripped off the source key - the entry survives as the plain
|
|
// directory it also is. Both keys are held under their write locks for the whole
|
|
// move, so no other S3 write interleaves; a crash between the two steps leaves the
|
|
// destination written and the source still there, which a retry settles.
|
|
func (s3a *S3ApiServer) renameKeyHoldingNestedKeys(bucket, srcObject, dstObject string, srcEntry *filer_pb.Entry) s3err.ErrorCode {
|
|
dstPath := util.FullPath(s3a.toFilerPath(bucket, dstObject))
|
|
dstDir, dstName := dstPath.DirAndName()
|
|
|
|
chunks, err := s3a.copyChunks(srcEntry, string(dstPath))
|
|
if err != nil {
|
|
glog.Errorf("RenameObject %s: copy chunks of %s: %v", bucket, srcObject, err)
|
|
return s3err.ErrInternalError
|
|
}
|
|
|
|
if err := s3a.mkFile(dstDir, dstName, chunks, func(entry *filer_pb.Entry) {
|
|
copyEntryToTarget(entry, srcEntry)
|
|
entry.Chunks = chunks
|
|
}); err != nil {
|
|
glog.Errorf("RenameObject %s: write %s: %v", bucket, dstObject, err)
|
|
s3a.deleteOrphanedChunks(chunks)
|
|
return filerErrorToS3Error(err)
|
|
}
|
|
|
|
// The destination holds copies now, so the source's own chunks go with it.
|
|
srcDir, srcName := util.FullPath(s3a.toFilerPath(bucket, srcObject)).DirAndName()
|
|
if err := s3a.rmObject(context.Background(), srcDir, srcName, true, false); err != nil {
|
|
glog.Errorf("RenameObject %s: strip %s: %v", bucket, srcObject, err)
|
|
return s3err.ErrInternalError
|
|
}
|
|
return s3err.ErrNone
|
|
}
|