Files
seaweedfs/weed/s3api/s3tables/filer_ops_race_test.go
T
Chris LuandClaude Opus 5.5 aa901b7ac7 s3tables, iceberg: create missing directories exclusively so a concurrent create keeps its record (#12600)
* s3tables: create a missing directory exclusively in ensureDirectory

ensureDirectory looked the path up and, when it was missing, created it with a
plain CreateEntry. A table create that lost the lookup race then replaced the
directory another create had made, claimed and confirmed since, erasing that
table's record, so a third create could claim the name and win as well.

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

* iceberg: create missing metadata parent directories exclusively

saveMetadataBlob created each missing directory on the way to the metadata
directory with a plain CreateEntry after a lookup. A table directory another
create had made and written its record onto since the lookup was replaced by a
bare directory, erasing that record.

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

* iceberg: create missing stage-create marker directories exclusively

The marker path starts at the table bucket directory, which carries the bucket
record; a plain create after a missed lookup replaces a directory another writer
made in between, with whatever it carries.

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

---------

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

103 lines
4.0 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]))
}
func TestEnsureDirectoryKeepsADirectoryCreatedSinceTheLookup(t *testing.T) {
fs := s3tablestest.Start(t)
tablePath := GetTablePath("bucket", "ns", "orders")
dir, name := splitPath(tablePath)
client := &competingCreateClient{SeaweedFilerClient: fs.Client, f: fs, dir: dir, name: name, plant: true}
require.NoError(t, NewS3TablesHandler().ensureDirectory(context.Background(), client, tablePath))
survivor := fs.Get(dir, name)
require.NotNil(t, survivor)
assert.Equal(t, `{"OwnerAccountID":"competitor"}`, string(survivor.Extended[ExtendedKeyMetadata]))
}