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 1fdbecb79 perf(rest): decode derived deletion vectors once (#2271)
1fdbecb79 is described below

commit 1fdbecb79954d3f53b73ce62662b59bc67d8bae1
Author: Minh Vu <[email protected]>
AuthorDate: Fri Oct 23 18:33:01 2026 +0200

    perf(rest): decode derived deletion vectors once (#2271)
    
    perf(rest): decode shared and derived deletion vectors once
    
    Signed-off-by: Minh Vu <[email protected]>
---
 catalog/rest/scan_task_decoder.go            | 62 +++++++++++++++++++++++++++-
 catalog/rest/scan_task_decoder_bench_test.go | 24 +++++++++++
 catalog/rest/scan_task_decoder_test.go       | 23 +++++++++++
 3 files changed, 107 insertions(+), 2 deletions(-)

diff --git a/catalog/rest/scan_task_decoder.go 
b/catalog/rest/scan_task_decoder.go
index 337eab18a..f61c667d3 100644
--- a/catalog/rest/scan_task_decoder.go
+++ b/catalog/rest/scan_task_decoder.go
@@ -27,6 +27,7 @@ import (
        "reflect"
        "slices"
        "strconv"
+       "strings"
 
        "github.com/apache/iceberg-go"
        "github.com/apache/iceberg-go/table"
@@ -123,6 +124,52 @@ func DecodeScanTasks(
        return decodeScanTasks(wire, metadata, schema, fallbackResidual, 
newPartitionDecodePlanCache())
 }
 
+// deriveDeletionVectorOwners identifies a unique data-file path for unowned
+// Puffin deletion vectors. A conflicting owner stays unresolved so the normal
+// task validation can report the conflict in its original order.
+func deriveDeletionVectorOwners(wire ScanTasks) []string {
+       hasDerivedDV := false
+       for _, del := range wire.DeleteFiles {
+               if del.ReferencedDataFile == nil &&
+                       strings.EqualFold(del.FileFormat, 
string(iceberg.PuffinFile)) {
+                       hasDerivedDV = true
+
+                       break
+               }
+       }
+       if !hasDerivedDV {
+               return nil
+       }
+
+       owners := make([]string, len(wire.DeleteFiles))
+       conflicts := make([]bool, len(wire.DeleteFiles))
+       for _, task := range wire.FileScanTasks {
+               if task.DataFile == nil || task.DataFile.FilePath == "" {
+                       continue
+               }
+               path := task.DataFile.FilePath
+               for _, ref := range task.DeleteFileReferences {
+                       // Leave malformed references for the regular 
validation pass.
+                       if ref < 0 || ref >= len(wire.DeleteFiles) || 
conflicts[ref] {
+                               continue
+                       }
+                       del := &wire.DeleteFiles[ref]
+                       if del.ReferencedDataFile != nil ||
+                               !strings.EqualFold(del.FileFormat, 
string(iceberg.PuffinFile)) {
+                               continue
+                       }
+                       if owners[ref] == "" {
+                               owners[ref] = path
+                       } else if owners[ref] != path {
+                               owners[ref] = ""
+                               conflicts[ref] = true
+                       }
+               }
+       }
+
+       return owners
+}
+
 func decodeScanTasks(
        wire ScanTasks,
        metadata table.ScanPlanningMetadata,
@@ -147,11 +194,19 @@ func decodeScanTasks(
                return nil, nil
        }
 
+       // Resolve unique owners first so unowned deletion vectors are built 
once,
+       // even when multiple scan tasks reference the same vector.
+       derivedDVOwners := deriveDeletionVectorOwners(wire)
+
        // Decode every delete before resolving references. This validates all 
wire
        // entries, including an unreferenced entry that will be rejected below.
        deletes := make([]iceberg.DataFile, len(wire.DeleteFiles))
        for i := range wire.DeleteFiles {
-               decoded, err := decodeRESTDeleteFile(wire.DeleteFiles[i], 
metadata, "", plans)
+               owner := ""
+               if derivedDVOwners != nil {
+                       owner = derivedDVOwners[i]
+               }
+               decoded, err := decodeRESTDeleteFile(wire.DeleteFiles[i], 
metadata, owner, plans)
                if err != nil {
                        return nil, fmt.Errorf("%w: decoding scan tasks: 
delete-files[%d]: %w", ErrRESTError, i, err)
                }
@@ -220,7 +275,10 @@ func decodeScanTasks(
                                dvOwners[ref] = dataFile.FilePath()
                                // Derive referenced-data-file from the 
FileScanTask association
                                // when the server omits Java's optional 
extension field.
-                               if wireDelete.ReferencedDataFile == nil {
+                               if wireDelete.ReferencedDataFile == nil &&
+                                       (derivedDVOwners == nil || 
derivedDVOwners[ref] != dataFile.FilePath()) {
+                                       // Conflicting or missing owners fall 
back to the original
+                                       // per-reference decoder, preserving 
the validation path.
                                        deleteFile, err = 
decodeRESTDeleteFile(wireDelete, metadata, dataFile.FilePath(), plans)
                                        if err != nil {
                                                return nil, fmt.Errorf("%w: 
decoding scan tasks: delete-files[%d]: %w", ErrRESTError, ref, err)
diff --git a/catalog/rest/scan_task_decoder_bench_test.go 
b/catalog/rest/scan_task_decoder_bench_test.go
index 6ac8466db..1b368f2e5 100644
--- a/catalog/rest/scan_task_decoder_bench_test.go
+++ b/catalog/rest/scan_task_decoder_bench_test.go
@@ -178,3 +178,27 @@ func BenchmarkDecodeJSONNumberForms(b *testing.B) {
                })
        }
 }
+
+func BenchmarkDecodeScanTasksSharedDeletionVector(b *testing.B) {
+       metadata := newScanTaskDecoderMetadata()
+       for _, taskCount := range []int{100, 1000, 10000} {
+               wire := deletionVectorScanTasksWire(1, false)
+               task := wire.FileScanTasks[0]
+               wire.FileScanTasks = make([]RESTFileScanTask, taskCount)
+               for i := range wire.FileScanTasks {
+                       wire.FileScanTasks[i] = task
+               }
+
+               b.Run(fmt.Sprintf("derived-owner/tasks=%d", taskCount), func(b 
*testing.B) {
+                       b.ReportAllocs()
+                       b.ResetTimer()
+                       for b.Loop() {
+                               tasks, err := DecodeScanTasks(wire, metadata, 
metadata.schema, nil)
+                               if err != nil {
+                                       b.Fatal(err)
+                               }
+                               decodeScanTasksBenchmarkSink = len(tasks)
+                       }
+               })
+       }
+}
diff --git a/catalog/rest/scan_task_decoder_test.go 
b/catalog/rest/scan_task_decoder_test.go
index 7d0022505..1ad68e576 100644
--- a/catalog/rest/scan_task_decoder_test.go
+++ b/catalog/rest/scan_task_decoder_test.go
@@ -310,6 +310,29 @@ func 
TestDecodeScanTasksDerivesDeletionVectorTargetWhenOmitted(t *testing.T) {
        assert.Equal(t, new("s3://bucket/table/data.parquet"), 
tasks[0].DeletionVectorFiles[0].ReferencedDataFile())
 }
 
+func TestDecodeScanTasksSharesDerivedDeletionVector(t *testing.T) {
+       t.Parallel()
+
+       metadata := newScanTaskDecoderMetadata()
+       wire := validScanTasksWire()
+       wire.DeleteFiles[0].FileFormat = "PUFFIN"
+       wire.DeleteFiles[0].ContentOffset = new(int64(10))
+       wire.DeleteFiles[0].ContentSizeInBytes = new(int64(20))
+       wire.FileScanTasks[0].DeleteFileReferences = []int{0}
+       // Two file-scan tasks for the same data file share a DV without an
+       // explicit referenced-data-file on the REST wire.
+       wire.FileScanTasks = append(wire.FileScanTasks, wire.FileScanTasks[0])
+
+       tasks, err := DecodeScanTasks(wire, metadata, metadata.schema, nil)
+       require.NoError(t, err)
+       require.Len(t, tasks, 2)
+       require.Len(t, tasks[0].DeletionVectorFiles, 1)
+       require.Len(t, tasks[1].DeletionVectorFiles, 1)
+       assert.Equal(t, new("s3://bucket/table/data.parquet"),
+               tasks[0].DeletionVectorFiles[0].ReferencedDataFile())
+       assert.Same(t, tasks[0].DeletionVectorFiles[0], 
tasks[1].DeletionVectorFiles[0])
+}
+
 func TestRESTValueMapMatchesJavaContentFileParser(t *testing.T) {
        t.Parallel()
 

Reply via email to