Files
seaweedfs/weed/s3api/s3tables/filer_ops_race_test.go
T
Chris Lu a724f4296c s3, s3tables: create buckets and namespaces exclusively (#11872)
* filer_pb: add MkdirExclusive

A taken name leaves the callback as a result, not an error, so filer
failover does not count a lost race as a filer failure.

* s3tables: create the entry with its attributes in one exclusive create

CreateTableBucket and CreateNamespace checked that the name was free and
then wrote the entry in several calls, so a second create in the window
replaced the first caller's entry including its owner. The create now
carries the marker, metadata and tags in one O_EXCL CreateEntry that
answers ErrEntryAlreadyExists with the same 409.

* s3: PutBucket uses an exclusive mkdir

The same window let a plain PutBucket replace a table bucket created
concurrently. The already-exists branch now answers from the entry itself,
since the registry just cached the name as absent, and the admin
auto-create path is exclusive too.

* s3: read the winning entry when a create loses the race

The concurrent-create branch called existingBucketError on a name the
registry had just cached as absent, answering NoSuchBucket, and the
auto-create fallback could assign an S3 owner to a table bucket it met
there. The negative cache is cleared before the existing-bucket error is
built, and a table bucket keeps its owner-less shape.

* s3: fail an auto-create that met a table bucket

A table bucket carries no S3 owner and must not gain one, and the upload
that auto-create ran for must not continue into it either.

* s3: fail an auto-create when the winning entry cannot be read

An unreadable existing entry fell through to success, so the write
continued into an unvalidated entry, possibly a table bucket.
2026-10-20 19:01:08 -07:00

90 lines
3.4 KiB
Go

package s3tables
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"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"
)
// competingCreateClient lands a foreign entry where the handler's existence
// check just answered "not there": the create that follows must lose to it
// rather than replace it, the way a second caller's create wins the window in
// a check-then-create race.
type competingCreateClient struct {
filer_pb.SeaweedFilerClient
f *s3tablestest.MemFiler
dir string
name string
plant bool
}
func (c *competingCreateClient) LookupDirectoryEntry(ctx context.Context, req *filer_pb.LookupDirectoryEntryRequest, opts ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
resp, err := c.SeaweedFilerClient.LookupDirectoryEntry(ctx, req, opts...)
if c.plant && err != nil && req.Directory == c.dir && req.Name == c.name {
c.plant = false
c.f.Put(c.dir, c.name, map[string][]byte{
ExtendedKeyTableBucket: []byte("true"),
ExtendedKeyMetadata: []byte(`{"OwnerAccountID":"competitor"}`),
})
}
return resp, err
}
func runHandler(t *testing.T, m *Manager, client filer_pb.SeaweedFilerClient, operation string, input interface{}) (int, S3TablesError) {
t.Helper()
body, err := json.Marshal(input)
require.NoError(t, err)
req, err := newManagerRequest(context.Background(), operation, body, "admin")
require.NoError(t, err)
recorder := httptest.NewRecorder()
m.handler.HandleRequest(recorder, req, NewManagerClient(client))
result := recorder.Result()
defer result.Body.Close()
var response S3TablesError
json.NewDecoder(result.Body).Decode(&response)
return result.StatusCode, response
}
func TestCreateTableBucketLostRaceKeepsTheFirstCreate(t *testing.T) {
fs := s3tablestest.Start(t)
m := NewManager()
client := &competingCreateClient{SeaweedFilerClient: fs.Client, f: fs, dir: TablesPath, name: "bucket", plant: true}
status, resp := runHandler(t, m, client, "CreateTableBucket", &CreateTableBucketRequest{Name: "bucket"})
assert.Equal(t, http.StatusConflict, status)
assert.Equal(t, ErrCodeBucketAlreadyExists, resp.Type)
survivor := fs.Get(TablesPath, "bucket")
require.NotNil(t, survivor)
assert.Equal(t, `{"OwnerAccountID":"competitor"}`, string(survivor.Extended[ExtendedKeyMetadata]))
}
func TestCreateNamespaceLostRaceKeepsTheFirstCreate(t *testing.T) {
fs := s3tablestest.Start(t)
m := NewManager()
bucketMetadata, _ := json.Marshal(&tableBucketMetadata{Name: "bucket", OwnerAccountID: DefaultAccountID})
fs.Put(TablesPath, "bucket", map[string][]byte{ExtendedKeyTableBucket: []byte("true"), ExtendedKeyMetadata: bucketMetadata})
arn, err := BuildBucketARN(DefaultRegion, DefaultAccountID, "bucket")
require.NoError(t, err)
client := &competingCreateClient{SeaweedFilerClient: fs.Client, f: fs, dir: GetTableBucketPath("bucket"), name: "ns", plant: true}
status, resp := runHandler(t, m, client, "CreateNamespace", &CreateNamespaceRequest{
TableBucketARN: arn,
Namespace: []string{"ns"},
})
assert.Equal(t, http.StatusConflict, status)
assert.Equal(t, ErrCodeNamespaceAlreadyExists, resp.Type)
survivor := fs.Get(GetTableBucketPath("bucket"), "ns")
require.NotNil(t, survivor)
assert.Equal(t, `{"OwnerAccountID":"competitor"}`, string(survivor.Extended[ExtendedKeyMetadata]))
}