You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by at...@apache.org on 2026/04/30 21:54:34 UTC

(iceberg-go) branch main updated: test(table): add regression test for UpdateSchema id counter seeding (#942) (#951)

This is an automated email from the ASF dual-hosted git repository.

laskoviymishka 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 6493d05b test(table): add regression test for UpdateSchema id counter seeding (#942) (#951)
6493d05b is described below

commit 6493d05ba043b2a1e81ac81f1eb88901c25821f8
Author: Tanmay Rauth <t_...@apple.com>
AuthorDate: Thu Apr 30 14:54:29 2026 -0700

    test(table): add regression test for UpdateSchema id counter seeding (#942) (#951)
    
    Added TestAddColumnAfterDropHighestID which exercises the add-then-drop
    path: adds a column (bumping last-column-id to 12), drops it (lowering
    HighestFieldID back to 11), then verifies a new column gets id 13.
    Reverting #936 causes the test to fail with id reuse.
    
    Fixes: #942
---
 table/update_schema_test.go | 101 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 101 insertions(+)

diff --git a/table/update_schema_test.go b/table/update_schema_test.go
index 4ebd9f14..16245c51 100644
--- a/table/update_schema_test.go
+++ b/table/update_schema_test.go
@@ -961,3 +961,104 @@ func TestAddColumnMonotonicFieldIDs(t *testing.T) {
 	assert.Equal(t, 14, fresh.ID,
 		"new field id must be allocated above metadata.LastColumnID() (13), not reused from the current schema's highest id (11)")
 }
+
+// TestAddColumnAfterDropHighestID is a regression test for #942.
+// It reproduces the exact scenario from the original bug: a column is added
+// (bumping last-column-id) then dropped (lowering the current schema's
+// HighestFieldID back down). A subsequent AddColumn must allocate an id above
+// last-column-id, not above HighestFieldID. Reverting #936 (i.e. seeding from
+// HighestFieldID instead of LastColumnID) causes the new column to reuse a
+// previously assigned id, which violates the Iceberg spec's monotonic id
+// invariant.
+func TestAddColumnAfterDropHighestID(t *testing.T) {
+	// originalSchema has field ids 1..11.
+	baseMeta, err := NewMetadata(originalSchema, iceberg.UnpartitionedSpec, UnsortedSortOrder, "", nil)
+	assert.NoError(t, err)
+	assert.Equal(t, 11, baseMeta.LastColumnID())
+
+	// Step 1: Add a column via UpdateSchema. This allocates id 12 and bumps
+	// last-column-id to 12.
+	tbl := New([]string{"id"}, baseMeta, "", nil, nil)
+	txn := tbl.NewTransaction()
+
+	withExtra, err := NewUpdateSchema(txn, true, true).
+		AddColumn([]string{"temp_col"}, iceberg.PrimitiveTypes.String, "", false, nil).
+		Apply()
+	assert.NoError(t, err)
+
+	tempCol, ok := withExtra.FindFieldByName("temp_col")
+	assert.True(t, ok)
+	assert.Equal(t, 12, tempCol.ID, "added column should get id 12")
+
+	// Persist the expanded schema into metadata so last-column-id is 12.
+	builder1, err := MetadataBuilderFromBase(baseMeta, "")
+	assert.NoError(t, err)
+	assert.NoError(t, builder1.AddSchema(withExtra))
+	assert.NoError(t, builder1.SetCurrentSchemaID(-1))
+
+	afterAdd, err := builder1.Build()
+	assert.NoError(t, err)
+	assert.Equal(t, 12, afterAdd.LastColumnID())
+	assert.Equal(t, 12, afterAdd.CurrentSchema().HighestFieldID())
+
+	// Step 2: Drop the highest-id column. The new current schema's
+	// HighestFieldID drops back to 11 while last-column-id stays at 12.
+	tbl2 := New([]string{"id"}, afterAdd, "", nil, nil)
+	txn2 := tbl2.NewTransaction()
+
+	afterDrop, err := NewUpdateSchema(txn2, true, true).
+		DeleteColumn([]string{"temp_col"}).
+		Apply()
+	assert.NoError(t, err)
+
+	_, found := afterDrop.FindFieldByName("temp_col")
+	assert.False(t, found, "temp_col should be gone after drop")
+
+	builder2, err := MetadataBuilderFromBase(afterAdd, "")
+	assert.NoError(t, err)
+	assert.NoError(t, builder2.AddSchema(afterDrop))
+	assert.NoError(t, builder2.SetCurrentSchemaID(-1))
+
+	afterDropMeta, err := builder2.Build()
+	assert.NoError(t, err)
+
+	assert.Equal(t, 11, afterDropMeta.CurrentSchema().HighestFieldID(),
+		"precondition: current schema's highest id should be 11 after dropping id-12 column")
+	assert.Equal(t, 12, afterDropMeta.LastColumnID(),
+		"precondition: last-column-id must still be 12 — ids are never reclaimed")
+
+	// Verify schema history still contains the dropped temp_col with id 12.
+	// A partial fix that strips dropped fields from historical schemas could
+	// still pass the id-monotonicity check above but break interop with
+	// Java/PyIceberg/Glue which expect historical schemas to be intact.
+	var foundInHistory bool
+	for _, s := range afterDropMeta.Schemas() {
+		if col, ok := s.FindFieldByID(12); ok {
+			assert.Equal(t, "temp_col", col.Name,
+				"historical schema must retain temp_col at id 12")
+			foundInHistory = true
+
+			break
+		}
+	}
+	assert.True(t, foundInHistory,
+		"dropped column (id 12) must still appear in at least one historical schema")
+
+	// Step 3: Add a new column. Its id must be 13 (last-column-id + 1), not
+	// 12 (HighestFieldID + 1). Using HighestFieldID would re-assign id 12,
+	// colliding with the dropped column still referenced by historical schemas.
+	tbl3 := New([]string{"id"}, afterDropMeta, "", nil, nil)
+	txn3 := tbl3.NewTransaction()
+
+	finalSchema, err := NewUpdateSchema(txn3, true, true).
+		AddColumn([]string{"new_col"}, iceberg.PrimitiveTypes.Int64, "", false, nil).
+		Apply()
+	assert.NoError(t, err)
+	assert.NotNil(t, finalSchema)
+
+	newCol, ok := finalSchema.FindFieldByName("new_col")
+	assert.True(t, ok, "new_col should be present in the final schema")
+	assert.Equal(t, 13, newCol.ID,
+		"new column id must be last-column-id+1 (13), not HighestFieldID+1 (12) — "+
+			"reusing 12 would collide with the dropped column still in historical schemas")
+}