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 d9bf431d5 fix(view): stop MetadataBuilder.Build from growing the
version log (#2109)
d9bf431d5 is described below
commit d9bf431d5a634884ad4b3c0078bc808ac15533c0
Author: Animesh Kumar <[email protected]>
AuthorDate: Sun Oct 11 23:45:38 2026 +0530
fix(view): stop MetadataBuilder.Build from growing the version log (#2109)
* fix(view): stop MetadataBuilder.Build from growing the version log
view.MetadataBuilder.Build appended the pending history entry to
b.versionLog, so building the same builder again added the entry again:
three builds gave three version-log entries. Append to a copy instead.
On the table side, updateSnapshotLog wrote the pruned log back into
b.snapshotLog. Running it again gives the same result, so this did not
change any output, but it was still a side effect of Build. It now
returns the pruned log (as updatedSnapshotLog) and leaves the builder
unchanged, like the metadata log since #2095.
lastUpdatedMS stays cached on the builder after the first Build, so
repeated builds report the same timestamp; that is now documented.
Closes #2108
Signed-off-by: Animesh Kumar <[email protected]>
* fix: make repeated builds stable and pin the pruning and expiry paths
- table: compute lastUpdatedMS per Build instead of storing it on the
builder, so a build after more changes gets a fresh timestamp.
- view: keep the UUID generated for a new view on the builder and reuse
it, so repeated builds describe the same view; SetUUID still works.
- Document on both Build methods that they can be called more than once.
- Tests: build twice after RemoveSnapshots, the view expiry path with
VersionHistorySizeKey=1, a builder from existing metadata, a stable
generated UUID, and a fresh timestamp after more changes.
* test(view): use the embedded Metadata methods directly
* fix: keep Build's guarantee scoped to the logs
Per review, Build only guarantees that it doesn't write the version log
or the snapshot log back to the builder.
- table: keep lastUpdatedMS cached on the builder, as Java's
TableMetadata.Builder does, and say when it is set.
- view: keep generating the UUID per build when none is assigned, as
Java's ViewMetadata.Builder does.
- Tests: drop the timestamp and UUID assertions, build a three-entry
base version log with a history size of 2, and pin the [A, A] snapshot
log after moving main back to A between builds.
- Say which calls move lastUpdatedMS after the first Build.
- Document that updatedSnapshotLog never aliases b.snapshotLog and
derives pruning from b.updates.
- Fix the [A, A] explanation and compare it with UpdateTableMetadata.
- Rename the view expiry test, pin its one-entry log, and point to the
from-base test for the clear branch.
---------
Signed-off-by: Animesh Kumar <[email protected]>
---
table/metadata.go | 27 ++++++++---
table/metadata_builder_internal_test.go | 52 +++++++++++++++++++++
view/metadata_builder.go | 9 ++--
view/metadata_builder_test.go | 82 +++++++++++++++++++++++++++++++++
4 files changed, 160 insertions(+), 10 deletions(-)
diff --git a/table/metadata.go b/table/metadata.go
index bf1dc9e26..4b26e898f 100644
--- a/table/metadata.go
+++ b/table/metadata.go
@@ -1064,7 +1064,7 @@ func (b *MetadataBuilder) RemoveSnapshots(snapshotIds
[]int64, postCommit bool)
if len(b.snapshotList) != previousSnapshotCount {
b.snapshotIndex = buildSnapshotIndex(b.snapshotList)
}
- // Snapshot-log pruning is deferred to updateSnapshotLog during Build so
+ // Snapshot-log pruning is deferred to updatedSnapshotLog during Build
so
// removed entries remain available when trimming history gaps.
validSnapshotIDs := make(map[int64]struct{}, len(b.snapshotList))
@@ -1509,10 +1509,16 @@ func (b *MetadataBuilder) buildCommonMetadata()
(*commonMetadata, error) {
}
defaultSpecID := b.defaultSpecID
- if err := b.updateSnapshotLog(); err != nil {
+ snapshotLog, err := b.updatedSnapshotLog()
+ if err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidMetadata, err)
}
+ // If no change has set lastUpdatedMS yet, the first Build sets it and
keeps it on
+ // the builder on purpose (Java's TableMetadata.Builder does the same),
so repeated
+ // builds of the same changes report the same time. After that only
AddSnapshot,
+ // SetSnapshotRef on main and SetLastUpdatedMS move it: a later
SetProperties or
+ // AddSchema followed by another Build still reports the first Build's
time.
if b.lastUpdatedMS == 0 {
b.lastUpdatedMS = time.Now().UnixMilli()
}
@@ -1544,7 +1550,7 @@ func (b *MetadataBuilder) buildCommonMetadata()
(*commonMetadata, error) {
SnapshotList: b.snapshotList,
snapshotIndex: b.snapshotIndex,
CurrentSnapshotID: b.currentSnapshotID,
- SnapshotLog: b.snapshotLog,
+ SnapshotLog: snapshotLog,
MetadataLog: metadataLog,
SortOrderList: b.sortOrderList,
DefaultSortOrderID: b.defaultSortOrderID,
@@ -1555,7 +1561,13 @@ func (b *MetadataBuilder) buildCommonMetadata()
(*commonMetadata, error) {
}, nil
}
-func (b *MetadataBuilder) updateSnapshotLog() error {
+// updatedSnapshotLog returns the snapshot log with intermediate and removed
+// snapshots dropped. It leaves b.snapshotLog unchanged, so Build stays free of
+// side effects on the log. The result is always a new slice that never aliases
+// b.snapshotLog, including the slices.Clone when nothing is pruned, so callers
+// may modify it. The pruning is derived again from b.updates on every call, so
+// it relies on b.updates holding every change made through this builder.
+func (b *MetadataBuilder) updatedSnapshotLog() ([]SnapshotLogEntry, error) {
addedIDs := make(map[int64]struct{}, 2)
hasRemoved := false
for _, upd := range b.updates {
@@ -1593,13 +1605,14 @@ func (b *MetadataBuilder) updateSnapshotLog() error {
if b.currentSnapshotID != nil && len(newSnapsLog) != 0 {
last := newSnapsLog[len(newSnapsLog)-1]
if last.SnapshotID != *b.currentSnapshotID {
- return fmt.Errorf("%w: cannot set invalid
snapshot log: latest entry is not the current snapshot",
iceberg.ErrInvalidArgument)
+ return nil, fmt.Errorf("%w: cannot set invalid
snapshot log: latest entry is not the current snapshot",
iceberg.ErrInvalidArgument)
}
}
- b.snapshotLog = newSnapsLog
+
+ return newSnapsLog, nil
}
- return nil
+ return slices.Clone(b.snapshotLog), nil
}
func (b *MetadataBuilder) GetSchemaByID(id int) (*iceberg.Schema, error) {
diff --git a/table/metadata_builder_internal_test.go
b/table/metadata_builder_internal_test.go
index 78b79298e..5030647e2 100644
--- a/table/metadata_builder_internal_test.go
+++ b/table/metadata_builder_internal_test.go
@@ -1060,6 +1060,52 @@ func TestSnapshotLogSkipsIntermediate(t *testing.T) {
TimestampMs: snapshot2.TimestampMs,
}, "expected snapshot to match added snapshot")
require.True(t, res.CurrentSnapshot().Equals(snapshot2))
+
+ require.Len(t, builder.snapshotLog, 2, "Build must not prune the
builder's own log")
+ again, err := builder.Build()
+ require.NoError(t, err)
+ require.Equal(t, res.(*metadataV2).SnapshotLog,
again.(*metadataV2).SnapshotLog)
+}
+
+func TestSnapshotLogAfterMovingMainBackAfterBuild(t *testing.T) {
+ // add A, set main to A, add B, set main to B, build, set main back to
A, build.
+ // In the first build main moved from A to B, so A is the intermediate
snapshot and
+ // that build logs [B]. Moving main back to A makes B the intermediate
one, while
+ // both of A's entries stay, so the second build logs [A, A]. Before
the fix, the
+ // first build wrote its pruned [B] back to the builder and the second
returned [A].
+ builder := builderWithoutChanges(2)
+ schemaID := 0
+ newSnapshot := func(id int64) *Snapshot {
+ return &Snapshot{
+ SnapshotID: id,
+ TimestampMs: builder.base.LastUpdatedMillis() + id,
+ ManifestList: fmt.Sprintf("/snap-%d.avro", id),
+ Summary: &Summary{Operation: OpAppend},
+ SchemaID: &schemaID,
+ }
+ }
+
+ require.NoError(t, builder.AddSnapshot(newSnapshot(1)))
+ require.NoError(t, builder.SetSnapshotRef(MainBranch, 1, BranchRef))
+ require.NoError(t, builder.AddSnapshot(newSnapshot(2)))
+ require.NoError(t, builder.SetSnapshotRef(MainBranch, 2, BranchRef))
+ _, err := builder.Build()
+ require.NoError(t, err)
+
+ require.NoError(t, builder.SetSnapshotRef(MainBranch, 1, BranchRef))
+ res, err := builder.Build()
+ require.NoError(t, err)
+
+ ids := make([]int64, 0, 2)
+ for entry := range res.SnapshotLogs() {
+ ids = append(ids, entry.SnapshotID)
+ }
+ require.Equal(t, []int64{1, 1}, ids)
+
+ // Committing the same updates gives the same log.
+ committed, err := UpdateTableMetadata(builder.base, builder.updates, "")
+ require.NoError(t, err)
+ require.Equal(t, slices.Collect(committed.SnapshotLogs()),
slices.Collect(res.SnapshotLogs()))
}
func TestRemoveSnapshotsPrunesSnapshotLogHistory(t *testing.T) {
@@ -1104,6 +1150,12 @@ func TestRemoveSnapshotsPrunesSnapshotLogHistory(t
*testing.T) {
SnapshotID: snapshot3ID,
TimestampMs: baseTimestamp + 3,
}}, slices.Collect(rebuilt.SnapshotLogs()))
+
+ // Building again prunes the same entries from the builder's log, which
keeps them.
+ again, err := newBuilder.Build()
+ require.NoError(t, err)
+ require.Equal(t, slices.Collect(rebuilt.SnapshotLogs()),
slices.Collect(again.SnapshotLogs()))
+ require.Len(t, newBuilder.snapshotLog, 3, "Build must not prune the
builder's own log")
}
func TestSetBranchSnapshotCreatesBranchIfNotExists(t *testing.T) {
diff --git a/view/metadata_builder.go b/view/metadata_builder.go
index d00018e56..3f2190748 100644
--- a/view/metadata_builder.go
+++ b/view/metadata_builder.go
@@ -435,8 +435,11 @@ func (b *MetadataBuilder) Build() (*MetadataBuildResult,
error) {
return nil, fmt.Errorf("unsupported format version %d",
b.formatVersion)
}
+ // Build may run more than once on the same builder, so the history
entry is
+ // appended to a copy. Appending to b.versionLog would add it again on
every call.
+ versionLog := slices.Clone(b.versionLog)
if b.versionHistoryEntry != nil {
- b.versionLog = append(b.versionLog, *b.versionHistoryEntry)
+ versionLog = append(versionLog, *b.versionHistoryEntry)
}
// If we had a previous version, check if we allow dropping dialects
and if we did drop one
@@ -459,10 +462,10 @@ func (b *MetadataBuilder) Build() (*MetadataBuildResult,
error) {
if len(b.versionList) > versionsToKeep {
retainedVersions = expireVersions(b.versionsById,
versionsToKeep, b.versionsById[b.currentVersionID])
retainedVersionIDs :=
internal.ToSet(internal.MapSlice(retainedVersions, func(v *Version) int64 {
return v.VersionID }))
- retainedHistory = updateHistory(b.versionLog,
retainedVersionIDs)
+ retainedHistory = updateHistory(versionLog, retainedVersionIDs)
} else {
retainedVersions = b.versionList
- retainedHistory = b.versionLog
+ retainedHistory = versionLog
}
md, err := b.buildMetadata(retainedVersions, retainedHistory)
diff --git a/view/metadata_builder_test.go b/view/metadata_builder_test.go
index 335ecbbb5..753bed0df 100644
--- a/view/metadata_builder_test.go
+++ b/view/metadata_builder_test.go
@@ -97,6 +97,88 @@ func TestBuild_NullAndMissingFields(t *testing.T) {
assert.ErrorContains(t, err, "cannot set uuid to null")
}
+func TestBuild_DoesNotGrowVersionLog(t *testing.T) {
+ b := newTestBuilder().
+ SetLoc("location").
+ AddSchema(newTestSchema(1)).
+ AddVersion(newTestVersion(1, LastAddedID)).
+ SetCurrentVersionID(LastAddedID)
+
+ for range 3 {
+ res, err := b.Build()
+ require.NoError(t, err)
+ require.Len(t, res.VersionLog(), 1)
+ require.Empty(t, b.versionLog, "Build must not append to the
builder's own log")
+ }
+}
+
+func TestBuild_RepeatedBuildsWithVersionExpiry(t *testing.T) {
+ // With a history size of 1, versions 1 and 2 are expired. The only
history entry is
+ // the one for version 3, which is retained, so updateHistory keeps it;
its clear
+ // branch is covered by TestBuild_FromBaseDoesNotGrowVersionLog.
Repeated builds
+ // return the same one-entry log.
+ b := newTestBuilder().
+ SetLoc("location").
+ SetProperties(iceberg.Properties{VersionHistorySizeKey: "1"}).
+ AddSchema(newTestSchema(1)).
+ AddVersion(newTestVersionWithSQL(1, LastAddedID, "select 1")).
+ AddVersion(newTestVersionWithSQL(2, LastAddedID, "select 2")).
+ AddVersion(newTestVersionWithSQL(3, LastAddedID, "select 3")).
+ SetCurrentVersionID(LastAddedID)
+
+ first, err := b.Build()
+ require.NoError(t, err)
+ require.Len(t, first.VersionLog(), 1)
+ require.Equal(t, int64(3), first.VersionLog()[0].VersionID)
+ for range 2 {
+ res, err := b.Build()
+ require.NoError(t, err)
+ require.Equal(t, first.VersionLog(), res.VersionLog())
+ require.Empty(t, b.versionLog, "Build must not append to the
builder's own log")
+ }
+}
+
+func TestBuild_FromBaseDoesNotGrowVersionLog(t *testing.T) {
+ // The commit-retry case: a builder from existing metadata with a
three-entry version
+ // log and a history size of 2, replacing the current version, built
more than once.
+ res, err := newTestBuilder().
+ SetLoc("location").
+ AddSchema(newTestSchema(1)).
+ AddVersion(newTestVersionWithSQL(1, LastAddedID, "select 1")).
+ SetCurrentVersionID(LastAddedID).
+ Build()
+ require.NoError(t, err)
+ for id := int64(2); id <= 3; id++ {
+ b, err := MetadataBuilderFromBase(res.Metadata)
+ require.NoError(t, err)
+ schemaID := res.CurrentVersion().SchemaID
+ res, err = b.AddVersion(newTestVersionWithSQL(id, schemaID,
fmt.Sprintf("select %d", id))).
+ SetCurrentVersionID(LastAddedID).
+ Build()
+ require.NoError(t, err)
+ }
+ baseLog := res.VersionLog()
+ require.Len(t, baseLog, 3)
+
+ b, err := MetadataBuilderFromBase(res.Metadata)
+ require.NoError(t, err)
+ schemaID := res.CurrentVersion().SchemaID
+ b.SetProperties(iceberg.Properties{VersionHistorySizeKey: "2"}).
+ AddVersion(newTestVersionWithSQL(4, schemaID, "select 4")).
+ SetCurrentVersionID(LastAddedID)
+
+ for range 3 {
+ res, err := b.Build()
+ require.NoError(t, err)
+ ids := make([]int64, 0, 2)
+ for _, entry := range res.VersionLog() {
+ ids = append(ids, entry.VersionID)
+ }
+ require.Equal(t, []int64{3, 4}, ids)
+ require.Equal(t, baseLog, b.versionLog, "Build must not append
to the builder's own log")
+ }
+}
+
func TestNewVersion_RepresentationValidation(t *testing.T) {
tests := []struct {
name string