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 15:59:51 UTC

(iceberg-go) branch main updated: fix(table): guard nil base in snapshot timestamp check (#1799)

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 6e678465d fix(table): guard nil base in snapshot timestamp check (#1799)
6e678465d is described below

commit 6e678465d6722a3f7e8783257448a12dbe9238d2
Author: Badal Prasad Singh <ba...@datazip.io>
AuthorDate: Fri Aug 14 21:29:45 2026 +0530

    fix(table): guard nil base in snapshot timestamp check (#1799)
    
    * fix: guard the nil base
    
    Signed-off-by: badalprasadsingh <ba...@datazip.io>
    
    * fix: nil base
    
    Signed-off-by: badalprasadsingh <ba...@datazip.io>
    
    ---------
    
    Signed-off-by: badalprasadsingh <ba...@datazip.io>
---
 table/metadata.go                       |  28 ++++++-
 table/metadata_builder_internal_test.go | 133 ++++++++++++++++++++++++++++++++
 2 files changed, 158 insertions(+), 3 deletions(-)

diff --git a/table/metadata.go b/table/metadata.go
index ea09c3be1..50cf8d8c1 100644
--- a/table/metadata.go
+++ b/table/metadata.go
@@ -42,6 +42,7 @@ const (
 	supportedTableFormatVersion = 3
 	minFormatVersionRowLineage  = 3
 	initialRowID                = int64(0)
+	initialSequenceNumber       = int64(0)
 	oneMinuteInMs               = int64(60_000)
 )
 
@@ -524,6 +525,7 @@ func (b *MetadataBuilder) addSnapshotInternal(snapshot *Snapshot, preserveUpdate
 		return nil
 	}
 
+	lastSeqNum := b.currentLastSequenceNumber()
 	if len(b.schemaList) == 0 {
 		return errors.New("can't add snapshot with no added schemas")
 	} else if len(b.specs) == 0 {
@@ -532,9 +534,9 @@ func (b *MetadataBuilder) addSnapshotInternal(snapshot *Snapshot, preserveUpdate
 		return fmt.Errorf("can't add snapshot with id %d, already exists", snapshot.SnapshotID)
 	} else if b.formatVersion >= 2 &&
 		snapshot.ParentSnapshotID != nil &&
-		snapshot.SequenceNumber <= *b.lastSequenceNumber {
+		snapshot.SequenceNumber <= lastSeqNum {
 		return fmt.Errorf("can't add snapshot with sequence number %d, must be > than last sequence number %d",
-			snapshot.SequenceNumber, *b.lastSequenceNumber)
+			snapshot.SequenceNumber, lastSeqNum)
 	}
 
 	if len(b.snapshotLog) > 0 {
@@ -544,7 +546,15 @@ func (b *MetadataBuilder) addSnapshotInternal(snapshot *Snapshot, preserveUpdate
 				snapshot.TimestampMs, last.TimestampMs)
 		}
 	}
-	maxTS := max(b.lastUpdatedMS, b.base.LastUpdatedMillis())
+
+	// b.base is nil for builders created by NewMetadataBuilder (the create-table path);
+	// only MetadataBuilderFromBase populates it. Treat the missing base as "no previous update"
+	// instead of dereferencing it, mirroring the fallback in currentNextRowID.
+	var baseLastUpdated int64
+	if b.base != nil {
+		baseLastUpdated = b.base.LastUpdatedMillis()
+	}
+	maxTS := max(b.lastUpdatedMS, baseLastUpdated)
 	if snapshot.TimestampMs-maxTS < -oneMinuteInMs {
 		return fmt.Errorf("invalid snapshot timestamp %d: before last updated timestamp %d",
 			snapshot.TimestampMs, maxTS)
@@ -604,6 +614,18 @@ func (b *MetadataBuilder) validateAndUpdateRowLineage(snapshot *Snapshot) error
 	return nil
 }
 
+// currentLastSequenceNumber returns the last sequence number assigned to a
+// snapshot. b.lastSequenceNumber is nil for v1 tables and for builders created
+// by NewMetadataBuilder that have not added a snapshot yet, in which case the
+// spec's initial sequence number is the correct floor.
+func (b *MetadataBuilder) currentLastSequenceNumber() int64 {
+	if b.lastSequenceNumber != nil {
+		return *b.lastSequenceNumber
+	}
+
+	return initialSequenceNumber
+}
+
 func (b *MetadataBuilder) currentNextRowID() int64 {
 	if b.nextRowID != nil {
 		return *b.nextRowID
diff --git a/table/metadata_builder_internal_test.go b/table/metadata_builder_internal_test.go
index 7156ac2ea..c7e81af52 100644
--- a/table/metadata_builder_internal_test.go
+++ b/table/metadata_builder_internal_test.go
@@ -118,6 +118,41 @@ func builderWithoutChanges(formatVersion int) MetadataBuilder {
 	return *builder
 }
 
+func freshMetadataBuilder(t *testing.T, formatVersion int) *MetadataBuilder {
+	t.Helper()
+
+	tableSchema := schema()
+	partitionSpec := partitionSpec()
+	sortOrder := sortOrder()
+
+	builder, err := NewMetadataBuilder(formatVersion)
+	require.NoError(t, err)
+	require.NoError(t, builder.SetLoc("s3://bucket/test/location"))
+	require.NoError(t, builder.AddSchema(&tableSchema))
+	require.NoError(t, builder.SetCurrentSchemaID(-1))
+	require.NoError(t, builder.AddSortOrder(&sortOrder))
+	require.NoError(t, builder.SetDefaultSortOrderID(-1))
+	require.NoError(t, builder.AddPartitionSpec(&partitionSpec, true))
+	require.NoError(t, builder.SetDefaultSpecID(-1))
+	require.Nil(t, builder.base, "builder from NewMetadataBuilder must have no base metadata")
+
+	return builder
+}
+
+func freshBuilderSnapshot(snapshotID int64, parentSnapshotID *int64, sequenceNumber, timestampMs int64) Snapshot {
+	schemaID := 0
+
+	return Snapshot{
+		SnapshotID:       snapshotID,
+		ParentSnapshotID: parentSnapshotID,
+		SequenceNumber:   sequenceNumber,
+		TimestampMs:      timestampMs,
+		ManifestList:     fmt.Sprintf("/snap-%d.avro", snapshotID),
+		Summary:          &Summary{Operation: OpAppend, Properties: map[string]string{}},
+		SchemaID:         &schemaID,
+	}
+}
+
 func TestBuildUnpartitionedUnsorted(t *testing.T) {
 	TestLocation := "file:///tmp/iceberg-test"
 	tableSchema := schema()
@@ -1301,6 +1336,104 @@ func TestAddSnapshotRejectsInvalidTimestamp(t *testing.T) {
 	require.NoError(t, err)
 }
 
+func TestAddSnapshotOnBuilderWithoutBase(t *testing.T) {
+	t.Run("parentless snapshot", func(t *testing.T) {
+		builder := freshMetadataBuilder(t, 2)
+		snapshot := freshBuilderSnapshot(1, nil, 0, time.Now().UnixMilli())
+
+		require.NoError(t, builder.AddSnapshot(&snapshot))
+
+		meta, err := builder.Build()
+		require.NoError(t, err)
+		require.Len(t, meta.Snapshots(), 1)
+		require.Equal(t, snapshot.TimestampMs, meta.LastUpdatedMillis())
+	})
+
+	t.Run("snapshot with parent", func(t *testing.T) {
+		builder := freshMetadataBuilder(t, 2)
+		parentSnapshotID := int64(1)
+		snapshot := freshBuilderSnapshot(2, &parentSnapshotID, 1, time.Now().UnixMilli())
+
+		require.NoError(t, builder.AddSnapshot(&snapshot))
+
+		meta, err := builder.Build()
+		require.NoError(t, err)
+		require.Len(t, meta.Snapshots(), 1)
+		require.Equal(t, int64(1), meta.LastSequenceNumber())
+	})
+
+	t.Run("rejects sequence number at the initial value", func(t *testing.T) {
+		builder := freshMetadataBuilder(t, 2)
+		parentSnapshotID := int64(1)
+		snapshot := freshBuilderSnapshot(2, &parentSnapshotID, 0, time.Now().UnixMilli())
+
+		err := builder.AddSnapshot(&snapshot)
+		require.ErrorContains(t, err, "can't add snapshot with sequence number 0, must be > than last sequence number 0")
+	})
+
+	t.Run("rejects timestamp before last update", func(t *testing.T) {
+		builder := freshMetadataBuilder(t, 2)
+		now := time.Now().UnixMilli()
+		first := freshBuilderSnapshot(1, nil, 0, now)
+		require.NoError(t, builder.AddSnapshot(&first))
+
+		// No ref has been set, so the snapshot log is empty and this exercises the
+		// last-updated bound rather than the last-snapshot bound.
+		require.Empty(t, builder.snapshotLog)
+		second := freshBuilderSnapshot(2, nil, 1, now-(2*oneMinuteInMs))
+
+		err := builder.AddSnapshot(&second)
+		require.ErrorContains(t, err, "before last updated timestamp")
+	})
+}
+
+func TestAddSnapshotOnBuilderWithoutBaseV3(t *testing.T) {
+	// v3 row lineage validation only became reachable for a builder without base
+	// metadata once AddSnapshot stopped panicking before it.
+	builder := freshMetadataBuilder(t, 3)
+	firstRowID, addedRows := int64(0), int64(10)
+	snapshot := freshBuilderSnapshot(1, nil, 0, time.Now().UnixMilli())
+	snapshot.FirstRowID, snapshot.AddedRows = &firstRowID, &addedRows
+
+	require.NoError(t, builder.AddSnapshot(&snapshot))
+	require.Equal(t, addedRows, builder.NextRowID())
+
+	behindFirstRowID := int64(5)
+	parentSnapshotID := int64(1)
+	stale := freshBuilderSnapshot(2, &parentSnapshotID, 1, time.Now().UnixMilli())
+	stale.FirstRowID, stale.AddedRows = &behindFirstRowID, &addedRows
+
+	err := builder.AddSnapshot(&stale)
+	require.ErrorIs(t, err, ErrInvalidRowLineage)
+	require.ErrorContains(t, err, "first-row-id 5 is behind table next-row-id 10")
+
+	meta, err := builder.Build()
+	require.NoError(t, err)
+	require.Len(t, meta.Snapshots(), 1)
+	require.Equal(t, addedRows, meta.NextRowID())
+}
+
+func TestAddSnapshotUpdateOnBuilderWithoutBase(t *testing.T) {
+	builder := freshMetadataBuilder(t, 2)
+	parentSnapshotID := int64(1)
+	snapshot := freshBuilderSnapshot(2, &parentSnapshotID, 1, time.Now().UnixMilli())
+	update := NewAddSnapshotUpdate(&snapshot)
+
+	require.NoError(t, builder.AddSnapshotUpdate(update))
+
+	// AddSnapshotUpdate must store the caller's update verbatim rather than
+	// rebuilding it, so runtime-only fields survive the OCC retry path.
+	require.NotEmpty(t, builder.updates)
+	stored, ok := builder.updates[len(builder.updates)-1].(*addSnapshotUpdate)
+	require.True(t, ok, "expected last update to be *addSnapshotUpdate, got %T", builder.updates[len(builder.updates)-1])
+	require.Same(t, update, stored)
+
+	meta, err := builder.Build()
+	require.NoError(t, err)
+	require.Len(t, meta.Snapshots(), 1)
+	require.Equal(t, int64(1), meta.LastSequenceNumber())
+}
+
 func TestConstructDefaultMainBranch(t *testing.T) {
 	// TODO: Not sure what this test is supposed to do Rust: `test_construct_default_main_branch`
 	meta, err := getTestTableMetadata("TableMetadataV2Valid.json")