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)
+ })
+}