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 b7232461d fix(table): skip empty historical-delete manifest merges
(#2057)
b7232461d is described below
commit b7232461d62427a480038c752b46c47f5acfd21b
Author: Stefan Wang <[email protected]>
AuthorDate: Wed Oct 7 04:55:51 2026 -0700
fix(table): skip empty historical-delete manifest merges (#2057)
---
table/snapshot_producers.go | 62 +++++++++---
table/snapshot_producers_test.go | 199 +++++++++++++++++++++++++++++++++++++++
2 files changed, 246 insertions(+), 15 deletions(-)
diff --git a/table/snapshot_producers.go b/table/snapshot_producers.go
index 9cb0b1dd9..ed6bd6c3b 100644
--- a/table/snapshot_producers.go
+++ b/table/snapshot_producers.go
@@ -507,42 +507,72 @@ func (m *manifestMergeManager) createManifest(specID int,
bin []iceberg.Manifest
return nil, err
}
- wr, path, counter, fileCloser, err := m.snap.newManifestWriter(spec)
- if err != nil {
- return nil, err
- }
- defer internal.CheckedClose(fileCloser, &err)
+ var wr *iceberg.ManifestWriter
+ var path string
+ var counter *internal.CountingWriter
+ var fileCloser io.Closer
writerClosed := false
+ // Close the ManifestWriter before the underlying file so a mid-loop
+ // write failure flushes into an open file, not a closed one.
defer func() {
- if !writerClosed {
+ if wr != nil && !writerClosed {
internal.CheckedClose(wr, &err)
}
+ if fileCloser != nil {
+ internal.CheckedClose(fileCloser, &err)
+ }
}()
+ ensureWriter := func() error {
+ if wr != nil {
+ return nil
+ }
+
+ var writerErr error
+ wr, path, counter, fileCloser, writerErr =
m.snap.newManifestWriter(spec)
+
+ return writerErr
+ }
+
for _, manifest := range bin {
- for entry, err := range m.snap.iterManifestEntries(manifest,
false) {
- if err != nil {
- return nil, err
+ for entry, iterErr := range
m.snap.iterManifestEntries(manifest, false) {
+ if iterErr != nil {
+ return nil, iterErr
}
switch {
case entry.Status() == iceberg.EntryStatusDELETED &&
entry.SnapshotID() == m.snap.snapshotID:
+ if iterErr = ensureWriter(); iterErr != nil {
+ return nil, iterErr
+ }
// only files deleted by this snapshot should
be added to the new manifest
- err = wr.Delete(entry)
+ iterErr = wr.Delete(entry)
case entry.Status() == iceberg.EntryStatusADDED &&
entry.SnapshotID() == m.snap.snapshotID:
+ if iterErr = ensureWriter(); iterErr != nil {
+ return nil, iterErr
+ }
// added entries from this snapshot are still
added, otherwise they should be existing
- err = wr.Add(entry)
+ iterErr = wr.Add(entry)
case entry.Status() != iceberg.EntryStatusDELETED:
+ if iterErr = ensureWriter(); iterErr != nil {
+ return nil, iterErr
+ }
// add all non-deleted files from the old
manifest as existing files
- err = wr.Existing(entry)
+ iterErr = wr.Existing(entry)
}
- if err != nil {
- return nil, err
+ if iterErr != nil {
+ return nil, iterErr
}
}
}
+ // Java's merge manager writes zero-count manifests; this path omits
bins
+ // whose entries are all deletes from earlier snapshots.
+ if wr == nil {
+ return nil, nil
+ }
+
// close the writer to force a flush and ensure counter.Count is
accurate
writerClosed = true
if err := wr.Close(); err != nil {
@@ -577,7 +607,9 @@ func (m *manifestMergeManager) mergeGroup(firstManifest
iceberg.ManifestFile, sp
if err != nil {
return nil, err
}
- output = append(output, created)
+ if created != nil {
+ output = append(output, created)
+ }
}
return output, nil
diff --git a/table/snapshot_producers_test.go b/table/snapshot_producers_test.go
index 45fe9518a..8346fe377 100644
--- a/table/snapshot_producers_test.go
+++ b/table/snapshot_producers_test.go
@@ -578,6 +578,205 @@ func TestManifestMergeManagerClosesWriterOnError(t
*testing.T) {
require.ErrorIs(t, err, errLimitedWrite)
}
+func TestManifestMergeManagerClosesWriterBeforeFileOnReadFailure(t *testing.T)
{
+ spec := iceberg.NewPartitionSpec()
+ schema := simpleSchema()
+ trackIO := newTrackingIO()
+ txn := createTestTransaction(t, trackIO, spec)
+ sp := newFastAppendFilesProducer(OpAppend, txn, trackIO, nil, nil)
+ df := newTestDataFile(t, spec, "file://data-1.parquet", nil)
+ manifest := writeTestManifestWithContent(t, trackIO, spec, schema,
sp.snapshotID,
+ "table-location/metadata/manifest-1.avro",
iceberg.ManifestContentData,
+ []iceberg.ManifestEntry{
+ iceberg.NewManifestEntry(iceberg.EntryStatusADDED,
&sp.snapshotID, nil, nil, df),
+ })
+ missingManifest := iceberg.NewManifestFile(2,
"table-location/metadata/missing.avro", 1,
+ int32(spec.ID()), sp.snapshotID).Build()
+
+ mgr := manifestMergeManager{snap: sp}
+ _, err := mgr.createManifest(spec.ID(),
[]iceberg.ManifestFile{manifest, missingManifest})
+ require.ErrorIs(t, err, fs.ErrNotExist)
+ require.NotContains(t, err.Error(), "write after close",
+ "buffered entries must flush before the underlying file closes")
+ require.Equal(t, 1, trackIO.GetWriterCount())
+ require.Empty(t, trackIO.GetUnclosedWriters())
+}
+
+func TestManifestMergeReturnsReadErrorBeforeCreatingWriter(t *testing.T) {
+ spec := iceberg.NewPartitionSpec()
+ schema := simpleSchema()
+ trackIO := newTrackingIO()
+ txn := createTestTransaction(t, trackIO, spec)
+ sp := newFastAppendFilesProducer(OpAppend, txn, trackIO, nil, nil)
+
+ oldSnapshotID := sp.snapshotID - 1
+ sequenceNumber := int64(1)
+ df := newTestDataFile(t, spec, "file://deleted.parquet", nil)
+ deletedManifest := writeTestManifestWithContent(t, trackIO, spec,
schema, oldSnapshotID,
+ "table-location/metadata/historical-delete.avro",
iceberg.ManifestContentData,
+ []iceberg.ManifestEntry{
+ iceberg.NewManifestEntry(iceberg.EntryStatusDELETED,
&oldSnapshotID, &sequenceNumber, nil, df),
+ })
+ missingManifest := iceberg.NewManifestFile(2,
"table-location/metadata/missing.avro", 1,
+ int32(spec.ID()), sp.snapshotID).Build()
+
+ mgr := manifestMergeManager{snap: sp}
+ _, err := mgr.createManifest(spec.ID(),
[]iceberg.ManifestFile{deletedManifest, missingManifest})
+ require.ErrorIs(t, err, fs.ErrNotExist)
+ require.Zero(t, trackIO.GetWriterCount())
+}
+
+func TestManifestMergeSkipsHistoricalDeletedOnlyManifest(t *testing.T) {
+ spec := iceberg.NewPartitionSpec()
+ schema := simpleSchema()
+ trackIO := newTrackingIO()
+ txn := createTestTransaction(t, trackIO, spec)
+ sp := newFastAppendFilesProducer(OpAppend, txn, trackIO, nil, nil)
+
+ oldSnapshotID := sp.snapshotID - 1
+ sequenceNumber := int64(1)
+ df := newTestDataFile(t, spec, "file://deleted.parquet", nil)
+ manifestFile := writeTestManifestWithContent(t, trackIO, spec, schema,
oldSnapshotID,
+ "table-location/metadata/historical-delete.avro",
iceberg.ManifestContentData,
+ []iceberg.ManifestEntry{
+ iceberg.NewManifestEntry(iceberg.EntryStatusDELETED,
&oldSnapshotID, &sequenceNumber, nil, df),
+ })
+
+ mgr := manifestMergeManager{snap: sp}
+ created, err := mgr.createManifest(spec.ID(),
[]iceberg.ManifestFile{manifestFile})
+ require.NoError(t, err)
+ require.Nil(t, created)
+ require.Zero(t, trackIO.GetWriterCount())
+}
+
+func TestManifestMergeKeepsCurrentSnapshotDeletedEntries(t *testing.T) {
+ spec := iceberg.NewPartitionSpec()
+ schema := simpleSchema()
+ txn, wfs := createTestTransactionWithMemIO(t, spec)
+ sp := newFastAppendFilesProducer(OpAppend, txn, wfs, nil, nil)
+
+ sequenceNumber := int64(1)
+ df := newTestDataFile(t, spec, "file://deleted.parquet", nil)
+ manifestFile := writeTestManifestWithContent(t, wfs, spec, schema,
sp.snapshotID,
+ "mem://default/table-location/metadata/current-delete.avro",
iceberg.ManifestContentData,
+ []iceberg.ManifestEntry{
+ iceberg.NewManifestEntry(iceberg.EntryStatusDELETED,
&sp.snapshotID, &sequenceNumber, nil, df),
+ })
+
+ mgr := manifestMergeManager{snap: sp}
+ created, err := mgr.createManifest(spec.ID(),
[]iceberg.ManifestFile{manifestFile})
+ require.NoError(t, err)
+ require.NotNil(t, created)
+
+ var entries []iceberg.ManifestEntry
+ for entry, err := range created.Entries(wfs, false) {
+ require.NoError(t, err)
+ entries = append(entries, entry)
+ }
+ require.Len(t, entries, 1)
+ require.Equal(t, iceberg.EntryStatusDELETED, entries[0].Status())
+ require.Equal(t, sp.snapshotID, entries[0].SnapshotID())
+ require.Equal(t, df.FilePath(), entries[0].DataFile().FilePath())
+}
+
+func TestManifestMergeGroupDropsEmptyMergedBin(t *testing.T) {
+ spec := iceberg.NewPartitionSpec()
+ schema := simpleSchema()
+ txn, wfs := createTestTransactionWithMemIO(t, spec)
+ sp := newFastAppendFilesProducer(OpAppend, txn, wfs, nil, nil)
+
+ oldSnapshotID := sp.snapshotID - 1
+ sequenceNumber := int64(1)
+ manifests := make([]iceberg.ManifestFile, 0, 2)
+ for i := range 2 {
+ df := newTestDataFile(t, spec,
"file://deleted-"+strconv.Itoa(i)+".parquet", nil)
+ manifests = append(manifests, writeTestManifestWithContent(t,
wfs, spec, schema, oldSnapshotID,
+
"mem://default/table-location/metadata/historical-delete-"+strconv.Itoa(i)+".avro",
+ iceberg.ManifestContentData,
+ []iceberg.ManifestEntry{
+
iceberg.NewManifestEntry(iceberg.EntryStatusDELETED, &oldSnapshotID,
&sequenceNumber, nil, df),
+ }))
+ }
+
+ mgr := manifestMergeManager{
+ targetSizeBytes: manifests[0].Length() + manifests[1].Length(),
+ minCountToMerge: 1,
+ mergeEnabled: true,
+ mergeConcurrency: 1,
+ snap: sp,
+ }
+
+ merged, err := mgr.mergeGroup(manifests[0], spec.ID(), manifests)
+ require.NoError(t, err)
+ require.Empty(t, merged)
+}
+
+func TestMergeAppendCommitSkipsHistoricalDeleteOnlyBin(t *testing.T) {
+ spec := iceberg.NewPartitionSpec()
+ txn, wfs := createTestTransactionWithMemIO(t, spec)
+ schema := simpleSchema()
+ parentSnapshotID := txn.meta.newSnapshotID()
+ sequenceNumber := int64(1)
+ historicalManifests := make([]iceberg.ManifestFile, 0, 2)
+ for i := range 2 {
+ dataFile := newTestDataFile(t, spec,
"file://deleted-"+strconv.Itoa(i)+".parquet", nil)
+ historicalManifests = append(historicalManifests,
writeTestManifestWithContent(t, wfs, spec, schema,
+ parentSnapshotID,
"mem://default/table-location/metadata/historical-delete-bin-"+strconv.Itoa(i)+".avro",
+ iceberg.ManifestContentData, []iceberg.ManifestEntry{
+
iceberg.NewManifestEntry(iceberg.EntryStatusDELETED, &parentSnapshotID,
&sequenceNumber, nil, dataFile),
+ }))
+ }
+ manifestListPath :=
"mem://default/table-location/metadata/snap-historical-delete-bin.avro"
+ manifestList, err := wfs.Create(manifestListPath)
+ require.NoError(t, err)
+ require.NoError(t, iceberg.WriteManifestList(
+ 2, manifestList, parentSnapshotID, nil, &sequenceNumber, 0,
historicalManifests))
+ require.NoError(t, manifestList.Close())
+ parentSnapshot := &Snapshot{
+ SnapshotID: parentSnapshotID,
+ SequenceNumber: sequenceNumber,
+ TimestampMs: time.Now().UnixMilli(),
+ ManifestList: manifestListPath,
+ Summary: &Summary{Operation: OpAppend, Properties:
iceberg.Properties{}},
+ }
+ require.NoError(t, txn.meta.AddSnapshot(parentSnapshot))
+ require.NoError(t, txn.meta.SetSnapshotRef(MainBranch,
parentSnapshotID, BranchRef))
+ meta, err := txn.meta.Build()
+ require.NoError(t, err)
+
+ mergeTxn := New(Identifier{"db", "tbl"}, meta, "metadata.json",
func(context.Context) (iceio.IO, error) {
+ return wfs, nil
+ }, nil).NewTransaction()
+ if mergeTxn.meta.props == nil {
+ mergeTxn.meta.props = make(iceberg.Properties)
+ }
+ mergeTxn.meta.props[ManifestMergeEnabledKey] = "true"
+ mergeTxn.meta.props[ManifestMinMergeCountKey] = "2"
+ mergeTxn.meta.props[ManifestTargetSizeBytesKey] = strconv.FormatInt(
+
historicalManifests[0].Length()+historicalManifests[1].Length(), 10)
+ mergeAppend := newMergeAppendFilesProducer(OpAppend, mergeTxn, wfs,
nil, nil)
+ newFile := newTestDataFile(t, spec, "file://new-data.parquet", nil)
+ mergeAppend.appendDataFile(newFile)
+ updates, requirements, err := mergeAppend.commit(context.Background())
+ require.NoError(t, err)
+ require.NoError(t, mergeTxn.apply(updates, requirements))
+ meta, err = mergeTxn.meta.Build()
+ require.NoError(t, err)
+
+ outputManifests, err := meta.CurrentSnapshot().Manifests(wfs)
+ require.NoError(t, err)
+ require.Len(t, outputManifests, 1)
+ require.Equal(t, int32(1), outputManifests[0].AddedDataFiles())
+ var outputEntries []iceberg.ManifestEntry
+ for entry, entryErr := range outputManifests[0].Entries(wfs, false) {
+ require.NoError(t, entryErr)
+ outputEntries = append(outputEntries, entry)
+ }
+ require.Len(t, outputEntries, 1)
+ require.Equal(t, iceberg.EntryStatusADDED, outputEntries[0].Status())
+ require.Equal(t, newFile.FilePath(),
outputEntries[0].DataFile().FilePath())
+}
+
func TestOverwriteFilesExistingManifestsClosesWriterOnError(t *testing.T) {
spec := partitionedSpec()
schema := simpleSchema()