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 407a4cdc8 fix(table): remove a removed data file's deletion vector on 
every replace path (#2239)
407a4cdc8 is described below

commit 407a4cdc8fd73352364d2ebf0af0f597f24ff9d5
Author: Revanth <[email protected]>
AuthorDate: Tue Oct 20 16:13:35 2026 -0500

    fix(table): remove a removed data file's deletion vector on every replace 
path (#2239)
---
 table/dv_rewrite_test.go | 110 +++++++++++++++++++++++++++++++++++++++++++++++
 table/rewrite_files.go   |   4 +-
 table/transaction.go     |  37 ++++++++++++++--
 3 files changed, 147 insertions(+), 4 deletions(-)

diff --git a/table/dv_rewrite_test.go b/table/dv_rewrite_test.go
index abbeed898..0da507fc2 100644
--- a/table/dv_rewrite_test.go
+++ b/table/dv_rewrite_test.go
@@ -21,6 +21,7 @@ import (
        "context"
        "fmt"
        "path/filepath"
+       "strconv"
        "testing"
 
        "github.com/apache/arrow-go/v18/arrow"
@@ -510,3 +511,112 @@ func deleteEntriesReferencing(t *testing.T, tbl 
*table.Table, refs map[string]st
 
        return found
 }
+
+// TestRemovingDataFileRemovesItsUnnamedDV covers #2238: the v3 spec requires a
+// removed data file to take its deletion vector with it, so every replace 
entry
+// point must expunge the DV even when the caller does not name it. Each case 
runs
+// with the files committed in earlier transactions and with everything staged 
in
+// one transaction. ReplaceFiles also removes an unrelated equality delete, 
which
+// takes the path that resolves delete files instead of the data-only one.
+//
+// Scan planning drops a DV whose data file is gone, so the assertions walk the
+// raw delete manifests.
+func TestRemovingDataFileRemovesItsUnnamedDV(t *testing.T) {
+       removers := []struct {
+               name   string
+               remove func(ctx context.Context, tx *table.Transaction, dataB, 
dataC, eqDelete iceberg.DataFile) error
+       }{
+               {"RewriteFiles", func(ctx context.Context, tx 
*table.Transaction, dataB, dataC, _ iceberg.DataFile) error {
+                       return 
tx.NewRewrite(nil).DeleteFile(dataB).AddDataFile(dataC).Commit(ctx)
+               }},
+               {"ReplaceDataFilesWithDataFiles", func(ctx context.Context, tx 
*table.Transaction, dataB, dataC, _ iceberg.DataFile) error {
+                       return tx.ReplaceDataFilesWithDataFiles(ctx, 
[]iceberg.DataFile{dataB}, []iceberg.DataFile{dataC}, nil)
+               }},
+               {"ReplaceDataFiles", func(ctx context.Context, tx 
*table.Transaction, dataB, _, _ iceberg.DataFile) error {
+                       return tx.ReplaceDataFiles(ctx, 
[]string{dataB.FilePath()}, nil, nil)
+               }},
+               {"ReplaceFiles", func(ctx context.Context, tx 
*table.Transaction, dataB, dataC, eqDelete iceberg.DataFile) error {
+                       return tx.ReplaceFiles(ctx, []iceberg.DataFile{dataB}, 
[]iceberg.DataFile{dataC}, []iceberg.DataFile{eqDelete}, nil)
+               }},
+       }
+
+       for _, rm := range removers {
+               for _, oneTxn := range []bool{false, true} {
+                       name := rm.name + "/separate-transactions"
+                       if oneTxn {
+                               name = rm.name + "/one-transaction"
+                       }
+                       t.Run(name, func(t *testing.T) {
+                               ctx := context.Background()
+                               tbl := newRowDeltaCommitTestTableVersion(t, 3)
+                               location := tbl.Location()
+                               dataA := buildDataFile(t, 
location+"/data/a.parquet")
+                               dataB := buildDataFile(t, 
location+"/data/b.parquet")
+                               cBuilder, err := 
iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec, iceberg.EntryContentData,
+                                       location+"/data/c.parquet", 
iceberg.ParquetFile, nil, nil, nil, 10, 1024)
+                               require.NoError(t, err)
+                               // The non-rewrite replace APIs require v3 data 
files to carry a first_row_id.
+                               dataC := cBuilder.FirstRowID(100).Build()
+                               dvB := buildDVFile(t, 
location+"/data/dv-b.puffin", dataB.FilePath())
+                               eqDelete := buildEqDeleteFile(t, 
location+"/data/eq-delete.parquet", []int{1})
+
+                               tx := tbl.NewTransaction()
+                               step := func(fn func(tx *table.Transaction) 
error) {
+                                       t.Helper()
+                                       require.NoError(t, fn(tx))
+                                       if oneTxn {
+                                               return
+                                       }
+                                       tbl, err = tx.Commit(ctx)
+                                       require.NoError(t, err)
+                                       tx = tbl.NewTransaction()
+                               }
+                               step(func(tx *table.Transaction) error {
+                                       return 
tx.NewRowDelta(nil).AddRows(dataA, dataB).Commit(ctx)
+                               })
+                               step(func(tx *table.Transaction) error {
+                                       return 
tx.NewRowDelta(nil).AddDeletes(dvB, eqDelete).Commit(ctx)
+                               })
+                               require.NoError(t, rm.remove(ctx, tx, dataB, 
dataC, eqDelete))
+                               tbl, err = tx.Commit(ctx)
+                               require.NoError(t, err)
+
+                               assert.Empty(t, deleteEntriesReferencing(t, 
tbl, map[string]struct{}{dataB.FilePath(): {}}),
+                                       "a DV must not outlive the data file it 
references")
+                               snap := tbl.CurrentSnapshot()
+                               live, removed := snapshotDeleteEntryFiles(t, 
snap, iceio.LocalFS{})
+                               assert.Contains(t, pathRefPairs(removed), 
[2]string{dvB.FilePath(), dataB.FilePath()})
+                               assert.Equal(t, "1", 
snap.Summary.Properties["removed-position-delete-files"])
+                               assert.Equal(t, strconv.Itoa(len(live)), 
snap.Summary.Properties["total-delete-files"])
+                       })
+               }
+       }
+}
+
+// TestRewriteFiles_UnnamedDVRemovalKeepsSharedPuffinSibling guards the 
removal key:
+// two DVs share one Puffin file, so removing data file A must expunge only 
A's DV
+// entry. B's DV stays live and still hides its row.
+func TestRewriteFiles_UnnamedDVRemovalKeepsSharedPuffinSibling(t *testing.T) {
+       ctx := context.Background()
+       tbl, _, pathA, pathB, dvA, dvB := newTableWithSharedPuffinDVs(t)
+
+       tasks, err := tbl.Scan().PlanFiles(ctx)
+       require.NoError(t, err)
+       var dataA iceberg.DataFile
+       for _, task := range tasks {
+               if task.File.FilePath() == pathA {
+                       dataA = task.File
+               }
+       }
+       require.NotNil(t, dataA)
+
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.NewRewrite(nil).DeleteFile(dataA).Commit(ctx))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+
+       live, removed := snapshotDeleteEntryFiles(t, tbl.CurrentSnapshot(), 
iceio.LocalFS{})
+       assert.Equal(t, [][2]string{{dvB.FilePath(), pathB}}, 
pathRefPairs(live))
+       assert.Equal(t, [][2]string{{dvA.FilePath(), pathA}}, 
pathRefPairs(removed))
+       assertRowCount(t, tbl, 1)
+}
diff --git a/table/rewrite_files.go b/table/rewrite_files.go
index 37c14306f..c9d26b863 100644
--- a/table/rewrite_files.go
+++ b/table/rewrite_files.go
@@ -95,7 +95,9 @@ func (t *Transaction) NewRewrite(snapshotProps 
iceberg.Properties) *RewriteFiles
 // content type: data files are queued as data-file replacements;
 // pos/eq-delete files are queued for delete-file removal alongside
 // the data rewrite (typical when a delete is fully applied to data
-// files being rewritten and is therefore safe to expunge).
+// files being rewritten and is therefore safe to expunge). On v3 a
+// removed data file's live deletion vector is removed with it, so it
+// does not need to be passed here.
 //
 // Any other content type stages an error that is returned from the
 // next [RewriteFiles.Commit] call.
diff --git a/table/transaction.go b/table/transaction.go
index 0966c86b5..ae1615d04 100644
--- a/table/transaction.go
+++ b/table/transaction.go
@@ -939,6 +939,8 @@ func (t *Transaction) Append(ctx context.Context, rdr 
array.RecordReader, snapsh
 // operation is only valid if the data is exactly the same as the previous 
snapshot.
 //
 // For now, we'll keep using an overwrite operation.
+//
+// On v3 a removed data file's live deletion vector is removed with it.
 func (t *Transaction) ReplaceDataFiles(ctx context.Context, filesToDelete, 
filesToAdd []string, snapshotProps iceberg.Properties) error {
        meta, err := t.txnMeta()
        if err != nil {
@@ -1004,6 +1006,15 @@ func (t *Transaction) ReplaceDataFiles(ctx 
context.Context, filesToDelete, files
                return errors.New("cannot delete files that do not belong to 
the table")
        }
 
+       // On v3 every removed file takes its deletion vector with it.
+       // DVs only exist from v3, so v2 skips the read.
+       var dvs liveDeletionVectors
+       if meta.formatVersion >= 3 && len(markedForDeletion) > 0 {
+               if dvs, err = t.readLiveDeletionVectors(fs); err != nil {
+                       return err
+               }
+       }
+
        if meta.NameMapping() == nil {
                nameMapping := meta.CurrentSchema().NameMapping()
                mappingJson, err := json.Marshal(nameMapping)
@@ -1020,7 +1031,7 @@ func (t *Transaction) ReplaceDataFiles(ctx 
context.Context, filesToDelete, files
        updater := t.updateSnapshot(wfs, snapshotProps, 
OpOverwrite).mergeOverwrite(&commitUUID, nil)
 
        for _, df := range markedForDeletion {
-               updater.deleteDataFile(df)
+               dvs.removeDataFile(updater, df)
        }
 
        // SetProperties above may have staged a name mapping, rebuilding 
t.meta;
@@ -1655,6 +1666,8 @@ func (t *Transaction) AddDataFiles(ctx context.Context, 
dataFiles []iceberg.Data
 // Callers are responsible for ensuring each DataFile is valid and consistent 
with the table.
 // Supplying incorrect DataFile metadata can produce an invalid snapshot and 
break reads.
 //
+// On v3 a removed data file's live deletion vector is removed with it.
+//
 // This is useful when:
 //   - Files are written via a separate I/O path and metadata is already known
 //   - Avoiding file scanning improves performance or reliability
@@ -1732,6 +1745,15 @@ func (t *Transaction) ReplaceDataFilesWithDataFiles(ctx 
context.Context, filesTo
                return errors.New("cannot delete files that do not belong to 
the table")
        }
 
+       // On v3 every removed file takes its deletion vector with it.
+       // DVs only exist from v3, so v2 skips the read.
+       var dvs liveDeletionVectors
+       if meta.formatVersion >= 3 {
+               if dvs, err = t.readLiveDeletionVectors(fs); err != nil {
+                       return err
+               }
+       }
+
        if !cfg.skipAutoNameMapping {
                if err := t.ensureNameMapping(); err != nil {
                        return err
@@ -1753,7 +1775,7 @@ func (t *Transaction) ReplaceDataFilesWithDataFiles(ctx 
context.Context, filesTo
        }
 
        for _, df := range markedForDeletion {
-               updater.deleteDataFile(df)
+               dvs.removeDataFile(updater, df)
        }
 
        for _, df := range filesToAdd {
@@ -1783,7 +1805,8 @@ type DeleteFileAddition struct {
 // ReplaceFiles atomically replaces data files and removes associated delete 
files
 // in a single snapshot. This is the commit primitive for compaction: old data 
files
 // are replaced with new (compacted) data files, and delete files that are 
fully
-// applied are removed.
+// applied are removed. On v3 a removed data file's live deletion vector is
+// removed with it, whether or not it is in deleteFilesToRemove.
 func (t *Transaction) ReplaceFiles(ctx context.Context, dataFilesToDelete, 
dataFilesToAdd, deleteFilesToRemove []iceberg.DataFile, snapshotProps 
iceberg.Properties, opts ...WriteOption) error {
        return t.replaceFiles(ctx, dataFilesToDelete, dataFilesToAdd, 
deleteFilesToRemove, nil, nil, snapshotProps, opts...)
 }
@@ -1941,6 +1964,7 @@ func (t *Transaction) replaceFiles(ctx context.Context, 
dataFilesToDelete, dataF
        markedDeleteForRemoval := make([]iceberg.DataFile, 0, 
len(setDeleteFilesToRemove))
        markedAutoDeleteForRemoval := make([]iceberg.DataFile, 0, 
len(autoSetDeleteFilesToRemove))
        markedDVsForRemoval := make(map[string]iceberg.DataFile, 
len(dvsToRemoveByRef))
+       danglingDVs := make(map[string]iceberg.DataFile)
        removedDeleteSequenceNumbers := make([]int64, 0, 
len(deleteFilesToRemove))
        removedDeleteContents := make(map[iceberg.ManifestEntryContent]struct{})
        liveDataFiles := make(map[string]rewriteFileState)
@@ -1985,6 +2009,10 @@ func (t *Transaction) replaceFiles(ctx context.Context, 
dataFilesToDelete, dataF
                                        }
                                } else if want, ok := 
autoDVsToRemoveByRef[*ref]; ok && want.FilePath() == path {
                                        markedDVsForRemoval[*ref] = df
+                               } else if _, ok := setToDelete[*ref]; ok {
+                                       // The spec requires a removed data 
file to take its DV with it, named or not.
+                                       // Like automatic removals, it does not 
set the sequence number of added delete files.
+                                       danglingDVs[*ref] = df
                                }
                        }
                }
@@ -2184,6 +2212,9 @@ func (t *Transaction) replaceFiles(ctx context.Context, 
dataFilesToDelete, dataF
        for _, df := range markedDVsForRemoval {
                updater.removeDeletionVector(df)
        }
+       for _, df := range danglingDVs {
+               updater.removeDeletionVector(df)
+       }
 
        updates, reqs, err := updater.commit(ctx)
        if err != nil {

Reply via email to