You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by ze...@apache.org on 2026/08/07 17:28:16 UTC
(iceberg-go) branch main updated: fix(table): reject tags as transaction targets (#1657)
This is an automated email from the ASF dual-hosted git repository.
zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new 0bb9e83ee fix(table): reject tags as transaction targets (#1657)
0bb9e83ee is described below
commit 0bb9e83eec356c80e2883dbfc47aa618c050574b
Author: Minh Vu <vu...@gmail.com>
AuthorDate: Fri Aug 7 19:28:10 2026 +0200
fix(table): reject tags as transaction targets (#1657)
* fix(table): reject tags as transaction targets
* fix(table): preserve branch-only transaction targets
---
table/requirements.go | 18 ++++++++++--
table/table.go | 35 ++++++++++++++++++++++-
table/transaction.go | 33 +++++++++++++++++++++-
table/transaction_internal_test.go | 57 ++++++++++++++++++++++++++++++++++++++
4 files changed, 139 insertions(+), 4 deletions(-)
diff --git a/table/requirements.go b/table/requirements.go
index d6004a656..f0daa2741 100644
--- a/table/requirements.go
+++ b/table/requirements.go
@@ -155,8 +155,9 @@ func (a *assertTableUuid) Validate(meta Metadata) error {
type assertRefSnapshotID struct {
baseRequirement
- Ref string `json:"ref"`
- SnapshotID *int64 `json:"snapshot-id"`
+ Ref string `json:"ref"`
+ SnapshotID *int64 `json:"snapshot-id"`
+ requireBranch bool
}
// AssertRefSnapshotID creates a requirement which ensures that the table branch
@@ -170,6 +171,15 @@ func AssertRefSnapshotID(ref string, id *int64) Requirement {
}
}
+func assertBranchRefSnapshotID(ref string, id *int64) Requirement {
+ return &assertRefSnapshotID{
+ baseRequirement: baseRequirement{Type: reqAssertRefSnapshotID},
+ Ref: ref,
+ SnapshotID: id,
+ requireBranch: true,
+ }
+}
+
func (a *assertRefSnapshotID) Validate(meta Metadata) error {
if meta == nil {
return errors.New("Requirement failed: current table metadata does not exist")
@@ -185,6 +195,10 @@ func (a *assertRefSnapshotID) Validate(meta Metadata) error {
}
if r != nil {
+ if a.requireBranch && r.SnapshotRefType != BranchRef {
+ return fmt.Errorf("requirement failed: ref %q is a %s; tags cannot be transaction targets", a.Ref, r.SnapshotRefType)
+ }
+
if a.SnapshotID == nil {
return fmt.Errorf("requirement failed: %s %q was created concurrently", r.SnapshotRefType, a.Ref)
}
diff --git a/table/table.go b/table/table.go
index 430fb9dc4..ac0cbf0b9 100644
--- a/table/table.go
+++ b/table/table.go
@@ -189,6 +189,18 @@ func (t Table) newBrokenTransaction(branch string, err error) *Transaction {
// callers to receive the precise initialization error instead of hitting
// panic/undefined behavior later.
func (t Table) NewTransactionOnBranchWithError(branch string) (*Transaction, error) {
+ for name, ref := range t.metadata.Refs() {
+ if name != branch {
+ continue
+ }
+ if ref.SnapshotRefType != BranchRef {
+ return nil, fmt.Errorf("%w: ref %q is a %s; tags cannot be transaction targets",
+ iceberg.ErrInvalidArgument, branch, ref.SnapshotRefType)
+ }
+
+ break
+ }
+
meta, err := MetadataBuilderFromBase(t.metadata, t.metadataLocation)
if err != nil {
return nil, err
@@ -592,6 +604,9 @@ func (t Table) doCommit(ctx context.Context, updates []Update, reqs []Requiremen
}
current = fresh.metadata
reqs = rewriteRefSnapshotRequirements(reqs, co.branch, current)
+ if err := validateBranchRequirement(reqs, co.branch, current); err != nil {
+ return nil, err
+ }
// Rebuild snapshot manifest lists to inherit all files committed
// by concurrent writers since the snapshot was originally built.
@@ -606,6 +621,10 @@ func (t Table) doCommit(ctx context.Context, updates []Update, reqs []Requiremen
updates = rebuiltUpdates
}
+ if err := validateBranchRequirement(reqs, co.branch, current); err != nil {
+ return nil, err
+ }
+
// Pre-flight client-side conflict validation. Producers can
// reject commits whose semantics are violated by concurrent
// peers (partition-filter overlap, referenced-file removal)
@@ -739,7 +758,11 @@ func rewriteRefSnapshotRequirements(reqs []Requirement, branch string, fresh Met
for i, r := range reqs {
if a, ok := r.(*assertRefSnapshotID); ok && a.Ref == branch {
newID := head.SnapshotID
- out[i] = AssertRefSnapshotID(branch, &newID)
+ if a.requireBranch {
+ out[i] = assertBranchRefSnapshotID(branch, &newID)
+ } else {
+ out[i] = AssertRefSnapshotID(branch, &newID)
+ }
continue
}
@@ -749,6 +772,16 @@ func rewriteRefSnapshotRequirements(reqs []Requirement, branch string, fresh Met
return out
}
+func validateBranchRequirement(reqs []Requirement, branch string, meta Metadata) error {
+ for _, req := range reqs {
+ if ref, ok := req.(*assertRefSnapshotID); ok && ref.requireBranch && ref.Ref == branch {
+ return ref.Validate(meta)
+ }
+ }
+
+ return nil
+}
+
// rebuildSnapshotUpdates returns a new slice of updates where any
// addSnapshotUpdate that carries a rebuildManifestList closure has its
// snapshot regenerated to inherit all data files committed to the branch
diff --git a/table/transaction.go b/table/transaction.go
index b5648c909..2b8bc7fb4 100644
--- a/table/transaction.go
+++ b/table/transaction.go
@@ -240,6 +240,37 @@ func requirementSemanticKey(r Requirement) (string, error) {
return string(data), nil
}
+func transactionRequirements(reqs []Requirement, branch string, base Metadata) []Requirement {
+ if branch == "" {
+ branch = MainBranch
+ }
+
+ out := slices.Clone(reqs)
+ for i, req := range out {
+ if ref, ok := req.(*assertRefSnapshotID); ok && ref.Ref == branch {
+ if ref.requireBranch {
+ return out
+ }
+
+ out[i] = assertBranchRefSnapshotID(branch, ref.SnapshotID)
+
+ return out
+ }
+ }
+
+ var snapshotID *int64
+ for name, ref := range base.Refs() {
+ if name == branch {
+ id := ref.SnapshotID
+ snapshotID = &id
+
+ break
+ }
+ }
+
+ return append(out, assertBranchRefSnapshotID(branch, snapshotID))
+}
+
// addValidator appends a conflict validator under t.mx. Producers
// that register validators from outside doCommit (RowDelta, RewriteFiles)
// must use this helper rather than mutating t.validators directly —
@@ -2373,7 +2404,7 @@ func (t *Transaction) Commit(ctx context.Context) (*Table, error) {
}
if len(meta.updates) > 0 {
- reqs := append(slices.Clone(t.reqs), AssertTableUUID(meta.uuid))
+ reqs := append(transactionRequirements(t.reqs, t.branch, t.tbl.metadata), AssertTableUUID(meta.uuid))
tbl, err := t.tbl.doCommit(ctx, meta.updates, reqs,
withCommitBranch(t.branch),
withCommitValidators(t.validators...),
diff --git a/table/transaction_internal_test.go b/table/transaction_internal_test.go
index 3f10e509d..a92a6d2a3 100644
--- a/table/transaction_internal_test.go
+++ b/table/transaction_internal_test.go
@@ -97,6 +97,63 @@ func TestNewTransactionOnBranchWithErrorReturnsTransactionInitError(t *testing.T
require.Nil(t, txn)
}
+func TestNewTransactionOnBranchRejectsTags(t *testing.T) {
+ txn := newTransactionWithSnapshotRefs(t)
+ require.NoError(t, txn.meta.SetSnapshotRef("release", 10, TagRef))
+
+ meta, err := txn.meta.Build()
+ require.NoError(t, err)
+ tbl := New(Identifier{"db", "table"}, meta, "metadata.json", nil, nil)
+
+ before := tbl.Metadata()
+ transaction, err := tbl.NewTransactionOnBranchWithError("release")
+ require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+ require.Nil(t, transaction)
+ require.True(t, before.Equals(tbl.Metadata()))
+
+ legacyTransaction := tbl.NewTransactionOnBranch("release")
+ require.ErrorIs(t, legacyTransaction.initErr, iceberg.ErrInvalidArgument)
+ require.ErrorContains(t, legacyTransaction.SetProperties(iceberg.Properties{"k": "v"}), "tags cannot be transaction targets")
+}
+
+func TestTransactionCommitRejectsSameIDTagRace(t *testing.T) {
+ seed := newTransactionWithSnapshotRefs(t)
+ baseMeta, err := seed.meta.Build()
+ require.NoError(t, err)
+
+ head := baseMeta.SnapshotByName(MainBranch)
+ require.NotNil(t, head)
+ tagBuilder, err := MetadataBuilderFromBase(baseMeta, "")
+ require.NoError(t, err)
+ require.NoError(t, tagBuilder.SetSnapshotRef(MainBranch, head.SnapshotID, TagRef))
+ tagMeta, err := tagBuilder.Build()
+ require.NoError(t, err)
+
+ cat := &headTrackingCatalog{metadata: tagMeta}
+ tbl := New(Identifier{"db", "tag-race"}, baseMeta, "metadata.json",
+ func(context.Context) (iceio.IO, error) { return iceio.LocalFS{}, nil }, cat)
+ txn := tbl.NewTransaction()
+ require.NoError(t, txn.SetProperties(iceberg.Properties{"k": "v"}))
+
+ _, err = txn.Commit(context.Background())
+ require.Error(t, err)
+ require.ErrorContains(t, err, "tags cannot be transaction targets")
+ require.Equal(t, int32(1), cat.attempts.Load())
+}
+
+func TestNewTransactionOnBranchAllowsBranchesAndNewRefs(t *testing.T) {
+ txn := newTransactionWithSnapshotRefs(t)
+ meta, err := txn.meta.Build()
+ require.NoError(t, err)
+ tbl := New(Identifier{"db", "table"}, meta, "metadata.json", nil, nil)
+
+ for _, branch := range []string{MainBranch, "feature", "new-branch"} {
+ transaction, err := tbl.NewTransactionOnBranchWithError(branch)
+ require.NoError(t, err, branch)
+ require.NotNil(t, transaction, branch)
+ }
+}
+
func TestNewTransactionOnBranchKeepsLegacySignatureAndFailsOnUse(t *testing.T) {
baseMeta, err := NewMetadata(simpleSchema(), iceberg.UnpartitionedSpec, UnsortedSortOrder, "table-location", nil)
require.NoError(t, err, "new metadata")