Files
seaweedfs/weed/s3api/iceberg/metadata_files_test.go
T
Chris Lu d044839ab2 iceberg: make a table commit a compare-and-swap (#10775)
* iceberg: make a table commit a compare-and-swap

The catalog validated the caller's version token, ran its authorization
checks, and only then wrote the new metadata xattr. Two engines
committing against the same base both passed that check and both wrote,
so the second silently dropped the first one's snapshot. Both also derive
the same v{N}.metadata.json name and the file write overwrote, leaving
the surviving pointer aimed at the loser's metadata - and the loser's
conflict cleanup then deleted the winner's file.

Write the metadata file with an exclusive create and update the xattr
conditionally on the bytes the handler read, the way the maintenance
worker already commits. A writer that lost the race re-reads and retries,
and reports 409 CommitFailedException once out of attempts.

* iceberg: stage a commit under a unique name when the versioned one is taken

Two follow-ups from review of the commit compare-and-swap:

Refusing to overwrite v{N}.metadata.json also refused to get past a file
left behind by a commit that died between staging and updating the
pointer. Every later commit derived the same name, saw the collision, and
reported a conflict, so the table stayed uncommittable until an orphan
sweep removed the file. Stage under v{N}-{uuid} instead: neither writer's
file is overwritten and the catalog pointer still decides who won, which
is how the maintenance worker has always staged its own metadata.
metadataVersionFromLocation learned to read the version back out of that
name.

The conditional update guarded only the metadata attribute while the
write replaced the whole entry, so a policy or tag written in the same
window was silently reverted. Guard every catalog attribute, which turns
that into a conflict the caller retries on fresh state.

* iceberg: give saveMetadataFile the exclusive flag instead of a second name

saveNewMetadataFile, saveMetadataBlobExclusive and uniqueMetadataFileName
were three new names around one existing helper. The flag now rides on
saveMetadataFile and saveMetadataBlob, and the unique-name construction
sits where it is used.

* iceberg: reuse the filer CAS helpers #10773 added, and stage transactions exclusively

#10773 landed mutateEntryExtended, which already writes an entry back under a
whole-entry precondition and retries. Drop the helper this branch added and
route the table commit through it: the check that the metadata is still the
one this request read now lives in the mutation, where it sees current state.

The policy the request was authorized against is asserted too, so an
administrator restricting it mid-commit sends the caller back through
authorization instead of having a stale decision applied. Bucket and
namespace policies live on other entries and a single-entry precondition
cannot cover them.

Multi-table transactions stage their metadata exclusively for the same
reason single-table commits do, and carry the name they landed on into the
pointer flip.
2026-08-16 12:55:42 -07:00

129 lines
4.8 KiB
Go

package iceberg
import (
"context"
"errors"
"path"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"google.golang.org/grpc"
)
// stubFilerClient answers only the calls the metadata writers make, tracking
// which paths exist so exclusive creates can be exercised.
type stubFilerClient struct {
filer_pb.SeaweedFilerClient
entries map[string][]byte
}
func newStubFilerClient() *stubFilerClient {
return &stubFilerClient{entries: make(map[string][]byte)}
}
func (c *stubFilerClient) WithFilerClient(_ bool, fn func(client filer_pb.SeaweedFilerClient) error) error {
return fn(c)
}
func (c *stubFilerClient) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest, _ ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
key := path.Join(req.Directory, req.Name)
if _, ok := c.entries[key]; !ok {
return nil, filer_pb.ErrNotFound
}
return &filer_pb.LookupDirectoryEntryResponse{Entry: &filer_pb.Entry{Name: req.Name}}, nil
}
func (c *stubFilerClient) CreateEntry(_ context.Context, req *filer_pb.CreateEntryRequest, _ ...grpc.CallOption) (*filer_pb.CreateEntryResponse, error) {
key := path.Join(req.Directory, req.Entry.Name)
if _, exists := c.entries[key]; exists && req.OExcl {
return &filer_pb.CreateEntryResponse{
Error: "entry already exists",
ErrorCode: filer_pb.FilerError_ENTRY_ALREADY_EXISTS,
}, nil
}
c.entries[key] = req.Entry.Content
return &filer_pb.CreateEntryResponse{}, nil
}
// Two commits racing off the same base metadata pick the same v{N} file name.
// The loser must be told, not allowed to replace the winner's metadata.
func TestSaveMetadataFileExclusiveRefusesToOverwrite(t *testing.T) {
client := newStubFilerClient()
s := &Server{filerClient: client}
ctx := context.Background()
if err := s.saveMetadataFile(ctx, "bkt", "ns/tbl", "v2.metadata.json", []byte(`{"winner":true}`), true); err != nil {
t.Fatalf("first write failed: %v", err)
}
err := s.saveMetadataFile(ctx, "bkt", "ns/tbl", "v2.metadata.json", []byte(`{"loser":true}`), true)
if !errors.Is(err, filer_pb.ErrEntryAlreadyExists) {
t.Fatalf("second write err = %v, want ErrEntryAlreadyExists", err)
}
stored := string(client.entries[path.Join(metadataDirPath("bkt", "ns/tbl"), "v2.metadata.json")])
if stored != `{"winner":true}` {
t.Errorf("stored metadata = %s, want the first writer's content", stored)
}
}
// A metadata file left behind by an interrupted commit must not wedge the
// table: the next commit stages under a unique name rather than failing or
// overwriting, and the catalog pointer still decides the winner.
func TestStageCommitMetadataFallsBackToAUniqueName(t *testing.T) {
client := newStubFilerClient()
s := &Server{filerClient: client}
ctx := context.Background()
name, location, err := s.stageCommitMetadata(ctx, "bkt", "ns/tbl", "s3://bkt/ns/tbl", "v2.metadata.json", []byte(`{"orphan":true}`))
if err != nil {
t.Fatalf("first stage failed: %v", err)
}
if name != "v2.metadata.json" {
t.Errorf("first stage used %q, want the plain versioned name", name)
}
if location != "s3://bkt/ns/tbl/metadata/v2.metadata.json" {
t.Errorf("location = %q", location)
}
name, location, err = s.stageCommitMetadata(ctx, "bkt", "ns/tbl", "s3://bkt/ns/tbl", "v2.metadata.json", []byte(`{"second":true}`))
if err != nil {
t.Fatalf("second stage failed: %v", err)
}
if !strings.HasPrefix(name, "v2-") || !strings.HasSuffix(name, ".metadata.json") {
t.Errorf("second stage used %q, want a unique v2-* name", name)
}
if location != "s3://bkt/ns/tbl/metadata/"+name {
t.Errorf("location = %q, want it to match the staged name", location)
}
stored := string(client.entries[path.Join(metadataDirPath("bkt", "ns/tbl"), "v2.metadata.json")])
if stored != `{"orphan":true}` {
t.Errorf("the first file was overwritten: %s", stored)
}
if fallback := string(client.entries[path.Join(metadataDirPath("bkt", "ns/tbl"), name)]); fallback != `{"second":true}` {
t.Errorf("fallback file holds %s, want the second writer's metadata", fallback)
}
}
// Paths that legitimately rewrite a file, such as manifest repair, keep the
// overwriting behaviour.
func TestSaveMetadataFileOverwrites(t *testing.T) {
client := newStubFilerClient()
s := &Server{filerClient: client}
ctx := context.Background()
if err := s.saveMetadataFile(ctx, "bkt", "ns/tbl", "v2.metadata.json", []byte(`{"first":true}`), false); err != nil {
t.Fatalf("first write failed: %v", err)
}
if err := s.saveMetadataFile(ctx, "bkt", "ns/tbl", "v2.metadata.json", []byte(`{"second":true}`), false); err != nil {
t.Fatalf("second write failed: %v", err)
}
stored := string(client.entries[path.Join(metadataDirPath("bkt", "ns/tbl"), "v2.metadata.json")])
if stored != `{"second":true}` {
t.Errorf("stored metadata = %s, want the second write", stored)
}
}