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/27 17:08:33 UTC

(iceberg-go) branch main updated: feat(table): add explicit default spec/sort-order commit fence (Transaction.AssertDefaultShape) (#1891)

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 5cd27e4da feat(table): add explicit default spec/sort-order commit fence (Transaction.AssertDefaultShape) (#1891)
5cd27e4da is described below

commit 5cd27e4da10907e4a1275042661b88a456a372c5
Author: ryanworl <ry...@gmail.com>
AuthorDate: Thu Aug 27 12:08:26 2026 -0500

    feat(table): add explicit default spec/sort-order commit fence (Transaction.AssertDefaultShape) (#1891)
    
    feat(table): add Transaction.AssertDefaultShape (Java UpdateRequirements parity)
    
    Java UpdateRequirements.forUpdateTable registers AssertDefaultSpecID
    and AssertDefaultSortOrderID when a commit changes those defaults, but
    commits that stage no spec or sort-order change of their own (for
    example a properties-only revision marker) carry no shape assertion, so
    two racers evolving a table's declaration can interleave. AssertDefaultShape
    is the explicit fence — the shape analogue of AssertRefSnapshotID —
    pinning both defaults at their base values. Requirements deduplicate
    against the producer-built assertions UpdateSpec and ReplaceSortOrder
    already register.
    
    Co-authored-by: Cursor <cu...@cursor.com>
---
 table/transaction.go                   |  32 +++++++
 table/transaction_assert_shape_test.go | 168 +++++++++++++++++++++++++++++++++
 2 files changed, 200 insertions(+)

diff --git a/table/transaction.go b/table/transaction.go
index 39bf8e98f..a6459a941 100644
--- a/table/transaction.go
+++ b/table/transaction.go
@@ -456,6 +456,38 @@ func (t *Transaction) AssertRefSnapshotID(branch string) error {
 	return nil
 }
 
+// AssertDefaultShape registers requirements pinning the table's default
+// partition spec ID and default sort order ID at their values in the
+// transaction's base metadata, so a concurrent change to either fails
+// this commit instead of interleaving silently — the shape analogue of
+// AssertRefSnapshotID's explicit branch fence. It gives commits that
+// stage no shape change of their own (for example a properties-only
+// revision marker) the same serialization against concurrent spec or
+// sort-order evolutions that Java UpdateRequirements.forUpdateTable
+// registers for SetDefaultPartitionSpec / SetDefaultSortOrder, and that
+// UpdateSpec and ReplaceSortOrder already register for shape-changing
+// commits; when both are present they pin the same base values and
+// deduplicate.
+//
+// Register the fence BEFORE staging spec or sort-order changes:
+// requirements are validated against the staged metadata at
+// registration time, so a fence registered after the transaction moved
+// a default fails immediately. Unlike ref assertions the fence is never
+// rewritten by the retry loop's refresh-and-replay, and a transaction
+// with no updates never contacts the catalog, so the fence alone does
+// not force a commit.
+func (t *Transaction) AssertDefaultShape() error {
+	if _, err := t.txnMeta(); err != nil {
+		return err
+	}
+	meta := t.tbl.Metadata()
+
+	return t.apply(nil, []Requirement{
+		AssertDefaultSpecID(meta.DefaultPartitionSpec()),
+		AssertDefaultSortOrderID(meta.DefaultSortOrder()),
+	})
+}
+
 // RemoveProperties removes the given property keys from the table. Keys that
 // are not currently set are ignored.
 func (t *Transaction) RemoveProperties(keys []string) error {
diff --git a/table/transaction_assert_shape_test.go b/table/transaction_assert_shape_test.go
new file mode 100644
index 000000000..c49d56380
--- /dev/null
+++ b/table/transaction_assert_shape_test.go
@@ -0,0 +1,168 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+	"testing"
+
+	"github.com/apache/iceberg-go"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// countShapeAssertions returns how many default-spec and default-sort-
+// order assertions the transaction has accumulated.
+func countShapeAssertions(tx *Transaction) (specs, orders int) {
+	for _, r := range tx.reqs {
+		switch r.(type) {
+		case *assertDefaultSpecId:
+			specs++
+		case *assertDefaultSortOrderId:
+			orders++
+		}
+	}
+
+	return specs, orders
+}
+
+// withDefaultSpecChanged returns base with a new identity-partitioned
+// spec installed as the default.
+func withDefaultSpecChanged(t *testing.T, base Metadata) Metadata {
+	t.Helper()
+
+	builder, err := MetadataBuilderFromBase(base, "")
+	require.NoError(t, err)
+	spec := iceberg.NewPartitionSpecID(base.DefaultPartitionSpec()+1,
+		iceberg.PartitionField{
+			SourceIDs: []int{1},
+			FieldID:   iceberg.PartitionDataIDStart,
+			Name:      "id",
+			Transform: iceberg.IdentityTransform{},
+		})
+	require.NoError(t, builder.AddPartitionSpec(&spec, false))
+	require.NoError(t, builder.SetDefaultSpecID(-1))
+	meta, err := builder.Build()
+	require.NoError(t, err)
+	require.NotEqual(t, base.DefaultPartitionSpec(), meta.DefaultPartitionSpec())
+
+	return meta
+}
+
+// withDefaultSortOrderChanged returns base with a new identity sort
+// order installed as the default.
+func withDefaultSortOrderChanged(t *testing.T, base Metadata) Metadata {
+	t.Helper()
+
+	builder, err := MetadataBuilderFromBase(base, "")
+	require.NoError(t, err)
+	order, err := NewSortOrder(1, []SortField{
+		{SourceIDs: []int{1}, Direction: SortASC, NullOrder: NullsFirst, Transform: iceberg.IdentityTransform{}},
+	})
+	require.NoError(t, err)
+	require.NoError(t, builder.AddSortOrder(&order))
+	require.NoError(t, builder.SetDefaultSortOrderID(-1))
+	meta, err := builder.Build()
+	require.NoError(t, err)
+	require.NotEqual(t, base.DefaultSortOrder(), meta.DefaultSortOrder())
+
+	return meta
+}
+
+// Why: a transaction that stages no spec or sort-order change of its
+// own (e.g. a properties-only commit) commits without any
+// shape assertion, so a concurrent spec or sort-order evolution can
+// interleave silently. AssertDefaultShape is the explicit fence pinning
+// both defaults at their base values, matching the assertions Java
+// UpdateRequirements.forUpdateTable registers for shape-changing
+// commits.
+// Condition: a properties-only transaction with AssertDefaultShape,
+// committed against a catalog whose default spec / default sort order
+// did or did not move in between.
+// Assertion: the commit succeeds when the shape is unmoved and fails
+// with ErrCommitFailed — with the properties left uncommitted — when
+// either default moved.
+func TestTransactionAssertDefaultShape(t *testing.T) {
+	head := int64(100)
+	retryProps := iceberg.Properties{
+		CommitNumRetriesKey:     "2",
+		CommitMinRetryWaitMsKey: "1",
+		CommitMaxRetryWaitMsKey: "2",
+	}
+
+	t.Run("succeeds when the shape is unmoved", func(t *testing.T) {
+		base := newConflictTestMetadataWithProps(t, &head, retryProps)
+		tbl, cat := newAssertRefTestTable(t, base, base)
+
+		tx := tbl.NewTransaction()
+		require.NoError(t, tx.AssertDefaultShape())
+		require.NoError(t, tx.SetProperties(iceberg.Properties{"marker": "7"}))
+
+		_, err := tx.Commit(t.Context())
+		require.NoError(t, err)
+		assert.Equal(t, "7", cat.metadata.Properties()["marker"])
+	})
+
+	t.Run("fails when the default spec moved", func(t *testing.T) {
+		base := newConflictTestMetadataWithProps(t, &head, retryProps)
+		tbl, cat := newAssertRefTestTable(t, base, withDefaultSpecChanged(t, base))
+
+		tx := tbl.NewTransaction()
+		require.NoError(t, tx.AssertDefaultShape())
+		require.NoError(t, tx.SetProperties(iceberg.Properties{"marker": "7"}))
+
+		_, err := tx.Commit(t.Context())
+		require.Error(t, err)
+		assert.ErrorIs(t, err, ErrCommitFailed)
+		assert.NotContains(t, cat.metadata.Properties(), "marker",
+			"a failed shape fence must not apply the properties")
+	})
+
+	t.Run("fails when the default sort order moved", func(t *testing.T) {
+		base := newConflictTestMetadataWithProps(t, &head, retryProps)
+		tbl, cat := newAssertRefTestTable(t, base, withDefaultSortOrderChanged(t, base))
+
+		tx := tbl.NewTransaction()
+		require.NoError(t, tx.AssertDefaultShape())
+		require.NoError(t, tx.SetProperties(iceberg.Properties{"marker": "7"}))
+
+		_, err := tx.Commit(t.Context())
+		require.Error(t, err)
+		assert.ErrorIs(t, err, ErrCommitFailed)
+		assert.NotContains(t, cat.metadata.Properties(), "marker")
+	})
+
+	t.Run("dedupes by type against itself and producer assertions", func(t *testing.T) {
+		base := newConflictTestMetadataWithProps(t, &head, retryProps)
+		tbl, _ := newAssertRefTestTable(t, base, base)
+
+		tx := tbl.NewTransaction()
+		require.NoError(t, tx.AssertDefaultShape())
+		require.NoError(t, tx.AssertDefaultShape())
+		// ReplaceSortOrder's own default-sort-order assertion pins the
+		// same base value and must collapse into the fence.
+		order, err := NewSortOrder(1, []SortField{
+			{SourceIDs: []int{1}, Direction: SortDESC, NullOrder: NullsLast, Transform: iceberg.IdentityTransform{}},
+		})
+		require.NoError(t, err)
+		require.NoError(t, tx.ReplaceSortOrder(order))
+
+		specs, orders := countShapeAssertions(tx)
+		assert.Equal(t, 1, specs)
+		assert.Equal(t, 1, orders)
+	})
+}