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 db7645084 perf(table): reuse position delete indexes across adjacent 
splits (#2255)
db7645084 is described below

commit db764508477e380e2a95d6aeef2ea4355a5abc07
Author: Minh Vu <[email protected]>
AuthorDate: Wed Oct 21 22:54:11 2026 +0200

    perf(table): reuse position delete indexes across adjacent splits (#2255)
    
    * perf(table): reuse positional-delete indexes across adjacent splits
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * test(table): cover split index reuse and changing delete sets
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * bench(table): compare positional-delete index construction A/B
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * style(table): gofmt positional delete index tests
    
    Signed-off-by: Minh Vu <[email protected]>
    
    ---------
    
    Signed-off-by: Minh Vu <[email protected]>
---
 table/arrow_scanner.go                             |  39 ++++++
 ...row_scanner_posdelete_index_reuse_bench_test.go | 135 +++++++++++++++++++++
 table/arrow_scanner_posdelete_index_reuse_test.go  |  97 +++++++++++++++
 3 files changed, 271 insertions(+)

diff --git a/table/arrow_scanner.go b/table/arrow_scanner.go
index cfa5cf4ba..8c884041b 100644
--- a/table/arrow_scanner.go
+++ b/table/arrow_scanner.go
@@ -257,6 +257,25 @@ func positionDeleteIndexKey(task FileScanTask) (key, 
targetPath string, deletePa
        return builder.String(), targetPath, deletePaths
 }
 
+// sameOrderedPositionDeleteFiles compares only the references used to build a
+// positional-delete index. Other delete types do not affect the index key.
+func sameOrderedPositionDeleteFiles(left, right []iceberg.DataFile) bool {
+       if len(left) != len(right) {
+               return false
+       }
+
+       for i, file := range left {
+               leftPosDelete := file.ContentType() == 
iceberg.EntryContentPosDeletes
+               rightPosDelete := right[i].ContentType() == 
iceberg.EntryContentPosDeletes
+               if leftPosDelete != rightPosDelete ||
+                       (leftPosDelete && file.FilePath() != 
right[i].FilePath()) {
+                       return false
+               }
+       }
+
+       return true
+}
+
 func newLazyPositionDeleteLoader(fs iceio.IO, tasks []FileScanTask) 
*lazyPositionDeleteLoader {
        uniqueDeletes, targetsByDelete := 
collectPositionDeleteFilesAndTargets(tasks)
        if len(uniqueDeletes) == 0 {
@@ -275,10 +294,29 @@ func newLazyPositionDeleteLoader(fs iceio.IO, tasks 
[]FileScanTask) *lazyPositio
                }
        }
        indexesByKey := make(map[string]*lazyPositionDeleteIndex)
+       var previousIndex *lazyPositionDeleteIndex
+       var previousTargetPath string
        for i, task := range tasks {
+               // Consecutive splits of a data file usually share its delete 
set.
+               // Avoid rebuilding and sorting the same long path key for each 
split.
+               if i > 0 && previousIndex != nil && task.File != nil &&
+                       len(task.DeletionVectorFiles) == 0 &&
+                       task.File.FilePath() == previousTargetPath &&
+                       sameOrderedPositionDeleteFiles(task.DeleteFiles, 
tasks[i-1].DeleteFiles) {
+                       loader.indexes[i] = positionDeleteTaskIndex{
+                               targetPath: previousTargetPath,
+                               index:      previousIndex,
+                       }
+
+                       continue
+               }
+
                key, targetPath, deletePaths := positionDeleteIndexKey(task)
                loader.indexes[i].targetPath = targetPath
+               previousTargetPath = targetPath
                if key == "" {
+                       previousIndex = nil
+
                        continue
                }
                index := indexesByKey[key]
@@ -290,6 +328,7 @@ func newLazyPositionDeleteLoader(fs iceio.IO, tasks 
[]FileScanTask) *lazyPositio
                        indexesByKey[key] = index
                }
                loader.indexes[i].index = index
+               previousIndex = index
        }
 
        return loader
diff --git a/table/arrow_scanner_posdelete_index_reuse_bench_test.go 
b/table/arrow_scanner_posdelete_index_reuse_bench_test.go
new file mode 100644
index 000000000..ebc9fb9fa
--- /dev/null
+++ b/table/arrow_scanner_posdelete_index_reuse_bench_test.go
@@ -0,0 +1,135 @@
+// 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 (
+       "fmt"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+)
+
+var benchmarkPositionDeleteIndexBuildSink *lazyPositionDeleteLoader
+
+// benchmarkPositionDeleteIndexBuildBefore keeps the previous key-generation
+// loop so the same benchmark invocation can measure before and after.
+func benchmarkPositionDeleteIndexBuildBefore(tasks []FileScanTask) 
*lazyPositionDeleteLoader {
+       uniqueDeletes, targetsByDelete := 
collectPositionDeleteFilesAndTargets(tasks)
+       if len(uniqueDeletes) == 0 {
+               return nil
+       }
+       loader := &lazyPositionDeleteLoader{
+               files:   make(map[string]*lazyPositionDeleteFile, 
len(uniqueDeletes)),
+               indexes: make([]positionDeleteTaskIndex, len(tasks)),
+       }
+       for path, deleteFile := range uniqueDeletes {
+               loader.files[path] = &lazyPositionDeleteFile{
+                       dataFile: deleteFile,
+                       targets:  targetsByDelete[path],
+               }
+       }
+       indexesByKey := make(map[string]*lazyPositionDeleteIndex)
+       for i, task := range tasks {
+               key, targetPath, deletePaths := positionDeleteIndexKey(task)
+               loader.indexes[i].targetPath = targetPath
+               if key == "" {
+                       continue
+               }
+               index := indexesByKey[key]
+               if index == nil {
+                       index = &lazyPositionDeleteIndex{
+                               targetPath:  targetPath,
+                               deletePaths: deletePaths,
+                       }
+                       indexesByKey[key] = index
+               }
+               loader.indexes[i].index = index
+       }
+
+       return loader
+}
+
+func benchmarkPositionDeleteIndexTasks(deleteCount int, interleaved bool) 
[]FileScanTask {
+       const fileCount, splitsPerFile = 1000, 10
+       dataFiles := make([]iceberg.DataFile, fileCount)
+       deleteFiles := make([][]iceberg.DataFile, fileCount)
+       for i := range dataFiles {
+               dataFiles[i] = &mockDataFile{
+                       path: 
fmt.Sprintf("s3://bucket/catalog/namespace/table/data/%016d-%032d.parquet", i, 
i),
+               }
+               deleteFiles[i] = make([]iceberg.DataFile, deleteCount)
+               for j := range deleteFiles[i] {
+                       deleteFiles[i][j] = &mockDataFile{
+                               path:        
fmt.Sprintf("s3://bucket/catalog/namespace/table/deletes/%016d-%012d-%032d.parquet",
 i, j, j),
+                               contentType: iceberg.EntryContentPosDeletes,
+                       }
+               }
+       }
+       tasks := make([]FileScanTask, fileCount*splitsPerFile)
+       for i := range tasks {
+               fileIndex := i / splitsPerFile
+               if interleaved {
+                       fileIndex = i % fileCount
+               }
+               tasks[i] = FileScanTask{
+                       File:        dataFiles[fileIndex],
+                       DeleteFiles: deleteFiles[fileIndex],
+                       Start:       int64(i * 128),
+                       Length:      128,
+               }
+       }
+
+       return tasks
+}
+
+// BenchmarkPositionDeleteIndexConstruction measures building the complete
+// scan-scoped index for 10,000 split tasks and 1,000 data files. The 
interleaved
+// control prevents a best-case-only result. It excludes Parquet IO.
+func BenchmarkPositionDeleteIndexConstruction(b *testing.B) {
+       for _, deleteCount := range []int{1, 4, 16} {
+               for _, layout := range []struct {
+                       name        string
+                       interleaved bool
+               }{
+                       {name: "contiguous"},
+                       {name: "interleaved", interleaved: true},
+               } {
+                       tasks := benchmarkPositionDeleteIndexTasks(deleteCount, 
layout.interleaved)
+                       b.Run(fmt.Sprintf("%s/deletes=%d", layout.name, 
deleteCount), func(b *testing.B) {
+                               for _, implementation := range []struct {
+                                       name  string
+                                       build func() *lazyPositionDeleteLoader
+                               }{
+                                       {name: "before", build: func() 
*lazyPositionDeleteLoader {
+                                               return 
benchmarkPositionDeleteIndexBuildBefore(tasks)
+                                       }},
+                                       {name: "after", build: func() 
*lazyPositionDeleteLoader {
+                                               return 
newLazyPositionDeleteLoader(nil, tasks)
+                                       }},
+                               } {
+                                       b.Run(implementation.name, func(b 
*testing.B) {
+                                               b.ReportAllocs()
+                                               for b.Loop() {
+                                                       
benchmarkPositionDeleteIndexBuildSink = implementation.build()
+                                               }
+                                       })
+                               }
+                       })
+               }
+       }
+}
diff --git a/table/arrow_scanner_posdelete_index_reuse_test.go 
b/table/arrow_scanner_posdelete_index_reuse_test.go
new file mode 100644
index 000000000..5c4889ecd
--- /dev/null
+++ b/table/arrow_scanner_posdelete_index_reuse_test.go
@@ -0,0 +1,97 @@
+// 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 TestPositionDeleteIndexReuseAcrossSplits(t *testing.T) {
+       data := &mockDataFile{path: "s3://bucket/data/a.parquet"}
+       otherData := &mockDataFile{path: "s3://bucket/data/b.parquet"}
+       deleteA := &mockDataFile{path: "s3://bucket/delete/a.parquet", 
contentType: iceberg.EntryContentPosDeletes}
+       deleteB := &mockDataFile{path: "s3://bucket/delete/b.parquet", 
contentType: iceberg.EntryContentPosDeletes}
+       deleteC := &mockDataFile{path: "s3://bucket/delete/c.parquet", 
contentType: iceberg.EntryContentPosDeletes}
+       otherDelete := &mockDataFile{path: 
"s3://bucket/delete/not-pos.parquet", contentType: iceberg.EntryContentData}
+
+       tasks := []FileScanTask{
+               {File: data, DeleteFiles: []iceberg.DataFile{deleteA, deleteB}},
+               {File: data, DeleteFiles: []iceberg.DataFile{deleteA, 
deleteB}},          // Adjacent identical set.
+               {File: data, DeleteFiles: []iceberg.DataFile{deleteB, 
deleteA}},          // Reordered set.
+               {File: data, DeleteFiles: []iceberg.DataFile{deleteA, 
deleteC}},          // Changed set.
+               {File: data, DeleteFiles: []iceberg.DataFile{deleteA, deleteB, 
deleteB}}, // Duplicate path.
+               {File: otherData, DeleteFiles: []iceberg.DataFile{deleteA, 
deleteB}},     // Different target.
+               {
+                       File: data, DeleteFiles: []iceberg.DataFile{deleteA, 
deleteB},
+                       DeletionVectorFiles: []iceberg.DataFile{deleteC}, // 
Superseding DV.
+               },
+               {File: data, DeleteFiles: []iceberg.DataFile{deleteA, 
deleteB}},     // After DV.
+               {File: data, DeleteFiles: []iceberg.DataFile{deleteA, 
otherDelete}}, // Non-positional delete.
+               {File: data, DeleteFiles: []iceberg.DataFile{deleteA}},
+               {File: data, DeleteFiles: []iceberg.DataFile{deleteA, deleteB}},
+               {DeleteFiles: []iceberg.DataFile{deleteA}}, // Missing data 
file path.
+               {File: data}, // No delete files.
+       }
+
+       loader := newLazyPositionDeleteLoader(nil, tasks)
+       require.NotNil(t, loader)
+       defer loader.release()
+
+       for i, task := range tasks {
+               wantPath := ""
+               if task.File != nil {
+                       wantPath = task.File.FilePath()
+               }
+               require.Equal(t, wantPath, loader.indexes[i].targetPath, "task 
%d", i)
+       }
+
+       indexes := loader.indexes
+       require.NotNil(t, indexes[0].index)
+       for _, i := range []int{1, 2, 4, 7, 10} {
+               require.Same(t, indexes[0].index, indexes[i].index, "task %d 
should share the canonical index", i)
+       }
+       require.Equal(t, []string{deleteA.FilePath(), deleteB.FilePath()}, 
indexes[0].index.deletePaths)
+       require.NotSame(t, indexes[0].index, indexes[3].index) // Changed 
delete set.
+       require.NotSame(t, indexes[0].index, indexes[5].index) // Changed 
target path.
+       require.Same(t, indexes[8].index, indexes[9].index)    // 
Non-positional entries are ignored.
+       require.NotSame(t, indexes[0].index, indexes[9].index)
+       for _, i := range []int{6, 11, 12} {
+               require.Nil(t, indexes[i].index, "task %d must not have an 
index", i)
+       }
+}
+
+func TestSameOrderedPositionDeleteFiles(t *testing.T) {
+       a := &mockDataFile{path: "delete-a.parquet", contentType: 
iceberg.EntryContentPosDeletes}
+       b := &mockDataFile{path: "delete-b.parquet", contentType: 
iceberg.EntryContentPosDeletes}
+       nonPosA := &mockDataFile{path: "other-a.parquet", contentType: 
iceberg.EntryContentData}
+       nonPosB := &mockDataFile{path: "other-b.parquet", contentType: 
iceberg.EntryContentData}
+
+       require.True(t, sameOrderedPositionDeleteFiles(
+               []iceberg.DataFile{a, nonPosA}, []iceberg.DataFile{a, nonPosB}))
+       require.False(t, sameOrderedPositionDeleteFiles(
+               []iceberg.DataFile{a, b}, []iceberg.DataFile{b, a}))
+       require.False(t, sameOrderedPositionDeleteFiles(
+               []iceberg.DataFile{a}, []iceberg.DataFile{b}))
+       require.False(t, sameOrderedPositionDeleteFiles(
+               []iceberg.DataFile{a}, []iceberg.DataFile{nonPosA}))
+       require.False(t, sameOrderedPositionDeleteFiles(
+               []iceberg.DataFile{a}, nil))
+}

Reply via email to