Files
seaweedfs/weed/s3api/s3tables/filer_ops_test.go
T
a94edebf00 fix(s3tables): keep the data under a deleted namespace (#12276)
* fix(s3tables): keep the data under a deleted namespace

A rename leaves the source table directory holding data the renamed table
still points at, and DeleteNamespace removed it recursively. The delete now
keeps the directory, minus its catalog record, when files remain under it;
CreateNamespace can take it back.

* fix(s3tables): keep hidden-directory data, report name takeover as 409

- DeleteNamespace also looks for files under hidden directories, since a
  table location may sit under one.
- CreateNamespace answers 409 when a file takes the name during the
  take-back of a kept directory.

* s3tables: a vanished leftover holds nothing to keep

A bare directory removed between the namespace listing and the walk
made dirHoldsFiles answer its ListEntries NotFound, and the delete
reported that miss as a 404 or 500 on a live namespace. Skip the gone
directory instead: whatever it held is already gone, so it cannot keep
the namespace.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3tables: a delete's gone entry is a conflict, not a success

The filer answers a precondition delete on an entry that is already gone with FailedPrecondition carrying the not-found text, which DoRemoveRequest reads as a completed delete -- DeleteNamespace answered 200 for a delete that never ran. deleteDirectoryIfMatches now calls DeleteEntry directly, keeping the FailedPrecondition-to-conflict mapping and refusing a response-level error. keepAsLeftover likewise reports a directory removed since the scan as ErrConcurrentUpdate, so the handler answers 409 rather than 500.

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-10-31 05:27:58 -07:00

129 lines
5.0 KiB
Go

package s3tables
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// deleteEntryStub answers DeleteEntry the way the filer does: a rejected delete
// comes back as a nil transport error with the reason in the response.
type deleteEntryStub struct {
filer_pb.SeaweedFilerClient
resp *filer_pb.DeleteEntryResponse
err error
req *filer_pb.DeleteEntryRequest
}
func (s *deleteEntryStub) DeleteEntry(_ context.Context, req *filer_pb.DeleteEntryRequest, _ ...grpc.CallOption) (*filer_pb.DeleteEntryResponse, error) {
s.req = req
return s.resp, s.err
}
func TestDeleteDirectoryReportsRejectedDelete(t *testing.T) {
stub := &deleteEntryStub{resp: &filer_pb.DeleteEntryResponse{Error: "fail to delete non-empty folder"}}
err := (&S3TablesHandler{}).deleteDirectory(context.Background(), stub, "/buckets/b/ns/t")
require.Error(t, err, "a delete the filer rejected must not be reported as success")
assert.Contains(t, err.Error(), "fail to delete non-empty folder")
}
func TestDeleteDirectoryToleratesMissingEntry(t *testing.T) {
t.Run("reported in the response", func(t *testing.T) {
stub := &deleteEntryStub{resp: &filer_pb.DeleteEntryResponse{Error: filer_pb.ErrNotFound.Error()}}
assert.NoError(t, (&S3TablesHandler{}).deleteDirectory(context.Background(), stub, "/buckets/b/ns/t"))
})
t.Run("reported as a transport error", func(t *testing.T) {
stub := &deleteEntryStub{err: status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())}
assert.NoError(t, (&S3TablesHandler{}).deleteDirectory(context.Background(), stub, "/buckets/b/ns/t"))
})
}
func TestDeleteDirectoryPropagatesTransportError(t *testing.T) {
stub := &deleteEntryStub{err: errors.New("filer unreachable")}
err := (&S3TablesHandler{}).deleteDirectory(context.Background(), stub, "/buckets/b/ns/t")
require.Error(t, err)
assert.Contains(t, err.Error(), "filer unreachable")
}
// deleteDirectory drops a whole subtree, so the delete has to stay recursive and
// take the data with it.
func TestDeleteDirectoryDeletesTheSubtree(t *testing.T) {
stub := &deleteEntryStub{resp: &filer_pb.DeleteEntryResponse{}}
require.NoError(t, (&S3TablesHandler{}).deleteDirectory(context.Background(), stub, "/buckets/b/ns/t"))
require.NotNil(t, stub.req)
assert.Equal(t, "/buckets/b/ns", stub.req.Directory)
assert.Equal(t, "t", stub.req.Name)
assert.True(t, stub.req.IsDeleteData)
assert.True(t, stub.req.IsRecursive)
assert.True(t, stub.req.IgnoreRecursiveError)
}
// The filer answers a precondition delete on an entry that is already gone
// with FailedPrecondition carrying the not-found text. DoRemoveRequest reads
// any error containing that text as a completed delete, so the conditional
// delete must keep the conflict itself: a name that changed under the delete
// is a concurrent update, not a success.
func TestDeleteDirectoryIfMatchesReportsAVanishedEntry(t *testing.T) {
stub := &deleteEntryStub{err: status.Error(codes.FailedPrecondition,
"extended attribute precondition /buckets/b/ns: "+filer_pb.ErrNotFound.Error())}
err := (&S3TablesHandler{}).deleteDirectoryIfMatches(context.Background(), stub, "/buckets/b/ns",
&filer_pb.Entry{Extended: map[string][]byte{ExtendedKeyMetadata: []byte("{}")}})
assert.ErrorIs(t, err, ErrConcurrentUpdate)
}
// A precondition delete the filer refuses inside the response is not a
// success either.
func TestDeleteDirectoryIfMatchesReportsResponseError(t *testing.T) {
stub := &deleteEntryStub{resp: &filer_pb.DeleteEntryResponse{Error: "fail to delete non-empty folder"}}
err := (&S3TablesHandler{}).deleteDirectoryIfMatches(context.Background(), stub, "/buckets/b/ns", &filer_pb.Entry{})
require.Error(t, err)
assert.Contains(t, err.Error(), "fail to delete non-empty folder")
}
// The bucket is the directory, so DeleteTableBucket must not answer success
// when the filer refused to remove it.
func TestDeleteTableBucketReportsRejectedDirectoryDelete(t *testing.T) {
fs := s3tablestest.Start(t)
bucketMeta, err := json.Marshal(tableBucketMetadata{Name: renameTestBucket, OwnerAccountID: DefaultAccountID})
require.NoError(t, err)
fs.Put(TablesPath, renameTestBucket, map[string][]byte{
ExtendedKeyTableBucket: []byte("{}"),
ExtendedKeyMetadata: bucketMeta,
})
bucketPath := GetTableBucketPath(renameTestBucket)
fs.RejectDelete = func(dir, name string) string {
if dir+"/"+name == bucketPath {
return "fail to delete non-empty folder"
}
return ""
}
m := NewManager()
m.SetTrusted(true)
err = m.Execute(context.Background(), NewManagerClient(fs.Client), "DeleteTableBucket",
&DeleteTableBucketRequest{TableBucketARN: mustBucketARN(t)}, nil, "")
require.Error(t, err, "a bucket the filer refused to delete must not be reported as deleted")
assert.NotNil(t, fs.Get(TablesPath, renameTestBucket), "the bucket is still there")
}