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 99bf5dad1 fix(table): replace files must only match live files when 
removing data and delete files (#2058)
99bf5dad1 is described below

commit 99bf5dad1863775a7ddd42df56ded9f65017cc8e
Author: Badal Prasad Singh <[email protected]>
AuthorDate: Sat Sep 26 03:01:07 2026 +0530

    fix(table): replace files must only match live files when removing data and 
delete files (#2058)
    
    * fix
    
    Signed-off-by: badalprasadsingh <[email protected]>
    
    * chore: minor
    
    Signed-off-by: badalprasadsingh <[email protected]>
    
    ---------
    
    Signed-off-by: badalprasadsingh <[email protected]>
---
 table/dv_rewrite_test.go           |  10 +-
 table/replace_files_test.go        | 436 +++++++++++++++++++++++++++++++++++--
 table/rewrite_files_test.go        |   6 +-
 table/row_delta.go                 |   5 +-
 table/snapshots.go                 |   7 +-
 table/transaction.go               |  57 +++--
 table/transaction_internal_test.go |   5 +-
 7 files changed, 458 insertions(+), 68 deletions(-)

diff --git a/table/dv_rewrite_test.go b/table/dv_rewrite_test.go
index 5620b87f1..abbeed898 100644
--- a/table/dv_rewrite_test.go
+++ b/table/dv_rewrite_test.go
@@ -225,7 +225,7 @@ func 
TestRewriteFiles_ExplicitDeleteRewriteIgnoresAutomaticDVRemoval(t *testing.
        tbl, err = tx.Commit(ctx)
        require.NoError(t, err)
 
-       assert.Equal(t, oldEqualitySequence, deleteFileSequence(t, tbl, 
newEqualityPath))
+       assert.Equal(t, oldEqualitySequence, currentManifestEntry(t, tbl, 
newEqualityPath).SequenceNum())
        assert.Empty(t, deleteEntriesReferencing(t, tbl, rewritten))
        assertRowCount(t, tbl, 3)
 }
@@ -246,7 +246,7 @@ func 
TestRewriteFiles_DataSequenceNumberKeepsDVApplicableToReplacement(t *testin
        require.NoError(t, err)
        require.Len(t, tasks, 1)
        oldData := tasks[0].File
-       oldDataSequence := fileDataSequence(t, tbl, oldDataPath)
+       oldDataSequence := currentManifestEntry(t, tbl, 
oldDataPath).SequenceNum()
 
        oldDeletePath := tbl.Location() + "/data/old-pos-delete.parquet"
        writeParquetFile(t, oldDeletePath, table.PositionalDeleteArrowSchema,
@@ -260,7 +260,7 @@ func 
TestRewriteFiles_DataSequenceNumberKeepsDVApplicableToReplacement(t *testin
        require.NoError(t, 
tx.NewRowDelta(nil).AddDeletes(oldDelete).Commit(t.Context()))
        tbl, err = tx.Commit(t.Context())
        require.NoError(t, err)
-       oldDeleteSequence := fileDataSequence(t, tbl, oldDeletePath)
+       oldDeleteSequence := currentManifestEntry(t, tbl, 
oldDeletePath).SequenceNum()
 
        tx = tbl.NewTransaction()
        require.NoError(t, tx.UpgradeFormatVersion(3))
@@ -302,8 +302,8 @@ func 
TestRewriteFiles_DataSequenceNumberKeepsDVApplicableToReplacement(t *testin
        tbl, err = tx.Commit(t.Context())
        require.NoError(t, err)
 
-       assert.Equal(t, oldDataSequence, fileDataSequence(t, tbl, newDataPath))
-       assert.Equal(t, oldDeleteSequence, fileDataSequence(t, tbl, 
newDVs[0].FilePath()))
+       assert.Equal(t, oldDataSequence, currentManifestEntry(t, tbl, 
newDataPath).SequenceNum())
+       assert.Equal(t, oldDeleteSequence, currentManifestEntry(t, tbl, 
newDVs[0].FilePath()).SequenceNum())
        assert.Equal(t, []int64{2}, scanIDs(t, tbl),
                "the deletion vector must remain applicable to the replacement 
data file")
 }
diff --git a/table/replace_files_test.go b/table/replace_files_test.go
index 46f20efb0..a8e2e62fc 100644
--- a/table/replace_files_test.go
+++ b/table/replace_files_test.go
@@ -20,11 +20,15 @@ package table_test
 import (
        "context"
        "fmt"
+       "os"
        "path/filepath"
+       "slices"
        "strconv"
+       "strings"
        "testing"
 
        "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/memory"
        "github.com/apache/iceberg-go"
        iceio "github.com/apache/iceberg-go/io"
        "github.com/apache/iceberg-go/table"
@@ -213,7 +217,7 @@ func 
TestReplaceFilesWithDeleteFilesPreservesDataSequenceNumber(t *testing.T) {
        require.NoError(t, err)
 
        oldDelete := oldDeleteBuilder.Build()
-       oldSequence := deleteFileSequence(t, tbl, oldDelete.FilePath())
+       oldSequence := currentManifestEntry(t, tbl, 
oldDelete.FilePath()).SequenceNum()
        newDeletePath := tbl.Location() + "/data/new-pos-delete.parquet"
        writeParquetFile(t, newDeletePath, table.PositionalDeleteArrowSchema,
                fmt.Sprintf(`[{"file_path":%q,"pos":0}]`, dataPath))
@@ -232,7 +236,7 @@ func 
TestReplaceFilesWithDeleteFilesPreservesDataSequenceNumber(t *testing.T) {
        tbl, err = tx.Commit(t.Context())
        require.NoError(t, err)
 
-       assert.Equal(t, oldSequence, deleteFileSequence(t, tbl, newDeletePath),
+       assert.Equal(t, oldSequence, currentManifestEntry(t, tbl, 
newDeletePath).SequenceNum(),
                "rewritten delete files must retain the replaced data sequence 
number")
        assert.NotEqual(t, tbl.CurrentSnapshot().SequenceNumber, oldSequence,
                "the new snapshot sequence must not replace the delete data 
sequence")
@@ -362,7 +366,7 @@ func 
TestReplaceFilesWithDeleteFilesValidatesDeletionVectorIdentity(t *testing.T
                require.NoError(t, 
tx.NewRowDelta(nil).AddDeletes(sourceDelete).Commit(t.Context()))
                sharedTbl, err := tx.Commit(t.Context())
                require.NoError(t, err)
-               sequence := deleteFileSequence(t, sharedTbl, 
sourceDelete.FilePath())
+               sequence := currentManifestEntry(t, sharedTbl, 
sourceDelete.FilePath()).SequenceNum()
 
                tx = sharedTbl.NewTransaction()
                require.NoError(t, tx.UpgradeFormatVersion(3))
@@ -454,7 +458,7 @@ func 
TestReplaceFilesWithDeleteFilesValidatesExistingPaths(t *testing.T) {
                        }
                        tbl, err = tx.Commit(t.Context())
                        require.NoError(t, err)
-                       sequence := deleteFileSequence(t, tbl, 
source.FilePath())
+                       sequence := currentManifestEntry(t, tbl, 
source.FilePath()).SequenceNum()
 
                        for _, tt := range []struct {
                                name string
@@ -525,7 +529,7 @@ func 
TestReplaceFilesWithDeleteFilesRejectsPartialPositionDeleteToDVRewrite(t *t
        tbl, err = tx.Commit(t.Context())
        require.NoError(t, err)
 
-       sequence := deleteFileSequence(t, tbl, oldDeletes[0].FilePath())
+       sequence := currentManifestEntry(t, tbl, 
oldDeletes[0].FilePath()).SequenceNum()
        offset, length := int64(8), int64(16)
        replacement := newRewriteDeletionVector(t, 
tbl.Location()+"/data/replacement.puffin", dataPath, &offset, &length)
 
@@ -568,7 +572,7 @@ func 
TestReplaceFilesWithDeleteFilesRejectsDeletionVectorPartitionMismatch(t *te
        require.NoError(t, 
tx.NewRowDelta(nil).AddDeletes(oldDelete).Commit(t.Context()))
        tbl, err = tx.Commit(t.Context())
        require.NoError(t, err)
-       deleteSequence := fileDataSequence(t, tbl, oldDeletePath)
+       deleteSequence := currentManifestEntry(t, tbl, 
oldDeletePath).SequenceNum()
 
        tx = tbl.NewTransaction()
        require.NoError(t, tx.UpgradeFormatVersion(3))
@@ -620,7 +624,7 @@ func 
TestReplaceFilesWithDeleteFilesIgnoresOlderSurvivingPositionDelete(t *testi
        require.NoError(t, 
tx.NewRowDelta(nil).AddDeletes(oldPartitionDelete).Commit(t.Context()))
        tbl, err = tx.Commit(t.Context())
        require.NoError(t, err)
-       oldPartitionDeleteSequence := fileDataSequence(t, tbl, 
oldPartitionDeletePath)
+       oldPartitionDeleteSequence := currentManifestEntry(t, tbl, 
oldPartitionDeletePath).SequenceNum()
 
        dataBPath := tbl.Location() + "/data/data-b.parquet"
        dataBBuilder, err := iceberg.NewDataFileBuilder(
@@ -631,7 +635,7 @@ func 
TestReplaceFilesWithDeleteFilesIgnoresOlderSurvivingPositionDelete(t *testi
        require.NoError(t, tx.AddDataFiles(t.Context(), 
[]iceberg.DataFile{dataBBuilder.Build()}, nil))
        tbl, err = tx.Commit(t.Context())
        require.NoError(t, err)
-       dataBSequence := fileDataSequence(t, tbl, dataBPath)
+       dataBSequence := currentManifestEntry(t, tbl, dataBPath).SequenceNum()
        require.Greater(t, dataBSequence, oldPartitionDeleteSequence)
 
        filePathField, ok := 
iceberg.PositionalDeleteSchema.FindFieldByName("file_path")
@@ -651,7 +655,7 @@ func 
TestReplaceFilesWithDeleteFilesIgnoresOlderSurvivingPositionDelete(t *testi
        require.NoError(t, 
tx.NewRowDelta(nil).AddDeletes(newPositionDelete).Commit(t.Context()))
        tbl, err = tx.Commit(t.Context())
        require.NoError(t, err)
-       newPositionDeleteSequence := fileDataSequence(t, tbl, 
newPositionDeletePath)
+       newPositionDeleteSequence := currentManifestEntry(t, tbl, 
newPositionDeletePath).SequenceNum()
 
        tx = tbl.NewTransaction()
        require.NoError(t, tx.UpgradeFormatVersion(3))
@@ -677,7 +681,7 @@ func 
TestReplaceFilesWithDeleteFilesIgnoresOlderSurvivingPositionDelete(t *testi
                "an older position delete cannot apply to the newer target data 
file")
        tbl, err = tx.Commit(t.Context())
        require.NoError(t, err)
-       assert.Equal(t, newPositionDeleteSequence, fileDataSequence(t, tbl, 
replacement.FilePath()))
+       assert.Equal(t, newPositionDeleteSequence, currentManifestEntry(t, tbl, 
replacement.FilePath()).SequenceNum())
 }
 
 func TestReplaceFilesWithDeleteFilesRejectsSurvivingDeletionVector(t 
*testing.T) {
@@ -707,7 +711,7 @@ func 
TestReplaceFilesWithDeleteFilesRejectsSurvivingDeletionVector(t *testing.T)
 
        offset, length := int64(8), int64(16)
        replacement := newRewriteDeletionVector(t, 
tbl.Location()+"/data/replacement-dv.puffin", target, &offset, &length)
-       sequence := deleteFileSequence(t, tbl, siblingDVs[0].FilePath())
+       sequence := currentManifestEntry(t, tbl, 
siblingDVs[0].FilePath()).SequenceNum()
        tx = tbl.NewTransaction()
        err = tx.ReplaceFilesWithDeleteFiles(t.Context(), nil, nil,
                []iceberg.DataFile{siblingDVs[0]},
@@ -729,7 +733,7 @@ func 
TestReplaceFilesWithDeleteFilesAllowsDroppedEqualityField(t *testing.T) {
        require.NoError(t, 
tx.NewRowDelta(nil).AddDeletes(oldDelete).Commit(t.Context()))
        tbl, err = tx.Commit(t.Context())
        require.NoError(t, err)
-       sequence := deleteFileSequence(t, tbl, oldDelete.FilePath())
+       sequence := currentManifestEntry(t, tbl, 
oldDelete.FilePath()).SequenceNum()
 
        tx = tbl.NewTransaction()
        require.NoError(t, tx.UpdateSchema(true, 
false).DeleteColumn([]string{"data"}).Commit())
@@ -750,11 +754,9 @@ func 
TestReplaceFilesWithDeleteFilesAllowsDroppedEqualityField(t *testing.T) {
                []table.DeleteFileAddition{{File: newDelete, 
DataSequenceNumber: sequence}}, nil))
 }
 
-func deleteFileSequence(t *testing.T, tbl *table.Table, path string) int64 {
-       return fileDataSequence(t, tbl, path)
-}
-
-func fileDataSequence(t *testing.T, tbl *table.Table, path string) int64 {
+// currentManifestEntry returns the manifest entry for path in the current
+// snapshot, including DELETED entries.
+func currentManifestEntry(t *testing.T, tbl *table.Table, path string) 
iceberg.ManifestEntry {
        t.Helper()
        snap := tbl.CurrentSnapshot()
        require.NotNil(t, snap)
@@ -764,13 +766,13 @@ func fileDataSequence(t *testing.T, tbl *table.Table, 
path string) int64 {
                for entry, err := range manifest.Entries(iceio.LocalFS{}, 
false) {
                        require.NoError(t, err)
                        if entry.DataFile().FilePath() == path {
-                               return entry.SequenceNum()
+                               return entry
                        }
                }
        }
        t.Fatalf("file %q not found in current snapshot", path)
 
-       return -1
+       return nil
 }
 
 func scanIDs(t *testing.T, tbl *table.Table) []int64 {
@@ -936,3 +938,399 @@ func TestReplaceFiles_ValidationErrors(t *testing.T) {
                assert.Contains(t, err.Error(), "cannot remove deletion vectors 
that do not belong to the table")
        })
 }
+
+// seedDataFilesWithPositionDeletes creates two v2 data files (ids 1-3 and 4-6)
+// each with a position delete on a different row. Tasks are sorted by path.
+func seedDataFilesWithPositionDeletes(t *testing.T) (*table.Table, 
[]table.FileScanTask) {
+       t.Helper()
+
+       tbl := newReplaceFilesTestTable(t)
+       arrowSc, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false, 
false)
+       require.NoError(t, err)
+
+       files := []struct {
+               rows      string
+               deletePos int64
+       }{
+               {
+                       rows:      `[{"id":1,"data":"a"}, {"id":2,"data":"b"}, 
{"id":3,"data":"c"}]`,
+                       deletePos: 0,
+               },
+               {
+                       rows:      `[{"id":4,"data":"d"}, {"id":5,"data":"e"}, 
{"id":6,"data":"f"}]`,
+                       deletePos: 1,
+               },
+       }
+       dataPaths := make([]string, len(files))
+       for i, f := range files {
+               dataPaths[i] = fmt.Sprintf("%s/data/data-%d.parquet", 
tbl.Location(), i)
+               writeParquetFile(t, dataPaths[i], arrowSc, f.rows)
+       }
+
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.AddFiles(t.Context(), dataPaths, nil, false))
+       tbl, err = tx.Commit(t.Context())
+       require.NoError(t, err)
+
+       filePathField, ok := 
iceberg.PositionalDeleteSchema.FindFieldByName("file_path")
+       require.True(t, ok)
+       tx = tbl.NewTransaction()
+       rowDelta := tx.NewRowDelta(nil)
+       for i, f := range files {
+               deletePath := fmt.Sprintf("%s/data/pos-delete-%d.parquet", 
tbl.Location(), i)
+               row := fmt.Sprintf(`[{"file_path":%q,"pos":%d}]`, dataPaths[i], 
f.deletePos)
+
+               writeParquetFile(t, deletePath, 
table.PositionalDeleteArrowSchema, row)
+               bound, err := 
iceberg.StringLiteral(dataPaths[i]).MarshalBinary()
+               require.NoError(t, err)
+
+               builder, err := iceberg.NewDataFileBuilder(
+                       *iceberg.UnpartitionedSpec, 
iceberg.EntryContentPosDeletes,
+                       deletePath, iceberg.ParquetFile, nil, nil, nil, 1, 128,
+               )
+               require.NoError(t, err)
+               rowDelta.AddDeletes(builder.
+                       LowerBoundValues(map[int][]byte{filePathField.ID: 
bound}).
+                       UpperBoundValues(map[int][]byte{filePathField.ID: 
bound}).
+                       Build())
+       }
+
+       require.NoError(t, rowDelta.Commit(t.Context()))
+       tbl, err = tx.Commit(t.Context())
+       require.NoError(t, err)
+       require.Equal(t, []int64{2, 3, 4, 6}, idsInTable(t, tbl))
+
+       tasks, err := tbl.Scan().PlanFiles(t.Context())
+       require.NoError(t, err)
+       require.Len(t, tasks, len(files))
+       slices.SortFunc(tasks, func(a, b table.FileScanTask) int {
+               return strings.Compare(a.File.FilePath(), b.File.FilePath())
+       })
+       for _, task := range tasks {
+               require.Len(t, task.DeleteFiles, 1, "each data file must carry 
only its own position delete")
+       }
+
+       return tbl, tasks
+}
+
+// seedReplaceFilesTableWithDelete returns a table with one data file and one 
delete on it:
+// a position delete on v2, a deletion vector on v3.
+func seedReplaceFilesTableWithDelete(t *testing.T, version int) (*table.Table, 
iceberg.DataFile, iceberg.DataFile) {
+       t.Helper()
+
+       switch version {
+       case 2:
+               tbl, tasks := seedDataFilesWithPositionDeletes(t)
+
+               return tbl, tasks[0].File, tasks[0].DeleteFiles[0]
+       case 3:
+               tbl, target := seedV3TableWithDV(t)
+               tasks, err := tbl.Scan().PlanFiles(t.Context())
+               require.NoError(t, err)
+
+               for _, task := range tasks {
+                       if task.File.FilePath() == target {
+                               require.Len(t, task.DeletionVectorFiles, 1)
+
+                               return tbl, task.File, 
task.DeletionVectorFiles[0]
+                       }
+               }
+               t.Fatalf("deletion vector target %q not found in scan tasks", 
target)
+       default:
+               t.Fatalf("unsupported format version %d", version)
+       }
+
+       return nil, nil, nil
+}
+
+// seedSupersededDeletionVector deletes twice from one v3 data file.
+// The first DV gets replaced and stays only as a DELETED entry.
+// It returns that old DV and the scan task holding the new, live DV.
+func seedSupersededDeletionVector(t *testing.T) (*table.Table, 
table.FileScanTask, iceberg.DataFile) {
+       t.Helper()
+
+       tbl := newMergeOnReadTestTableVersion(t, "3")
+       arrowSc, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false, 
false)
+       require.NoError(t, err)
+
+       data, err := array.TableFromJSON(memory.DefaultAllocator, arrowSc, 
[]string{
+               `[{"id":1,"data":"a"},{"id":2,"data":"b"},
+               {"id":3,"data":"c"},{"id":4,"data":"d"},
+               {"id":5,"data":"e"}]`,
+       })
+       require.NoError(t, err)
+       defer data.Release()
+
+       tbl, err = tbl.Append(t.Context(), array.NewTableReader(data, -1), nil)
+       require.NoError(t, err)
+
+       var superseded iceberg.DataFile
+       for _, id := range []int64{2, 4} {
+               tbl, err = tbl.Delete(t.Context(), 
iceberg.EqualTo(iceberg.Reference("id"), id), nil)
+               require.NoError(t, err)
+
+               if superseded == nil {
+                       tasks, err := tbl.Scan().PlanFiles(t.Context())
+                       require.NoError(t, err)
+                       require.Len(t, tasks, 1)
+                       require.Len(t, tasks[0].DeletionVectorFiles, 1)
+                       superseded = tasks[0].DeletionVectorFiles[0]
+               }
+       }
+       require.Equal(t, 1, liveDVCount(t, tbl))
+       require.Equal(t, iceberg.EntryStatusDELETED, currentManifestEntry(t, 
tbl, superseded.FilePath()).Status(),
+               "the second delete must keep the first DV only as a DELETED 
entry")
+
+       tasks, err := tbl.Scan().PlanFiles(t.Context())
+       require.NoError(t, err)
+       require.Len(t, tasks, 1)
+       require.Len(t, tasks[0].DeletionVectorFiles, 1)
+       require.Equal(t, superseded.ReferencedDataFile(), 
tasks[0].DeletionVectorFiles[0].ReferencedDataFile())
+       require.NotEqual(t, superseded.FilePath(), 
tasks[0].DeletionVectorFiles[0].FilePath())
+
+       return tbl, tasks[0], superseded
+}
+
+func newCompactedDataFile(t *testing.T, tbl *table.Table, path string, 
recordCount int64) iceberg.DataFile {
+       t.Helper()
+
+       builder, err := iceberg.NewDataFileBuilder(
+               *iceberg.UnpartitionedSpec, iceberg.EntryContentData,
+               path, iceberg.ParquetFile, nil, nil, nil, recordCount, 512,
+       )
+       require.NoError(t, err)
+       if tbl.Metadata().Version() >= 3 {
+               builder.FirstRowID(tbl.Metadata().NextRowID())
+       }
+
+       return builder.Build()
+}
+
+// compactWithDeletes swaps data for compacted and removes dels, either through
+// ReplaceFiles or, when automatic is set, through RewriteFiles.
+func compactWithDeletes(ctx context.Context, tx *table.Transaction, automatic 
bool, data, compacted, dels []iceberg.DataFile) error {
+       if !automatic {
+               return tx.ReplaceFiles(ctx, data, compacted, dels, nil)
+       }
+
+       result := table.CompactionGroupResult{OldDataFiles: data, NewDataFiles: 
compacted}
+       for _, del := range dels {
+               if table.IsDeletionVector(del) {
+                       result.SafeDeletionVectors = 
append(result.SafeDeletionVectors, del)
+               } else {
+                       result.SafePosDeletes = append(result.SafePosDeletes, 
del)
+               }
+       }
+
+       return tx.NewRewrite(nil).ApplyResult(result).Commit(ctx)
+}
+
+// metadataFileCount counts files in the table's metadata folder.
+// Staging a commit writes files there, so an unchanged count means nothing 
was written.
+func metadataFileCount(t *testing.T, tbl *table.Table) int {
+       t.Helper()
+
+       entries, err := os.ReadDir(filepath.FromSlash(tbl.Location() + 
"/metadata"))
+       require.NoError(t, err)
+
+       return len(entries)
+}
+
+// A compaction planned before another writer deleted one of its data files 
must fail.
+// Committing it would bring the deleted rows back.
+func TestReplaceFilesRejectsDataFileDeletedByCurrentSnapshot(t *testing.T) {
+       tbl, tasks := seedDataFilesWithPositionDeletes(t)
+       arrowSc, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false, 
false)
+       require.NoError(t, err)
+       compactedPath := tbl.Location() + "/data/compacted-0.parquet"
+
+       jsonData := `[{"id":2,"data":"b"},{"id":3,"data":"c"}]`
+       writeParquetFile(t, compactedPath, arrowSc, jsonData)
+
+       path := tbl.Location() + "/data/compacted-1.parquet"
+       compacted := []iceberg.DataFile{
+               newCompactedDataFile(t, tbl, compactedPath, 2),
+               newCompactedDataFile(t, tbl, path, 2),
+       }
+
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.Delete(t.Context(), 
iceberg.GreaterThanEqual(iceberg.Reference("id"), int64(4)), nil))
+       tbl, err = tx.Commit(t.Context())
+       require.NoError(t, err)
+       require.Equal(t, []int64{2, 3}, idsInTable(t, tbl))
+       require.Equal(t, iceberg.EntryStatusDELETED, currentManifestEntry(t, 
tbl, tasks[1].File.FilePath()).Status())
+       require.NotEqual(t, iceberg.EntryStatusDELETED, currentManifestEntry(t, 
tbl, tasks[0].File.FilePath()).Status())
+
+       before := metadataFileCount(t, tbl)
+       tx = tbl.NewTransaction()
+       err = tx.ReplaceFiles(t.Context(),
+               []iceberg.DataFile{tasks[0].File, tasks[1].File}, compacted,
+               []iceberg.DataFile{tasks[0].DeleteFiles[0], 
tasks[1].DeleteFiles[0]}, nil)
+       assert.ErrorContains(t, err, "cannot delete data files that do not 
belong to the table")
+       assert.Equal(t, before, metadataFileCount(t, tbl), "a rejected replace 
must not write manifests")
+
+       tx = tbl.NewTransaction()
+       require.NoError(t, tx.ReplaceFiles(t.Context(), 
[]iceberg.DataFile{tasks[0].File}, compacted[:1], tasks[0].DeleteFiles, nil),
+               "the data file the delete left live must still compact")
+       assert.Greater(t, metadataFileCount(t, tbl), before, "staging a replace 
must write manifests")
+
+       tbl, err = tx.Commit(t.Context())
+       require.NoError(t, err)
+       assert.Equal(t, []int64{2, 3}, idsInTable(t, tbl))
+}
+
+func TestReplaceFilesRejectsDeleteFileRemovedByCurrentSnapshot(t *testing.T) {
+       for _, tt := range []struct {
+               name      string
+               version   int
+               automatic bool
+               wantErr   string
+       }{
+               {
+                       name:      "position delete",
+                       version:   2,
+                       automatic: false,
+                       wantErr:   "cannot remove delete files that do not 
belong to the table",
+               },
+               {
+                       name:      "automatic position delete",
+                       version:   2,
+                       automatic: true,
+                       wantErr:   "cannot remove automatic delete files that 
do not belong to the table",
+               },
+               {
+                       name:      "deletion vector",
+                       version:   3,
+                       automatic: false,
+                       wantErr:   "cannot remove deletion vectors that do not 
belong to the table",
+               },
+               {
+                       name:      "automatic deletion vector",
+                       version:   3,
+                       automatic: true,
+                       wantErr:   "cannot remove deletion vectors that do not 
belong to the table",
+               },
+       } {
+               t.Run(tt.name, func(t *testing.T) {
+                       tbl, data, del := seedReplaceFilesTableWithDelete(t, 
tt.version)
+
+                       tx := tbl.NewTransaction()
+                       require.NoError(t, tx.ReplaceFiles(t.Context(), nil, 
nil, []iceberg.DataFile{del}, nil))
+                       tbl, err := tx.Commit(t.Context())
+                       require.NoError(t, err)
+                       require.Equal(t, iceberg.EntryStatusDELETED, 
currentManifestEntry(t, tbl, del.FilePath()).Status())
+                       require.NotEqual(t, iceberg.EntryStatusDELETED, 
currentManifestEntry(t, tbl, data.FilePath()).Status())
+
+                       compacted := newCompactedDataFile(t, tbl, 
tbl.Location()+"/data/compacted.parquet", 1)
+                       before := metadataFileCount(t, tbl)
+                       err = compactWithDeletes(t.Context(), 
tbl.NewTransaction(), tt.automatic,
+                               []iceberg.DataFile{data}, 
[]iceberg.DataFile{compacted}, []iceberg.DataFile{del})
+                       assert.ErrorContains(t, err, tt.wantErr)
+                       assert.Equal(t, before, metadataFileCount(t, tbl), "a 
rejected replace must not write manifests")
+               })
+       }
+}
+
+// A rewrite planned with an old DV must fail once a newer DV has replaced it,
+// because the newer DV has deletes the rewrite never saw.
+func TestReplaceFilesRejectsDeletionVectorSupersededByCurrentSnapshot(t 
*testing.T) {
+       type stageFunc func(t *testing.T, tx *table.Transaction, tbl 
*table.Table, task table.FileScanTask, superseded iceberg.DataFile) error
+       compaction := func(automatic bool) stageFunc {
+               return func(t *testing.T, tx *table.Transaction, tbl 
*table.Table, task table.FileScanTask, superseded iceberg.DataFile) error {
+                       compacted := newCompactedDataFile(t, tbl, 
tbl.Location()+"/data/compacted.parquet", 4)
+
+                       return compactWithDeletes(t.Context(), tx, automatic,
+                               []iceberg.DataFile{task.File}, 
[]iceberg.DataFile{compacted}, []iceberg.DataFile{superseded})
+               }
+       }
+
+       for _, tt := range []struct {
+               name  string
+               stage stageFunc
+       }{
+               {
+                       name:  "compaction",
+                       stage: compaction(false),
+               },
+               {
+                       name:  "automatic compaction",
+                       stage: compaction(true),
+               },
+               {
+                       name: "deletion vector rewrite",
+                       stage: func(t *testing.T, tx *table.Transaction, tbl 
*table.Table, task table.FileScanTask, superseded iceberg.DataFile) error {
+                               writer := dv.NewDVWriter(iceio.LocalFS{}, 
unpartitionedSpecByID)
+                               require.NoError(t, 
writer.Add(task.File.FilePath(), []int64{1}, 0, nil))
+                               rewritten, err := writer.Flush(t.Context(), 
tbl.Location()+"/data/rewritten-dv.puffin")
+                               require.NoError(t, err)
+                               require.Len(t, rewritten, 1)
+
+                               return 
tx.ReplaceFilesWithDeleteFiles(t.Context(), nil, nil,
+                                       []iceberg.DataFile{superseded},
+                                       []table.DeleteFileAddition{{
+                                               File:               
rewritten[0],
+                                               DataSequenceNumber: 
currentManifestEntry(t, tbl, superseded.FilePath()).SequenceNum(),
+                                       }}, nil)
+                       },
+               },
+       } {
+               t.Run(tt.name, func(t *testing.T) {
+                       tbl, task, superseded := seedSupersededDeletionVector(t)
+
+                       before := metadataFileCount(t, tbl)
+                       err := tt.stage(t, tbl.NewTransaction(), tbl, task, 
superseded)
+                       assert.ErrorContains(t, err, "cannot remove deletion 
vectors that do not belong to the table")
+                       assert.Equal(t, before, metadataFileCount(t, tbl), "a 
rejected replace must not write manifests")
+               })
+       }
+}
+
+// Removing the current DV must remove that DV, not the old replaced one that
+// is still listed as a DELETED entry.
+func TestReplaceFilesRemovesLiveDeletionVectorNotSupersededEntry(t *testing.T) 
{
+       t.Run("compaction", func(t *testing.T) {
+               tbl, task, _ := seedSupersededDeletionVector(t)
+               arrowSc, err := table.SchemaToArrowSchema(tbl.Schema(), nil, 
false, false)
+               require.NoError(t, err)
+
+               compactedPath := tbl.Location() + "/data/compacted.parquet"
+               jsonData := 
`[{"id":1,"data":"a"},{"id":3,"data":"c"},{"id":5,"data":"e"}]`
+               writeParquetFile(t, compactedPath, arrowSc, jsonData)
+
+               tx := tbl.NewTransaction()
+               require.NoError(t, tx.ReplaceFiles(t.Context(),
+                       []iceberg.DataFile{task.File},
+                       []iceberg.DataFile{newCompactedDataFile(t, tbl, 
compactedPath, 3)},
+                       task.DeletionVectorFiles, nil))
+               tbl, err = tx.Commit(t.Context())
+               require.NoError(t, err)
+
+               assert.Zero(t, liveDVCount(t, tbl), "the live deletion vector 
must be removed with its data file")
+               assert.Equal(t, []int64{1, 3, 5}, idsInTable(t, tbl))
+       })
+
+       t.Run("deletion vector rewrite", func(t *testing.T) {
+               tbl, task, _ := seedSupersededDeletionVector(t)
+               liveDV := task.DeletionVectorFiles[0]
+               writer := dv.NewDVWriter(iceio.LocalFS{}, unpartitionedSpecByID)
+               require.NoError(t, writer.Add(task.File.FilePath(), []int64{1, 
3}, 0, nil))
+
+               rewritten, err := writer.Flush(t.Context(), 
tbl.Location()+"/data/rewritten-dv.puffin")
+               require.NoError(t, err)
+               require.Len(t, rewritten, 1)
+
+               tx := tbl.NewTransaction()
+               require.NoError(t, tx.ReplaceFilesWithDeleteFiles(t.Context(), 
nil, nil,
+                       []iceberg.DataFile{liveDV},
+                       []table.DeleteFileAddition{{
+                               File:               rewritten[0],
+                               DataSequenceNumber: currentManifestEntry(t, 
tbl, liveDV.FilePath()).SequenceNum(),
+                       }}, nil))
+               tbl, err = tx.Commit(t.Context())
+               require.NoError(t, err)
+
+               assert.Equal(t, 1, liveDVCount(t, tbl), "a data file must keep 
exactly one deletion vector")
+               assert.Equal(t, iceberg.EntryStatusDELETED, 
currentManifestEntry(t, tbl, liveDV.FilePath()).Status())
+               assert.Equal(t, []int64{1, 3, 5}, idsInTable(t, tbl))
+       })
+}
diff --git a/table/rewrite_files_test.go b/table/rewrite_files_test.go
index d205d62ae..63ed00b25 100644
--- a/table/rewrite_files_test.go
+++ b/table/rewrite_files_test.go
@@ -327,7 +327,7 @@ func 
TestRewriteFiles_PreservesIndependentPositionDeleteSequences(t *testing.T)
                tbl, err = tx.Commit(t.Context())
                require.NoError(t, err)
                oldDeletes = append(oldDeletes, builder)
-               oldSequences = append(oldSequences, deleteFileSequence(t, tbl, 
path))
+               oldSequences = append(oldSequences, currentManifestEntry(t, 
tbl, path).SequenceNum())
        }
        require.NotEqual(t, oldSequences[0], oldSequences[1])
 
@@ -349,7 +349,7 @@ func 
TestRewriteFiles_PreservesIndependentPositionDeleteSequences(t *testing.T)
        require.NoError(t, err)
 
        for i, df := range newDeletes {
-               assert.Equal(t, oldSequences[i], deleteFileSequence(t, tbl, 
df.FilePath()))
+               assert.Equal(t, oldSequences[i], currentManifestEntry(t, tbl, 
df.FilePath()).SequenceNum())
        }
        assert.Empty(t, scanIDs(t, tbl),
                "each replacement must retain the applicability of its 
corresponding old position delete")
@@ -814,7 +814,7 @@ func 
TestRewriteFiles_RejectsConcurrentDeleteForAddedDVTarget(t *testing.T) {
        require.NoError(t, 
tx.NewRowDelta(nil).AddDeletes(oldDelete).Commit(t.Context()))
        tbl, err = tx.Commit(t.Context())
        require.NoError(t, err)
-       oldDeleteSequence := fileDataSequence(t, tbl, oldDelete.FilePath())
+       oldDeleteSequence := currentManifestEntry(t, tbl, 
oldDelete.FilePath()).SequenceNum()
 
        tx = tbl.NewTransaction()
        require.NoError(t, tx.UpgradeFormatVersion(3))
diff --git a/table/row_delta.go b/table/row_delta.go
index 0e9508528..a8fee000f 100644
--- a/table/row_delta.go
+++ b/table/row_delta.go
@@ -435,13 +435,10 @@ func (rd *RowDelta) resolveRemovedDeletes(fs iceio.IO, 
meta *MetadataBuilder) (r
        }
 
        liveByPath := make(map[string][]iceberg.DataFile, len(rd.removedDels))
-       for entry, err := range snap.entries(fs, 
iceberg.ManifestContentDeletes) {
+       for entry, err := range snap.entries(fs, 
iceberg.ManifestContentDeletes, true) {
                if err != nil {
                        return nil, nil, err
                }
-               if entry.Status() == iceberg.EntryStatusDELETED {
-                       continue
-               }
                df := entry.DataFile()
                if _, ok := want[df.FilePath()]; ok {
                        liveByPath[df.FilePath()] = 
append(liveByPath[df.FilePath()], df)
diff --git a/table/snapshots.go b/table/snapshots.go
index 8869515c1..4d084332b 100644
--- a/table/snapshots.go
+++ b/table/snapshots.go
@@ -508,8 +508,9 @@ func (s Snapshot) dataFiles(fio iceio.IO, fileFilter 
set[iceberg.ManifestEntryCo
 // matters.
 //
 // manifestContent < 0 yields entries across both data and delete
-// manifests.
-func (s Snapshot) entries(fio iceio.IO, manifestContent 
iceberg.ManifestContent) iter.Seq2[iceberg.ManifestEntry, error] {
+// manifests. Set discardDeleted to skip DELETED entries: they are files
+// already removed from the table, not files still in it.
+func (s Snapshot) entries(fio iceio.IO, manifestContent 
iceberg.ManifestContent, discardDeleted bool) iter.Seq2[iceberg.ManifestEntry, 
error] {
        return func(yield func(iceberg.ManifestEntry, error) bool) {
                manifests, err := s.Manifests(fio)
                if err != nil {
@@ -523,7 +524,7 @@ func (s Snapshot) entries(fio iceio.IO, manifestContent 
iceberg.ManifestContent)
                                continue
                        }
 
-                       for entry, err := range m.Entries(fio, false) {
+                       for entry, err := range m.Entries(fio, discardDeleted) {
                                if err != nil {
                                        yield(nil, err)
 
diff --git a/table/transaction.go b/table/transaction.go
index ab03f560a..22687295f 100644
--- a/table/transaction.go
+++ b/table/transaction.go
@@ -1846,7 +1846,7 @@ func (t *Transaction) replaceFiles(ctx context.Context, 
dataFilesToDelete, dataF
        }
 
        setDeleteFilesToRemove := make(map[string]struct{}, 
len(deleteFilesToRemove))
-       dvRefsToRemove := make(map[string]struct{}, len(deleteFilesToRemove))
+       dvsToRemoveByRef := make(map[string]iceberg.DataFile, 
len(deleteFilesToRemove))
        for i, df := range deleteFilesToRemove {
                if df == nil {
                        return fmt.Errorf("nil delete file at index %d for 
ReplaceFiles", i)
@@ -1860,10 +1860,10 @@ func (t *Transaction) replaceFiles(ctx context.Context, 
dataFilesToDelete, dataF
                        if ref == nil {
                                return errors.New("deletion vector to remove is 
missing referenced_data_file for ReplaceFiles")
                        }
-                       if _, ok := dvRefsToRemove[*ref]; ok {
+                       if _, ok := dvsToRemoveByRef[*ref]; ok {
                                return errors.New("deletion vectors to remove 
must reference distinct data files for ReplaceFiles")
                        }
-                       dvRefsToRemove[*ref] = struct{}{}
+                       dvsToRemoveByRef[*ref] = df
 
                        continue
                }
@@ -1873,7 +1873,7 @@ func (t *Transaction) replaceFiles(ctx context.Context, 
dataFilesToDelete, dataF
                setDeleteFilesToRemove[path] = struct{}{}
        }
        autoSetDeleteFilesToRemove := make(map[string]struct{}, 
len(autoDeleteFilesToRemove))
-       autoDVRefsToRemove := make(map[string]struct{}, 
len(autoDeleteFilesToRemove))
+       autoDVsToRemoveByRef := make(map[string]iceberg.DataFile, 
len(autoDeleteFilesToRemove))
        for i, df := range autoDeleteFilesToRemove {
                if df == nil {
                        return fmt.Errorf("nil automatic delete file at index 
%d for ReplaceFiles", i)
@@ -1887,13 +1887,13 @@ func (t *Transaction) replaceFiles(ctx context.Context, 
dataFilesToDelete, dataF
                        if ref == nil {
                                return errors.New("automatic deletion vector to 
remove is missing referenced_data_file for ReplaceFiles")
                        }
-                       if _, ok := dvRefsToRemove[*ref]; ok {
+                       if _, ok := dvsToRemoveByRef[*ref]; ok {
                                return errors.New("delete vectors to remove 
must reference distinct data files for ReplaceFiles")
                        }
-                       if _, ok := autoDVRefsToRemove[*ref]; ok {
+                       if _, ok := autoDVsToRemoveByRef[*ref]; ok {
                                return errors.New("automatic deletion vectors 
to remove must reference distinct data files for ReplaceFiles")
                        }
-                       autoDVRefsToRemove[*ref] = struct{}{}
+                       autoDVsToRemoveByRef[*ref] = df
 
                        continue
                }
@@ -1925,26 +1925,28 @@ func (t *Transaction) replaceFiles(ctx context.Context, 
dataFilesToDelete, dataF
        markedDataForDeletion := make([]iceberg.DataFile, 0, len(setToDelete))
        markedDeleteForRemoval := make([]iceberg.DataFile, 0, 
len(setDeleteFilesToRemove))
        markedAutoDeleteForRemoval := make([]iceberg.DataFile, 0, 
len(autoSetDeleteFilesToRemove))
-       markedDVsForRemoval := make(map[string]iceberg.DataFile, 
len(dvRefsToRemove))
+       markedDVsForRemoval := make(map[string]iceberg.DataFile, 
len(dvsToRemoveByRef))
        removedDeleteSequenceNumbers := make([]int64, 0, 
len(deleteFilesToRemove))
        removedDeleteContents := make(map[iceberg.ManifestEntryContent]struct{})
        liveDataFiles := make(map[string]rewriteFileState)
        survivingPositionDeletes := make([]rewriteFileState, 0)
-       for entry, err := range s.entries(fs, -1) {
+       for entry, err := range s.entries(fs, -1, false) {
                if err != nil {
                        return err
                }
                df := entry.DataFile()
                path := df.FilePath()
                isData := df.ContentType() == iceberg.EntryContentData
+               // A DELETED entry is a file already removed from the table. It 
must not
+               // count as found when removing files, but it still blocks 
re-adding its path.
                isLive := entry.Status() != iceberg.EntryStatusDELETED
                if isData && isLive {
                        liveDataFiles[path] = rewriteFileState{file: df, 
dataSequenceNumber: entry.SequenceNum()}
+                       if _, ok := setToDelete[path]; ok {
+                               markedDataForDeletion = 
append(markedDataForDeletion, df)
+                       }
                }
-               if _, ok := setToDelete[path]; ok && isData {
-                       markedDataForDeletion = append(markedDataForDeletion, 
df)
-               }
-               if !isData {
+               if !isData && isLive {
                        if _, ok := setDeleteFilesToRemove[path]; ok {
                                markedDeleteForRemoval = 
append(markedDeleteForRemoval, df)
                                if seq := entry.SequenceNum(); seq >= 0 {
@@ -1956,7 +1958,9 @@ func (t *Transaction) replaceFiles(ctx context.Context, 
dataFilesToDelete, dataF
                        } else if _, ok := autoSetDeleteFilesToRemove[path]; ok 
{
                                markedAutoDeleteForRemoval = 
append(markedAutoDeleteForRemoval, df)
                        } else if ref := 
iceberginternal.BorrowedDataFileReferencedDataFile(df); IsDeletionVector(df) && 
ref != nil {
-                               if _, ok := dvRefsToRemove[*ref]; ok {
+                               // Match the DV's path too, not only its data 
file. Otherwise an old DV that
+                               // was already replaced would match the newer 
DV, which holds more deletes.
+                               if want, ok := dvsToRemoveByRef[*ref]; ok && 
want.FilePath() == path {
                                        markedDVsForRemoval[*ref] = df
                                        if seq := entry.SequenceNum(); seq >= 0 
{
                                                removedDeleteSequenceNumbers = 
append(removedDeleteSequenceNumbers, seq)
@@ -1964,7 +1968,7 @@ func (t *Transaction) replaceFiles(ctx context.Context, 
dataFilesToDelete, dataF
                                        } else {
                                                return fmt.Errorf("deletion 
vector %s has no data sequence number in the current snapshot", path)
                                        }
-                               } else if _, ok := autoDVRefsToRemove[*ref]; ok 
{
+                               } else if want, ok := 
autoDVsToRemoveByRef[*ref]; ok && want.FilePath() == path {
                                        markedDVsForRemoval[*ref] = df
                                }
                        }
@@ -2001,10 +2005,10 @@ func (t *Transaction) replaceFiles(ctx context.Context, 
dataFilesToDelete, dataF
                        if _, addingReplacement := 
setDeleteFilesToAdd.dvsByRef[*ref]; !addingReplacement {
                                continue
                        }
-                       if _, explicitlyRemoved := dvRefsToRemove[*ref]; 
explicitlyRemoved {
+                       if _, explicitlyRemoved := dvsToRemoveByRef[*ref]; 
explicitlyRemoved {
                                continue
                        }
-                       if _, automaticallyRemoved := autoDVRefsToRemove[*ref]; 
automaticallyRemoved {
+                       if _, automaticallyRemoved := 
autoDVsToRemoveByRef[*ref]; automaticallyRemoved {
                                continue
                        }
 
@@ -2043,9 +2047,9 @@ func (t *Transaction) replaceFiles(ctx context.Context, 
dataFilesToDelete, dataF
        if len(markedAutoDeleteForRemoval) != len(autoSetDeleteFilesToRemove) {
                return errors.New("cannot remove automatic delete files that do 
not belong to the table")
        }
-       // Keyed by referenced data file, so duplicate DV entries for one ref 
collapse
-       // to one slot; equality then means every requested ref exists in the 
table.
-       if len(markedDVsForRemoval) != 
len(dvRefsToRemove)+len(autoDVRefsToRemove) {
+       // markedDVsForRemoval has one slot per data file, so equal counts mean
+       // every requested DV was found live in the table.
+       if len(markedDVsForRemoval) != 
len(dvsToRemoveByRef)+len(autoDVsToRemoveByRef) {
                return errors.New("cannot remove deletion vectors that do not 
belong to the table")
        }
 
@@ -3142,19 +3146,12 @@ func (t *Transaction) collectExistingDVs(fs io.IO, 
files []iceberg.DataFile) (ma
        }
 
        result := make(map[string]iceberg.DataFile)
-       // Iterate delete manifests only and skip DELETED-status entries: a
-       // superseded DV lingers as a DELETED entry in the deleted-files 
manifest
-       // against the same referenced data file. Including it here would let 
the
-       // stale ghost win the last-write into result (manifest concat order 
places
-       // deleted entries last), seeding the new DV from an outdated bitmap and
-       // resurrecting rows removed by the prior delete. See issue #1372.
-       for entry, err := range s.entries(fs, iceberg.ManifestContentDeletes) {
+       // Skip DELETED entries: an old, replaced DV stays behind as one. 
Reading it
+       // would build the new DV from old data and bring back deleted rows. 
See #1372.
+       for entry, err := range s.entries(fs, iceberg.ManifestContentDeletes, 
true) {
                if err != nil {
                        return nil, fmt.Errorf("scanning existing deletion 
vectors: %w", err)
                }
-               if entry.Status() == iceberg.EntryStatusDELETED {
-                       continue
-               }
                df := entry.DataFile()
                if !IsDeletionVector(df) {
                        continue
diff --git a/table/transaction_internal_test.go 
b/table/transaction_internal_test.go
index d673b2d43..7754928c3 100644
--- a/table/transaction_internal_test.go
+++ b/table/transaction_internal_test.go
@@ -174,11 +174,8 @@ func liveDataFilePathsForSnapshot(t *testing.T, snap 
*Snapshot, fs iceio.IO) []s
        t.Helper()
        require.NotNil(t, snap)
        var paths []string
-       for e, err := range snap.entries(fs, iceberg.ManifestContentData) {
+       for e, err := range snap.entries(fs, iceberg.ManifestContentData, true) 
{
                require.NoError(t, err)
-               if e.Status() == iceberg.EntryStatusDELETED {
-                       continue
-               }
                if e.DataFile().ContentType() == iceberg.EntryContentData {
                        paths = append(paths, e.DataFile().FilePath())
                }

Reply via email to