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/14 16:01:50 UTC

(iceberg-go) branch main updated: fix(table): look up removed-file partition specs by ID (#1816)

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 a6f08ca91 fix(table): look up removed-file partition specs by ID (#1816)
a6f08ca91 is described below

commit a6f08ca9102427fd83901dc2cf9438f53f75b311
Author: anchor <co...@gmail.com>
AuthorDate: Sat Aug 15 00:01:45 2026 +0800

    fix(table): look up removed-file partition specs by ID (#1816)
    
    accumulateSummaryDelta resolved partition specs two different ways.
    Added files went through sp.spec(int(df.SpecID())), a by-ID lookup over
    GetSpecByID, while removed data files, delete files and deletion
    vectors indexed the spec slice directly with specs[df.SpecID()].
    
    Spec ID is an identifier, not a slice position. RemovePartitionSpecs
    filters the slice without renumbering the survivors, and specs loaded
    from metadata keep the partition-specs array order, so the two come
    apart on ordinary tables. The index expression is evaluated as a call
    argument, so it fails before removeFile ever runs: a Delete, Overwrite
    or RewriteFiles commit that removes files panics with index out of
    range, or silently attributes the change to the wrong partition when
    the ID stays in bounds but points at the wrong entry.
    
    Use sp.spec() in the three removal loops so both halves of the function
    agree, and drop the now-unused local. Unknown IDs then yield an empty
    unpartitioned spec instead of panicking, matching the add path.
    
    The regression test evolves specs 0/1/2, removes spec 1 so the slice
    becomes [id0, id2], then sends the same spec-2 data file down both
    branches and asserts they report the same changed partition.
    
    Fixes #1815
    
    Signed-off-by: codeAnqiang-ma <27...@users.noreply.github.com>
    Co-authored-by: codeAnqiang-ma <27...@users.noreply.github.com>
    Co-authored-by: Cursor <cu...@cursor.com>
---
 table/snapshot_producers.go      |  7 +++--
 table/snapshot_producers_test.go | 56 ++++++++++++++++++++++++++++++++++++++++
 2 files changed, 59 insertions(+), 4 deletions(-)

diff --git a/table/snapshot_producers.go b/table/snapshot_producers.go
index a9a5ac241..ef2ebc959 100644
--- a/table/snapshot_producers.go
+++ b/table/snapshot_producers.go
@@ -1018,9 +1018,8 @@ func (sp *snapshotProducer) accumulateSummaryDelta(countDeleteRemoval func(icebe
 		}
 	}
 
-	specs := sp.txn.meta.specs
 	for _, df := range sp.deletedFiles {
-		if err = ssc.removeFile(df, currentSchema, specs[df.SpecID()]); err != nil {
+		if err = ssc.removeFile(df, currentSchema, sp.spec(int(df.SpecID()))); err != nil {
 			return nil, err
 		}
 	}
@@ -1028,7 +1027,7 @@ func (sp *snapshotProducer) accumulateSummaryDelta(countDeleteRemoval func(icebe
 		if countDeleteRemoval != nil && !countDeleteRemoval(df) {
 			continue
 		}
-		if err = ssc.removeFile(df, currentSchema, specs[df.SpecID()]); err != nil {
+		if err = ssc.removeFile(df, currentSchema, sp.spec(int(df.SpecID()))); err != nil {
 			return nil, err
 		}
 	}
@@ -1036,7 +1035,7 @@ func (sp *snapshotProducer) accumulateSummaryDelta(countDeleteRemoval func(icebe
 		if countDeleteRemoval != nil && !countDeleteRemoval(df) {
 			continue
 		}
-		if err = ssc.removeFile(df, currentSchema, specs[df.SpecID()]); err != nil {
+		if err = ssc.removeFile(df, currentSchema, sp.spec(int(df.SpecID()))); err != nil {
 			return nil, err
 		}
 	}
diff --git a/table/snapshot_producers_test.go b/table/snapshot_producers_test.go
index bcd0c70af..f3e492899 100644
--- a/table/snapshot_producers_test.go
+++ b/table/snapshot_producers_test.go
@@ -26,6 +26,7 @@ import (
 	"io/fs"
 	"runtime"
 	"strconv"
+	"strings"
 	"sync"
 	"testing"
 	"time"
@@ -1062,6 +1063,61 @@ func TestAddedDeleteManifestsUseDeleteFileSpecID(t *testing.T) {
 	require.ElementsMatch(t, []int32{0, 1}, deleteManifestSpecIDs)
 }
 
+// changedPartitionPaths returns the partition paths named by a snapshot
+// summary's changed-partition entries.
+func changedPartitionPaths(summary iceberg.Properties) map[string]bool {
+	paths := make(map[string]bool)
+	for key := range summary {
+		if path, ok := strings.CutPrefix(key, changedPartitionPrefix); ok {
+			paths[path] = true
+		}
+	}
+
+	return paths
+}
+
+// TestAccumulateSummaryDeltaResolvesRemovedFileSpecByID checks that removed
+// files resolve their partition spec by ID, the way added files already do.
+// Removing a non-default spec that precedes the default one leaves a specs
+// slice whose positions no longer line up with spec IDs, so treating a spec ID
+// as a slice index reads the wrong spec or runs off the end of the slice.
+func TestAccumulateSummaryDeltaResolvesRemovedFileSpecByID(t *testing.T) {
+	txn, wfs := createTestTransactionWithMemIO(t, iceberg.NewPartitionSpec())
+	require.NoError(t, txn.meta.SetProperties(iceberg.Properties{WritePartitionSummaryLimitKey: "10"}))
+
+	bucketSpec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+		SourceIDs: []int{1}, FieldID: 1000, Name: "id_bucket", Transform: iceberg.BucketTransform{NumBuckets: 4},
+	})
+	require.NoError(t, txn.meta.AddPartitionSpec(&bucketSpec, false))
+	truncSpec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+		SourceIDs: []int{1}, FieldID: 1001, Name: "id_trunc", Transform: iceberg.TruncateTransform{Width: 8},
+	})
+	require.NoError(t, txn.meta.AddPartitionSpec(&truncSpec, false))
+	require.NoError(t, txn.meta.SetDefaultSpecID(-1))
+	require.NoError(t, txn.meta.RemovePartitionSpecs([]int{1}))
+
+	currentSpec, err := txn.meta.CurrentSpec()
+	require.NoError(t, err)
+	require.Equal(t, 2, currentSpec.ID(), "test setup should leave spec 2 as the default")
+	require.Len(t, txn.meta.specs, 2, "removing spec 1 should decouple slice positions from spec IDs")
+
+	df := newTestDataFile(t, *currentSpec, "mem://default/table-location/data/spec-2.parquet",
+		map[int]any{currentSpec.Field(0).FieldID: int32(0)})
+
+	appended := newOverwriteFilesProducer(OpOverwrite, txn, wfs, nil, nil)
+	appended.appendDataFile(df)
+	addedSummary, err := appended.accumulateSummaryDelta(nil)
+	require.NoError(t, err)
+	require.Equal(t, map[string]bool{"id_trunc=0": true}, changedPartitionPaths(addedSummary))
+
+	deleted := newOverwriteFilesProducer(OpOverwrite, txn, wfs, nil, nil)
+	deleted.deleteDataFile(df)
+	removedSummary, err := deleted.accumulateSummaryDelta(nil)
+	require.NoError(t, err)
+	require.Equal(t, changedPartitionPaths(addedSummary), changedPartitionPaths(removedSummary),
+		"adding and removing the same file must report the same changed partition")
+}
+
 // TestCreateManifestClosesUnderlyingFile tests that createManifest properly
 // closes the underlying file writer. This is related to issue #644 and #681.
 func TestCreateManifestClosesUnderlyingFile(t *testing.T) {