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 a1e50aa5c fix(table): avoid duplicate column IDs when last-column-id 
is stale (#2453)
a1e50aa5c is described below

commit a1e50aa5c9b0bace998eaece66a5c0850af8977e
Author: Shruthi <[email protected]>
AuthorDate: Mon Nov 9 09:27:28 2026 -0800

    fix(table): avoid duplicate column IDs when last-column-id is stale (#2453)
    
    * fix(table): return error from UpdateSchema when last-column-id is below 
assigned field IDs
    
    Signed-off-by: Shruthi Yaramada <[email protected]>
    
    * fix(table): seed column ID counter from schema history
    
    Signed-off-by: Shruthi Yaramada <[email protected]>
    
    ---------
    
    Signed-off-by: Shruthi Yaramada <[email protected]>
    Co-authored-by: Shruthi Yaramada <[email protected]>
---
 table/metadata.go           | 14 ++++++++++-
 table/update_schema.go      | 13 ++++++-----
 table/update_schema_test.go | 57 +++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 77 insertions(+), 7 deletions(-)

diff --git a/table/metadata.go b/table/metadata.go
index 1ba78b3bf..01e203b18 100644
--- a/table/metadata.go
+++ b/table/metadata.go
@@ -823,7 +823,7 @@ func (b *MetadataBuilder) AddSchema(schema *iceberg.Schema) 
error {
                return nil
        }
 
-       b.lastColumnId = max(b.lastColumnId, schema.HighestFieldID())
+       b.lastColumnId = max(columnIDFloor(b.lastColumnId, b.schemaList), 
schema.HighestFieldID())
 
        schema.ID = newSchemaID
 
@@ -852,6 +852,18 @@ func partitionFieldIDFloor(lastPartitionID *int, specs 
[]iceberg.PartitionSpec)
        return floor
 }
 
+// columnIDFloor returns an allocation floor for new field IDs that accounts
+// for the persisted last-column-id and every field ID in schema history, so
+// a stale counter can never cause an ID to be reused.
+func columnIDFloor(lastColumnID int, schemas []*iceberg.Schema) int {
+       floor := lastColumnID
+       for _, schema := range schemas {
+               floor = max(floor, schema.HighestFieldID())
+       }
+
+       return floor
+}
+
 func (b *MetadataBuilder) AddPartitionSpec(spec *iceberg.PartitionSpec, 
initial bool) error {
        newSpecID := b.reuseOrCreateNewPartitionSpecID(*spec)
        curSchema := b.CurrentSchema()
diff --git a/table/update_schema.go b/table/update_schema.go
index 69ee4951d..45f7e8cc3 100644
--- a/table/update_schema.go
+++ b/table/update_schema.go
@@ -167,12 +167,13 @@ func NewUpdateSchema(txn *Transaction, caseSensitive 
bool, allowIncompatibleChan
                return u
        }
 
-       // Seed from metadata's last-column-id rather than the current schema's
-       // highest field id. Per the Iceberg spec, last-column-id is a monotonic
-       // counter that preserves ids across schema evolution (including
-       // deletions) so that newly-allocated ids never collide with ids still
-       // referenced by historical schemas.
-       u.lastColumnID = meta.LastColumnID()
+       // Seed from the larger of metadata's last-column-id and the highest
+       // field id in any schema, including historical ones. Per the Iceberg
+       // spec, last-column-id is a monotonic counter that preserves ids across
+       // schema evolution (including deletions), but it may be stale in
+       // metadata written by other tools. Taking the max ensures 
newly-allocated
+       // ids never collide with ids in the current or historical schemas.
+       u.lastColumnID = columnIDFloor(meta.LastColumnID(), meta.schemaList)
 
        for _, opt := range opts {
                opt(u)
diff --git a/table/update_schema_test.go b/table/update_schema_test.go
index 6a1d795a6..db1a73b06 100644
--- a/table/update_schema_test.go
+++ b/table/update_schema_test.go
@@ -19,6 +19,7 @@ package table
 
 import (
        "encoding/json"
+       "strings"
        "testing"
 
        "github.com/apache/iceberg-go"
@@ -58,6 +59,62 @@ func TestNewUpdateSchemaWithNilTransactionReturnsError(t 
*testing.T) {
        assert.ErrorContains(t, err, "transaction is nil")
 }
 
+func TestUpdateSchemaStaleLastColumnIDAssignsFreshID(t *testing.T) {
+       // Field "z" has ID 3, but the stale counter says 2. The new column
+       // must get an ID above every existing one (4), not reuse ID 3.
+       data := strings.Replace(ExampleTableMetadataV2,
+               `"last-column-id": 3`, `"last-column-id": 2`, 1)
+       meta, err := ParseMetadataString(data)
+       require.NoError(t, err)
+       require.Equal(t, 2, meta.LastColumnID())
+
+       tbl := New([]string{"id"}, meta, "", nil, nil)
+       txn := tbl.NewTransaction()
+
+       newSchema, err := txn.UpdateSchema(true, false).
+               AddColumn([]string{"w"}, iceberg.PrimitiveTypes.Int32, "", 
false, nil).
+               Apply()
+       require.NoError(t, err)
+
+       w, ok := newSchema.FindFieldByName("w")
+       require.True(t, ok)
+       assert.Equal(t, 4, w.ID)
+}
+
+func TestUpdateSchemaAssignsIDAboveHistoricalSchemas(t *testing.T) {
+       // Field "old" (ID 5) exists only in the older schema 0, as if it was
+       // dropped, and last-column-id is still 3. The new column must not reuse
+       // the dropped column's ID.
+       data := strings.Replace(ExampleTableMetadataV2,
+               `"schema-id": 0, "fields": [{"id": 1, "name": "x", "required": 
true, "type": "long"}]`,
+               `"schema-id": 0, "fields": [{"id": 1, "name": "x", "required": 
true, "type": "long"}, {"id": 5, "name": "old", "required": false, "type": 
"long"}]`,
+               1)
+       require.NotEqual(t, ExampleTableMetadataV2, data)
+       meta, err := ParseMetadataString(data)
+       require.NoError(t, err)
+       require.Equal(t, 3, meta.LastColumnID())
+
+       tbl := New([]string{"id"}, meta, "", nil, nil)
+       txn := tbl.NewTransaction()
+
+       newSchema, err := txn.UpdateSchema(true, false).
+               AddColumn([]string{"w"}, iceberg.PrimitiveTypes.Int32, "", 
false, nil).
+               Apply()
+       require.NoError(t, err)
+
+       w, ok := newSchema.FindFieldByName("w")
+       require.True(t, ok)
+       assert.Equal(t, 6, w.ID)
+
+       // Even a rename should fix the stored counter (3 -> 5).
+       require.NoError(t, txn.UpdateSchema(true, false).
+               RenameColumn([]string{"y"}, "y2").
+               Commit())
+       written, err := txn.txnMeta()
+       require.NoError(t, err)
+       assert.Equal(t, 5, written.LastColumnID())
+}
+
 func TestAddColumn(t *testing.T) {
        t.Run("test update schema with add primitive type on top level", func(t 
*testing.T) {
                table := New([]string{"id"}, testMetadata, "", nil, nil)

Reply via email to