Files
seaweedfs/weed/s3api/s3api_handlers.go
T
Chris Lu e6a0534e3b s3api: make ambiguous routed-PUT recovery atomic and stop transaction replay (#11787)
* filer_pb: map PRECONDITION_FAILED to a sentinel error

A conditional CreateEntry that the owner's path lock refuses comes back as
FilerError_PRECONDITION_FAILED; mapping it to ErrPreconditionFailed lets the
S3 gateway tell a rejected precondition apart from a transport failure and
report 412 instead of retrying the write.

* s3api: make the ambiguous-PUT recovery create conditional under the lock

createAfterAmbiguousRoute verified state and then issued an unconditional
create, so a write landing between the lookup and the create was still
clobbered by the stale re-commit (issue #11631). The recovery create now
carries a WriteCondition — IF_NOT_EXISTS for a proven-absent entry,
IF_ENTRY_EQUAL for a directory it may need to write onto — which the owner
filer evaluates under its per-path lock, making the check and the write
atomic. A refused precondition loops back to re-read the state instead of
overwriting whatever won the race.

* s3api: do not fail over a routed transaction past a possibly-sent request

withFilerClientFailover retried the callback on the next filer on any
transport error, so an ObjectTransaction whose response was lost could be
re-applied over a write that committed in between (issue #11631). A gRPC
error cannot distinguish "never sent" from "committed", but channel
readiness can: a non-replayable callback now runs only on a connection that
just reached READY, and failover continues only over channels that never
came up — the one failure mode that provably sent nothing.

* s3api: run ambiguous-route recovery for empty PUTs too

The recovery was gated on a nonempty chunk list, so a zero-byte PUT whose
routed transaction lost its response still fell back to an unconditional
create that could overwrite a newer write and delete its chunks. Chunk-count
mismatch on an empty upload list is enough to distinguish a superseding
entry from this PUT's — there is nothing to save by skipping the check.

* s3api: keep conditional recovery creates off the replay path

The conditional create in the ambiguous-PUT fallback rode the replayable
WithFilerClient failover, so a committed create whose response was lost
could be re-sent to another filer — where a delete in between would let
IF_NOT_EXISTS pass again and resurrect an entry pointing at reclaimed
chunks. Conditional creates now run through the non-replayable failover;
an ambiguous result falls through to confirmCreateLanded, which resolves
the committed entry rather than resending.

* s3api: do not mark a filer unhealthy for an answered request

A reply that reached the callback — including an application-level failure
like a refused write precondition — proves the filer is alive. Checking
received before the health bookkeeping keeps definitive application errors
from tripping the failure circuit or flagging a live routed owner as
unreachable.

* s3api: do not claim an empty stored entry as an ambiguous PUT's own

Two chunkless entries compare equal under sameFileChunks, so an older
empty object satisfies "the route landed" even though it predates this
write — recovery would return success while leaving the request's
Content-Type and metadata unapplied. A landed PUT stores the entry
exactly as built, so require mime/size/md5/content and the extended
attributes to match before claiming it; anything else stays uncertain
and the client retries.

* s3api: prove ambiguous empty PUTs by write id and pin recovery to the owner

- Conditional recovery creates are sent to the routed owner alone instead of failing over: the condition only serializes with routed writes under the owner's per-path lock, and another filer's forwarding is only as correct as its ring view. An unreachable owner now yields an uncertain result rather than a create under a different serialization boundary.
- A proven-absent path no longer satisfies an empty, chunkless PUT: the routed commit may have landed and been deleted, and there are no chunks to verify, so IF_NOT_EXISTS could resurrect a removed object. Recovery returns uncertain for the client to retry.
- Chunkless identity moves from metadata equality to a per-write id (x-seaweedfs-write-id) stamped into Extended: an identical older object can match mime/md5/content but not the id. Entries without this write's id are not claimed, and confirmCreateLanded can now positively confirm a committed empty object instead of always reporting failure.
2026-10-17 21:36:39 -07:00

270 lines
9.8 KiB
Go

package s3api
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"google.golang.org/grpc"
"google.golang.org/grpc/connectivity"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)
var _ = filer_pb.FilerClient(&S3ApiServer{})
// WithFilerClient satisfies filer_pb.FilerClient, whose signature carries no
// context. Callers with a budget to spend call withFilerClient directly.
func (s3a *S3ApiServer) WithFilerClient(streamingMode bool, fn func(filer_pb.SeaweedFilerClient) error) error {
return s3a.withFilerClient(context.Background(), streamingMode, fn)
}
// withFilerClient runs fn with ctx as the budget bounding the calls fn makes. It
// cancels nothing itself -- fn owns its own RPC contexts -- but the walk needs to
// know whose budget ran out: a caller's expiry is not evidence against the filer
// that was answering it.
func (s3a *S3ApiServer) withFilerClient(ctx context.Context, streamingMode bool, fn func(filer_pb.SeaweedFilerClient) error) error {
// Use filerClient for proper connection management and failover
if s3a.filerClient != nil {
return s3a.withFilerClientFailover(ctx, "", streamingMode, true, fn)
}
// Fallback to direct connection if filerClient not initialized
// This should only happen during initialization or testing
return pb.WithGrpcClient(context.Background(), streamingMode, s3a.randomClientId, func(grpcConnection *grpc.ClientConn) error {
client := filer_pb.NewSeaweedFilerClient(grpcConnection)
return fn(client)
}, s3a.getFilerAddress().ToGrpcAddress(), false, s3a.option.GrpcDialOption)
}
// withFilerClientFailover runs fn against preferred (if set) first, then the
// remaining filers (current, then the rest) with healthy ones before unhealthy.
// Failover is for transport errors only: a reached filer's ErrNotFound is
// authoritative (no
// fan-out, no resurrecting a peer's not-yet-replicated tombstone). preferred lets a
// caller route to a key's ring owner for read-after-write; it may be a filer outside
// the static list (the bookkeeping no-ops for untracked addresses). A failover
// updates the current filer; a preferred read does not, as its owner is per-key.
// Failover replays fn from scratch, so it stops once any response has reached
// fn: a replay after that could silently duplicate state fn accumulated (a
// listing that failed mid-stream, say), so the error surfaces instead and the
// caller decides whether a clean-slate retry is safe. ctx is the budget bounding
// fn's own calls; it is only read, to tell a filer's failure apart from the
// caller running out of time.
//
// replayable=false is for requests that must not be re-sent once possibly
// applied (a committed transaction whose response was lost is
// indistinguishable from one that never arrived): such fn runs only on a
// channel that just proved ready, and any error it returns ends the walk —
// moving to the next filer is reserved for channels that never got ready, the
// one failure mode that provably sent nothing.
func (s3a *S3ApiServer) withFilerClientFailover(ctx context.Context, preferred pb.ServerAddress, streamingMode bool, replayable bool, fn func(filer_pb.SeaweedFilerClient) error) error {
currentFiler := s3a.filerClient.GetCurrentFiler()
candidates := make([]pb.ServerAddress, 0, 2+len(s3a.option.Filers))
seen := make(map[pb.ServerAddress]bool)
addCandidate := func(filer pb.ServerAddress) {
if filer == "" || seen[filer] {
return
}
seen[filer] = true
candidates = append(candidates, filer)
}
addCandidate(preferred)
addCandidate(currentFiler)
for _, filer := range s3a.filerClient.GetAllFilers() {
addCandidate(filer)
}
// preferred (the routed owner) is tried first even when health-flagged, so a
// replica's authoritative NotFound can't mask a write that only reached the owner.
// A recently-unreachable filer counts as unhealthy, deferring a dead owner that is
// also the current filer to the tail rather than dialing it before healthy replicas.
var healthy, unhealthy []pb.ServerAddress
for _, filer := range candidates {
if filer == preferred {
continue
}
if s3a.filerClient.ShouldSkipUnhealthyFiler(filer) || s3a.ownerRecentlyUnreachable(filer) {
unhealthy = append(unhealthy, filer)
} else {
healthy = append(healthy, filer)
}
}
ordered := make([]pb.ServerAddress, 0, len(candidates))
if preferred != "" {
ordered = append(ordered, preferred)
}
ordered = append(ordered, healthy...)
ordered = append(ordered, unhealthy...)
var lastErr error
for _, filer := range ordered {
received := false
fnRan := false
// Background, not ctx: WithGrpcClient's context only decides whether an
// error invalidates the shared connection, and that call is fn's to make,
// with the context fn's own RPC ran on.
err := pb.WithGrpcClient(context.Background(), streamingMode, s3a.randomClientId, func(grpcConnection *grpc.ClientConn) error {
if !replayable && !filerConnReady(ctx, grpcConnection) {
return errFilerConnNotReady
}
fnRan = true
return fn(filer_pb.NewSeaweedFilerClient(receiveTrackingConn{ClientConnInterface: grpcConnection, received: &received}))
}, filer.ToGrpcAddress(), false, s3a.option.GrpcDialOption)
if err == nil {
s3a.filerClient.RecordFilerSuccess(filer)
if filer != currentFiler && filer != preferred {
s3a.filerClient.SetCurrentFiler(filer)
glog.V(1).Infof("WithFilerClient: failover from %s to %s succeeded", currentFiler, filer)
}
return nil
}
if errors.Is(err, filer_pb.ErrNotFound) {
return err
}
// The caller's own budget expiring is not evidence against this filer, and
// the next one has no time left to answer either. Recorded as a failure, a
// slow master upstream would flag every filer in the walk, and the three
// that open the circuit take unrelated object reads down with them.
if ctx.Err() != nil {
glog.V(2).Infof("WithFilerClient: giving up on %s, the caller's context ended: %v", filer, err)
return err
}
// fn consumed part of a response; a replay would stack a second copy
// onto whatever it accumulated, so surface the error unwrapped. The filer
// answered, so this also says nothing about its health.
if received {
return err
}
s3a.filerClient.RecordFilerFailure(filer)
// A preferred owner is often outside the static filer list, where the health
// tracking above no-ops; flag it so route-by-key reads skip it briefly.
if filer == preferred {
s3a.markOwnerUnreachable(filer)
}
glog.V(2).Infof("WithFilerClient: filer %s failed: %v", filer, err)
// A non-replayable fn that ran may have committed server-side; only a
// channel that never became ready proves nothing was sent.
if !replayable && fnRan {
return err
}
lastErr = err
}
if lastErr == nil {
lastErr = fmt.Errorf("no filer available")
}
return fmt.Errorf("all filers failed, last error: %w", lastErr)
}
// errFilerConnNotReady marks a candidate whose channel never became usable, so
// the request was provably never sent and the walk may continue.
var errFilerConnNotReady = errors.New("filer connection not ready")
// filerConnReadyWait bounds how long a candidate gets to bring its channel up
// before the walk tries the next filer.
const filerConnReadyWait = 2 * time.Second
func filerConnReady(ctx context.Context, conn *grpc.ClientConn) bool {
readyCtx, cancel := context.WithTimeout(ctx, filerConnReadyWait)
defer cancel()
conn.Connect()
for {
s := conn.GetState()
if s == connectivity.Ready {
return true
}
if s == connectivity.TransientFailure || s == connectivity.Shutdown {
return false
}
if !conn.WaitForStateChange(readyCtx, s) {
return false
}
}
}
// receiveTrackingConn flags *received once a unary reply or a streamed message
// has been handed to the callback, the point past which a failover replay is
// no longer transparent.
type receiveTrackingConn struct {
grpc.ClientConnInterface
received *bool
}
func (c receiveTrackingConn) Invoke(ctx context.Context, method string, args, reply any, opts ...grpc.CallOption) error {
err := c.ClientConnInterface.Invoke(ctx, method, args, reply, opts...)
if err == nil {
*c.received = true
}
return err
}
func (c receiveTrackingConn) NewStream(ctx context.Context, desc *grpc.StreamDesc, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) {
stream, err := c.ClientConnInterface.NewStream(ctx, desc, method, opts...)
if err != nil {
return nil, err
}
return receiveTrackingStream{ClientStream: stream, received: c.received}, nil
}
type receiveTrackingStream struct {
grpc.ClientStream
received *bool
}
func (s receiveTrackingStream) RecvMsg(m any) error {
err := s.ClientStream.RecvMsg(m)
if err == nil {
*s.received = true
}
return err
}
func (s3a *S3ApiServer) AdjustedUrl(location *filer_pb.Location) string {
return location.Url
}
func (s3a *S3ApiServer) GetDataCenter() string {
return s3a.option.DataCenter
}
func writeSuccessResponseXML(w http.ResponseWriter, r *http.Request, response interface{}) {
s3err.WriteXMLResponse(w, r, http.StatusOK, response)
s3err.PostLog(r, http.StatusOK, s3err.ErrNone)
}
func writeSuccessResponseXMLBytes(w http.ResponseWriter, r *http.Request, response []byte) {
s3err.WriteResponse(w, r, http.StatusOK, response, s3err.MimeXML)
s3err.PostLog(r, http.StatusOK, s3err.ErrNone)
}
func writeSuccessResponseEmpty(w http.ResponseWriter, r *http.Request) {
s3err.WriteEmptyResponse(w, r, http.StatusOK)
}
func writeFailureResponse(w http.ResponseWriter, r *http.Request, errCode s3err.ErrorCode) {
s3err.WriteErrorResponse(w, r, errCode)
}
func validateContentMd5(h http.Header) ([]byte, error) {
md5B64, ok := h["Content-Md5"]
if ok {
if md5B64[0] == "" {
return nil, fmt.Errorf("Content-Md5 header set to empty value")
}
return base64.StdEncoding.DecodeString(md5B64[0])
}
return []byte{}, nil
}