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 b00fada37 feat(table): remove dangling delete files (#2408)
b00fada37 is described below

commit b00fada37e8238e4538ec409eef822850fe32053
Author: Minh Vu <[email protected]>
AuthorDate: Wed Nov 4 19:20:30 2026 +0100

    feat(table): remove dangling delete files (#2408)
    
    * feat(table): remove dangling delete files
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * fix(table): preserve partition-scoped deletes during dangling cleanup
    
    * perf(table): avoid scanning data manifests without dangling delete 
candidates
    
    * test(table): cover scoped delete references and fix nlreturn lint
    
    * perf(table): index only explicitly referenced data paths
    
    * test(table): verify dangling delete cleanup without delete manifests is a 
no-op
    
    * test(table): fence dangling delete cleanup with retries enabled
    
    * fix(table): include unpartitioned tables in dangling delete cleanup
    
    * test(table): verify unpartitioned dangling delete cleanup
    
    * fix(table): match Java scan-based dangling delete cleanup
    
    * style(table): remove trailing blank lines in dangling delete tests
    
    * refactor(table): stage dangling delete cleanup in transactions
    
    ---------
    
    Signed-off-by: Minh Vu <[email protected]>
---
 table/remove_dangling_delete_files.go              | 269 +++++++++++++++++++
 ...emove_dangling_delete_files_integration_test.go | 286 +++++++++++++++++++++
 table/remove_dangling_delete_files_test.go         |  94 +++++++
 3 files changed, 649 insertions(+)

diff --git a/table/remove_dangling_delete_files.go 
b/table/remove_dangling_delete_files.go
new file mode 100644
index 000000000..4bac00ac2
--- /dev/null
+++ b/table/remove_dangling_delete_files.go
@@ -0,0 +1,269 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package table
+
+import (
+       "context"
+
+       "github.com/apache/iceberg-go"
+)
+
+// danglingDeleteKey follows the scan-planning identity of a delete file.
+// Regular position/equality deletes are identified by path. Multiple deletion
+// vectors can share a Puffin file, so each vector needs its own blob identity.
+type danglingDeleteKey struct {
+       path       string
+       referenced string
+       offset     int64
+       length     int64
+       vector     bool
+}
+
+func keyForDanglingDelete(df iceberg.DataFile) danglingDeleteKey {
+       key := danglingDeleteKey{path: df.FilePath()}
+       if !IsDeletionVector(df) {
+               return key
+       }
+
+       key.vector = true
+       key.referenced = explicitReferencedDataFile(df)
+       key.offset, key.length = -1, -1
+       if offset := df.ContentOffset(); offset != nil {
+               key.offset = *offset
+       }
+       if length := df.ContentSizeInBytes(); length != nil {
+               key.length = *length
+       }
+
+       return key
+}
+
+// decideDanglingDeleteFiles mirrors Java's RemoveDanglingDeleteFilesAction:
+// delete files that are referenced by at least one scan task are still live.
+// This includes metrics pruning and DV supersession, not only partition and
+// sequence checks. Do not inspect physical delete-file contents.
+func decideDanglingDeleteFiles(candidates []iceberg.DataFile, tasks 
[]FileScanTask) []iceberg.DataFile {
+       referenced := make(map[danglingDeleteKey]struct{})
+       for _, task := range tasks {
+               for _, df := range task.DeleteFiles {
+                       referenced[keyForDanglingDelete(df)] = struct{}{}
+               }
+               for _, df := range task.EqualityDeleteFiles {
+                       referenced[keyForDanglingDelete(df)] = struct{}{}
+               }
+               for _, df := range task.DeletionVectorFiles {
+                       referenced[keyForDanglingDelete(df)] = struct{}{}
+               }
+       }
+
+       var dangling []iceberg.DataFile
+       for _, df := range candidates {
+               if _, ok := referenced[keyForDanglingDelete(df)]; !ok {
+                       dangling = append(dangling, df)
+               }
+       }
+
+       return dangling
+}
+
+// RemoveDanglingDeleteFilesResult reports delete-file references removed by 
cleanup.
+type RemoveDanglingDeleteFilesResult struct {
+       // Table is set by the table-level action after a successful commit.
+       // It is nil when the cleanup is only staged on a transaction.
+       Table *Table
+
+       // RemovedDeleteFiles counts removed position/equality delete files and
+       // deletion vectors. Physical files are never deleted by this action.
+       RemovedDeleteFiles int
+}
+
+// PlanDanglingDeleteFiles identifies delete files that are not attached to any
+// data file by an unfiltered scan of the current snapshot. It reads metadata
+// only; no physical data, delete files, or Puffin blobs are opened.
+//
+// Position and equality deletes use the scan planner's partition, sequence,
+// and metrics checks. DVs are identified by their blob, not just Puffin path.
+// Unknown delete sequence numbers and incomplete DV metadata are retained.
+func (t Table) PlanDanglingDeleteFiles(ctx context.Context) 
([]iceberg.DataFile, error) {
+       snap := t.CurrentSnapshot()
+       if snap == nil || t.metadata.FormatVersion() < 2 {
+               return nil, nil
+       }
+
+       return t.planDanglingDeleteFiles(ctx, snap, t.Scan())
+}
+
+// PlanDanglingDeleteFiles plans cleanup against the transaction's target 
branch.
+// It also sees snapshots already staged in the transaction, not just the
+// original table head. The scan is local so an external planner cannot read
+// committed catalog state instead of the staged metadata.
+func (tx *Transaction) PlanDanglingDeleteFiles(ctx context.Context) 
([]iceberg.DataFile, error) {
+       meta, err := tx.txnMeta()
+       if err != nil {
+               return nil, err
+       }
+       snap := tx.planningSnapshot(meta)
+       if snap == nil || meta.formatVersion < 2 {
+               return nil, nil
+       }
+
+       built, err := meta.Build()
+       if err != nil {
+               return nil, err
+       }
+       view := *tx.tbl
+       view.metadata = built
+       scan := view.Scan(WithScanPlanningMode(ScanPlanningLocal))
+       // Missing branches fork from main, matching 
Transaction.planningSnapshot.
+       // Existing branches must scan their own head using the current schema.
+       if tx.branch != "" && tx.branch != MainBranch && 
built.SnapshotByName(tx.branch) != nil {
+               branchScan, err := scan.UseRef(tx.branch)
+               _ = scan.Close()
+               if err != nil {
+                       return nil, err
+               }
+               scan = branchScan
+       }
+
+       return view.planDanglingDeleteFiles(ctx, snap, scan)
+}
+
+func (t Table) planDanglingDeleteFiles(ctx context.Context, snap *Snapshot, 
scan *Scan) ([]iceberg.DataFile, error) {
+       defer scan.Close()
+
+       fs, err := t.FS(ctx)
+       if err != nil {
+               return nil, err
+       }
+       manifests, err := snap.Manifests(fs)
+       if err != nil {
+               return nil, err
+       }
+
+       seen := make(map[danglingDeleteKey]struct{})
+       var candidates []iceberg.DataFile
+       for _, manifest := range manifests {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+               if manifest.ManifestContent() != iceberg.ManifestContentDeletes 
{
+                       continue
+               }
+               for entry, err := range manifest.Entries(fs, true) {
+                       if err != nil {
+                               return nil, err
+                       }
+                       df := entry.DataFile()
+                       if df.ContentType() != iceberg.EntryContentPosDeletes &&
+                               df.ContentType() != 
iceberg.EntryContentEqDeletes {
+                               continue
+                       }
+                       if entry.DataSequenceNumber() < 0 {
+                               continue
+                       }
+                       if IsDeletionVector(df) &&
+                               (df.ReferencedDataFile() == nil || 
df.ContentOffset() == nil || df.ContentSizeInBytes() == nil) {
+                               continue
+                       }
+
+                       key := keyForDanglingDelete(df)
+                       if _, ok := seen[key]; ok {
+                               continue
+                       }
+                       seen[key] = struct{}{}
+                       candidates = append(candidates, df)
+               }
+       }
+
+       // No delete files means no scan planning, which matters for 
append-only tables.
+       if len(candidates) == 0 {
+               return nil, nil
+       }
+
+       tasks, err := scan.PlanFiles(ctx)
+       if err != nil {
+               return nil, err
+       }
+
+       return decideDanglingDeleteFiles(candidates, tasks), nil
+}
+
+// RemoveDanglingDeleteFiles removes metadata references to whole dangling
+// delete files in one replace snapshot. It never deletes physical files,
+// which may still be reachable through older snapshots.
+//
+// The returned result contains the committed table. A no-op returns the input
+// table without writing a new snapshot. Use 
Transaction.RemoveDanglingDeleteFiles
+// to stage cleanup alongside other updates or target a non-main branch.
+func (t Table) RemoveDanglingDeleteFiles(ctx context.Context) 
(*RemoveDanglingDeleteFilesResult, error) {
+       tx, err := t.NewTransactionOnBranch(MainBranch)
+       if err != nil {
+               return nil, err
+       }
+       result, err := tx.RemoveDanglingDeleteFiles(ctx)
+       if err != nil {
+               return nil, err
+       }
+       if result.RemovedDeleteFiles == 0 {
+               result.Table = &t
+
+               return result, nil
+       }
+
+       result.Table, err = tx.Commit(ctx)
+
+       return result, err
+}
+
+// RemoveDanglingDeleteFiles stages the removal of entire dangling position and
+// equality delete files on the transaction's branch. It does not commit the
+// transaction or delete physical files. Unpartitioned tables are eligible;
+// partially dangling position-delete files require a separate rewrite.
+//
+// The branch head used for planning is pinned for the eventual commit. If it
+// changes concurrently, the commit fails rather than replaying stale removals.
+func (tx *Transaction) RemoveDanglingDeleteFiles(ctx context.Context) 
(*RemoveDanglingDeleteFilesResult, error) {
+       candidates, err := tx.PlanDanglingDeleteFiles(ctx)
+       if err != nil {
+               return nil, err
+       }
+       result := &RemoveDanglingDeleteFilesResult{RemovedDeleteFiles: 
len(candidates)}
+       if len(candidates) == 0 {
+               return result, nil
+       }
+
+       // A transaction commit already asserts its base branch head. Pin that
+       // assertion against retries without revalidating it against snapshots
+       // staged earlier in this same transaction.
+       branch := tx.branch
+       if branch == "" {
+               branch = MainBranch
+       }
+       if err := tx.applyPinned(nil, nil, []string{branch}); err != nil {
+               return nil, err
+       }
+
+       rewrite := tx.NewRewrite(nil)
+       for _, df := range candidates {
+               rewrite.DeleteFile(df)
+       }
+       if err := rewrite.Commit(ctx); err != nil {
+               return nil, err
+       }
+
+       return result, nil
+}
diff --git a/table/remove_dangling_delete_files_integration_test.go 
b/table/remove_dangling_delete_files_integration_test.go
new file mode 100644
index 000000000..486b2eda5
--- /dev/null
+++ b/table/remove_dangling_delete_files_integration_test.go
@@ -0,0 +1,286 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table_test
+
+import (
+       "context"
+       "path/filepath"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/apache/iceberg-go/table"
+       "github.com/stretchr/testify/require"
+)
+
+func TestRemoveDanglingDeleteFilesCommit(t *testing.T) {
+       location := filepath.ToSlash(t.TempDir())
+       schema := iceberg.NewSchema(0, iceberg.NestedField{ID: 1, Name: "id", 
Type: iceberg.PrimitiveTypes.Int64, Required: true})
+       spec := iceberg.NewPartitionSpec(iceberg.PartitionField{SourceIDs: 
[]int{1}, FieldID: 1000, Name: "id", Transform: iceberg.IdentityTransform{}})
+       meta, err := table.NewMetadata(schema, &spec, table.UnsortedSortOrder, 
location, iceberg.Properties{
+               table.PropertyFormatVersion:        "2",
+               table.CommitNumRetriesKey:          "2",
+               table.CommitMinRetryWaitMsKey:      "1",
+               table.CommitMaxRetryWaitMsKey:      "2",
+               table.CommitTotalRetryTimeoutMsKey: "1000",
+       })
+       require.NoError(t, err)
+       fsF := func(context.Context) (iceio.IO, error) { return 
iceio.LocalFS{}, nil }
+       cat := &concurrentTestCatalog{metadata: meta, location: location + 
"/metadata/v1.metadata.json", fsF: fsF}
+       tbl := table.New(table.Identifier{"db", "test"}, meta, cat.location, 
fsF, cat)
+       partition := map[int]any{1000: int64(7)}
+       data, err := iceberg.NewDataFileBuilder(spec, iceberg.EntryContentData, 
location+"/data/live.parquet", iceberg.ParquetFile, partition, nil, nil, 1, 128)
+       require.NoError(t, err)
+       tx := tbl.NewTransaction()
+       require.NoError(t, 
tx.NewRowDelta(nil).AddRows(data.Build()).Commit(t.Context()))
+       tbl, err = tx.Commit(t.Context())
+       require.NoError(t, err)
+
+       // With no delete files, planning must leave the table unchanged.
+       before, err := tbl.PlanDanglingDeleteFiles(t.Context())
+       require.NoError(t, err)
+       require.Empty(t, before)
+       noOp, err := tbl.RemoveDanglingDeleteFiles(t.Context())
+       require.NoError(t, err)
+       require.Zero(t, noOp.RemovedDeleteFiles)
+       require.Equal(t, tbl.Metadata(), noOp.Table.Metadata())
+
+       live, err := iceberg.NewDataFileBuilder(spec, 
iceberg.EntryContentPosDeletes, location+"/data/live-delete.parquet", 
iceberg.ParquetFile, partition, nil, nil, 1, 128)
+       require.NoError(t, err)
+       dead, err := iceberg.NewDataFileBuilder(spec, 
iceberg.EntryContentPosDeletes, location+"/data/dead-delete.parquet", 
iceberg.ParquetFile, map[int]any{1000: int64(8)}, nil, nil, 1, 128)
+       require.NoError(t, err)
+       liveFile := live.ReferencedDataFile(data.Build().FilePath()).Build()
+       deadFile := dead.Build()
+       tx = tbl.NewTransaction()
+       require.NoError(t, tx.NewRowDelta(nil).AddDeletes(liveFile, 
deadFile).Commit(t.Context()))
+       tbl, err = tx.Commit(t.Context())
+       require.NoError(t, err)
+       planned, err := tbl.PlanDanglingDeleteFiles(t.Context())
+       require.NoError(t, err)
+       require.Len(t, planned, 1)
+       require.Equal(t, deadFile.FilePath(), planned[0].FilePath())
+       result, err := tbl.RemoveDanglingDeleteFiles(t.Context())
+       require.NoError(t, err)
+       require.Equal(t, 1, result.RemovedDeleteFiles)
+       updated := result.Table
+       require.Empty(t, mustPlanDangling(t, updated))
+       tasks, err := updated.Scan().PlanFiles(t.Context())
+       require.NoError(t, err)
+       require.Len(t, tasks, 1)
+       require.Len(t, tasks[0].DeleteFiles, 1)
+       require.Equal(t, liveFile.FilePath(), 
tasks[0].DeleteFiles[0].FilePath())
+       // The stale table must not replay the same cleanup on a moved branch,
+       // even with retries enabled.
+       attemptsBefore := cat.attempts.Load()
+       _, err = tbl.RemoveDanglingDeleteFiles(t.Context())
+       require.ErrorIs(t, err, table.ErrCommitFailed)
+       require.Equal(t, attemptsBefore+1, cat.attempts.Load(),
+               "cleanup must not retry after a pinned branch head moves")
+}
+
+func TestRemoveDanglingDeleteFilesUnpartitionedCleanup(t *testing.T) {
+       ctx := t.Context()
+       tbl, _ := newConcurrentRewriteTestTable(t)
+       dataPath := tbl.Location() + "/data/live.parquet"
+       missingPath := tbl.Location() + "/data/missing.parquet"
+       posPath := tbl.Location() + "/data/dangling-pos.parquet"
+       eqPath := tbl.Location() + "/data/dangling-eq.parquet"
+
+       data, err := iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec, 
iceberg.EntryContentData,
+               dataPath, iceberg.ParquetFile, nil, nil, nil, 1, 128)
+       require.NoError(t, err)
+       pos, err := iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec, 
iceberg.EntryContentPosDeletes,
+               posPath, iceberg.ParquetFile, nil, nil, nil, 1, 128)
+       require.NoError(t, err)
+       eq, err := iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec, 
iceberg.EntryContentEqDeletes,
+               eqPath, iceberg.ParquetFile, nil, nil, nil, 1, 128)
+       require.NoError(t, err)
+
+       // An explicit position-delete target is absent, and an equality delete
+       // does not apply to data from the same sequence number.
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.NewRowDelta(nil).
+               AddRows(data.Build()).
+               AddDeletes(pos.ReferencedDataFile(missingPath).Build(), 
eq.EqualityFieldIDs([]int{1}).Build()).
+               Commit(ctx))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+
+       planned, err := tbl.PlanDanglingDeleteFiles(ctx)
+       require.NoError(t, err)
+       var paths []string
+       for _, file := range planned {
+               paths = append(paths, file.FilePath())
+       }
+       require.ElementsMatch(t, []string{posPath, eqPath}, paths)
+
+       result, err := tbl.RemoveDanglingDeleteFiles(ctx)
+       require.NoError(t, err)
+       require.Equal(t, 2, result.RemovedDeleteFiles)
+       updated := result.Table
+       require.Empty(t, mustPlanDangling(t, updated))
+
+       tasks, err := updated.Scan().PlanFiles(ctx)
+       require.NoError(t, err)
+       require.Len(t, tasks, 1)
+       require.Equal(t, dataPath, tasks[0].File.FilePath())
+       require.Empty(t, tasks[0].DeleteFiles)
+       require.Empty(t, tasks[0].EqualityDeleteFiles)
+}
+
+func mustPlanDangling(t *testing.T, tbl *table.Table) []iceberg.DataFile {
+       t.Helper()
+       files, err := tbl.PlanDanglingDeleteFiles(t.Context())
+       require.NoError(t, err)
+
+       return files
+}
+
+func TestRemoveDanglingDeleteFilesPrunesEqualityBounds(t *testing.T) {
+       ctx := t.Context()
+       tbl, _ := newConcurrentRewriteTestTable(t)
+       makeFile := func(content iceberg.ManifestEntryContent, name, value 
string) iceberg.DataFile {
+               t.Helper()
+               builder, err := iceberg.NewDataFileBuilder(
+                       *iceberg.UnpartitionedSpec, content, 
tbl.Location()+"/data/"+name,
+                       iceberg.ParquetFile, nil, nil, nil, 1, 128)
+               require.NoError(t, err)
+               builder.LowerBoundValues(map[int][]byte{2: []byte(value)})
+               builder.UpperBoundValues(map[int][]byte{2: []byte(value)})
+               builder.ValueCounts(map[int]int64{2: 1})
+               builder.NullValueCounts(map[int]int64{2: 0})
+               if content == iceberg.EntryContentEqDeletes {
+                       builder.EqualityFieldIDs([]int{2})
+               }
+
+               return builder.Build()
+       }
+
+       data := makeFile(iceberg.EntryContentData, "live.parquet", "a")
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.NewRowDelta(nil).AddRows(data).Commit(ctx))
+       var err error
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+
+       liveEquality := makeFile(iceberg.EntryContentEqDeletes, 
"live-eq.parquet", "a")
+       danglingEquality := makeFile(iceberg.EntryContentEqDeletes, 
"dangling-eq.parquet", "z")
+       tx = tbl.NewTransaction()
+       require.NoError(t, tx.NewRowDelta(nil).AddDeletes(liveEquality, 
danglingEquality).Commit(ctx))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+
+       tasks, err := tbl.Scan().PlanFiles(ctx)
+       require.NoError(t, err)
+       require.Len(t, tasks, 1)
+       require.Len(t, tasks[0].EqualityDeleteFiles, 1)
+       require.Equal(t, liveEquality.FilePath(), 
tasks[0].EqualityDeleteFiles[0].FilePath())
+
+       planned, err := tbl.PlanDanglingDeleteFiles(ctx)
+       require.NoError(t, err)
+       require.Len(t, planned, 1)
+       require.Equal(t, danglingEquality.FilePath(), planned[0].FilePath())
+
+       result, err := tbl.RemoveDanglingDeleteFiles(ctx)
+       require.NoError(t, err)
+       require.Equal(t, 1, result.RemovedDeleteFiles)
+       updated := result.Table
+       require.Empty(t, mustPlanDangling(t, updated))
+}
+
+func TestRemoveDanglingDeleteFilesTransactionStagesAfterRowDelta(t *testing.T) 
{
+       ctx := t.Context()
+       tbl, _ := newConcurrentRewriteTestTable(t)
+       data := newDataFile(t, tbl.Location()+"/data/live.parquet")
+       posBuilder, err := iceberg.NewDataFileBuilder(
+               *iceberg.UnpartitionedSpec, iceberg.EntryContentPosDeletes,
+               tbl.Location()+"/data/dangling.parquet", iceberg.ParquetFile, 
nil, nil, nil, 1, 128)
+       require.NoError(t, err)
+
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.NewRowDelta(nil).
+               AddRows(data).
+               
AddDeletes(posBuilder.ReferencedDataFile(tbl.Location()+"/data/missing.parquet").Build()).
+               Commit(ctx))
+
+       result, err := tx.RemoveDanglingDeleteFiles(ctx)
+       require.NoError(t, err)
+       require.Nil(t, result.Table, "transaction action stages rather than 
commits")
+       require.Equal(t, 1, result.RemovedDeleteFiles)
+       require.Nil(t, tbl.CurrentSnapshot(), "base table must remain 
unchanged")
+
+       committed, err := tx.Commit(ctx)
+       require.NoError(t, err)
+       require.Empty(t, mustPlanDangling(t, committed))
+       tasks, err := committed.Scan().PlanFiles(ctx)
+       require.NoError(t, err)
+       require.Len(t, tasks, 1)
+       require.Equal(t, data.FilePath(), tasks[0].File.FilePath())
+       require.Empty(t, tasks[0].DeleteFiles)
+}
+
+func TestRemoveDanglingDeleteFilesTransactionTargetsBranch(t *testing.T) {
+       ctx := t.Context()
+       tbl, _ := newConcurrentRewriteTestTable(t)
+       data := newDataFile(t, tbl.Location()+"/data/main.parquet")
+       posBuilder, err := iceberg.NewDataFileBuilder(
+               *iceberg.UnpartitionedSpec, iceberg.EntryContentPosDeletes,
+               tbl.Location()+"/data/main-delete.parquet", 
iceberg.ParquetFile, nil, nil, nil, 1, 128)
+       require.NoError(t, err)
+       liveDelete := posBuilder.ReferencedDataFile(data.FilePath()).Build()
+
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.NewRowDelta(nil).AddRows(data).Commit(ctx))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+       tx = tbl.NewTransaction()
+       require.NoError(t, 
tx.NewRowDelta(nil).AddDeletes(liveDelete).Commit(ctx))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+
+       // Fork from main, then remove the target data only on the branch.
+       branchTx, err := tbl.NewTransactionOnBranch("cleanup")
+       require.NoError(t, err)
+       rewrite := branchTx.NewRewrite(nil)
+       rewrite.DeleteFile(data)
+       require.NoError(t, rewrite.Commit(ctx))
+       tbl, err = branchTx.Commit(ctx)
+       require.NoError(t, err)
+       require.Empty(t, mustPlanDangling(t, tbl), "delete remains live on 
main")
+
+       branchTx, err = tbl.NewTransactionOnBranch("cleanup")
+       require.NoError(t, err)
+       require.NoError(t, 
branchTx.SetProperties(iceberg.Properties{"dangling-cleanup-test": "yes"}))
+       planned, err := branchTx.PlanDanglingDeleteFiles(ctx)
+       require.NoError(t, err)
+       require.Len(t, planned, 1)
+       require.Equal(t, liveDelete.FilePath(), planned[0].FilePath())
+       result, err := branchTx.RemoveDanglingDeleteFiles(ctx)
+       require.NoError(t, err)
+       require.Nil(t, result.Table)
+       require.Equal(t, 1, result.RemovedDeleteFiles)
+
+       updated, err := branchTx.Commit(ctx)
+       require.NoError(t, err)
+       require.Equal(t, "yes", updated.Properties()["dangling-cleanup-test"])
+       mainData, mainDeletes := liveFilesOnRef(t, updated, table.MainBranch)
+       require.Contains(t, mainData, data.FilePath())
+       require.Contains(t, mainDeletes, liveDelete.FilePath())
+       _, branchDeletes := liveFilesOnRef(t, updated, "cleanup")
+       require.Empty(t, branchDeletes)
+}
diff --git a/table/remove_dangling_delete_files_test.go 
b/table/remove_dangling_delete_files_test.go
new file mode 100644
index 000000000..5634f4015
--- /dev/null
+++ b/table/remove_dangling_delete_files_test.go
@@ -0,0 +1,94 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package table
+
+import (
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       "github.com/stretchr/testify/require"
+)
+
+func TestDecideDanglingDeleteFilesAllTypes(t *testing.T) {
+       makeDelete := func(path string, content iceberg.ManifestEntryContent) 
iceberg.DataFile {
+               t.Helper()
+               builder, err := iceberg.NewDataFileBuilder(
+                       *iceberg.UnpartitionedSpec, content, path,
+                       iceberg.ParquetFile, nil, nil, nil, 1, 128)
+               require.NoError(t, err)
+               if content == iceberg.EntryContentEqDeletes {
+                       builder.EqualityFieldIDs([]int{1})
+               }
+
+               return builder.Build()
+       }
+
+       livePosition := makeDelete("live-pos", iceberg.EntryContentPosDeletes)
+       danglingPosition := makeDelete("dangling-pos", 
iceberg.EntryContentPosDeletes)
+       liveEquality := makeDelete("live-eq", iceberg.EntryContentEqDeletes)
+       danglingEquality := makeDelete("dangling-eq", 
iceberg.EntryContentEqDeletes)
+
+       got := decideDanglingDeleteFiles(
+               []iceberg.DataFile{livePosition, danglingPosition, 
liveEquality, danglingEquality},
+               []FileScanTask{
+                       {DeleteFiles: []iceberg.DataFile{livePosition}, 
EqualityDeleteFiles: []iceberg.DataFile{liveEquality}},
+                       {DeleteFiles: []iceberg.DataFile{livePosition}},
+               })
+       require.Len(t, got, 2)
+       require.Equal(t, danglingPosition.FilePath(), got[0].FilePath())
+       require.Equal(t, danglingEquality.FilePath(), got[1].FilePath())
+}
+
+func TestDecideDanglingDeleteFilesSharedPuffin(t *testing.T) {
+       // A Puffin container can hold multiple DV blobs. Seeing one live vector
+       // must not preserve a different, dangling vector in the same container.
+       makeVector := func(reference string, offset, length int64) 
iceberg.DataFile {
+               return &dvMockDataFile{
+                       mockDataFile: mockDataFile{
+                               path:        "shared.puffin",
+                               contentType: iceberg.EntryContentPosDeletes,
+                               format:      iceberg.PuffinFile,
+                       },
+                       referencedDataFile: &reference,
+                       contentOffset:      &offset,
+                       contentSizeInBytes: &length,
+               }
+       }
+
+       live := makeVector("live.parquet", 0, 100)
+       dangling := makeVector("missing.parquet", 100, 100)
+       got := decideDanglingDeleteFiles(
+               []iceberg.DataFile{live, dangling},
+               []FileScanTask{{DeletionVectorFiles: []iceberg.DataFile{live}}},
+       )
+       require.Len(t, got, 1)
+       require.Equal(t, dangling.ReferencedDataFile(), 
got[0].ReferencedDataFile())
+       require.Equal(t, dangling.ContentOffset(), got[0].ContentOffset())
+}
+
+func TestRemoveDanglingDeleteFilesUnpartitionedNoop(t *testing.T) {
+       schema := iceberg.NewSchema(0,
+               iceberg.NestedField{ID: 1, Name: "id", Type: 
iceberg.PrimitiveTypes.Int64, Required: true},
+       )
+       meta, err := NewMetadata(schema, iceberg.UnpartitionedSpec, 
UnsortedSortOrder, t.TempDir(), nil)
+       require.NoError(t, err)
+       tbl := New([]string{"db", "example"}, meta, "", nil, nil)
+       result, err := tbl.RemoveDanglingDeleteFiles(t.Context())
+       require.NoError(t, err)
+       require.Zero(t, result.RemovedDeleteFiles)
+       require.Equal(t, tbl.Metadata(), result.Table.Metadata())
+}

Reply via email to