Files
seaweedfs/weed/s3api/s3api_object_routed_write.go
T
Chris Lu 20934cc854 s3: map routed write/delete errors and fall back on directory markers
Address review feedback on the routed fast paths:

- PutObject: map the response's machine-readable FilerError code to the same S3
  error the lock path produces (key-too-long, parent/existing-is-file,
  existing-is-directory, precondition), instead of re-wrapping resp.Error in a
  fresh error that filerErrorToS3Error can no longer match by sentinel. Unknown
  codes and in-band errors fall back to the lock path for exact semantics.

- DeleteObject: on any non-precondition routed error, fall back to the lock
  path instead of returning InternalError. This restores directory-marker
  handling (demote a marker that still has children rather than failing on a
  non-empty folder), which the raw DeleteEntry call lacks.
2026-05-22 23:07:26 -07:00

188 lines
7.4 KiB
Go

package s3api
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// routedObjectOwner returns the filer that owns this object's metadata for
// route-by-key, or ok=false when the object's writes must keep the distributed
// lock. Versioned and object-lock buckets stay on the lock path: their
// mutations span multiple entries / extra metadata checks a single conditional
// create or delete does not cover. On any lookup error it falls back to be safe.
func (s3a *S3ApiServer) routedObjectOwner(bucket, object string) (pb.ServerAddress, bool) {
if object == "" || s3a.objectWriteLockClient == nil {
return "", false
}
if configured, err := s3a.isVersioningConfigured(bucket); err != nil || configured {
return "", false
}
if locked, err := s3a.isObjectLockEnabled(bucket); err != nil || locked {
return "", false
}
lockKey := fmt.Sprintf("s3.object.write:%s", s3a.toFilerPath(bucket, object))
owner := s3a.objectWriteLockClient.PrimaryForKey(lockKey)
if owner == "" {
return "", false
}
return owner, true
}
// routedObjectWrite decides whether an object PUT can take the route-by-key
// fast path: the metadata write goes straight to the key's owner filer, which
// serializes it with its local per-path lock and evaluates the precondition,
// instead of acquiring a distributed lock. It returns the owner filer and the
// reduced precondition, or ok=false to fall back to withObjectWriteLock.
//
// The fast path additionally requires no post-create hook and a precondition
// that reduces to one primitive.
func (s3a *S3ApiServer) routedObjectWrite(r *http.Request, bucket, object string, hasAfterCreate bool) (owner pb.ServerAddress, cond *filer_pb.WriteCondition, ok bool) {
if hasAfterCreate {
return "", nil, false
}
cond, condOk := buildWriteCondition(r)
if !condOk {
return "", nil, false
}
owner, ok = s3a.routedObjectOwner(bucket, object)
if !ok {
return "", nil, false
}
return owner, cond, true
}
// buildWriteCondition reduces the request's conditional headers to a single
// WriteCondition primitive the filer can evaluate. It only handles the
// unambiguous single-condition cases; anything else (header combinations,
// time-based conditions, ETag lists, weak ETags) returns ok=false so the caller
// keeps the existing gateway-side evaluation under the distributed lock.
func buildWriteCondition(r *http.Request) (*filer_pb.WriteCondition, bool) {
headers, errCode := parseConditionalHeaders(r)
if errCode != s3err.ErrNone {
return nil, false
}
if !headers.isSet {
return &filer_pb.WriteCondition{Kind: filer_pb.WriteCondition_NONE}, true
}
// Time-based conditions are rare on writes; let the lock path handle them.
if !headers.ifModifiedSince.IsZero() || !headers.ifUnmodifiedSince.IsZero() {
return nil, false
}
hasMatch := headers.ifMatch != ""
hasNoneMatch := headers.ifNoneMatch != ""
switch {
case hasMatch && !hasNoneMatch:
if headers.ifMatch == "*" {
return &filer_pb.WriteCondition{Kind: filer_pb.WriteCondition_IF_EXISTS}, true
}
if etag, single := singleStrongETag(headers.ifMatch); single {
return &filer_pb.WriteCondition{Kind: filer_pb.WriteCondition_IF_ETAG_MATCH, Etag: etag}, true
}
return nil, false
case hasNoneMatch && !hasMatch:
if headers.ifNoneMatch == "*" {
return &filer_pb.WriteCondition{Kind: filer_pb.WriteCondition_IF_NOT_EXISTS}, true
}
if etag, single := singleStrongETag(headers.ifNoneMatch); single {
return &filer_pb.WriteCondition{Kind: filer_pb.WriteCondition_IF_ETAG_NOT_MATCH, Etag: etag}, true
}
return nil, false
default:
// Both If-Match and If-None-Match present — leave to the lock path.
return nil, false
}
}
// singleStrongETag returns the normalized ETag when the header carries exactly
// one strong ETag, and false for ETag lists or weak ("W/") ETags, which the
// fast path does not replicate.
func singleStrongETag(v string) (string, bool) {
v = strings.TrimSpace(v)
if strings.Contains(v, ",") {
return "", false
}
if strings.HasPrefix(v, "W/") || strings.HasPrefix(v, "w/") {
return "", false
}
return strings.Trim(v, `"`), true
}
// createEntryOnFiler sends a CreateEntry directly to the given owner filer so
// its local per-path lock serializes the write. The raw response is returned so
// the caller can distinguish PRECONDITION_FAILED from other outcomes.
func (s3a *S3ApiServer) createEntryOnFiler(owner pb.ServerAddress, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
var resp *filer_pb.CreateEntryResponse
err := pb.WithFilerClient(false, 0, owner, s3a.option.GrpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
var e error
resp, e = client.CreateEntry(context.Background(), req)
return e
})
return resp, err
}
// filerErrorCodeToS3Error maps a routed response's machine-readable FilerError
// to the same S3 error the lock path produces via filerErrorToS3Error, so the
// fast path keeps identical semantics. ok is false for codes it does not map,
// signalling the caller to fall back to the lock path for exact behavior.
func filerErrorCodeToS3Error(code filer_pb.FilerError) (s3err.ErrorCode, bool) {
switch code {
case filer_pb.FilerError_PRECONDITION_FAILED:
return s3err.ErrPreconditionFailed, true
case filer_pb.FilerError_ENTRY_NAME_TOO_LONG:
return s3err.ErrKeyTooLongError, true
case filer_pb.FilerError_PARENT_IS_FILE, filer_pb.FilerError_EXISTING_IS_FILE:
return s3err.ErrExistingObjectIsFile, true
case filer_pb.FilerError_EXISTING_IS_DIRECTORY:
return s3err.ErrExistingObjectIsDirectory, true
default:
return s3err.ErrNone, false
}
}
// buildDeleteCondition reduces a DeleteObject's If-Match header to a primitive.
// DeleteObject only honors If-Match (matching checkDeleteIfMatch), so other
// conditional headers are ignored here as they are on the existing path.
func buildDeleteCondition(r *http.Request) (*filer_pb.WriteCondition, bool) {
ifMatch := strings.TrimSpace(r.Header.Get(s3_constants.IfMatch))
switch {
case ifMatch == "":
return &filer_pb.WriteCondition{Kind: filer_pb.WriteCondition_NONE}, true
case ifMatch == "*":
return &filer_pb.WriteCondition{Kind: filer_pb.WriteCondition_IF_EXISTS}, true
default:
if etag, single := singleStrongETag(ifMatch); single {
return &filer_pb.WriteCondition{Kind: filer_pb.WriteCondition_IF_ETAG_MATCH, Etag: etag}, true
}
return nil, false
}
}
// deleteEntryOnFiler sends a non-recursive DeleteEntry for the object directly
// to its owner filer, which serializes it and evaluates the precondition under
// the per-path lock. Flags mirror the unversioned delete path (doDeleteEntry).
func (s3a *S3ApiServer) deleteEntryOnFiler(owner pb.ServerAddress, bucket, object string, cond *filer_pb.WriteCondition) (*filer_pb.DeleteEntryResponse, error) {
dir, name := util.NewFullPath(s3a.bucketDir(bucket), object).DirAndName()
var resp *filer_pb.DeleteEntryResponse
err := pb.WithFilerClient(false, 0, owner, s3a.option.GrpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
var e error
resp, e = client.DeleteEntry(context.Background(), &filer_pb.DeleteEntryRequest{
Directory: dir,
Name: name,
IsDeleteData: true,
IsRecursive: false,
IgnoreRecursiveError: true,
Condition: cond,
})
return e
})
return resp, err
}