mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-30 17:00:48 +01:00
* s3: read regular copy sources from their write owner * filer: report mutation sentinel codes in ObjectTransaction responses A failed mutation only surfaced resp.Error text, so routed writers could not distinguish an existing-directory conflict from a generic failure and could not apply the prefix-object retry the plain CreateEntry path has. * s3api: retry a routed mkfile as a prefix object over a directory mkFile retries ErrExistingIsDirectory by marking the entry a prefix object; the routed path returned the conflict instead, so a routed write to a key that is also a prefix failed where the lock path succeeds. The filer now reports the code on the transaction response, making the retry recognizable. * s3api: commit copy destinations on the key's write owner An owner-routed source read still left the destination on the gateway's default filer, so a second copy resolved the first copy's destination on the owner, found nothing, and returned NoSuchKey. Write unversioned and suspended destinations through routedMkFile, and write the version file on the owner the finalize recomputes on. * s3api: cache remote objects on the filer that served the entry An owner-routed lookup can return a remote-only entry that has not replicated to the gateway's default filer; the cache RPC then cannot find it and the copy or read degrades to a 503. Ask the entry's write owner to cache it first, falling back to the default filer. * s3: read versioned object state from the key's write owner CopyObject destinations now commit version files and the .versions pointer on the owner's object-scope ring key, so versioned reads through the default filer could miss them until replication. Generalize the routed read to dir/name lookups and paginated lists scoped to the object's owner, and point the versioned read paths at it: getSpecificObjectVersion, doGetLatestObjectVersion, the .versions quick-checks in Get/Head/attributes, the self-heal rescans, tagging, delete conditionals, directory-marker history checks, rename destination, multipart completion checks, and the version-id format probe. Reads fall back to the default filer when no ring owner is resolvable, preserving single-filer deployments. * test(s3api): fake RECOMPUTE_LATEST/DELETE and cover versioned copy reads The ambiguous-PUT fake filer now creates parent directories on PUT (mirroring InsertEntry), applies DELETE mutations, and re-derives the .versions pointer for RECOMPUTE_LATEST the way applyRecomputeLatest does, so owner-vs-replica tests can exercise versioned writes. TestVersionedCopyDestinationReadsOnOwner copies into a versioned bucket whose writes commit on the owner while the gateway's default filer is a replica with no replication, then asserts reads by versionId, latest-version resolution, and a copy chained off the new version all resolve on the owner. * test(s3api): gofmt the versioned-copy coverage * s3: only swap in a cache fill of the resolved remote generation A cache response can come back from a filer holding an older generation of the key. Replacing the authorized source or swapping its chunks under a size and range computed for the resolved entry serves the wrong object or truncates the response. Match RemoteEntry identity and size before using a cached entry, in both the copy and streaming paths. * filer: keep directory attributes when an object lands as prefix object The object-onto-directory retry replaced the existing directory's mode, owner, and extended attributes with the object's. Preserve the directory identity; merge directory-held extended keys only when the old entry never held an object, so a prior generation's object metadata is not carried forward. * s3: keep marker lock checks on the filer that replaces the marker The object-lock precondition read the key's write owner while mkdir still replaces the marker on the default filer, so a locked marker not yet replicated to the owner could be overwritten. Read the entry where the replacement lands, in both PutObjectHandler and zip extraction. Also hash a trailing-slash marker key to the same route key as the object it names, so marker and object share one owner and one write lock. * s3: route tag writes to the owner tag reads resolve GetObjectTagging reads the key's write owner, but PutObjectTagging and DeleteObjectTagging still wrote through the default filer, so a successful change returned the owner's stale tags until replication. setTags, rmTags, and the versioned tag updates now write on the owner first, falling back to the default filer when no owner resolves. * s3: keep rename lookups on the filer that performs the move AtomicRenameEntry and the retry token it carries both execute on the default filer, but the source and destination reads went to the write owner — a completed rename's token was invisible to its own retry, and a source that only exists on the default filer looked absent. Read both ends on the default filer. * s3: send the .versions pointer repair with the object's route key The heal reads the .versions entry on the object's owner, but the conditional UpdateEntry is routed by the .versions path's own ring key, which can hash to a different filer — leaving the pointer the routed reads consult unchanged. Ship the CAS repair as a PATCH_EXTENDED ObjectTransaction on the object's route key instead. * filer: preserve only directory identity on a prefix-object write Merging the old directory's extended attributes could carry a marker's encryption, version, or etag fields onto the new plaintext object, since a directory's own keys cannot be told apart from the object it held. Keep only mode, uid, gid, user name and group names. * s3: also require matching size when a cache fill replaces the source The remote-generation check covers identity; size equality is the cheap guard that keeps the streamed or copied range consistent with what was resolved. * s3: lock the object path for a routed .versions pointer repair conditionalUpdateEntryRouted held the lock on the .versions path while version writes and deletes serialize on the object path, so a repair could pass IF_ENTRY_EQUAL and then apply over a pointer a writer had advanced in between. Lock the object path like the writers and keep the condition on the .versions entry via condition_key. * s3: do not replay an owner-side tag mutation on another filer withWriteOwnerFilerClient re-ran fn on the default filer after any owner error, so an authoritative not-found could update a stale replica entry and still report success, and a post-apply transport error could apply the mutation twice. The failover walk now ends on any error fn returns; only a channel that never came up moves to another filer. * filer: keep a directory's xattrs when a prefix object replaces it The prefix-object branch preserves the directory's identity but dropped its Extended map wholesale; FUSE xattrs live there under the xattr- prefix and belong to the directory, not to the object the marker held. * s3: delete null and specific versions on the filer that serves them On the default filer an absent entry deletes as success, so a version that only reached the write owner survived a reported success while owner-routed reads kept serving it. The suspended-versioning and lifecycle deletes now go through routedDeleteNullVersion and routedDeleteSpecificVersion when an owner resolves; an unsettled attempt keeps the lock path. * s3: route a lifecycle null-version delete to the object entry The "null" version is the regular object entry, not a .versions file, so it goes through routedDeleteNullVersion; only a real version id takes the .versions-file delete. * s3: repoint .versions only while it still names the deleted version routedDeleteSpecificVersion recomputed the latest pointer on every version delete, so removing a noncurrent version re-derived the pointer to the newest remaining file — resurrecting an older version over a cleared pointer (a suspended bucket's null write keeps it clear while the null object stays current) or over a pointer that moved on. The pointer is now read on the owner and repointed under an IF_ENTRY_EQUAL guard only while it still names the deleted version; a contested pointer is left for the self-heal path rather than overwritten by a stale scan. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: let lifecycle expiry keep its metadata-only deletes on routed paths The routed lifecycle deletes hardcoded IsDeleteData, so expiring an entry with a volume TTL queued per-chunk deletions instead of leaving the data for the volume to reclaim — extra work on the owner for deletes recordMetadataOnlyIf still counts as metadata-only. Pass the caller's cleanup choice through routedDeleteNullVersion and routedDeleteSpecificVersion; the interactive DELETE path keeps deleting data. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: let RECOMPUTE_LATEST skip a pointer that moved on A version delete repoints .versions only while the pointer still names the deleted version, but the gateway cannot carry the pointer's current state into the transaction's condition — the condition slot is already spent on the version's WORM guards. only_if_current_name moves the check under the transaction lock: the filer compares the pointer's name_to_key value before scanning and skips the recompute when it moved (or was cleared by a suspended bucket's null write), so a noncurrent delete cannot resurrect an older version. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: repoint inside the version's locked delete repointLatestBeforeVersionDelete ran in its own transaction ahead of the delete, so a version delete denied by retention or legal hold still moved the latest pointer — GET and HEAD went on serving the older version it rolled to. The repoint and the delete now ride one transaction behind the version's WORM guards, atomically gated by the new only_if_current_name recompute guard instead of a stale-read IF_ENTRY_EQUAL. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
483 lines
20 KiB
Go
483 lines
20 KiB
Go
package s3api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"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.
|
|
// x-amz-client-token travels with it too, so a rename whose response was lost
|
|
// answers its own retry instead of the NoSuchKey its vanished source would give.
|
|
// 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 {
|
|
// Same tag-conditioned-Allow deferral as the copy path: the first
|
|
// phase ran without the source's tags, so a tag-gated Allow can still
|
|
// grant both the read and the delete the rename performs.
|
|
if s3a.recheckPolicyForKeyWithLookup(r, http.MethodGet, bucket, srcObject, "", string(s3_constants.ACTION_READ), "RenameObjectHandler") != s3err.ErrNone ||
|
|
s3a.recheckPolicyForKeyWithLookup(r, http.MethodDelete, bucket, srcObject, "", string(s3_constants.ACTION_WRITE), "RenameObjectHandler") != 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
|
|
}
|
|
|
|
clientToken := r.Header.Get(s3_constants.AmzClientToken)
|
|
renameSource := r.Header.Get(s3_constants.AmzRenameSource)
|
|
|
|
errCode = s3a.withRenameWriteLocks(bucket, srcObject, dstObject, func() s3err.ErrorCode {
|
|
// The move runs on the default filer, so the source is read there too —
|
|
// a routed read could miss an entry the rename can still reach.
|
|
srcDir, srcName := util.FullPath(s3a.toFilerPath(bucket, srcObject)).DirAndName()
|
|
entry, err := s3a.getEntry(srcDir, srcName)
|
|
srcIsPrefixObject := entry.IsPrefixObject()
|
|
entry = prefixObjectSource(entry)
|
|
srcErrCode := classifyCopySourceError(entry, err)
|
|
|
|
dstEntry, errCode := s3a.lookupRenameDestination(bucket, dstObject)
|
|
if errCode != s3err.ErrNone {
|
|
return errCode
|
|
}
|
|
if errCode, settled := retryRenameDecision(dstEntry, clientToken, renameSource, dstObject, srcErrCode); settled {
|
|
return errCode
|
|
}
|
|
if srcErrCode != s3err.ErrNone {
|
|
return srcErrCode
|
|
}
|
|
if errCode := s3a.recheckCopySourcePolicy(r, bucket, srcObject, "", entry.Extended, "RenameObjectHandler"); errCode != s3err.ErrNone {
|
|
return errCode
|
|
}
|
|
// The rename also deletes the source; a tag-gated Deny on the delete
|
|
// applies the same way it does to a direct DELETE.
|
|
if errCode := s3a.recheckPolicyWithObjectEntry(objectKeyPolicyRequest(r, http.MethodDelete, bucket, srcObject, ""), bucket, srcObject, string(s3_constants.ACTION_WRITE), entry.Extended, "RenameObjectHandler"); 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
|
|
}
|
|
|
|
// 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.
|
|
keyHoldsNestedKeys := srcIsPrefixObject || (dstEntry != nil && dstEntry.IsDirectory)
|
|
if clientToken != "" {
|
|
s3a.stampRenameToken(bucket, srcObject, dstObject, entry, clientToken, renameSource, keyHoldsNestedKeys)
|
|
}
|
|
return s3a.renameObjectEntry(r.Context(), bucket, srcObject, dstObject, entry, keyHoldsNestedKeys)
|
|
})
|
|
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
|
|
}
|
|
srcDir, srcName := util.FullPath(s3a.toFilerPath(bucket, candidate)).DirAndName()
|
|
entry, err := s3a.getEntry(srcDir, srcName)
|
|
if !renameSourceAbsent(entry, err) {
|
|
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, "")
|
|
}
|
|
|
|
// renameTokenValidity bounds how long a committed rename answers for its own
|
|
// retry. An SDK gives up retrying long before this, so the window covers every
|
|
// retry there is, while a token that comes back days later no longer stands in
|
|
// for a move the caller means to happen now.
|
|
const renameTokenValidity = 24 * time.Hour
|
|
|
|
// renameToken is what a rename leaves on the object it moves, so that the same
|
|
// request replayed after its response was lost can be answered from the
|
|
// destination instead of from a source that is no longer there.
|
|
//
|
|
// Source is the x-amz-rename-source header as it was sent. A retry resends the
|
|
// request byte for byte, so equality on the raw value recognises it, and any
|
|
// other value is the token used for a different rename. Dest is the key the
|
|
// rename wrote, which tells a token that travelled here some other way - on a
|
|
// CopyObject of a renamed object, say - from one this rename left.
|
|
type renameToken struct {
|
|
Token string `json:"token"`
|
|
Source string `json:"source"`
|
|
Dest string `json:"dest"`
|
|
Unix int64 `json:"unix"`
|
|
}
|
|
|
|
type renameTokenVerdict int
|
|
|
|
const (
|
|
// renameTokenUnrelated: the destination carries no live token of this request's.
|
|
renameTokenUnrelated renameTokenVerdict = iota
|
|
// renameTokenSameRequest: this very rename already committed here.
|
|
renameTokenSameRequest
|
|
// renameTokenReused: the same token was sent for a different rename.
|
|
renameTokenReused
|
|
)
|
|
|
|
// classifyRenameToken reads what the destination object says about a request
|
|
// carrying clientToken.
|
|
func classifyRenameToken(dstEntry *filer_pb.Entry, clientToken, renameSource, dstObject string) renameTokenVerdict {
|
|
if clientToken == "" || dstEntry == nil {
|
|
return renameTokenUnrelated
|
|
}
|
|
stamped, found := dstEntry.Extended[s3_constants.SeaweedFSRenameToken]
|
|
if !found {
|
|
return renameTokenUnrelated
|
|
}
|
|
var token renameToken
|
|
if err := json.Unmarshal(stamped, &token); err != nil {
|
|
glog.Warningf("RenameObject: unreadable rename token on %s: %v", dstEntry.Name, err)
|
|
return renameTokenUnrelated
|
|
}
|
|
if token.Token != clientToken || token.Dest != dstObject || time.Since(time.Unix(token.Unix, 0)) > renameTokenValidity {
|
|
return renameTokenUnrelated
|
|
}
|
|
if token.Source != renameSource {
|
|
return renameTokenReused
|
|
}
|
|
return renameTokenSameRequest
|
|
}
|
|
|
|
// retryRenameDecision says what to do with a request whose destination carries
|
|
// a rename token. settled=true means the request is answered (return errCode);
|
|
// settled=false means continue with the rename.
|
|
//
|
|
// A reused token is refused. A token from this very rename is answered as
|
|
// success only when the source is gone — the move already committed and only
|
|
// its response was lost. A source that is back is not that retry: the move is
|
|
// still to be made, and making it is what leaves the caller where it asked to
|
|
// be, so the decision falls through and the rename proceeds.
|
|
func retryRenameDecision(dstEntry *filer_pb.Entry, clientToken, renameSource, dstObject string, srcErrCode s3err.ErrorCode) (s3err.ErrorCode, bool) {
|
|
switch classifyRenameToken(dstEntry, clientToken, renameSource, dstObject) {
|
|
case renameTokenReused:
|
|
return s3err.ErrIdempotentParameterMismatch, true
|
|
case renameTokenSameRequest:
|
|
if srcErrCode == s3err.ErrNoSuchKey {
|
|
return s3err.ErrNone, true
|
|
}
|
|
}
|
|
return s3err.ErrNone, false
|
|
}
|
|
|
|
// markRenameToken puts the client token on the entry the rename moves.
|
|
func markRenameToken(srcEntry *filer_pb.Entry, clientToken, renameSource, dstObject string) error {
|
|
stamped, err := json.Marshal(renameToken{Token: clientToken, Source: renameSource, Dest: dstObject, Unix: time.Now().Unix()})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if srcEntry.Extended == nil {
|
|
srcEntry.Extended = make(map[string][]byte)
|
|
}
|
|
srcEntry.Extended[s3_constants.SeaweedFSRenameToken] = stamped
|
|
return nil
|
|
}
|
|
|
|
// stampRenameToken records the client token on the object about to be moved, so
|
|
// that the move carries it to the destination.
|
|
//
|
|
// The token goes on before the move rather than after it: a move that commits
|
|
// and then loses its token is exactly the failure the token exists to cover,
|
|
// while a token left on a move that never happened simply rides along with the
|
|
// next attempt. Failing to record it costs this rename its idempotency and
|
|
// nothing else, so the move goes ahead either way.
|
|
func (s3a *S3ApiServer) stampRenameToken(bucket, srcObject, dstObject string, srcEntry *filer_pb.Entry, clientToken, renameSource string, keyHoldsNestedKeys bool) {
|
|
// The ETag as it stands now, read before the token joins it.
|
|
expected := map[string][]byte{s3_constants.ExtETagKey: srcEntry.Extended[s3_constants.ExtETagKey]}
|
|
|
|
if err := markRenameToken(srcEntry, clientToken, renameSource, dstObject); err != nil {
|
|
glog.Errorf("RenameObject %s: rename token for %s: %v", bucket, srcObject, err)
|
|
return
|
|
}
|
|
|
|
// renameKeyHoldingNestedKeys writes the destination out of this entry, so the
|
|
// token reaches it without a write of its own.
|
|
if keyHoldsNestedKeys {
|
|
return
|
|
}
|
|
|
|
// AtomicRenameEntry moves the entry as the filer holds it, so the token has to
|
|
// be on the source for the move to carry it. markRenameToken already mutated
|
|
// srcEntry.Extended in place, so the move carries the token to the destination
|
|
// whether or not this UpdateEntry succeeds — the write only persists the token
|
|
// on the source ahead of the move, and a lost write costs idempotency, not
|
|
// correctness. The precondition keeps the write off an object another gateway
|
|
// replaced in the meantime.
|
|
srcDir, _ := util.FullPath(s3a.toFilerPath(bucket, srcObject)).DirAndName()
|
|
if err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
return filer_pb.UpdateEntry(context.Background(), client, &filer_pb.UpdateEntryRequest{
|
|
Directory: srcDir,
|
|
Entry: srcEntry,
|
|
ExpectedExtended: expected,
|
|
})
|
|
}); err != nil {
|
|
glog.Warningf("RenameObject %s: record rename token on %s: %v", bucket, srcObject, err)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
})
|
|
}
|
|
|
|
// lookupRenameDestination reads what the destination key already holds. A key
|
|
// nothing lives at is the ordinary case and comes back as a nil entry.
|
|
//
|
|
// 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.
|
|
func (s3a *S3ApiServer) lookupRenameDestination(bucket, dstObject string) (*filer_pb.Entry, s3err.ErrorCode) {
|
|
dstDir, dstName := util.FullPath(s3a.toFilerPath(bucket, dstObject)).DirAndName()
|
|
|
|
// AtomicRenameEntry and the retry token it carries land on the default
|
|
// filer, so the destination is read there too — a routed read can miss
|
|
// both until replication.
|
|
entry, err := s3a.getEntry(dstDir, dstName)
|
|
if err != nil {
|
|
if errors.Is(err, filer_pb.ErrNotFound) {
|
|
return nil, s3err.ErrNone
|
|
}
|
|
glog.Errorf("RenameObject %s: destination %s: %v", bucket, dstObject, err)
|
|
return nil, s3err.ErrInternalError
|
|
}
|
|
return entry, s3err.ErrNone
|
|
}
|
|
|
|
func (s3a *S3ApiServer) renameObjectEntry(ctx context.Context, bucket, srcObject, dstObject string, srcEntry *filer_pb.Entry, keyHoldsNestedKeys bool) s3err.ErrorCode {
|
|
srcDir, srcName := util.FullPath(s3a.toFilerPath(bucket, srcObject)).DirAndName()
|
|
dstDir, dstName := util.FullPath(s3a.toFilerPath(bucket, dstObject)).DirAndName()
|
|
|
|
if keyHoldsNestedKeys {
|
|
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
|
|
}
|