laskoviymishka commented on code in PR #1858:
URL: https://github.com/apache/iceberg-go/pull/1858#discussion_r3850546348


##########
catalog/multi_table_transaction.go:
##########
@@ -102,8 +102,10 @@ func (m *MultiTableTransaction) AddTransaction(tx 
*table.Transaction) error {
 
 // Commit extracts pending changes from all added transactions and
 // commits them atomically. On success, all transactions are marked
-// as committed. On failure, no transactions are marked committed
-// and the caller may retry.
+// as committed. On failure, no transactions are marked committed.
+//
+// A retry must be rebuilt from freshly loaded tables:

Review Comment:
   A metadata-only transaction with no staged updates returns `{Requirements: 
[], Updates: []}` from `TableCommit`, and `Commit` here forwards every 
collected transaction unconditionally. A conformant REST catalog can reject a 
`TableChange` with zero updates (400/422), which would fail the whole atomic 
batch over one no-op entry.
   
   I'd either filter zero-update transactions out in `Commit`, or document on 
`AddTransaction`/`Commit` that a no-update transaction shouldn't be added. wdyt?



##########
catalog/multi_table_transaction_test.go:
##########
@@ -277,3 +277,78 @@ func TestCommitAndReloadPartialFailure(t *testing.T) {
        // First table was loaded successfully before the second failed.
        assert.Len(t, tables, 1)
 }
+
+func mtxTableWithHead(t *testing.T, name string, headID, childID int64) 
(*table.Table, table.Metadata) {
+       t.Helper()
+
+       base := mtxTestTable(t, "db", name).Metadata()
+       withHead := mtxGraftSnapshot(t, base, headID, nil)
+       advanced := mtxGraftSnapshot(t, withHead, childID, &headID)
+
+       return table.New(table.Identifier{"db", name}, withHead, "", nil, nil), 
advanced
+}
+
+func mtxGraftSnapshot(t *testing.T, base table.Metadata, id int64, parent 
*int64) table.Metadata {
+       t.Helper()
+
+       builder, err := table.MetadataBuilderFromBase(base, "")
+       require.NoError(t, err)
+       require.NoError(t, builder.AddSnapshot(&table.Snapshot{
+               SnapshotID:       id,
+               ParentSnapshotID: parent,
+               SequenceNumber:   base.LastSequenceNumber() + 1,
+               TimestampMs:      base.LastUpdatedMillis() + 1,
+               Summary:          &table.Summary{Operation: table.OpAppend},
+       }))
+       require.NoError(t, builder.SetSnapshotRef(table.MainBranch, id, 
table.BranchRef))
+       out, err := builder.Build()
+       require.NoError(t, err)
+
+       return out
+}
+
+// Why: with a distinct head per table, a shared or missing assertion would 
still let one table's concurrent writer through.
+func TestMultiTableTransactionFencesEachBranchHead(t *testing.T) {
+       tbl1, advanced1 := mtxTableWithHead(t, "t1", 100, 101)
+       tbl2, advanced2 := mtxTableWithHead(t, "t2", 200, 201)
+
+       stub := &stubCatalog{}
+       mtx := &MultiTableTransaction{cat: stub}
+
+       for _, tbl := range []*table.Table{tbl1, tbl2} {
+               tx := tbl.NewTransaction()
+               require.NoError(t, 
tx.SetProperties(map[string]string{"offsets": "42"}))
+               require.NoError(t, mtx.AddTransaction(tx))
+       }
+
+       require.NoError(t, mtx.Commit(context.Background()))

Review Comment:
   Small consistency thing: the new tests in `transaction_internal_test.go` all 
use `t.Context()`, but this one reaches for `context.Background()`. I'd match 
`t.Context()` here.



##########
table/commit.go:
##########
@@ -77,9 +78,9 @@ func (t *Transaction) TableCommit() (TableCommit, error) {
                }, nil
        }
 
-       reqs := make([]Requirement, len(t.reqs), len(t.reqs)+1)
-       copy(reqs, t.reqs)
-       reqs = append(reqs, AssertTableUUID(meta.uuid))
+       // Same derivation as Commit. Copying t.reqs alone leaves metadata-only 
transactions unfenced:
+       // their updates stage no ref requirement.
+       reqs := append(transactionRequirements(t.reqs, t.branch, 
t.tbl.metadata), AssertTableUUID(meta.uuid))

Review Comment:
   This is safe today only because `transactionRequirements` always starts with 
`slices.Clone(reqs)`, so the returned slice never aliases `t.reqs`. That's a 
non-local invariant now, and we're appending into the result while holding 
`t.mx`; the old `make + copy` spelled the ownership out.
   
   I'd add a one-liner here noting the returned slice is a fresh allocation, or 
state that guarantee in `transactionRequirements`'s doc, so a future fast-path 
return can't silently reintroduce aliasing.



##########
table/transaction_internal_test.go:
##########
@@ -1785,3 +1786,258 @@ func 
TestRollbackToSnapshotCommitRejectsBranchTurnedTag(t *testing.T) {
        require.ErrorContains(t, err, "tags cannot be transaction targets")
        require.Equal(t, int32(1), cat.attempts.Load(), "a type conflict must 
not be retried")
 }
+
+type reqCapturingCatalog struct {
+       metadata Metadata
+       reqs     [][]Requirement
+}
+
+func (c *reqCapturingCatalog) LoadTable(_ context.Context, ident Identifier) 
(*Table, error) {
+       return New(ident, c.metadata, "",
+               func(context.Context) (iceio.IO, error) { return 
iceio.LocalFS{}, nil }, c), nil
+}
+
+func (c *reqCapturingCatalog) CommitTable(_ context.Context, _ Identifier, 
reqs []Requirement, updates []Update) (Metadata, string, error) {
+       c.reqs = append(c.reqs, reqs)
+       meta, err := UpdateTableMetadata(c.metadata, updates, "")
+       if err != nil {
+               return nil, "", err
+       }
+       c.metadata = meta
+
+       return meta, "", nil
+}
+
+func refRequirements(t *testing.T, reqs []Requirement, ref string) 
[]*assertRefSnapshotID {
+       t.Helper()
+
+       var out []*assertRefSnapshotID
+       for _, r := range reqs {
+               if r.GetType() != reqAssertRefSnapshotID {
+                       continue
+               }
+               req, ok := r.(*assertRefSnapshotID)
+               require.True(t, ok, "requirement of type %s must be an 
*assertRefSnapshotID", r.GetType())
+               if req.Ref == ref {
+                       out = append(out, req)
+               }
+       }
+
+       return out
+}
+
+func soleRefRequirement(t *testing.T, reqs []Requirement, ref string) 
*assertRefSnapshotID {
+       t.Helper()
+
+       found := refRequirements(t, reqs, ref)
+       require.Len(t, found, 1, "expected exactly one snapshot-id assertion 
for ref %q", ref)
+
+       return found[0]
+}
+
+func metadataWithRef(t *testing.T, base Metadata, name string, refType 
RefType) Metadata {
+       t.Helper()
+
+       head := base.SnapshotByName(MainBranch)
+       require.NotNil(t, head, "base must have a main branch head")
+
+       builder, err := MetadataBuilderFromBase(base, "")
+       require.NoError(t, err)
+       require.NoError(t, builder.SetSnapshotRef(name, head.SnapshotID, 
refType))
+       out, err := builder.Build()
+       require.NoError(t, err)
+
+       return out
+}
+
+func multiTableTestTable(t *testing.T, meta Metadata) *Table {
+       t.Helper()
+
+       return New(Identifier{"db", "multi-table"}, meta, "metadata.json",
+               func(context.Context) (iceio.IO, error) { return 
iceio.LocalFS{}, nil },
+               &headTrackingCatalog{metadata: meta})
+}
+
+// Why: TableCommit copied the staged requirements verbatim, and metadata-only 
transactions
+// stage no ref requirement of their own.
+func TestTableCommitFencesTargetBranch(t *testing.T) {
+       head := int64(100)
+
+       t.Run("metadata-only transaction pins the base branch head", func(t 
*testing.T) {
+               base := newConflictTestMetadata(t, &head)
+               tx := multiTableTestTable(t, base).NewTransaction()
+               require.NoError(t, 
tx.SetProperties(iceberg.Properties{"offsets": "42"}))
+
+               tc, err := tx.TableCommit()
+               require.NoError(t, err)
+
+               req := soleRefRequirement(t, tc.Requirements, MainBranch)
+               require.NotNil(t, req.SnapshotID)
+               assert.Equal(t, head, *req.SnapshotID)
+               assert.True(t, req.requireBranch, "the target ref must be 
asserted as a branch")
+
+               assert.NoError(t, req.Validate(base))
+               err = req.Validate(graftSnapshotOnto(t, base, MainBranch, 200))
+               require.Error(t, err, "a concurrent snapshot on main must fail 
the assertion")
+               assert.ErrorContains(t, err, "has changed")
+       })
+
+       t.Run("explicit ref assertion is kept without duplication", func(t 
*testing.T) {
+               base := newConflictTestMetadata(t, &head)
+               tx := multiTableTestTable(t, base).NewTransaction()
+               require.NoError(t, tx.AssertRefSnapshotID(MainBranch))
+               require.NoError(t, 
tx.SetProperties(iceberg.Properties{"offsets": "42"}))
+
+               tc, err := tx.TableCommit()
+               require.NoError(t, err)
+
+               req := soleRefRequirement(t, tc.Requirements, MainBranch)
+               require.NotNil(t, req.SnapshotID)
+               assert.Equal(t, head, *req.SnapshotID)
+       })
+
+       t.Run("branch transaction pins only that branch", func(t *testing.T) {
+               base := metadataWithRef(t, newConflictTestMetadata(t, &head), 
"audit", BranchRef)
+               tx := multiTableTestTable(t, 
base).NewTransactionOnBranch("audit")
+               require.NoError(t, 
tx.SetProperties(iceberg.Properties{"offsets": "42"}))
+
+               tc, err := tx.TableCommit()
+               require.NoError(t, err)
+
+               req := soleRefRequirement(t, tc.Requirements, "audit")
+               require.NotNil(t, req.SnapshotID)
+               assert.Equal(t, head, *req.SnapshotID)
+               assert.Empty(t, refRequirements(t, tc.Requirements, MainBranch),
+                       "a branch transaction must not fence main")
+       })
+
+       t.Run("absent target branch requires absence", func(t *testing.T) {
+               base := newConflictTestMetadata(t, &head)
+               tx := multiTableTestTable(t, 
base).NewTransactionOnBranch("audit")
+               require.NoError(t, 
tx.SetProperties(iceberg.Properties{"offsets": "42"}))
+
+               tc, err := tx.TableCommit()
+               require.NoError(t, err)
+
+               req := soleRefRequirement(t, tc.Requirements, "audit")
+               assert.Nil(t, req.SnapshotID, "a branch absent on the base must 
be required to stay absent")
+
+               assert.NoError(t, req.Validate(base))
+               err = req.Validate(metadataWithRef(t, base, "audit", BranchRef))
+               require.Error(t, err)
+               assert.ErrorContains(t, err, "created concurrently")
+       })
+
+       t.Run("target name created as a tag is rejected", func(t *testing.T) {
+               base := newConflictTestMetadata(t, &head)
+               tx := multiTableTestTable(t, 
base).NewTransactionOnBranch("audit")
+               require.NoError(t, 
tx.SetProperties(iceberg.Properties{"offsets": "42"}))
+
+               tc, err := tx.TableCommit()
+               require.NoError(t, err)
+
+               req := soleRefRequirement(t, tc.Requirements, "audit")
+               err = req.Validate(metadataWithRef(t, base, "audit", TagRef))

Review Comment:
   This sub-case proves the tag guard fires through a local `Validate` call, 
but that isn't what runs on the multi-table path this PR is fencing. 
`MultiTableTransaction.Commit` hands the payload to `CommitTransaction` with no 
local retry or `Validate` loop, and `requireBranch` has no wire representation, 
so the server only sees 
`{"type":"assert-ref-snapshot-id","ref":"audit","snapshot-id":...}` and 
enforces snapshot-id equality, not ref type.
   
   So the "created as a tag is rejected" protection is real for single-table 
`Commit` but silently absent once the payload goes out over REST. I'd scope 
this with a comment to client-side validation, so it isn't read as multi-table 
tag-race coverage. wdyt?



##########
table/transaction_internal_test.go:
##########
@@ -1785,3 +1786,258 @@ func 
TestRollbackToSnapshotCommitRejectsBranchTurnedTag(t *testing.T) {
        require.ErrorContains(t, err, "tags cannot be transaction targets")
        require.Equal(t, int32(1), cat.attempts.Load(), "a type conflict must 
not be retried")
 }
+
+type reqCapturingCatalog struct {
+       metadata Metadata
+       reqs     [][]Requirement
+}
+
+func (c *reqCapturingCatalog) LoadTable(_ context.Context, ident Identifier) 
(*Table, error) {
+       return New(ident, c.metadata, "",
+               func(context.Context) (iceio.IO, error) { return 
iceio.LocalFS{}, nil }, c), nil
+}
+
+func (c *reqCapturingCatalog) CommitTable(_ context.Context, _ Identifier, 
reqs []Requirement, updates []Update) (Metadata, string, error) {
+       c.reqs = append(c.reqs, reqs)
+       meta, err := UpdateTableMetadata(c.metadata, updates, "")
+       if err != nil {
+               return nil, "", err
+       }
+       c.metadata = meta
+
+       return meta, "", nil
+}
+
+func refRequirements(t *testing.T, reqs []Requirement, ref string) 
[]*assertRefSnapshotID {
+       t.Helper()
+
+       var out []*assertRefSnapshotID
+       for _, r := range reqs {
+               if r.GetType() != reqAssertRefSnapshotID {
+                       continue
+               }
+               req, ok := r.(*assertRefSnapshotID)
+               require.True(t, ok, "requirement of type %s must be an 
*assertRefSnapshotID", r.GetType())
+               if req.Ref == ref {
+                       out = append(out, req)
+               }
+       }
+
+       return out
+}
+
+func soleRefRequirement(t *testing.T, reqs []Requirement, ref string) 
*assertRefSnapshotID {
+       t.Helper()
+
+       found := refRequirements(t, reqs, ref)
+       require.Len(t, found, 1, "expected exactly one snapshot-id assertion 
for ref %q", ref)
+
+       return found[0]
+}
+
+func metadataWithRef(t *testing.T, base Metadata, name string, refType 
RefType) Metadata {
+       t.Helper()
+
+       head := base.SnapshotByName(MainBranch)
+       require.NotNil(t, head, "base must have a main branch head")
+
+       builder, err := MetadataBuilderFromBase(base, "")
+       require.NoError(t, err)
+       require.NoError(t, builder.SetSnapshotRef(name, head.SnapshotID, 
refType))
+       out, err := builder.Build()
+       require.NoError(t, err)
+
+       return out
+}
+
+func multiTableTestTable(t *testing.T, meta Metadata) *Table {
+       t.Helper()
+
+       return New(Identifier{"db", "multi-table"}, meta, "metadata.json",
+               func(context.Context) (iceio.IO, error) { return 
iceio.LocalFS{}, nil },
+               &headTrackingCatalog{metadata: meta})
+}
+
+// Why: TableCommit copied the staged requirements verbatim, and metadata-only 
transactions
+// stage no ref requirement of their own.
+func TestTableCommitFencesTargetBranch(t *testing.T) {
+       head := int64(100)
+
+       t.Run("metadata-only transaction pins the base branch head", func(t 
*testing.T) {
+               base := newConflictTestMetadata(t, &head)
+               tx := multiTableTestTable(t, base).NewTransaction()
+               require.NoError(t, 
tx.SetProperties(iceberg.Properties{"offsets": "42"}))
+
+               tc, err := tx.TableCommit()
+               require.NoError(t, err)
+
+               req := soleRefRequirement(t, tc.Requirements, MainBranch)
+               require.NotNil(t, req.SnapshotID)
+               assert.Equal(t, head, *req.SnapshotID)
+               assert.True(t, req.requireBranch, "the target ref must be 
asserted as a branch")
+
+               assert.NoError(t, req.Validate(base))
+               err = req.Validate(graftSnapshotOnto(t, base, MainBranch, 200))
+               require.Error(t, err, "a concurrent snapshot on main must fail 
the assertion")
+               assert.ErrorContains(t, err, "has changed")
+       })
+
+       t.Run("explicit ref assertion is kept without duplication", func(t 
*testing.T) {
+               base := newConflictTestMetadata(t, &head)
+               tx := multiTableTestTable(t, base).NewTransaction()
+               require.NoError(t, tx.AssertRefSnapshotID(MainBranch))
+               require.NoError(t, 
tx.SetProperties(iceberg.Properties{"offsets": "42"}))
+
+               tc, err := tx.TableCommit()
+               require.NoError(t, err)
+
+               req := soleRefRequirement(t, tc.Requirements, MainBranch)
+               require.NotNil(t, req.SnapshotID)
+               assert.Equal(t, head, *req.SnapshotID)
+       })
+
+       t.Run("branch transaction pins only that branch", func(t *testing.T) {
+               base := metadataWithRef(t, newConflictTestMetadata(t, &head), 
"audit", BranchRef)
+               tx := multiTableTestTable(t, 
base).NewTransactionOnBranch("audit")
+               require.NoError(t, 
tx.SetProperties(iceberg.Properties{"offsets": "42"}))
+
+               tc, err := tx.TableCommit()
+               require.NoError(t, err)
+
+               req := soleRefRequirement(t, tc.Requirements, "audit")
+               require.NotNil(t, req.SnapshotID)
+               assert.Equal(t, head, *req.SnapshotID)
+               assert.Empty(t, refRequirements(t, tc.Requirements, MainBranch),
+                       "a branch transaction must not fence main")
+       })
+
+       t.Run("absent target branch requires absence", func(t *testing.T) {
+               base := newConflictTestMetadata(t, &head)
+               tx := multiTableTestTable(t, 
base).NewTransactionOnBranch("audit")
+               require.NoError(t, 
tx.SetProperties(iceberg.Properties{"offsets": "42"}))
+
+               tc, err := tx.TableCommit()
+               require.NoError(t, err)
+
+               req := soleRefRequirement(t, tc.Requirements, "audit")
+               assert.Nil(t, req.SnapshotID, "a branch absent on the base must 
be required to stay absent")
+
+               assert.NoError(t, req.Validate(base))
+               err = req.Validate(metadataWithRef(t, base, "audit", BranchRef))
+               require.Error(t, err)
+               assert.ErrorContains(t, err, "created concurrently")
+       })
+
+       t.Run("target name created as a tag is rejected", func(t *testing.T) {
+               base := newConflictTestMetadata(t, &head)
+               tx := multiTableTestTable(t, 
base).NewTransactionOnBranch("audit")
+               require.NoError(t, 
tx.SetProperties(iceberg.Properties{"offsets": "42"}))
+
+               tc, err := tx.TableCommit()
+               require.NoError(t, err)
+
+               req := soleRefRequirement(t, tc.Requirements, "audit")
+               err = req.Validate(metadataWithRef(t, base, "audit", TagRef))
+               require.Error(t, err)
+               assert.ErrorContains(t, err, "tags cannot be transaction 
targets")
+       })
+
+       t.Run("repeated calls are stable and leave the transaction unchanged", 
func(t *testing.T) {
+               base := newConflictTestMetadata(t, &head)
+               tx := multiTableTestTable(t, base).NewTransaction()
+               require.NoError(t, tx.AssertRefSnapshotID(MainBranch))
+               require.NoError(t, 
tx.SetProperties(iceberg.Properties{"offsets": "42"}))
+
+               staged := soleRefRequirement(t, tx.reqs, MainBranch)
+               require.False(t, staged.requireBranch,
+                       "the staged requirement starts untyped; the payload 
upgrade must not happen in place")
+
+               first, err := tx.TableCommit()
+               require.NoError(t, err)
+               second, err := tx.TableCommit()
+               require.NoError(t, err)
+
+               assert.Equal(t, first.Requirements, second.Requirements)
+               assert.Len(t, tx.reqs, 1)
+               assert.False(t, staged.requireBranch,
+                       "building the payload must not mutate the transaction's 
own requirements")
+       })
+}
+
+// Why: whole-list equality, so any future divergence between the two paths 
fails here
+// rather than only when a ref requirement goes missing.
+func TestTableCommitRequirementsMatchCommit(t *testing.T) {
+       head := int64(100)
+
+       for _, branch := range []string{MainBranch, "audit"} {
+               t.Run(branch, func(t *testing.T) {
+                       base := metadataWithRef(t, newConflictTestMetadata(t, 
&head), "audit", BranchRef)
+
+                       cat := &reqCapturingCatalog{metadata: base}
+                       committed := New(Identifier{"db", "multi-table"}, base, 
"metadata.json",
+                               func(context.Context) (iceio.IO, error) { 
return iceio.LocalFS{}, nil }, cat)
+
+                       txCommit := committed.NewTransactionOnBranch(branch)
+                       require.NoError(t, 
txCommit.SetProperties(iceberg.Properties{"offsets": "42"}))
+                       _, err := txCommit.Commit(t.Context())
+                       require.NoError(t, err)
+                       require.Len(t, cat.reqs, 1)
+
+                       txPayload := multiTableTestTable(t, 
base).NewTransactionOnBranch(branch)
+                       require.NoError(t, 
txPayload.SetProperties(iceberg.Properties{"offsets": "42"}))
+                       tc, err := txPayload.TableCommit()
+                       require.NoError(t, err)
+
+                       assert.Equal(t, cat.reqs[0], tc.Requirements,
+                               "a multi-table payload must carry the same 
fencing as Commit")
+               })
+       }
+}
+
+// Why: a producer stages its own ref assertion, which must be reused rather 
than duplicated.
+func TestTableCommitWithSnapshotProducerPinsBaseHead(t *testing.T) {
+       tbl, _, _ := newProducerAssertRefTable(t)
+
+       seed := tbl.NewTransaction()
+       require.NoError(t, seed.AddFiles(t.Context(), nil, nil, false))
+       tbl, err := seed.Commit(t.Context())
+       require.NoError(t, err)
+
+       baseHead := tbl.CurrentSnapshot()
+       require.NotNil(t, baseHead)
+
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.AddFiles(t.Context(), nil, nil, false))
+
+       tc, err := tx.TableCommit()
+       require.NoError(t, err)
+
+       req := soleRefRequirement(t, tc.Requirements, MainBranch)
+       require.NotNil(t, req.SnapshotID)
+       assert.Equal(t, baseHead.SnapshotID, *req.SnapshotID)
+       assert.True(t, req.requireBranch, "the target ref must be asserted as a 
branch")
+
+       staged, err := tx.StagedTable()
+       require.NoError(t, err)
+       stagedHead := staged.CurrentSnapshot()
+       require.NotNil(t, stagedHead)
+       assert.NotEqual(t, stagedHead.SnapshotID, *req.SnapshotID,
+               "the assertion must not name a snapshot the catalog has never 
seen")
+}
+
+// Why: a transaction with nothing to commit enforces nothing on the 
single-table path either.
+func TestTableCommitWithoutUpdatesStaysEmpty(t *testing.T) {
+       head := int64(100)
+       base := newConflictTestMetadata(t, &head)
+
+       tx := multiTableTestTable(t, base).NewTransaction()
+       require.NoError(t, tx.AssertRefSnapshotID(MainBranch))
+
+       tc, err := tx.TableCommit()
+       require.NoError(t, err)
+
+       assert.Empty(t, tc.Requirements)

Review Comment:
   The `NotNil` pair already catches a nil return, but the intent, a non-nil 
zero-length slice that serializes as `[]` and not `null`, reads more directly 
as `assert.Equal(t, []Requirement{}, tc.Requirements)` (same for `Updates`). 
One assertion, exact intent.



##########
table/transaction_internal_test.go:
##########
@@ -1785,3 +1786,258 @@ func 
TestRollbackToSnapshotCommitRejectsBranchTurnedTag(t *testing.T) {
        require.ErrorContains(t, err, "tags cannot be transaction targets")
        require.Equal(t, int32(1), cat.attempts.Load(), "a type conflict must 
not be retried")
 }
+
+type reqCapturingCatalog struct {
+       metadata Metadata
+       reqs     [][]Requirement
+}
+
+func (c *reqCapturingCatalog) LoadTable(_ context.Context, ident Identifier) 
(*Table, error) {
+       return New(ident, c.metadata, "",
+               func(context.Context) (iceio.IO, error) { return 
iceio.LocalFS{}, nil }, c), nil
+}
+
+func (c *reqCapturingCatalog) CommitTable(_ context.Context, _ Identifier, 
reqs []Requirement, updates []Update) (Metadata, string, error) {
+       c.reqs = append(c.reqs, reqs)
+       meta, err := UpdateTableMetadata(c.metadata, updates, "")
+       if err != nil {
+               return nil, "", err
+       }
+       c.metadata = meta
+
+       return meta, "", nil
+}
+
+func refRequirements(t *testing.T, reqs []Requirement, ref string) 
[]*assertRefSnapshotID {
+       t.Helper()
+
+       var out []*assertRefSnapshotID
+       for _, r := range reqs {
+               if r.GetType() != reqAssertRefSnapshotID {
+                       continue
+               }
+               req, ok := r.(*assertRefSnapshotID)
+               require.True(t, ok, "requirement of type %s must be an 
*assertRefSnapshotID", r.GetType())
+               if req.Ref == ref {
+                       out = append(out, req)
+               }
+       }
+
+       return out
+}
+
+func soleRefRequirement(t *testing.T, reqs []Requirement, ref string) 
*assertRefSnapshotID {
+       t.Helper()
+
+       found := refRequirements(t, reqs, ref)
+       require.Len(t, found, 1, "expected exactly one snapshot-id assertion 
for ref %q", ref)
+
+       return found[0]
+}
+
+func metadataWithRef(t *testing.T, base Metadata, name string, refType 
RefType) Metadata {
+       t.Helper()
+
+       head := base.SnapshotByName(MainBranch)
+       require.NotNil(t, head, "base must have a main branch head")
+
+       builder, err := MetadataBuilderFromBase(base, "")
+       require.NoError(t, err)
+       require.NoError(t, builder.SetSnapshotRef(name, head.SnapshotID, 
refType))
+       out, err := builder.Build()
+       require.NoError(t, err)
+
+       return out
+}
+
+func multiTableTestTable(t *testing.T, meta Metadata) *Table {
+       t.Helper()
+
+       return New(Identifier{"db", "multi-table"}, meta, "metadata.json",
+               func(context.Context) (iceio.IO, error) { return 
iceio.LocalFS{}, nil },
+               &headTrackingCatalog{metadata: meta})
+}
+
+// Why: TableCommit copied the staged requirements verbatim, and metadata-only 
transactions
+// stage no ref requirement of their own.
+func TestTableCommitFencesTargetBranch(t *testing.T) {
+       head := int64(100)
+
+       t.Run("metadata-only transaction pins the base branch head", func(t 
*testing.T) {
+               base := newConflictTestMetadata(t, &head)
+               tx := multiTableTestTable(t, base).NewTransaction()
+               require.NoError(t, 
tx.SetProperties(iceberg.Properties{"offsets": "42"}))
+
+               tc, err := tx.TableCommit()
+               require.NoError(t, err)
+
+               req := soleRefRequirement(t, tc.Requirements, MainBranch)
+               require.NotNil(t, req.SnapshotID)
+               assert.Equal(t, head, *req.SnapshotID)
+               assert.True(t, req.requireBranch, "the target ref must be 
asserted as a branch")
+
+               assert.NoError(t, req.Validate(base))
+               err = req.Validate(graftSnapshotOnto(t, base, MainBranch, 200))
+               require.Error(t, err, "a concurrent snapshot on main must fail 
the assertion")
+               assert.ErrorContains(t, err, "has changed")
+       })
+
+       t.Run("explicit ref assertion is kept without duplication", func(t 
*testing.T) {
+               base := newConflictTestMetadata(t, &head)
+               tx := multiTableTestTable(t, base).NewTransaction()
+               require.NoError(t, tx.AssertRefSnapshotID(MainBranch))
+               require.NoError(t, 
tx.SetProperties(iceberg.Properties{"offsets": "42"}))
+
+               tc, err := tx.TableCommit()
+               require.NoError(t, err)
+
+               req := soleRefRequirement(t, tc.Requirements, MainBranch)
+               require.NotNil(t, req.SnapshotID)
+               assert.Equal(t, head, *req.SnapshotID)
+       })
+
+       t.Run("branch transaction pins only that branch", func(t *testing.T) {
+               base := metadataWithRef(t, newConflictTestMetadata(t, &head), 
"audit", BranchRef)
+               tx := multiTableTestTable(t, 
base).NewTransactionOnBranch("audit")
+               require.NoError(t, 
tx.SetProperties(iceberg.Properties{"offsets": "42"}))
+
+               tc, err := tx.TableCommit()
+               require.NoError(t, err)
+
+               req := soleRefRequirement(t, tc.Requirements, "audit")
+               require.NotNil(t, req.SnapshotID)
+               assert.Equal(t, head, *req.SnapshotID)
+               assert.Empty(t, refRequirements(t, tc.Requirements, MainBranch),
+                       "a branch transaction must not fence main")
+       })
+
+       t.Run("absent target branch requires absence", func(t *testing.T) {
+               base := newConflictTestMetadata(t, &head)
+               tx := multiTableTestTable(t, 
base).NewTransactionOnBranch("audit")
+               require.NoError(t, 
tx.SetProperties(iceberg.Properties{"offsets": "42"}))
+
+               tc, err := tx.TableCommit()
+               require.NoError(t, err)
+
+               req := soleRefRequirement(t, tc.Requirements, "audit")
+               assert.Nil(t, req.SnapshotID, "a branch absent on the base must 
be required to stay absent")
+
+               assert.NoError(t, req.Validate(base))
+               err = req.Validate(metadataWithRef(t, base, "audit", BranchRef))
+               require.Error(t, err)
+               assert.ErrorContains(t, err, "created concurrently")
+       })
+
+       t.Run("target name created as a tag is rejected", func(t *testing.T) {
+               base := newConflictTestMetadata(t, &head)
+               tx := multiTableTestTable(t, 
base).NewTransactionOnBranch("audit")
+               require.NoError(t, 
tx.SetProperties(iceberg.Properties{"offsets": "42"}))
+
+               tc, err := tx.TableCommit()
+               require.NoError(t, err)
+
+               req := soleRefRequirement(t, tc.Requirements, "audit")
+               err = req.Validate(metadataWithRef(t, base, "audit", TagRef))
+               require.Error(t, err)
+               assert.ErrorContains(t, err, "tags cannot be transaction 
targets")
+       })
+
+       t.Run("repeated calls are stable and leave the transaction unchanged", 
func(t *testing.T) {
+               base := newConflictTestMetadata(t, &head)
+               tx := multiTableTestTable(t, base).NewTransaction()
+               require.NoError(t, tx.AssertRefSnapshotID(MainBranch))
+               require.NoError(t, 
tx.SetProperties(iceberg.Properties{"offsets": "42"}))
+
+               staged := soleRefRequirement(t, tx.reqs, MainBranch)
+               require.False(t, staged.requireBranch,
+                       "the staged requirement starts untyped; the payload 
upgrade must not happen in place")
+
+               first, err := tx.TableCommit()
+               require.NoError(t, err)
+               second, err := tx.TableCommit()
+               require.NoError(t, err)
+
+               assert.Equal(t, first.Requirements, second.Requirements)
+               assert.Len(t, tx.reqs, 1)
+               assert.False(t, staged.requireBranch,
+                       "building the payload must not mutate the transaction's 
own requirements")
+       })
+}
+
+// Why: whole-list equality, so any future divergence between the two paths 
fails here
+// rather than only when a ref requirement goes missing.
+func TestTableCommitRequirementsMatchCommit(t *testing.T) {
+       head := int64(100)
+
+       for _, branch := range []string{MainBranch, "audit"} {
+               t.Run(branch, func(t *testing.T) {
+                       base := metadataWithRef(t, newConflictTestMetadata(t, 
&head), "audit", BranchRef)
+
+                       cat := &reqCapturingCatalog{metadata: base}
+                       committed := New(Identifier{"db", "multi-table"}, base, 
"metadata.json",
+                               func(context.Context) (iceio.IO, error) { 
return iceio.LocalFS{}, nil }, cat)
+
+                       txCommit := committed.NewTransactionOnBranch(branch)
+                       require.NoError(t, 
txCommit.SetProperties(iceberg.Properties{"offsets": "42"}))
+                       _, err := txCommit.Commit(t.Context())
+                       require.NoError(t, err)
+                       require.Len(t, cat.reqs, 1)
+
+                       txPayload := multiTableTestTable(t, 
base).NewTransactionOnBranch(branch)

Review Comment:
   This passes only because `txPayload` is rebuilt from `base`, the same 
starting metadata the `Commit` side used. `reqCapturingCatalog.CommitTable` 
advances `c.metadata` in place, so if someone later seeds this from the 
post-commit metadata, the two sides would start from different heads and the 
equality could pass for the wrong reason.
   
   I'd assert the two transactions start from identical metadata, or at least 
leave a comment pinning that assumption.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to