Files
seaweedfs/weed/s3api/s3api_object_routed_write.go
T
Chris LuandClaude Opus 5.5 45cd6033a0 s3: make conditional DeleteObject atomic on versioned buckets (#12624)
* s3: re-check a versioned DeleteObject If-Match under the object lock

A versioned or suspended DeleteObject without a versionId routed to the
owner filer after checking If-Match outside any lock, so concurrent
conditional deletes could all pass the check and each add a delete
marker. Keep conditional deletes on the lock path, which re-checks the
precondition under the object write lock, as conditional versioned PUTs
already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3: guard a conditional version delete on the owner filer

DeleteObject with a versionId checked If-Match before routing the
delete to the owner filer, so concurrent conditional deletes of one
version could all report success. Carry the If-Match clause into the
owner's transaction as a guard on the version (the null entry for
versionId=null), evaluated under the object's path lock before anything
applies, and map that guard failing to 412 instead of the object-lock
403. An If-Match the filer cannot evaluate stays on the lock path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3: carry an If-Match ETag list into the routed delete

A DeleteObject whose If-Match listed several ETags fell back to the
gateway's object write lock, while a single-ETag delete of the same key
ran under the owner filer's path lock. The two locks do not exclude each
other, so a list delete and a single-ETag delete could both report
success. Send every candidate to the filer, whose strong comparison
matches checkDeleteIfMatch, so all conditional deletes with an owner are
decided under the owner's lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3: fail a conditional null delete on an owner transport error

The owner may have committed the delete before the error, and the gateway
lock path it fell back to does not exclude routed deletes, so a second
If-Match delete could pass there too. Answer 500 as the specific-version
path does; unconditional deletes still fall back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3: fail a conditional null delete on any owner error but the nested refusal

Only the owner's non-empty-folder refusal is known to apply nothing. After
any other owner error the delete may have been partly applied, and a lock-path
re-check does not exclude a concurrent routed delete of the same version, so a
conditional null-version delete answers 500 instead of falling back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3: strip a nested conditional null delete on the owner

A conditional versionId=null delete that the owner refuses because other
keys are nested under the entry used to fall back to the gateway lock and
re-check If-Match there. That lock does not exclude routed deletes: once
the last nested key is removed, a concurrent routed delete can pass the
same check on the owner, and the fallback then treats the missing entry as
deleted, so both requests answer 204.

Demote the entry on the owner instead, in a transaction guarded by the
If-Match clauses and by the entry it was read as, with the object-lock
guards on the request. A missing or already demoted entry answers 412.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* filer: add an IF_OBJECT_EXISTS write condition

IF_EXISTS also holds for a plain directory, which is what is left of a key
once the object on it is stripped while other keys stay nested under it.
IF_OBJECT_EXISTS holds only for an entry that carries an object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3: require an object for a conditional null delete with If-Match: *

Stripping the null version off an entry other keys are nested under leaves a
plain directory. Once the last nested key is gone, a routed delete with
If-Match: * still found an entry there and removed the empty directory, so a
second conditional delete could also answer 204. The owner now checks that
the entry carries an object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3: settle a wildcard null delete on the entry when the filer predates IF_OBJECT_EXISTS

A filer older than IF_OBJECT_EXISTS fails it closed, so with the gateway
upgraded first every If-Match: * delete of a null version answered 412. When
the narrowed guard fails, the gateway now reads the entry on the owner: absent
or a plain directory is a 412, otherwise the file is deleted, or the directory
stripped, under IF_EXISTS and IF_ENTRY_EQUAL on the entry it read. The demote
keeps the caller's If-Match clauses, since the entry guard already requires an
object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 12:47:22 -08:00

404 lines
18 KiB
Go

package s3api
import (
"context"
"errors"
"fmt"
"net/http"
"path"
"strings"
"time"
"github.com/google/uuid"
"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"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// objectWriteRouteKeyPrefix namespaces an object's full path into the ring key
// used to resolve and forward its writes. Shared by every routed builder so the
// gateway, the admin dashboard and the filer hash the same key.
const objectWriteRouteKeyPrefix = s3_constants.ObjectWriteRouteKeyPrefix
// objectRouteKey is the ring key the gateway hashes to resolve an object's owner
// filer. It is also sent as route_key on each routed transaction, so a non-owner
// filer (reached because the gateway's ring view was stale) forwards the
// transaction to the owner. All of an object's writes share this key.
func (s3a *S3ApiServer) objectRouteKey(bucket, object string) string {
// A trailing-slash marker key ("dir/") names the same entry as the object
// ("dir"), so both hash and lock to one owner.
return objectWriteRouteKeyPrefix + strings.TrimSuffix(s3a.toFilerPath(bucket, object), "/")
}
// routableWriteOwner returns the owner filer for an object's writes, or "" to
// keep them on the distributed lock. All writes to one object (versioned,
// suspended, non-versioned) share the owner. Any lookup error falls back.
func (s3a *S3ApiServer) routableWriteOwner(bucket, object string) pb.ServerAddress {
if object == "" || s3a.objectWriteLockClient == nil {
return ""
}
// Object-lock PUTs route: a versioned PUT creates a new version (never an
// overwrite of a locked one), and a non-versioned overwrite is WORM-checked
// gateway-side before dispatch. WORM-checked deletes use routedObjectOwner.
return s3a.objectWriteLockClient.PrimaryForKey(s3a.objectRouteKey(bucket, object))
}
// routedObjectOwner is routableWriteOwner restricted to non-versioned,
// non-object-lock buckets, for the unversioned DELETE fast path.
func (s3a *S3ApiServer) routedObjectOwner(bucket, object string) (pb.ServerAddress, bool) {
if configured, err := s3a.isVersioningConfigured(bucket); err != nil || configured {
return "", false
}
// An unversioned object-lock delete enforces WORM in the lock path; keep it
// on the lock rather than routing past the check.
if locked, err := s3a.isObjectLockEnabled(bucket); err != nil || locked {
return "", false
}
owner := s3a.routableWriteOwner(bucket, object)
return owner, owner != ""
}
// routeWriteCondition reduces the request's conditional headers for a routed
// create. A unique version path carries no precondition and only routes when the
// request is unconditional (a conditional versioned write must check the latest,
// which the lock path does); an overwrite carries the reduced condition.
func routeWriteCondition(r *http.Request, uniqueWritePath bool) (*filer_pb.WriteCondition, bool) {
cond, ok := buildWriteCondition(r)
if !ok {
return nil, false
}
if uniqueWritePath && cond != nil {
return nil, false
}
return cond, true
}
// buildWriteCondition reduces the request's conditional headers to a
// WriteCondition evaluated under the owner's object-path lock. Every header
// form reduces to clauses (ETag lists and weak ETags compare through the same
// canonicalization the gateway applies; time conditions become unix bounds),
// composed per RFC 7232 precedence: If-Unmodified-Since is ignored when
// If-Match is present, If-Modified-Since when If-None-Match is. ok=false only
// when the headers cannot even parse; a nil condition with ok=true means
// unconditional.
func buildWriteCondition(r *http.Request) (*filer_pb.WriteCondition, bool) {
headers, errCode := parseConditionalHeaders(r)
if errCode != s3err.ErrNone {
return nil, false
}
if !headers.isSet {
return nil, true
}
var clauses []*filer_pb.WriteCondition_Clause
if headers.ifMatch == "*" {
clauses = append(clauses, &filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_EXISTS})
} else if headers.ifMatch != "" {
clauses = append(clauses, &filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_ETAG_MATCH, Etags: etagList(headers.ifMatch)})
}
if !headers.ifUnmodifiedSince.IsZero() && headers.ifMatch == "" {
clauses = append(clauses, &filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_UNMODIFIED_SINCE, UnixTime: headers.ifUnmodifiedSince.Unix()})
}
if headers.ifNoneMatch == "*" {
clauses = append(clauses, &filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_NOT_EXISTS})
} else if headers.ifNoneMatch != "" {
clauses = append(clauses, &filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_ETAG_NOT_MATCH, Etags: etagList(headers.ifNoneMatch)})
}
if !headers.ifModifiedSince.IsZero() && headers.ifNoneMatch == "" {
clauses = append(clauses, &filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_MODIFIED_SINCE, UnixTime: headers.ifModifiedSince.Unix()})
}
return &filer_pb.WriteCondition{Clauses: clauses}, true
}
// buildDeleteCondition reduces a DeleteObject's If-Match header to a condition,
// nil when unconditional; DeleteObject honors only If-Match, matching
// checkDeleteIfMatch, whose comparison the filer applies to every listed ETag.
func buildDeleteCondition(r *http.Request) *filer_pb.WriteCondition {
ifMatch := strings.TrimSpace(r.Header.Get(s3_constants.IfMatch))
switch {
case ifMatch == "":
return nil
case ifMatch == "*":
return clause(filer_pb.WriteCondition_IF_EXISTS)
default:
return &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{{Kind: filer_pb.WriteCondition_IF_ETAG_MATCH, Etags: etagList(ifMatch)}}}
}
}
func clause(kind filer_pb.WriteCondition_Kind) *filer_pb.WriteCondition {
return &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{{Kind: kind}}}
}
// etagList splits a conditional ETag header into its candidates, each kept
// verbatim — the evaluator's canonicalization handles quotes and weak markers.
func etagList(v string) []string {
parts := strings.Split(v, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return parts
}
// objectTxnOnFiler applies a routed transaction, preferring the object's owner
// filer but failing over to a live filer when the owner is unreachable, so a
// restarted filer's stale ring address (e.g. a rolled K8s pod's old IP) can't hang
// the write. The reached filer forwards to the real owner by route_key, keeping its
// per-path lock authoritative. Mirrors getObjectEntryRoutedByKey on the read path.
// The transaction is not replayable: it fails over only over a channel that
// never came up, never past a possibly-committed attempt — a lost response is
// ambiguous and re-sending could re-apply mutations over a write that landed in
// between. The caller resolves that ambiguity (e.g. createAfterAmbiguousRoute).
func (s3a *S3ApiServer) objectTxnOnFiler(owner pb.ServerAddress, req *filer_pb.ObjectTransactionRequest) (*filer_pb.ObjectTransactionResponse, error) {
// One id per logical transaction: failover retries reuse it so the owner
// replays the recorded outcome instead of re-applying the mutations.
if req.RequestId == "" {
req.RequestId = uuid.NewString()
}
var resp *filer_pb.ObjectTransactionResponse
txn := func(client filer_pb.SeaweedFilerClient) error {
var e error
resp, e = client.ObjectTransaction(context.Background(), req)
return e
}
if s3a.filerClient == nil {
err := pb.WithFilerClient(false, 0, owner, s3a.option.GrpcDialOption, txn)
return resp, err
}
// Skip an owner whose recent write hit a transport error so a dead/stale
// address isn't re-dialed every request.
preferred := owner
if s3a.ownerRecentlyUnreachable(owner) {
preferred = ""
}
err := s3a.withFilerClientFailover(context.Background(), preferred, false, false, txn)
return resp, err
}
// withWriteOwnerFilerClient runs fn on the object's write owner so a read
// routed to the owner sees the change without waiting for replication. An
// error fn returns on the owner is authoritative and surfaces as-is: replaying
// the mutation on a lagging replica could update a stale entry and still
// report success. Only a channel that never came up — provably nothing sent —
// lets the failover walk reach another filer; with no resolved owner the
// default filer applies the mutation.
func (s3a *S3ApiServer) withWriteOwnerFilerClient(owner pb.ServerAddress, fn func(client filer_pb.SeaweedFilerClient) error) error {
if owner == "" {
return s3a.WithFilerClient(false, fn)
}
if s3a.filerClient == nil {
return pb.WithFilerClient(false, 0, owner, s3a.option.GrpcDialOption, fn)
}
preferred := owner
if s3a.ownerRecentlyUnreachable(owner) {
preferred = ""
}
return s3a.withFilerClientFailover(context.Background(), preferred, false, false, fn)
}
// routedPut writes an entry as a PUT, optionally followed by finalize mutations,
// as one ObjectTransaction applied in order under lockKey on the owner filer.
// lockKey is normally the entry's own path; a versioned add instead passes the
// object path plus a RECOMPUTE_LATEST finalize, so the version's PUT and its
// .versions pointer flip commit atomically (the recompute scans .versions/ after
// the PUT and sees the new version).
// putCondition, when set, guards the PUT mutation itself against putConditionKey
// (e.g. the upload directory a multipart commit must still find); the request
// condition keeps guarding the object the transaction is about.
func (s3a *S3ApiServer) routedPut(owner pb.ServerAddress, routeKey, lockKey, filePath string, entry *filer_pb.Entry, cond *filer_pb.WriteCondition, conditionKey, conditionAltKey string, conditionPreferAlt bool, putCondition *filer_pb.WriteCondition, putConditionKey string, finalize []*filer_pb.ObjectMutation) (*filer_pb.ObjectTransactionResponse, error) {
mutations := make([]*filer_pb.ObjectMutation, 0, 1+len(finalize))
mutations = append(mutations, &filer_pb.ObjectMutation{
Type: filer_pb.ObjectMutation_PUT,
Directory: path.Dir(filePath),
Entry: entry,
Condition: putCondition,
ConditionKey: putConditionKey,
})
mutations = append(mutations, finalize...)
return s3a.objectTxnOnFiler(owner, &filer_pb.ObjectTransactionRequest{
LockKey: lockKey,
RouteKey: routeKey,
Condition: cond,
ConditionKey: conditionKey,
ConditionAltKey: conditionAltKey,
ConditionPreferAlt: conditionPreferAlt,
Mutations: mutations,
})
}
// routedMkFile builds an entry like filer_pb.MkFile and writes it through a
// routed PUT on the owner filer, for callers that would otherwise mkFile to the
// default filer (e.g. multipart completion of a non-versioned object). cond is
// the request's write condition, evaluated on the object under the transaction
// lock; condAltKey falls back to the .versions directory when the object slot
// is empty (suspended or versioned buckets).
func (s3a *S3ApiServer) routedMkFile(owner pb.ServerAddress, routeKey, parentDir, name string, chunks []*filer_pb.FileChunk, fn func(*filer_pb.Entry), removal *uploadRemovalTxn, cond *filer_pb.WriteCondition, condAltKey string) error {
now := time.Now().Unix()
entry := &filer_pb.Entry{
Name: name,
Attributes: &filer_pb.FuseAttributes{
Mtime: now,
Crtime: now,
FileMode: uint32(0770),
Uid: filer_pb.OS_UID,
Gid: filer_pb.OS_GID,
},
Chunks: chunks,
}
if fn != nil {
fn(entry)
}
filePath := parentDir + "/" + name
var putCondition *filer_pb.WriteCondition
var putConditionKey string
var finalize []*filer_pb.ObjectMutation
if removal != nil {
putCondition, putConditionKey = removal.putCondition, removal.putConditionKey
finalize = []*filer_pb.ObjectMutation{removal.mutation}
}
resp, err := s3a.routedPut(owner, routeKey, filePath, filePath, entry, cond, "", condAltKey, false, putCondition, putConditionKey, finalize)
if err != nil {
return err
}
if resp.ErrorCode == filer_pb.FilerError_EXISTING_IS_DIRECTORY && !isReservedDirectoryName(name) {
// Other keys are nested under this one, so the object goes onto the
// directory they live in — the same retry mkFile makes off the ring.
entry.MarkPrefixObject()
resp, err = s3a.routedPut(owner, routeKey, filePath, filePath, entry, cond, "", condAltKey, false, putCondition, putConditionKey, finalize)
if err != nil {
return err
}
}
if resp.ErrorCode == filer_pb.FilerError_PRECONDITION_FAILED {
if resp.FailedMutation == nil {
return errObjectPrecondition
}
return errUploadRemoved
}
if resp.Error != "" {
return fmt.Errorf("routed mkfile %s/%s: %s", parentDir, name, resp.Error)
}
return nil
}
// errUploadRemoved is a routed commit rejected because the upload directory's
// precondition found it already deleted, mapped to NoSuchUpload by callers.
var errUploadRemoved = errors.New("upload directory already removed")
// errObjectPrecondition is a routed commit rejected because the request's write
// condition (e.g. If-None-Match: *) found the object slot already taken by a
// racing commit, mapped to PreconditionFailed by callers.
var errObjectPrecondition = errors.New("object precondition failed")
// writeMultipartObject writes a completed multipart object entry, routed to the
// owner when known (so it serializes with concurrent writes to the same key)
// and falling back to a plain mkFile otherwise. removal carries the upload
// precondition and deletion applied in the same transaction as the routed PUT.
// routeKey must be the same key the caller used to resolve owner, so owner
// selection and forwarding stay consistent. cond is the request's write
// condition, enforced on the owner under the transaction lock; condAltKey is
// the .versions directory a conditional check falls back to when the bucket
// tracks versions.
func (s3a *S3ApiServer) writeMultipartObject(owner pb.ServerAddress, routeKey, dir, name string, chunks []*filer_pb.FileChunk, fn func(*filer_pb.Entry), removal *uploadRemovalTxn, cond *filer_pb.WriteCondition, condAltKey string) error {
if owner != "" {
return s3a.routedMkFile(owner, routeKey, dir, name, chunks, fn, removal, cond, condAltKey)
}
return s3a.mkFile(dir, name, chunks, fn)
}
// uploadRemovalTxn carries the PUT precondition and mutation that drop a
// completed upload's directory inside the object's commit transaction. The
// precondition fails the commit when a delete that does not take the object
// lock (abort, lifecycle, s3.clean.uploads) removed the directory first,
// instead of publishing the object over freed chunks.
type uploadRemovalTxn struct {
putCondition *filer_pb.WriteCondition
putConditionKey string
mutation *filer_pb.ObjectMutation
}
// routedUploadRemoval returns the transaction parts that remove a completed
// upload's directory inside the object's commit, after freeing the part
// entries the object does not reference. It is nil when the write is not
// routed and the upload directory still needs post-commit cleanup.
func (s3a *S3ApiServer) routedUploadRemoval(ctx context.Context, owner pb.ServerAddress, uploadDirectory, bucket, uploadId string, completionState *multipartCompletionState) (*uploadRemovalTxn, error) {
if owner == "" {
return nil, nil
}
// Older filers ignore the mutation's upload-exists condition, so during an
// upgrade this gateway-side check keeps the same guard window the
// pre-transaction code had.
if exists, err := s3a.exists(s3a.genUploadsFolder(bucket), uploadId, true); err == nil && !exists {
return nil, errUploadRemoved
}
if err := s3a.deleteUnusedPartEntries(ctx, uploadDirectory, bucket, uploadId, completionState); err != nil {
return nil, err
}
conditionKey, condition := uploadExistsCondition(uploadDirectory)
return &uploadRemovalTxn{
putCondition: condition,
putConditionKey: conditionKey,
mutation: s3a.removeUploadDirMutation(bucket, uploadId),
}, nil
}
func (s3a *S3ApiServer) routedDelete(owner pb.ServerAddress, bucket, object string, cond *filer_pb.WriteCondition) (*filer_pb.ObjectTransactionResponse, error) {
// NewFullPath normalizes a trailing-slash directory-marker key (e.g. "dir/")
// to the entry name "dir", matching deleteUnversionedObjectWithClient.
fullpath := util.NewFullPath(s3a.bucketDir(bucket), object)
dir, name := fullpath.DirAndName()
return s3a.objectTxnOnFiler(owner, &filer_pb.ObjectTransactionRequest{
LockKey: string(fullpath),
RouteKey: s3a.objectRouteKey(bucket, object),
Condition: cond,
Mutations: []*filer_pb.ObjectMutation{{
Type: filer_pb.ObjectMutation_DELETE,
Directory: dir,
Name: name,
IsDeleteData: true,
}},
})
}
// routedSelfCopy updates metadata and attributes together, guarded by the raw
// source snapshot. UpdateEntry routes conditional writes to the owner and checks
// the condition under its path lock, so concurrent content, ACL or retention
// updates cannot be overwritten by a stale self-copy. Chunks are reused in place.
func (s3a *S3ApiServer) routedSelfCopy(ctx context.Context, owner pb.ServerAddress, bucket, object string, current, updated *filer_pb.Entry) error {
fullpath := util.NewFullPath(s3a.bucketDir(bucket), object)
dir, _ := fullpath.DirAndName()
req := &filer_pb.UpdateEntryRequest{
Directory: dir,
Entry: updated,
Condition: &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{{
Kind: filer_pb.WriteCondition_IF_ENTRY_EQUAL,
ExpectedEntry: current,
}}},
}
var conditionErr error
update := func(client filer_pb.SeaweedFilerClient) error {
err := filer_pb.UpdateEntry(ctx, client, req)
switch status.Code(err) {
case codes.FailedPrecondition, codes.NotFound:
// These are authoritative replies from a healthy owner, not a
// transport failure. Do not replay them or mark the owner down.
conditionErr = err
return nil
default:
return err
}
}
var err error
if s3a.filerClient == nil {
err = pb.WithFilerClient(false, 0, owner, s3a.option.GrpcDialOption, update)
} else {
err = s3a.withFilerClientFailover(ctx, owner, false, false, update)
}
if err != nil {
return err
}
return conditionErr
}