mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-06 12:07:51 +01:00
* s3api: name the check that a completion already committed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: answer success when a failed completion already committed A finalize can fail after its commit: the filer stored the object and the call still reported an error. The client then got a 500 with an unknown outcome. When the failure may have left this attempt's object behind, check the key the way a retry would and answer success with the committed result when it holds this upload's object. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: keep part data when the commit found is another attempt's The object carrying this upload ID can be a concurrent attempt's commit of the same upload, and its part selection can include entries this attempt counts as unused. Free those parts' data only when the committed chunks are this attempt's own; otherwise drop their metadata only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: test the committed-completion read on versioned buckets Cover the latest-version read (version id and checksum in the answer), a suspended bucket whose delete marker is still current, and that the upload directory is removed without its data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: test versioned recovery through the completion handler Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
317 lines
14 KiB
Go
317 lines
14 KiB
Go
package s3api
|
|
|
|
import (
|
|
"context"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/service/s3"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
// memCompleteFiler keeps entries in memory. A failing CreateEntry still
|
|
// stores what storedOnFailure returns, and a failing ObjectTransaction still
|
|
// applies its mutations, as a filer whose commit landed before the call failed.
|
|
type memCompleteFiler struct {
|
|
filer_pb.UnimplementedSeaweedFilerServer
|
|
mu sync.Mutex
|
|
entries map[string]map[string]*filer_pb.Entry
|
|
createFailure error
|
|
storedOnFailure func(*filer_pb.Entry) *filer_pb.Entry
|
|
deletedData map[string]bool
|
|
}
|
|
|
|
func (f *memCompleteFiler) put(dir string, entry *filer_pb.Entry) {
|
|
if f.entries[dir] == nil {
|
|
f.entries[dir] = map[string]*filer_pb.Entry{}
|
|
}
|
|
f.entries[dir][entry.Name] = entry
|
|
}
|
|
|
|
func (f *memCompleteFiler) LookupDirectoryEntry(ctx context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if entry := f.entries[req.Directory][req.Name]; entry != nil {
|
|
return &filer_pb.LookupDirectoryEntryResponse{Entry: entry}, nil
|
|
}
|
|
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
|
|
}
|
|
|
|
func (f *memCompleteFiler) ListEntries(req *filer_pb.ListEntriesRequest, stream filer_pb.SeaweedFiler_ListEntriesServer) error {
|
|
f.mu.Lock()
|
|
var entries []*filer_pb.Entry
|
|
for _, entry := range f.entries[req.Directory] {
|
|
entries = append(entries, entry)
|
|
}
|
|
f.mu.Unlock()
|
|
for _, entry := range entries {
|
|
if err := stream.Send(&filer_pb.ListEntriesResponse{Entry: entry}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *memCompleteFiler) CreateEntry(ctx context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.createFailure == nil {
|
|
f.put(req.Directory, req.Entry)
|
|
return &filer_pb.CreateEntryResponse{}, nil
|
|
}
|
|
if f.storedOnFailure != nil {
|
|
if entry := f.storedOnFailure(req.Entry); entry != nil {
|
|
f.put(req.Directory, entry)
|
|
}
|
|
}
|
|
return nil, f.createFailure
|
|
}
|
|
|
|
func (f *memCompleteFiler) DeleteEntry(ctx context.Context, req *filer_pb.DeleteEntryRequest) (*filer_pb.DeleteEntryResponse, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.remove(req.Directory, req.Name, req.IsDeleteData)
|
|
return &filer_pb.DeleteEntryResponse{}, nil
|
|
}
|
|
|
|
func (f *memCompleteFiler) remove(dir, name string, isDeleteData bool) {
|
|
delete(f.entries[dir], name)
|
|
if f.deletedData == nil {
|
|
f.deletedData = map[string]bool{}
|
|
}
|
|
f.deletedData[name] = isDeleteData
|
|
}
|
|
|
|
// ObjectTransaction applies PUT and DELETE as given and points a recompute at
|
|
// the transaction's PUT, the newest version.
|
|
func (f *memCompleteFiler) ObjectTransaction(ctx context.Context, req *filer_pb.ObjectTransactionRequest) (*filer_pb.ObjectTransactionResponse, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var put *filer_pb.Entry
|
|
for _, m := range req.Mutations {
|
|
switch m.Type {
|
|
case filer_pb.ObjectMutation_PUT:
|
|
put = m.Entry
|
|
f.put(m.Directory, m.Entry)
|
|
case filer_pb.ObjectMutation_DELETE:
|
|
f.remove(m.Directory, m.Name, m.IsDeleteData)
|
|
case filer_pb.ObjectMutation_RECOMPUTE_LATEST:
|
|
f.put(m.Directory, &filer_pb.Entry{Name: m.Name, IsDirectory: true, Extended: map[string][]byte{
|
|
s3_constants.ExtLatestVersionIdKey: put.Extended[s3_constants.ExtVersionIdKey],
|
|
s3_constants.ExtLatestVersionFileNameKey: []byte(put.Name),
|
|
}})
|
|
}
|
|
}
|
|
if f.createFailure != nil {
|
|
return nil, f.createFailure
|
|
}
|
|
return &filer_pb.ObjectTransactionResponse{}, nil
|
|
}
|
|
|
|
func newCompleteTestServer(t *testing.T, f *memCompleteFiler) *S3ApiServer {
|
|
t.Helper()
|
|
s3a := newFailoverTestServer(t, startFakeFiler(t, f))
|
|
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
|
|
s3a.bucketConfigCache.Set("b", &BucketConfig{Name: "b"})
|
|
uploadsDir := s3a.genUploadsFolder("b")
|
|
f.put(uploadsDir, &filer_pb.Entry{
|
|
Name: "u1",
|
|
IsDirectory: true,
|
|
Extended: map[string][]byte{s3_constants.ExtMultipartObjectKey: []byte("a.bin")},
|
|
})
|
|
f.put(uploadsDir+"/u1", &filer_pb.Entry{
|
|
Name: "0001.part",
|
|
Attributes: &filer_pb.FuseAttributes{FileSize: 11},
|
|
Chunks: []*filer_pb.FileChunk{{FileId: "3,01637037d6", Size: 11}},
|
|
Extended: map[string][]byte{s3_constants.ExtETagKey: []byte("5eb63bbbe01eeed093cb22bb8f5acdc3")},
|
|
})
|
|
// An earlier upload of part 1 the completion does not use.
|
|
f.put(uploadsDir+"/u1", &filer_pb.Entry{
|
|
Name: "0001_old.part",
|
|
Attributes: &filer_pb.FuseAttributes{FileSize: 11},
|
|
Chunks: []*filer_pb.FileChunk{{FileId: "3,02637037d6", Size: 11}},
|
|
Extended: map[string][]byte{s3_constants.ExtETagKey: []byte("0f343b0931126a20f133d67c2b018a3b")},
|
|
})
|
|
return s3a
|
|
}
|
|
|
|
func completeU1(s3a *S3ApiServer) (*CompleteMultipartUploadResult, s3err.ErrorCode) {
|
|
input := &s3.CompleteMultipartUploadInput{Bucket: aws.String("b"), Key: aws.String("a.bin"), UploadId: aws.String("u1")}
|
|
parts := &CompleteMultipartUpload{Parts: []CompletedPart{{PartNumber: 1, ETag: `"5eb63bbbe01eeed093cb22bb8f5acdc3"`}}}
|
|
return s3a.completeMultipartUpload(httptest.NewRequest("POST", "/b/a.bin?uploadId=u1", nil), input, parts)
|
|
}
|
|
|
|
// A completion whose commit landed but whose call failed answers success, as
|
|
// its retry would, instead of a 500 the client cannot interpret.
|
|
func TestCompleteMultipartUploadCommittedDespiteFailure(t *testing.T) {
|
|
f := &memCompleteFiler{entries: map[string]map[string]*filer_pb.Entry{}, createFailure: status.Error(codes.Internal, "response lost after commit"),
|
|
storedOnFailure: func(entry *filer_pb.Entry) *filer_pb.Entry { return entry }}
|
|
s3a := newCompleteTestServer(t, f)
|
|
|
|
output, code := completeU1(s3a)
|
|
if code != s3err.ErrNone || output == nil {
|
|
t.Fatalf("code = %v, want success for the committed object", s3err.GetAPIError(code).Code)
|
|
}
|
|
entry := f.entries[s3a.bucketDir("b")]["a.bin"]
|
|
if entry == nil || string(entry.Extended[s3_constants.SeaweedFSUploadId]) != "u1" {
|
|
t.Fatalf("object entry = %v, want the committed upload u1", entry)
|
|
}
|
|
if got, want := aws.StringValue(output.ETag), getEtagFromEntry(entry); got != want {
|
|
t.Errorf("ETag = %s, want the committed object's %s", got, want)
|
|
}
|
|
if _, ok := f.entries[s3a.genUploadsFolder("b")]["u1"]; ok {
|
|
t.Error("the upload directory survived the completed upload")
|
|
}
|
|
if deleteData, ok := f.deletedData["0001_old.part"]; !ok || !deleteData {
|
|
t.Errorf("unused part deleted = %v, with data = %v; want its data freed as on success", ok, deleteData)
|
|
}
|
|
if deleteData, ok := f.deletedData["u1"]; !ok || deleteData {
|
|
t.Errorf("upload directory deleted = %v, with data = %v; want metadata only, its parts are the object's", ok, deleteData)
|
|
}
|
|
}
|
|
|
|
func newVersionedCommitTestServer(t *testing.T, versioning string) (*S3ApiServer, *memCompleteFiler) {
|
|
t.Helper()
|
|
f := &memCompleteFiler{entries: map[string]map[string]*filer_pb.Entry{}}
|
|
s3a := newFailoverTestServer(t, startFakeFiler(t, f))
|
|
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
|
|
s3a.bucketConfigCache.Set("b", &BucketConfig{Name: "b", Versioning: versioning})
|
|
return s3a, f
|
|
}
|
|
|
|
func pointLatestVersion(f *memCompleteFiler, s3a *S3ApiServer, versionId string, version *filer_pb.Entry) {
|
|
f.put(s3a.bucketDir("b"), &filer_pb.Entry{Name: "a.bin" + s3_constants.VersionsFolder, IsDirectory: true, Extended: map[string][]byte{
|
|
s3_constants.ExtLatestVersionIdKey: []byte(versionId),
|
|
s3_constants.ExtLatestVersionFileNameKey: []byte(version.Name),
|
|
}})
|
|
f.put(s3a.bucketDir("b")+"/a.bin"+s3_constants.VersionsFolder, version)
|
|
}
|
|
|
|
// On a versioned bucket the committed version is read through the latest
|
|
// pointer, and the answer carries its version id and checksum.
|
|
func TestCommittedCompletionReadsTheLatestVersion(t *testing.T) {
|
|
s3a, f := newVersionedCommitTestServer(t, s3_constants.VersioningEnabled)
|
|
pointLatestVersion(f, s3a, "3a1b", &filer_pb.Entry{Name: "v_3a1b", Attributes: &filer_pb.FuseAttributes{FileSize: 11}, Extended: map[string][]byte{
|
|
s3_constants.SeaweedFSUploadId: []byte("u1"),
|
|
s3_constants.ExtVersionIdKey: []byte("3a1b"),
|
|
s3_constants.ExtETagKey: []byte("8a6d5f0e5c8f7b2f4e1d0c9b8a7f6e5d-1"),
|
|
s3_constants.ExtChecksumAlgorithm: []byte(s3_constants.AmzChecksumCRC32C),
|
|
s3_constants.ExtChecksumValue: []byte("fx4FQw==-1"),
|
|
s3_constants.ExtChecksumType: []byte(s3_constants.ChecksumTypeComposite),
|
|
}})
|
|
|
|
input := &s3.CompleteMultipartUploadInput{Bucket: aws.String("b"), Key: aws.String("a.bin"), UploadId: aws.String("u1")}
|
|
output, entry := s3a.committedCompletion(httptest.NewRequest("POST", "/b/a.bin?uploadId=u1", nil), input)
|
|
if output == nil || entry == nil {
|
|
t.Fatal("did not find the committed version")
|
|
}
|
|
if got := aws.StringValue(output.VersionId); got != "3a1b" {
|
|
t.Errorf("VersionId = %q, want 3a1b", got)
|
|
}
|
|
if output.ChecksumCRC32C != "fx4FQw==-1" || output.ChecksumType != s3_constants.ChecksumTypeComposite {
|
|
t.Errorf("checksum = %q %q, want the committed version's", output.ChecksumCRC32C, output.ChecksumType)
|
|
}
|
|
}
|
|
|
|
// A versioned finalize whose transaction landed but whose call failed answers
|
|
// with the committed version's id and checksum.
|
|
func TestCompleteMultipartUploadVersionedCommittedDespiteFailure(t *testing.T) {
|
|
f := &memCompleteFiler{entries: map[string]map[string]*filer_pb.Entry{}, createFailure: status.Error(codes.Internal, "response lost after commit")}
|
|
s3a := newCompleteTestServer(t, f)
|
|
s3a.bucketConfigCache.Set("b", &BucketConfig{Name: "b", Versioning: s3_constants.VersioningEnabled})
|
|
uploadsDir := s3a.genUploadsFolder("b")
|
|
f.entries[uploadsDir]["u1"].Extended[s3_constants.ExtChecksumAlgorithm] = []byte(s3_constants.AmzChecksumCRC32C)
|
|
part := f.entries[uploadsDir+"/u1"]["0001.part"]
|
|
part.Extended[s3_constants.ExtChecksumAlgorithm] = []byte(s3_constants.AmzChecksumCRC32C)
|
|
part.Extended[s3_constants.ExtChecksumValue] = []byte("yZRlqg==")
|
|
|
|
input := &s3.CompleteMultipartUploadInput{Bucket: aws.String("b"), Key: aws.String("a.bin"), UploadId: aws.String("u1")}
|
|
parts := &CompleteMultipartUpload{Parts: []CompletedPart{{PartNumber: 1, ETag: `"5eb63bbbe01eeed093cb22bb8f5acdc3"`, ChecksumResult: ChecksumResult{ChecksumCRC32C: "yZRlqg=="}}}}
|
|
output, code := s3a.completeMultipartUpload(httptest.NewRequest("POST", "/b/a.bin?uploadId=u1", nil), input, parts)
|
|
if code != s3err.ErrNone || output == nil {
|
|
t.Fatalf("code = %v, want success for the committed version", s3err.GetAPIError(code).Code)
|
|
}
|
|
pointer := f.entries[s3a.bucketDir("b")]["a.bin"+s3_constants.VersionsFolder]
|
|
version := f.entries[s3a.bucketDir("b")+"/a.bin"+s3_constants.VersionsFolder][string(pointer.Extended[s3_constants.ExtLatestVersionFileNameKey])]
|
|
if version == nil {
|
|
t.Fatal("the transaction did not store the version")
|
|
}
|
|
if got, want := aws.StringValue(output.VersionId), string(version.Extended[s3_constants.ExtVersionIdKey]); got == "" || got != want {
|
|
t.Errorf("VersionId = %q, want the committed version's %q", got, want)
|
|
}
|
|
if got, want := output.ChecksumCRC32C, string(version.Extended[s3_constants.ExtChecksumValue]); got == "" || got != want {
|
|
t.Errorf("ChecksumCRC32C = %q, want the committed version's %q", got, want)
|
|
}
|
|
if output.ChecksumType != s3_constants.ChecksumTypeComposite {
|
|
t.Errorf("ChecksumType = %q, want %q", output.ChecksumType, s3_constants.ChecksumTypeComposite)
|
|
}
|
|
}
|
|
|
|
// Suspended versioning whose null write left the delete marker current reads
|
|
// as deleted, so the upload's object behind it is not a completed answer.
|
|
func TestCommittedCompletionBehindADeleteMarker(t *testing.T) {
|
|
s3a, f := newVersionedCommitTestServer(t, s3_constants.VersioningSuspended)
|
|
f.put(s3a.bucketDir("b"), &filer_pb.Entry{Name: "a.bin", Attributes: &filer_pb.FuseAttributes{FileSize: 11}, Extended: map[string][]byte{
|
|
s3_constants.SeaweedFSUploadId: []byte("u1"),
|
|
s3_constants.ExtVersionIdKey: []byte("null"),
|
|
}})
|
|
pointLatestVersion(f, s3a, "7c2d", &filer_pb.Entry{Name: "v_7c2d", Attributes: &filer_pb.FuseAttributes{}, Extended: map[string][]byte{
|
|
s3_constants.ExtVersionIdKey: []byte("7c2d"),
|
|
s3_constants.ExtDeleteMarkerKey: []byte("true"),
|
|
}})
|
|
|
|
input := &s3.CompleteMultipartUploadInput{Bucket: aws.String("b"), Key: aws.String("a.bin"), UploadId: aws.String("u1")}
|
|
if output, _ := s3a.committedCompletion(httptest.NewRequest("POST", "/b/a.bin?uploadId=u1", nil), input); output != nil {
|
|
t.Fatalf("answered %+v for a key that reads as deleted", output)
|
|
}
|
|
}
|
|
|
|
// The object found may be another attempt's commit of the same upload, whose
|
|
// parts this attempt can count as unused: their data must stay.
|
|
func TestCompleteMultipartUploadKeepsPartDataUnderAnotherAttemptsCommit(t *testing.T) {
|
|
other := &filer_pb.Entry{
|
|
Name: "a.bin",
|
|
Attributes: &filer_pb.FuseAttributes{FileSize: 11},
|
|
Chunks: []*filer_pb.FileChunk{{FileId: "3,02637037d6", Size: 11}},
|
|
Extended: map[string][]byte{
|
|
s3_constants.SeaweedFSUploadId: []byte("u1"),
|
|
s3_constants.ExtETagKey: []byte("8a6d5f0e5c8f7b2f4e1d0c9b8a7f6e5d-1"),
|
|
},
|
|
}
|
|
f := &memCompleteFiler{entries: map[string]map[string]*filer_pb.Entry{}, createFailure: status.Error(codes.Internal, "lost the race"),
|
|
storedOnFailure: func(*filer_pb.Entry) *filer_pb.Entry { return other }}
|
|
s3a := newCompleteTestServer(t, f)
|
|
|
|
output, code := completeU1(s3a)
|
|
if code != s3err.ErrNone || output == nil {
|
|
t.Fatalf("code = %v, want success for the upload's committed object", s3err.GetAPIError(code).Code)
|
|
}
|
|
if got, want := aws.StringValue(output.ETag), getEtagFromEntry(other); got != want {
|
|
t.Errorf("ETag = %s, want the committed object's %s", got, want)
|
|
}
|
|
if deleteData, ok := f.deletedData["0001_old.part"]; ok && deleteData {
|
|
t.Error("freed the data of a part the committed object references")
|
|
}
|
|
}
|
|
|
|
// A failure that left another object at the key still reports the failure.
|
|
func TestCompleteMultipartUploadFailureOverAnotherObject(t *testing.T) {
|
|
f := &memCompleteFiler{entries: map[string]map[string]*filer_pb.Entry{}, createFailure: status.Error(codes.Internal, "store failed")}
|
|
s3a := newCompleteTestServer(t, f)
|
|
f.put(s3a.bucketDir("b"), &filer_pb.Entry{Name: "a.bin", Attributes: &filer_pb.FuseAttributes{}, Extended: map[string][]byte{s3_constants.SeaweedFSUploadId: []byte("u0")}})
|
|
|
|
if output, code := completeU1(s3a); code != s3err.ErrInternalError || output != nil {
|
|
t.Fatalf("code = %v, output = %v, want InternalError", s3err.GetAPIError(code).Code, output)
|
|
}
|
|
if _, ok := f.entries[s3a.genUploadsFolder("b")]["u1"]; !ok {
|
|
t.Error("the failed completion removed its upload directory")
|
|
}
|
|
}
|