From 477e42fcc163c4a6d999c556de8e7e30d7ea428e Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Thu, 12 Nov 2026 20:19:40 -0800 Subject: [PATCH] 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 --- .../iceberg_create_table_conflict_test.go | 20 +++++++++++++++++++ weed/s3api/iceberg/stage_create.go | 1 + 2 files changed, 21 insertions(+) diff --git a/weed/s3api/iceberg/iceberg_create_table_conflict_test.go b/weed/s3api/iceberg/iceberg_create_table_conflict_test.go index 7b3c12db54..12e5122688 100644 --- a/weed/s3api/iceberg/iceberg_create_table_conflict_test.go +++ b/weed/s3api/iceberg/iceberg_create_table_conflict_test.go @@ -960,6 +960,26 @@ func TestSaveMetadataBlobKeepsATableDirectoryCreatedSinceTheLookup(t *testing.T) } } +// A stage-create marker write keeps the attributes of a directory on its path +// that another writer created after the marker's lookup missed it. +func TestStageCreateMarkerKeepsADirectoryCreatedSinceTheLookup(t *testing.T) { + e := newConflictEnv(t) + bucketDir := path.Join(s3tables.TablesPath, conflictBucket) + attrs := map[string][]byte{"probe": []byte("kept")} + e.hooks.beforeCreate = func(req *filer_pb.CreateEntryRequest) { + if req.Directory == bucketDir && req.Entry.Name == stageCreateMarkerDirName { + e.hooks.beforeCreate = nil + e.filer.Put(bucketDir, stageCreateMarkerDirName, attrs) + } + } + if err := e.server.writeStageCreateMarker(t.Context(), conflictBucket, []string{conflictNamespace}, conflictTable, uuid.New(), conflictLocation, ""); err != nil { + t.Fatalf("write stage-create marker: %v", err) + } + if got := e.filer.Get(bucketDir, stageCreateMarkerDirName); got == nil || string(got.Extended["probe"]) != "kept" { + t.Errorf("directory replaced by the marker's directory create: %+v", got) + } +} + // Two stage-create commits for one name: one creates the table and the other is // told it lost, and the file the catalog points at belongs to the winner. func TestConcurrentFinalizeCreateOnCommitHasOneWinner(t *testing.T) { diff --git a/weed/s3api/iceberg/stage_create.go b/weed/s3api/iceberg/stage_create.go index d94ee72c09..7af71bc038 100644 --- a/weed/s3api/iceberg/stage_create.go +++ b/weed/s3api/iceberg/stage_create.go @@ -191,6 +191,7 @@ func (s *Server) writeStageCreateMarker(ctx context.Context, bucketName string, resp, createErr := client.CreateEntry(opCtx, &filer_pb.CreateEntryRequest{ Directory: parent, + OExcl: true, Entry: &filer_pb.Entry{ Name: name, IsDirectory: true,