badalprasadsingh commented on code in PR #2434:
URL: https://github.com/apache/iceberg-go/pull/2434#discussion_r4503430888


##########
table/transaction_delete.go:
##########
@@ -200,8 +200,36 @@ func (t *Transaction) performMergeOnReadDeletion(ctx 
context.Context, snapshotPr
                        return nil, err
                }
                if len(referenced) > 0 {
+                       // A first v3 deletion vector is otherwise replayable. 
If a
+                       // concurrent writer adds a DV for the same file, 
replaying ours
+                       // would leave two live vectors and make the table 
unreadable.
+                       dvTargets := make(map[string]struct{}, len(referenced))
+                       if meta.formatVersion >= 3 {
+                               for _, ref := range referenced {
+                                       dvTargets[ref] = struct{}{}
+                               }
+                       }
                        t.addValidator(func(cc *conflictContext) error {
-                               return validateDataFilesExist(cc, referenced)
+                               if err := validateDataFilesExist(cc, 
referenced); err != nil {
+                                       return err
+                               }
+                               if len(dvTargets) == 0 {
+                                       return nil
+                               }
+
+                               return 
cc.forEachAddedEntry(iceberg.ManifestContentDeletes, func(snap Snapshot, entry 
iceberg.ManifestEntry) error {
+                                       file := entry.DataFile()
+                                       ref := explicitReferencedDataFile(file)
+                                       if !IsDeletionVector(file) || ref == "" 
{
+                                               return nil
+                                       }
+                                       if _, ok := dvTargets[ref]; ok {
+                                               return fmt.Errorf("cannot add a 
deletion vector for data file %s: concurrent snapshot %d committed a live 
deletion vector %s for it; reload the table and retry the delete",
+                                                       ref, snap.SnapshotID, 
file.FilePath())

Review Comment:
   please wrap `ErrConflictingDeleteFiles` like the other conflict checks



##########
table/mor_delete_conflict_test.go:
##########
@@ -117,6 +117,27 @@ func 
TestMergeOnReadDeleteConflict_ReferencedDataFileRemoved(t *testing.T) {
        require.ErrorIs(t, err, table.ErrDataFilesMissing)
 }
 
+// A first deletion vector is replayable until another writer commits a
+// vector for the same data file. The second writer must reject replay instead
+// of leaving two live vectors, which makes all subsequent scans fail.
+func TestMergeOnReadDeleteConflict_ConcurrentFirstDeletionVector(t *testing.T) 
{
+       ctx := t.Context()
+       tbl := appendTenRows(t, newV3MoRConflictTestTable(t))
+
+       pending := tbl.NewTransaction()
+       require.NoError(t, pending.Delete(ctx, 
iceberg.EqualTo(iceberg.Reference("id"), int64(7)), nil))
+
+       // Both writers plan against the same file with no existing DV.
+       peer, err := tbl.Delete(ctx, iceberg.EqualTo(iceberg.Reference("id"), 
int64(2)), nil)

Review Comment:
   here: peer is another delete, no test covers the case this fix came from: 
the conversion commits its dv first, in a replace snapshot. 
   
   please add that case, so a later filter by snapshot operation can't skip it



##########
table/equality_delete_dv_conversion_test.go:
##########
@@ -0,0 +1,557 @@
+// 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 (
+       "fmt"
+       "os"
+       "path/filepath"
+       "strings"
+       "testing"
+
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/memory"
+       "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/table"
+       "github.com/stretchr/testify/require"
+)
+
+func TestConvertEqualityDeletesToDeletionVectors(t *testing.T) {
+       ctx := t.Context()
+       tbl := appendRows(t, newMergeOnReadTestTableVersion(t, "3"),
+               
`[{"id":1,"data":"a"},{"id":2,"data":"b"},{"id":3,"data":"c"},{"id":4,"data":"d"},{"id":5,"data":"e"}]`)
+       beforeDelete := tbl.CurrentSnapshot().SnapshotID
+       tbl = appendEqualityDelete(t, tbl, []int{1}, `[{"id":2},{"id":4}]`)
+       deleteSnapshot := tbl.CurrentSnapshot().SnapshotID
+
+       result, err := tbl.ConvertEqualityDeletesToDeletionVectors(ctx)
+       require.NoError(t, err)
+       require.NotNil(t, result.Table)
+       require.Equal(t, 1, result.ConvertedEqualityDeleteFiles)
+       require.Equal(t, 1, result.AddedDeletionVectors)
+       require.Equal(t, []int64{1, 3, 5}, idsInTable(t, result.Table))
+       require.Equal(t, 1, liveDVCount(t, result.Table))
+       require.Equal(t, 0, liveDeleteFileCount(t, result.Table, 
iceberg.EntryContentEqDeletes))
+       require.Equal(t, table.OpReplace, 
result.Table.CurrentSnapshot().Summary.Operation)
+
+       // The equality-delete snapshot emits the deletes once. The conversion
+       // snapshot is maintenance and must not emit them again.
+       deleteRows := readChangelog(t, ctx,
+               
tbl.NewIncrementalChangelogScan().FromSnapshotExclusive(beforeDelete).ToSnapshot(deleteSnapshot))
+       require.Equal(t, []int64{2, 4}, changelogIDs(deleteRows, 
table.ChangelogOpDelete))
+       conversionRows := readChangelog(t, ctx,
+               
result.Table.NewIncrementalChangelogScan().FromSnapshotExclusive(deleteSnapshot))
+       require.Empty(t, conversionRows)
+}
+
+// Equality deletes may use different key sets. Each must resolve against the
+// same data file before their positions are merged into a single DV.
+func TestConvertEqualityDeletesWithDifferentEqualityFields(t *testing.T) {
+       ctx := t.Context()
+       tbl := appendRows(t, newMergeOnReadTestTableVersion(t, "3"),
+               
`[{"id":1,"data":"a"},{"id":2,"data":"b"},{"id":3,"data":"c"},{"id":4,"data":"d"}]`)
+       tbl = appendEqualityDelete(t, tbl, []int{1}, `[{"id":2}]`)
+       tbl = appendEqualityDelete(t, tbl, []int{2}, `[{"data":"c"}]`)
+
+       require.Equal(t, []int64{1, 4}, idsInTable(t, tbl))
+       result, err := tbl.ConvertEqualityDeletesToDeletionVectors(ctx)
+       require.NoError(t, err)
+       require.Equal(t, 2, result.ConvertedEqualityDeleteFiles)
+       require.Equal(t, 1, result.AddedDeletionVectors)
+       require.Equal(t, 1, liveDVCount(t, result.Table))
+       require.Zero(t, liveDeleteFileCount(t, result.Table, 
iceberg.EntryContentEqDeletes))
+       require.Equal(t, []int64{1, 4}, idsInTable(t, result.Table))
+}
+
+// A conversion staged after write.data.path changes must use the transaction's
+// updated output location, not the base table's data directory.
+func TestConvertEqualityDeletesUsesStagedDataPath(t *testing.T) {
+       ctx := t.Context()
+       tbl := appendRows(t, newMergeOnReadTestTableVersion(t, "3"),
+               `[{"id":1,"data":"a"},{"id":2,"data":"b"}]`)
+       tbl = appendEqualityDelete(t, tbl, []int{1}, `[{"id":2}]`)
+
+       dataPath := filepath.ToSlash(filepath.Join(t.TempDir(), 
"custom-dv-data"))
+       tx := tbl.NewTransaction()
+       require.NoError(t, 
tx.SetProperties(iceberg.Properties{table.WriteDataPathKey: dataPath}))
+
+       result, err := tx.ConvertEqualityDeletesToDeletionVectors(ctx)
+       require.NoError(t, err)
+       require.Equal(t, 1, result.AddedDeletionVectors)
+       committed, err := tx.Commit(ctx)
+       require.NoError(t, err)
+       require.Equal(t, []int64{1}, idsInTable(t, committed))
+
+       fs, err := committed.FS(ctx)
+       require.NoError(t, err)
+       manifests, err := committed.CurrentSnapshot().Manifests(ctx, fs)
+       require.NoError(t, err)
+
+       var dvPaths []string
+       for _, manifest := range manifests {
+               if manifest.ManifestContent() != iceberg.ManifestContentDeletes 
{
+                       continue
+               }
+               for entry, err := range manifest.Entries(ctx, fs, false) {
+                       require.NoError(t, err)
+                       if entry.Status() != iceberg.EntryStatusDELETED && 
table.IsDeletionVector(entry.DataFile()) {
+                               dvPaths = append(dvPaths, 
entry.DataFile().FilePath())
+                       }
+               }
+       }
+       require.Len(t, dvPaths, 1)
+       require.True(t, strings.HasPrefix(dvPaths[0], dataPath+"/"), dvPaths[0])
+}
+
+func TestConvertEqualityDeletesMergesExistingPositionDeletes(t *testing.T) {
+       ctx := t.Context()
+       tbl := appendRows(t, newMergeOnReadTestTableVersion(t, "2"),
+               
`[{"id":1,"data":"a"},{"id":2,"data":"b"},{"id":3,"data":"c"},{"id":4,"data":"d"},{"id":5,"data":"e"}]`)
+       var err error
+       tbl, err = tbl.Delete(ctx, iceberg.EqualTo(iceberg.Reference("id"), 
int64(2)), nil)
+       require.NoError(t, err)
+       require.Equal(t, 1, liveDeleteFileCount(t, tbl, 
iceberg.EntryContentPosDeletes))
+
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.UpgradeFormatVersion(3))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+       tbl = appendEqualityDelete(t, tbl, []int{1}, `[{"id":4}]`)
+
+       result, err := tbl.ConvertEqualityDeletesToDeletionVectors(ctx)
+       require.NoError(t, err)
+       require.Equal(t, 1, result.RemovedPositionDeleteFiles)
+       require.Zero(t, liveDeleteFileCount(t, result.Table, 
iceberg.EntryContentPosDeletes))
+       require.Equal(t, 1, liveDVCount(t, result.Table))
+       require.Equal(t, []int64{1, 3, 5}, idsInTable(t, result.Table),
+               "the old position delete and the new equality delete must both 
remain applied")
+}
+
+// A legacy position-delete file can cover multiple data files. Replacing it
+// with a DV for only one of those files must not leave the shared file live.
+func TestConvertEqualityDeletesRejectsSharedPositionDeletes(t *testing.T) {
+       ctx := t.Context()
+       tbl := newMergeOnReadTestTableVersion(t, "2")
+       tbl = appendRows(t, tbl,
+               `[{"id":1,"data":"a"},{"id":2,"data":"b"}]`)
+       tbl = appendRows(t, tbl,
+               `[{"id":3,"data":"c"},{"id":4,"data":"d"}]`)
+
+       scan := tbl.Scan()
+       defer scan.Close()
+       tasks, err := scan.PlanFiles(ctx)
+       require.NoError(t, err)
+       require.Len(t, tasks, 2)
+
+       positionPath := tbl.Location() + "/data/shared-position-delete.parquet"
+       writeParquetFile(t, positionPath, table.PositionalDeleteArrowSchema, 
fmt.Sprintf(
+               `[{"file_path":%q,"pos":0},{"file_path":%q,"pos":0}]`,
+               tasks[0].File.FilePath(), tasks[1].File.FilePath()))
+       info, err := os.Stat(positionPath)
+       require.NoError(t, err)
+       builder, err := iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec,
+               iceberg.EntryContentPosDeletes, positionPath, 
iceberg.ParquetFile, nil, nil, nil, 2, info.Size())
+       require.NoError(t, err)
+       // Deliberately omit ReferencedDataFile: both files share this delete 
file.
+       tx := tbl.NewTransaction()
+       require.NoError(t, 
tx.NewRowDelta(nil).AddDeletes(builder.Build()).Commit(ctx))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+
+       tx = tbl.NewTransaction()
+       require.NoError(t, tx.UpgradeFormatVersion(3))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+       tbl = appendEqualityDelete(t, tbl, []int{1}, `[{"id":2}]`)
+
+       // Enable retries so the final commit verifies replay after the peer 
update,
+       // rather than failing immediately when main changes.
+       retryTx := tbl.NewTransaction()
+       require.NoError(t, 
retryTx.SetProperties(iceberg.Properties{table.CommitNumRetriesKey: "2"}))
+       tbl, err = retryTx.Commit(ctx)
+       require.NoError(t, err)
+
+       beforeSnapshot := tbl.CurrentSnapshot().SnapshotID
+       require.Equal(t, []int64{4}, idsInTable(t, tbl))
+
+       _, err = tbl.ConvertEqualityDeletesToDeletionVectors(ctx)
+       require.ErrorIs(t, err, table.ErrInvalidOperation)

Review Comment:
   no test checks that the new Puffin file is removed after this rejection
   
   if the rejection stays: could you walk the table location here and assert no 
`.puffin` file remains?



##########
table/transaction.go:
##########
@@ -2154,6 +2205,9 @@ func (t *Transaction) replaceFiles(ctx context.Context, 
dataFilesToDelete, dataF
                        }
                }
        }
+       // Every new DV must replace all applicable legacy position-delete 
files.
+       // A conversion that leaves an unscoped position-delete file behind is
+       // unsafe, even if its positions were already merged into the new 
vector.

Review Comment:
   I think its better to say "not supported" than "unsafe" here



##########
table/scanner.go:
##########
@@ -2218,12 +2218,53 @@ func (scan *Scan) ReadTasks(ctx context.Context, tasks 
[]FileScanTask) (*arrow.S
 // single data file in tasks: rows that survive that task's own deletes are
 // kept only when they match addedDeletes. Normal scans pass nil.
 func (scan *Scan) readFileTasks(ctx context.Context, tasks []FileScanTask, 
addedDeletes *FileScanTask) (*arrow.Schema, iter.Seq2[arrow.RecordBatch, 
error], error) {
+       var added []FileScanTask
+       if addedDeletes != nil {
+               added = []FileScanTask{*addedDeletes}
+       }
+
+       return scan.readFileTasksWithAddedDeletes(ctx, tasks, added, nil)
+}
+
+// readFileTasksWithAddedDeletes reads tasks while retaining rows matched by 
the
+// corresponding added-delete task for each data file. positionSinks, when set,
+// receives the original file positions of those retained rows before they are
+// returned to the caller.

Review Comment:
   nit: with a sink these rows are never returned, because keepAddedDeleteRows 
clears them
   
   better: say they are consumed here, not returned



##########
table/convert_equality_deletes_to_dv.go:
##########
@@ -0,0 +1,382 @@
+// 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"
+       "fmt"
+       "slices"
+       "sync"
+
+       "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/table/dv"
+       "github.com/google/uuid"
+)
+
+// equalityDeletePositionBuffer accumulates matched positions for one data 
file.
+// Each buffer has its own mutex so parallel scans of different files do not
+// contend on a table-wide lock. The scan finishes before bitmap is read.
+type equalityDeletePositionBuffer struct {
+       mu              sync.Mutex
+       bitmap          *dv.RoaringPositionBitmap
+       invalidPosition int64
+}
+
+func (b *equalityDeletePositionBuffer) addPositions(positions []int64) {
+       if len(positions) == 0 {
+               return
+       }
+
+       b.mu.Lock()
+       defer b.mu.Unlock()
+
+       if b.invalidPosition < 0 {
+               return
+       }
+       for _, position := range positions {
+               if position < 0 {
+                       b.invalidPosition = position
+
+                       return
+               }
+       }
+
+       if b.bitmap == nil {
+               b.bitmap = dv.NewRoaringPositionBitmap()
+       }
+       for _, position := range positions {
+               b.bitmap.Set(uint64(position))
+       }
+}
+
+// EqualityDeleteToDVResult summarizes an equality-delete conversion.
+type EqualityDeleteToDVResult struct {
+       // Table is the table returned by the table-level action. It is nil 
when the
+       // action is staged on a Transaction and set after that transaction 
commits.

Review Comment:
   nit: `txn.Commit` never sets this field on a staged result, so "set after 
that transaction commits" misleads. 
   
   better: say it stays nil for transaction callers



##########
table/equality_delete_dv_conversion_test.go:
##########
@@ -0,0 +1,557 @@
+// 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

Review Comment:
   nit: tests here usually match their source file, like 
`remove_dangling_delete_files_test.go`
   
   better: please name these after `convert_equality_deletes_to_dv.go`, using 
`convert_equality_deletes_to_dv_internal_test.go` for the buffer test in 
package table



##########
table/convert_equality_deletes_to_dv.go:
##########
@@ -0,0 +1,382 @@
+// 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"
+       "fmt"
+       "slices"
+       "sync"
+
+       "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/table/dv"
+       "github.com/google/uuid"
+)
+
+// equalityDeletePositionBuffer accumulates matched positions for one data 
file.
+// Each buffer has its own mutex so parallel scans of different files do not
+// contend on a table-wide lock. The scan finishes before bitmap is read.
+type equalityDeletePositionBuffer struct {
+       mu              sync.Mutex
+       bitmap          *dv.RoaringPositionBitmap
+       invalidPosition int64
+}
+
+func (b *equalityDeletePositionBuffer) addPositions(positions []int64) {
+       if len(positions) == 0 {
+               return
+       }
+
+       b.mu.Lock()
+       defer b.mu.Unlock()
+
+       if b.invalidPosition < 0 {
+               return
+       }
+       for _, position := range positions {
+               if position < 0 {
+                       b.invalidPosition = position
+
+                       return
+               }
+       }
+
+       if b.bitmap == nil {
+               b.bitmap = dv.NewRoaringPositionBitmap()
+       }
+       for _, position := range positions {
+               b.bitmap.Set(uint64(position))
+       }
+}
+
+// EqualityDeleteToDVResult summarizes an equality-delete conversion.
+type EqualityDeleteToDVResult struct {
+       // Table is the table returned by the table-level action. It is nil 
when the
+       // action is staged on a Transaction and set after that transaction 
commits.
+       Table *Table
+
+       // ConvertedEqualityDeleteFiles is the number of live equality delete 
files
+       // removed by the rewrite.
+       ConvertedEqualityDeleteFiles int
+       // AddedDeletionVectors is the number of file-specific deletion vectors 
added.
+       AddedDeletionVectors int
+       // RemovedPositionDeleteFiles is the number of legacy position delete 
files
+       // scoped to one data file, either by referenced_data_file or equal
+       // file_path bounds, removed with the conversion.
+       RemovedPositionDeleteFiles int
+       // ReplacedDeletionVectors is the number of existing vectors merged 
into new
+       // vectors and removed by the rewrite.
+       ReplacedDeletionVectors int
+}
+
+// ConvertEqualityDeletesToDeletionVectors converts live equality deletes on
+// the main branch of a v3 table to file-specific deletion vectors. The rewrite
+// commits as one replace snapshot so changelog readers do not see a second
+// logical deletion event.
+func (t Table) ConvertEqualityDeletesToDeletionVectors(ctx context.Context) 
(*EqualityDeleteToDVResult, error) {
+       txn, err := t.NewTransactionOnBranch(MainBranch)
+       if err != nil {
+               return nil, err
+       }
+
+       result, err := txn.ConvertEqualityDeletesToDeletionVectors(ctx)
+       if err != nil {
+               return result, err
+       }
+       if result.ConvertedEqualityDeleteFiles == 0 {
+               result.Table = &t
+
+               return result, nil
+       }
+
+       committed, err := txn.Commit(ctx)
+       if err == nil {
+               result.Table = committed
+       }
+
+       return result, err
+}
+
+// ConvertEqualityDeletesToDeletionVectors stages an equality-delete to
+// deletion-vector conversion on the transaction's branch. Commit the
+// transaction to publish the replace snapshot.
+func (t *Transaction) ConvertEqualityDeletesToDeletionVectors(ctx 
context.Context) (*EqualityDeleteToDVResult, error) {
+       meta, err := t.txnMeta()
+       if err != nil {
+               return nil, err
+       }
+       if meta.formatVersion != 3 {
+               return nil, fmt.Errorf("%w: equality-delete to deletion-vector 
conversion requires a v3 table, got v%d",
+                       iceberg.ErrNotImplemented, meta.formatVersion)
+       }
+
+       snapshot := t.planningSnapshot(meta)
+       if snapshot == nil {
+               return &EqualityDeleteToDVResult{}, nil
+       }
+
+       fs, err := t.tbl.FS(ctx)
+       if err != nil {
+               return nil, err
+       }
+       manifests, err := snapshot.Manifests(ctx, fs)
+       if err != nil {
+               return nil, err
+       }
+       equalityDeleteFiles := make(map[string]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(ctx, fs, true) {
+                       if err != nil {
+                               return nil, err
+                       }
+                       file := entry.DataFile()
+                       if file.ContentType() != iceberg.EntryContentEqDeletes {
+                               continue
+                       }
+                       if _, exists := equalityDeleteFiles[file.FilePath()]; 
exists {
+                               return nil, fmt.Errorf("%w: duplicate live 
equality delete file %s",
+                                       ErrInvalidMetadata, file.FilePath())
+                       }
+                       equalityDeleteFiles[file.FilePath()] = file
+               }
+       }
+       if len(equalityDeleteFiles) == 0 {
+               return &EqualityDeleteToDVResult{}, nil
+       }
+       // Only projected equality keys are needed to resolve their matching row
+       // positions. Missing (dropped) keys are handled by the delete loader.
+       keyNames := make(map[string]struct{})
+       for _, file := range equalityDeleteFiles {
+               for _, id := range file.EqualityFieldIDs() {
+                       if name, ok := meta.CurrentSchema().FindColumnName(id); 
ok {
+                               keyNames[name] = struct{}{}
+                       }
+               }
+       }
+       selectedFields := make([]string, 0, len(keyNames))
+       for name := range keyNames {
+               selectedFields = append(selectedFields, name)
+       }
+       slices.Sort(selectedFields)
+       scanOpts := []ScanOption{WithScanPlanningMode(ScanPlanningLocal)}
+       // WithSelectedFields with no names selects every column. Keep that
+       // fallback when all equality keys were dropped from the current schema.
+       if len(selectedFields) > 0 {
+               scanOpts = append(scanOpts, 
WithSelectedFields(selectedFields...))
+       }
+
+       scan, err := t.Scan(scanOpts...)
+       if err != nil {
+               return nil, err
+       }
+       if t.branch != "" && t.branch != MainBranch && 
scan.metadata.SnapshotByName(t.branch) != nil {
+               branchScan, err := scan.UseRef(t.branch)
+               _ = scan.Close()
+               if err != nil {
+                       return nil, err
+               }
+               scan = branchScan
+       }
+       defer scan.Close()
+
+       plannedTasks, err := scan.PlanFiles(ctx)
+       if err != nil {
+               return nil, err
+       }
+       baseTasks := make([]FileScanTask, 0, len(plannedTasks))
+       addedDeleteTasks := make([]FileScanTask, 0, len(plannedTasks))
+       positionsByPath := make(map[string]*equalityDeletePositionBuffer)
+       positionSinks := make(map[string]func([]int64))
+       addedTaskByPath := make(map[string]int)
+       for _, task := range plannedTasks {
+               if len(task.EqualityDeleteFiles) == 0 {
+                       continue
+               }
+               if task.File == nil {
+                       return nil, fmt.Errorf("%w: scan task with equality 
deletes has no data file", ErrInvalidMetadata)
+               }
+               path := task.File.FilePath()
+               if _, exists := positionsByPath[path]; !exists {
+                       positions := &equalityDeletePositionBuffer{}
+                       positionsByPath[path] = positions
+                       positionSinks[path] = positions.addPositions
+               }
+
+               base := task
+               base.EqualityDeleteFiles = nil
+               baseTasks = append(baseTasks, base)
+
+               if _, exists := addedTaskByPath[path]; !exists {
+                       added := task
+                       added.DeleteFiles = nil
+                       added.DeletionVectorFiles = nil
+                       addedTaskByPath[path] = len(addedDeleteTasks)
+                       addedDeleteTasks = append(addedDeleteTasks, added)
+               }
+       }
+
+       if len(baseTasks) > 0 {
+               _, records, err := scan.readFileTasksWithAddedDeletes(ctx, 
baseTasks, addedDeleteTasks, positionSinks)
+               if err != nil {
+                       return nil, err
+               }
+               for record, err := range records {
+                       if err != nil {
+                               return nil, err
+                       }
+                       record.Release()
+               }
+       }
+
+       // A row-position cursor should never produce negative positions. Unlike
+       // DVWriter.Add, DVWriter.Load accepts an unsigned bitmap, so reject
+       // invalid positions before they can wrap to huge uint64 values.
+       for path, positions := range positionsByPath {
+               if positions.invalidPosition < 0 {
+                       return nil, fmt.Errorf("%w: negative equality-delete 
row position %d for data file %s",
+                               iceberg.ErrInvalidArgument, 
positions.invalidPosition, path)
+               }
+       }
+
+       writeFS, err := requireWriteFileIO(fs)
+       if err != nil {
+               return nil, err
+       }
+       metadata, err := meta.Build()
+       if err != nil {
+               return nil, err
+       }
+       positionDeleteLoader := newLazyPositionDeleteLoader(fs, baseTasks)
+       defer positionDeleteLoader.release()
+       deletionVectorLoader, err := newLazyDeletionVectorLoader(fs, baseTasks)
+       if err != nil {
+               return nil, err
+       }
+
+       result := &EqualityDeleteToDVResult{ConvertedEqualityDeleteFiles: 
len(equalityDeleteFiles)}
+       deleteFilesToRemove := make([]iceberg.DataFile, 0, 
len(equalityDeleteFiles))
+       addRemoval := func(file iceberg.DataFile) {
+               deleteFilesToRemove = append(deleteFilesToRemove, file)
+               if IsDeletionVector(file) {
+                       result.ReplacedDeletionVectors++
+               }
+       }
+
+       equalityPaths := make([]string, 0, len(equalityDeleteFiles))
+       for path := range equalityDeleteFiles {
+               equalityPaths = append(equalityPaths, path)
+       }
+       slices.Sort(equalityPaths)
+       for _, path := range equalityPaths {
+               addRemoval(equalityDeleteFiles[path])
+       }
+
+       var writer *dv.DVWriter
+       seenDataPaths := make(map[string]struct{}, len(positionsByPath))
+       for i, task := range baseTasks {
+               path := task.File.FilePath()
+               if _, seen := seenDataPaths[path]; seen {
+                       continue
+               }
+               seenDataPaths[path] = struct{}{}
+               positions := positionsByPath[path].bitmap
+               if positions == nil || positions.IsEmpty() {
+                       continue
+               }
+               partition := dataFilePartition(task.File)
+               if writer == nil {
+                       writer = dv.NewDVWriter(writeFS, func(id int32) 
*iceberg.PartitionSpec {
+                               return metadata.PartitionSpecByID(int(id))
+                       })
+               }
+               writer.Load(path, positions, task.File.SpecID(), partition)
+
+               var positionDeletes *dv.RoaringPositionBitmap
+               if positionDeleteLoader != nil {
+                       positionDeletes, err = positionDeleteLoader.load(ctx, 
i, path)
+                       if err != nil {
+                               return nil, fmt.Errorf("read existing position 
deletes for %s: %w", path, err)
+                       }
+               }
+               if positionDeletes != nil {
+                       writer.Load(path, positionDeletes, task.File.SpecID(), 
partition)
+               }
+               existingDV, err := deletionVectorLoader.load(ctx, path)
+               if err != nil {
+                       return nil, fmt.Errorf("read existing deletion vector 
for %s: %w", path, err)
+               }
+               if existingDV != nil {
+                       writer.Load(path, existingDV, task.File.SpecID(), 
partition)
+               }
+               for _, file := range task.DeletionVectorFiles {
+                       addRemoval(file)
+               }
+               for _, file := range task.DeleteFiles {
+                       if referencedDataFilePath(file) == path {
+                               addRemoval(file)
+                               result.RemovedPositionDeleteFiles++
+                       }
+               }
+       }
+
+       var additions []rewriteDeleteFileAddition
+       var outputPath string
+       staged := false
+       defer func() {
+               if !staged && outputPath != "" {
+                       _ = writeFS.Remove(context.WithoutCancel(ctx), 
outputPath)
+               }
+       }()
+       if writer != nil {
+               // Respect staged table location and write.data.path 
properties, including
+               // object store location providers. The base table may still 
point elsewhere.
+               locationProvider, err := LoadLocationProvider(meta.loc, 
meta.props)
+               if err != nil {
+                       return nil, fmt.Errorf("resolve deletion vector 
location: %w", err)
+               }
+               outputPath = locationProvider.NewDataLocation(
+                       
"00000-0-"+uuid.New().String()+"-equality-delete-conversion.puffin", "")
+               deletionVectors, err := writer.Flush(ctx, outputPath)
+               if err != nil {
+                       return nil, err
+               }
+               sequenceNumber := meta.nextSequenceNumber()
+               for _, file := range deletionVectors {
+                       sequence := sequenceNumber
+                       additions = append(additions, rewriteDeleteFileAddition{
+                               file:               file,
+                               dataSequenceNumber: &sequence,

Review Comment:
   nit: sequence copies a value nothing changes, thus, every addition can share 
&sequenceNumber



##########
table/convert_equality_deletes_to_dv.go:
##########
@@ -0,0 +1,382 @@
+// 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"
+       "fmt"
+       "slices"
+       "sync"
+
+       "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/table/dv"
+       "github.com/google/uuid"
+)
+
+// equalityDeletePositionBuffer accumulates matched positions for one data 
file.
+// Each buffer has its own mutex so parallel scans of different files do not
+// contend on a table-wide lock. The scan finishes before bitmap is read.
+type equalityDeletePositionBuffer struct {
+       mu              sync.Mutex
+       bitmap          *dv.RoaringPositionBitmap
+       invalidPosition int64
+}
+
+func (b *equalityDeletePositionBuffer) addPositions(positions []int64) {
+       if len(positions) == 0 {
+               return
+       }
+
+       b.mu.Lock()
+       defer b.mu.Unlock()
+
+       if b.invalidPosition < 0 {
+               return
+       }
+       for _, position := range positions {
+               if position < 0 {
+                       b.invalidPosition = position
+
+                       return
+               }
+       }
+
+       if b.bitmap == nil {
+               b.bitmap = dv.NewRoaringPositionBitmap()
+       }
+       for _, position := range positions {
+               b.bitmap.Set(uint64(position))
+       }
+}
+
+// EqualityDeleteToDVResult summarizes an equality-delete conversion.
+type EqualityDeleteToDVResult struct {
+       // Table is the table returned by the table-level action. It is nil 
when the
+       // action is staged on a Transaction and set after that transaction 
commits.
+       Table *Table
+
+       // ConvertedEqualityDeleteFiles is the number of live equality delete 
files
+       // removed by the rewrite.
+       ConvertedEqualityDeleteFiles int
+       // AddedDeletionVectors is the number of file-specific deletion vectors 
added.
+       AddedDeletionVectors int
+       // RemovedPositionDeleteFiles is the number of legacy position delete 
files
+       // scoped to one data file, either by referenced_data_file or equal
+       // file_path bounds, removed with the conversion.
+       RemovedPositionDeleteFiles int
+       // ReplacedDeletionVectors is the number of existing vectors merged 
into new
+       // vectors and removed by the rewrite.
+       ReplacedDeletionVectors int
+}
+
+// ConvertEqualityDeletesToDeletionVectors converts live equality deletes on
+// the main branch of a v3 table to file-specific deletion vectors. The rewrite
+// commits as one replace snapshot so changelog readers do not see a second
+// logical deletion event.
+func (t Table) ConvertEqualityDeletesToDeletionVectors(ctx context.Context) 
(*EqualityDeleteToDVResult, error) {
+       txn, err := t.NewTransactionOnBranch(MainBranch)
+       if err != nil {
+               return nil, err
+       }
+
+       result, err := txn.ConvertEqualityDeletesToDeletionVectors(ctx)
+       if err != nil {
+               return result, err
+       }
+       if result.ConvertedEqualityDeleteFiles == 0 {
+               result.Table = &t
+
+               return result, nil
+       }
+
+       committed, err := txn.Commit(ctx)
+       if err == nil {
+               result.Table = committed
+       }
+
+       return result, err
+}
+
+// ConvertEqualityDeletesToDeletionVectors stages an equality-delete to
+// deletion-vector conversion on the transaction's branch. Commit the
+// transaction to publish the replace snapshot.

Review Comment:
   lets mention the limitations: 
   - a concurrent commit on the branch fails this with ErrCommitFailed, with no 
retry
   - unscoped position deletes are rejected
   - every affected data file is read
   - abandoned transactions leave a Puffin file for orphan cleanup.
   



##########
table/transaction.go:
##########
@@ -1874,6 +1885,36 @@ func (t *Transaction) replaceFiles(ctx context.Context, 
dataFilesToDelete, dataF
        if err != nil {
                return err
        }
+       if cfg.equalityDeleteToDVConversion {
+               if !cfg.rewriteSemantics || len(dataFilesToDelete) > 0 || 
len(dataFilesToAdd) > 0 ||
+                       len(autoDeleteFilesToRemove) > 0 || 
len(deleteFilesToRemove) == 0 {

Review Comment:
   only one caller sets this option, and it builds these values right above the 
call, so none of these checks can fire
   
   better: remove this block, and the IsDeletionVector check in the loop 
further down



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to