This is an automated email from the ASF dual-hosted git repository.

laskoviymishka pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new 89a9104ec perf(table): filter position-delete reads by file path 
(#1937)
89a9104ec is described below

commit 89a9104ec3639d38ab07e4461721cb5270e95571
Author: Minh Vu <[email protected]>
AuthorDate: Mon Sep 28 13:00:13 2026 +0200

    perf(table): filter position-delete reads by file path (#1937)
    
    perf(table): filter lazy position-delete reads by file path
---
 table/arrow_scanner.go                             | 231 ++++++-
 table/arrow_scanner_lazy_delete_regression_test.go |  18 +-
 table/arrow_scanner_nested_delete_test.go          |  71 +++
 table/arrow_scanner_posdelete_bench_test.go        | 111 +++-
 table/arrow_scanner_posdelete_regression_test.go   | 684 ++++++++++++++++++++-
 table/arrow_scanner_test.go                        |  11 +-
 6 files changed, 1076 insertions(+), 50 deletions(-)

diff --git a/table/arrow_scanner.go b/table/arrow_scanner.go
index 7b5469902..4d29a69e4 100644
--- a/table/arrow_scanner.go
+++ b/table/arrow_scanner.go
@@ -57,15 +57,16 @@ const (
 var PositionalDeleteArrowSchema, _ = 
SchemaToArrowSchema(iceberg.PositionalDeleteSchema, nil, true, false)
 
 type (
-       positionDeletes   = []*arrow.Chunked
-       perFilePosDeletes = map[string]positionDeletes
+       positionDeletes      = []*arrow.Chunked
+       perFilePosDeletes    = map[string]positionDeletes
+       perDeleteFileTargets = map[string]map[string]struct{}
 )
 
 // releasePerFilePosDeletes releases every Arrow chunk in a positional-delete
 // map. Required on every error return between readAllDeleteFiles and the
 // iterator returned by createIterator — Arrow allocations are not freed by
 // GC, so dropping the map on the floor leaks the chunks. Safe to call on a
-// nil map; the nil-chunk guard is defensive — readDeletes never inserts a
+// nil map; the nil-chunk guard is defensive — position-delete readers never 
insert a
 // nil *arrow.Chunked, but the guard keeps callers safe if that invariant
 // ever changes.
 func releasePerFilePosDeletes(deletesPerFile perFilePosDeletes) {
@@ -78,25 +79,51 @@ func releasePerFilePosDeletes(deletesPerFile 
perFilePosDeletes) {
        }
 }
 
-// readAllDeleteFiles reads every referenced positional-delete file up front. 
It
-// remains the path used by Transaction.makePositionDeleteRecordsForFilter and 
by
-// the eager-vs-lazy benchmark; arrowScan.GetRecords uses 
lazyPositionDeleteLoader.
-func readAllDeleteFiles(ctx context.Context, fs iceio.IO, tasks 
[]FileScanTask, concurrency int) (perFilePosDeletes, error) {
-       deletesPerFile := make(perFilePosDeletes)
+// collectPositionDeleteFilesAndTargets indexes each positional-delete file and
+// the data-file paths of tasks that reference it. A nil target set preserves
+// the whole-file fallback when any referencing task lacks a usable data path.
+func collectPositionDeleteFilesAndTargets(tasks []FileScanTask) 
(map[string]iceberg.DataFile, perDeleteFileTargets) {
        uniqueDeletes := make(map[string]iceberg.DataFile)
+       targetsByDelete := make(perDeleteFileTargets)
 
-       for _, t := range tasks {
-               for _, d := range t.DeleteFiles {
-                       if d.ContentType() != iceberg.EntryContentPosDeletes {
+       for _, task := range tasks {
+               for _, deleteFile := range task.DeleteFiles {
+                       if deleteFile.ContentType() != 
iceberg.EntryContentPosDeletes {
+                               continue
+                       }
+
+                       deletePath := deleteFile.FilePath()
+                       if _, ok := uniqueDeletes[deletePath]; !ok {
+                               uniqueDeletes[deletePath] = deleteFile
+                       }
+
+                       targets, ok := targetsByDelete[deletePath]
+                       if !ok {
+                               targets = make(map[string]struct{})
+                               targetsByDelete[deletePath] = targets
+                       }
+                       if targets == nil {
                                continue
                        }
+                       if task.File == nil || task.File.FilePath() == "" {
+                               targetsByDelete[deletePath] = nil
 
-                       if _, ok := uniqueDeletes[d.FilePath()]; !ok {
-                               uniqueDeletes[d.FilePath()] = d
+                               continue
                        }
+                       targets[task.File.FilePath()] = struct{}{}
                }
        }
 
+       return uniqueDeletes, targetsByDelete
+}
+
+// readAllDeleteFiles reads every referenced positional-delete file up front. 
It
+// remains the path used by Transaction.makePositionDeleteRecordsForFilter and 
by
+// the eager-vs-lazy benchmark; arrowScan.GetRecords uses 
lazyPositionDeleteLoader.
+func readAllDeleteFiles(ctx context.Context, fs iceio.IO, tasks 
[]FileScanTask, concurrency int) (perFilePosDeletes, error) {
+       deletesPerFile := make(perFilePosDeletes)
+       uniqueDeletes, targetsByDelete := 
collectPositionDeleteFilesAndTargets(tasks)
+
        if len(uniqueDeletes) == 0 {
                return deletesPerFile, nil
        }
@@ -112,7 +139,7 @@ func readAllDeleteFiles(ctx context.Context, fs iceio.IO, 
tasks []FileScanTask,
                defer close(perFileChan)
                for _, v := range uniqueDeletes {
                        g.Go(func() error {
-                               deletes, err := readDeletes(gctx, fs, v)
+                               deletes, err := readDeletesForPaths(gctx, fs, 
v, targetsByDelete[v.FilePath()])
                                if err != nil {
                                        return fmt.Errorf("read position 
deletes from %s: %w", v.FilePath(), err)
                                }
@@ -167,6 +194,7 @@ var errPositionDeleteLoaderReleased = errors.New("position 
delete loader already
 
 type lazyPositionDeleteFile struct {
        dataFile iceberg.DataFile
+       targets  map[string]struct{}
 
        once    sync.Once
        deletes map[string]*arrow.Chunked
@@ -174,21 +202,16 @@ type lazyPositionDeleteFile struct {
 }
 
 func newLazyPositionDeleteLoader(fs iceio.IO, tasks []FileScanTask) 
*lazyPositionDeleteLoader {
+       uniqueDeletes, targetsByDelete := 
collectPositionDeleteFilesAndTargets(tasks)
        loader := &lazyPositionDeleteLoader{
                fs:    fs,
-               files: make(map[string]*lazyPositionDeleteFile),
+               files: make(map[string]*lazyPositionDeleteFile, 
len(uniqueDeletes)),
        }
 
-       for _, task := range tasks {
-               for _, deleteFile := range task.DeleteFiles {
-                       if deleteFile.ContentType() != 
iceberg.EntryContentPosDeletes {
-                               continue
-                       }
-
-                       path := deleteFile.FilePath()
-                       if _, ok := loader.files[path]; !ok {
-                               loader.files[path] = 
&lazyPositionDeleteFile{dataFile: deleteFile}
-                       }
+       for path, deleteFile := range uniqueDeletes {
+               loader.files[path] = &lazyPositionDeleteFile{
+                       dataFile: deleteFile,
+                       targets:  targetsByDelete[path],
                }
        }
 
@@ -234,9 +257,9 @@ func (l *lazyPositionDeleteLoader) load(ctx 
context.Context, task FileScanTask)
                }
 
                cached.once.Do(func() {
-                       cached.deletes, cached.err = readDeletes(ctx, l.fs, 
cached.dataFile)
+                       cached.deletes, cached.err = readDeletesForPaths(ctx, 
l.fs, cached.dataFile, cached.targets)
                        if cached.err != nil {
-                               // readDeletes currently returns nil on errors. 
Release defensively
+                               // readDeletesForPaths currently returns nil on 
errors. Release defensively
                                // in case a future reader returns partial 
Arrow ownership.
                                releasePosDeletes(cached.deletes)
                                cached.deletes = nil
@@ -505,12 +528,14 @@ func (c *posDeleteCursor) next() (int64, bool) {
 
 type posDeleteAccumulator struct {
        mem      memory.Allocator
+       targets  map[string]struct{}
        builders map[string]*array.Int64Builder
 }
 
-func newPosDeleteAccumulator(ctx context.Context) *posDeleteAccumulator {
+func newPosDeleteAccumulator(ctx context.Context, targets map[string]struct{}) 
*posDeleteAccumulator {
        return &posDeleteAccumulator{
                mem:      compute.GetAllocator(ctx),
+               targets:  targets,
                builders: make(map[string]*array.Int64Builder),
        }
 }
@@ -611,6 +636,12 @@ func (a *posDeleteAccumulator) appendFilePathChunk(ctx 
context.Context, filePath
                }
 
                path := paths.Value(i)
+               if len(a.targets) > 0 {
+                       if _, ok := a.targets[path]; !ok {
+                               continue
+                       }
+               }
+
                builder, ok := a.builders[path]
                if !ok {
                        path = strings.Clone(path)
@@ -649,6 +680,7 @@ func (a *posDeleteAccumulator) appendChunked(ctx 
context.Context, filePathCol, p
        return ctx.Err()
 }
 
+// appendRecord requires columns projected in file_path, pos order.
 func (a *posDeleteAccumulator) appendRecord(ctx context.Context, record 
arrow.RecordBatch) error {
        if record.NumCols() != 2 {
                return fmt.Errorf("%w: projected position delete record has %d 
columns, expected 2",
@@ -661,8 +693,15 @@ func (a *posDeleteAccumulator) appendRecord(ctx 
context.Context, record arrow.Re
                posCol.DataType(), posCol.NullN()); err != nil {
                return err
        }
+       if err := validatePosDeleteColumnLengths(filePathCol.Len(), 
posCol.Len()); err != nil {
+               return err
+       }
 
-       posArr := posCol.(*array.Int64)
+       posArr, ok := posCol.(*array.Int64)
+       if !ok {
+               return fmt.Errorf("%w: unsupported pos record array type %T in 
position delete file",
+                       iceberg.ErrInvalidSchema, posCol)
+       }
        posCursor := posDeleteCursor{chunks: []*array.Int64{posArr}}
        if err := a.appendFilePathChunk(ctx, filePathCol, &posCursor); err != 
nil {
                return err
@@ -672,7 +711,7 @@ func (a *posDeleteAccumulator) appendRecord(ctx 
context.Context, record arrow.Re
 }
 
 func groupPosDeletesByFilePath(ctx context.Context, filePathCol, posCol 
*arrow.Chunked) (results map[string]*arrow.Chunked, err error) {
-       acc := newPosDeleteAccumulator(ctx)
+       acc := newPosDeleteAccumulator(ctx, nil)
        defer func() {
                if err != nil {
                        acc.release()
@@ -746,7 +785,13 @@ func releasePosDeletes(deletes map[string]*arrow.Chunked) {
        }
 }
 
-func readDeletes(ctx context.Context, fs iceio.IO, dataFile iceberg.DataFile) 
(_ map[string]*arrow.Chunked, err error) {
+func readDeletes(ctx context.Context, fs iceio.IO, dataFile iceberg.DataFile) 
(map[string]*arrow.Chunked, error) {
+       return readDeletesForPaths(ctx, fs, dataFile, nil)
+}
+
+func readDeletesForPaths(ctx context.Context, fs iceio.IO, dataFile 
iceberg.DataFile,
+       targets map[string]struct{},
+) (_ map[string]*arrow.Chunked, err error) {
        src, err := tblutils.GetFile(ctx, fs, dataFile, true)
        if err != nil {
                return nil, err
@@ -768,7 +813,12 @@ func readDeletes(ctx context.Context, fs iceio.IO, 
dataFile iceberg.DataFile) (_
                return nil, err
        }
 
-       records, err := rdr.GetRecords(ctx, columns, nil)
+       tester, err := newPositionDeleteRowGroupTester(schema, targets)
+       if err != nil {
+               return nil, err
+       }
+
+       records, err := rdr.GetRecords(ctx, columns, tester)
        if err != nil {
                return nil, err
        }
@@ -776,7 +826,7 @@ func readDeletes(ctx context.Context, fs iceio.IO, dataFile 
iceberg.DataFile) (_
        // string values, so independent dictionaries across batches are safe.
        defer records.Release()
 
-       acc := newPosDeleteAccumulator(ctx)
+       acc := newPosDeleteAccumulator(ctx, targets)
        defer func() {
                // Returning an error assigns the named return value before 
deferred
                // functions run, which releases builders on every error path.
@@ -800,6 +850,121 @@ func readDeletes(ctx context.Context, fs iceio.IO, 
dataFile iceberg.DataFile) (_
        return acc.finish(), nil
 }
 
+func newPositionDeleteRowGroupTester(schema *arrow.Schema, targets 
map[string]struct{}) (*tblutils.ParquetRowGroupTester, error) {
+       if len(targets) == 0 || len(targets) > inPredicateLimit {
+               return nil, nil
+       }
+       pruningEnabled, err := positionDeletePruningEnabled(schema)
+       if err != nil {
+               return nil, err
+       }
+       if !pruningEnabled {
+               return nil, nil
+       }
+
+       paths := make([]string, 0, len(targets))
+       for path := range targets {
+               paths = append(paths, path)
+       }
+
+       var filter iceberg.BooleanExpression
+       if len(paths) == 1 {
+               // A single target is the common case. EqualTo avoids building 
the
+               // set literal used by IsIn and gives the stats/bloom planners 
the
+               // simpler predicate directly.
+               filter = iceberg.EqualTo(iceberg.Reference("file_path"), 
paths[0])
+       } else {
+               slices.Sort(paths)
+               filter = iceberg.IsIn(iceberg.Reference("file_path"), paths...)
+       }
+       filter, err = iceberg.BindExpr(iceberg.PositionalDeleteSchema, filter, 
true)
+       if err != nil {
+               return nil, err
+       }
+
+       statsFn, err := 
newParquetRowGroupStatsEvaluator(iceberg.PositionalDeleteSchema, filter, false)
+       if err != nil {
+               return nil, err
+       }
+       bloomPreds, err := newBloomFilterPredicates(filter)
+       if err != nil {
+               return nil, err
+       }
+
+       return &tblutils.ParquetRowGroupTester{
+               StatsFn:    statsFn,
+               BloomPreds: bloomPreds,
+       }, nil
+}
+
+func positionDeletePruningEnabled(schema *arrow.Schema) (bool, error) {
+       // Row-group stats and Bloom predicates are keyed by Parquet physical 
field
+       // IDs, while projection resolves these columns by their spec-defined 
names.
+       // pqarrow carries the Parquet IDs into Arrow metadata, so only enable
+       // pushdown when those two views agree for the reserved delete columns.
+       physicalIDs := make(map[int]int)
+       var collectIDs func([]arrow.Field)
+       collectIDs = func(fields []arrow.Field) {
+               for _, field := range fields {
+                       if id := getFieldID(field); id != nil {
+                               physicalIDs[*id]++
+                       }
+                       if nested, ok := field.Type.(arrow.NestedType); ok {
+                               collectIDs(nested.Fields())
+                       }
+               }
+       }
+       collectIDs(schema.Fields())
+       if len(physicalIDs) == 0 {
+               // External position-delete files are allowed to omit Iceberg 
field IDs.
+               // The name-based projection and row-level target filter remain 
safe, but
+               // stats and Bloom pruning cannot be trusted without the IDs.
+               return false, nil
+       }
+
+       deleteFields := iceberg.PositionalDeleteSchema.Fields()
+       for _, field := range deleteFields {
+               if physicalIDs[field.ID] > 1 {
+                       return false, fmt.Errorf("%w: position delete field ID 
%d is not unique",
+                               iceberg.ErrInvalidSchema, field.ID)
+               }
+       }
+
+       pruningEnabled := true
+       for _, want := range deleteFields {
+               indices := schema.FieldIndices(want.Name)
+               if len(indices) != 1 {
+                       return false, fmt.Errorf("%w: position delete file must 
contain exactly one %q column, found %d",
+                               iceberg.ErrInvalidSchema, want.Name, 
len(indices))
+               }
+
+               fieldID := getFieldID(schema.Field(indices[0]))
+               if fieldID == nil {
+                       if physicalIDs[want.ID] != 0 {
+                               return false, fmt.Errorf("%w: position delete 
field ID %d is assigned to another column instead of %q",
+                                       iceberg.ErrInvalidSchema, want.ID, 
want.Name)
+                       }
+                       // Missing IDs only disable pruning. Keep checking the 
remaining
+                       // columns so this fallback cannot hide an invalid ID 
mapping.
+                       pruningEnabled = false
+
+                       continue
+               }
+               if *fieldID != want.ID {
+                       if physicalIDs[want.ID] != 0 {
+                               return false, fmt.Errorf("%w: position delete 
column %q has field ID %d, want %d; field ID %d is assigned to another column",
+                                       iceberg.ErrInvalidSchema, want.Name, 
*fieldID, want.ID, want.ID)
+                       }
+                       // A non-canonical ID is safe for name-based reading, 
but not for
+                       // stats or Bloom pruning. Keep the pre-pruning read 
compatible with
+                       // external writers that renumber fields.
+                       pruningEnabled = false
+               }
+       }
+
+       return pruningEnabled, nil
+}
+
 func positionDeleteProjectionIndices(schema *arrow.Schema, reader 
tblutils.FileReader) ([]int, error) {
        filePathIndex, posIndex, err := positionDeleteColumnIndices(schema)
        if err != nil {
diff --git a/table/arrow_scanner_lazy_delete_regression_test.go 
b/table/arrow_scanner_lazy_delete_regression_test.go
index ba0dc0bf8..a1ba540e3 100644
--- a/table/arrow_scanner_lazy_delete_regression_test.go
+++ b/table/arrow_scanner_lazy_delete_regression_test.go
@@ -114,17 +114,19 @@ func 
TestLazyPositionDeleteLoaderDefersReadsAndSharesResults(t *testing.T) {
        ctx := compute.WithAllocator(t.Context(), mem)
 
        const (
-               deletePath = "mem://bucket/deletes/shared.parquet"
-               dataPathA  = "mem://bucket/data/a.parquet"
-               dataPathB  = "mem://bucket/data/b.parquet"
+               deletePath    = "mem://bucket/deletes/shared.parquet"
+               dataPathA     = "mem://bucket/data/a.parquet"
+               dataPathB     = "mem://bucket/data/b.parquet"
+               unrelatedPath = "mem://bucket/data/unrelated.parquet"
        )
        memFS := &countingOpenMemFS{MemFS: iceio.NewMemFS()}
        writePosDeleteParquetToMemFS(t, memFS.MemFS, deletePath, `[
                {"file_path": "`+dataPathA+`", "pos": 1},
-               {"file_path": "`+dataPathB+`", "pos": 3}
+               {"file_path": "`+dataPathB+`", "pos": 3},
+               {"file_path": "`+unrelatedPath+`", "pos": 5}
        ]`)
 
-       deleteFile := newPosDeleteFile(t, deletePath, 2, 128)
+       deleteFile := newPosDeleteFile(t, deletePath, 3, 128)
        tasks := []FileScanTask{
                {
                        File:        newLazyDataFile(t, dataPathA),
@@ -145,6 +147,12 @@ func 
TestLazyPositionDeleteLoaderDefersReadsAndSharesResults(t *testing.T) {
        assert.Equal(t, []int64{1}, int64Values(gotA[0]))
        assert.Equal(t, int64(1), memFS.opens.Load())
 
+       cached := loader.files[deletePath]
+       require.NotNil(t, cached)
+       assert.Equal(t, map[string]struct{}{dataPathA: {}, dataPathB: {}}, 
cached.targets)
+       assert.NotContains(t, cached.deletes, unrelatedPath,
+               "lazy delete reads must filter paths that are not referenced by 
scan tasks")
+
        gotB, err := loader.load(ctx, tasks[1])
        require.NoError(t, err)
        require.Len(t, gotB, 1)
diff --git a/table/arrow_scanner_nested_delete_test.go 
b/table/arrow_scanner_nested_delete_test.go
new file mode 100644
index 000000000..e976c6b97
--- /dev/null
+++ b/table/arrow_scanner_nested_delete_test.go
@@ -0,0 +1,71 @@
+// 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/arrow-go/v18/arrow"
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/compute"
+       "github.com/apache/arrow-go/v18/arrow/memory"
+       "github.com/apache/arrow-go/v18/parquet"
+       "github.com/apache/arrow-go/v18/parquet/pqarrow"
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+func TestReadDeletesForPathsRejectsNestedBloomFieldIDCollision(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       ctx := compute.WithAllocator(t.Context(), mem)
+       defer mem.AssertSize(t, 0)
+
+       fields := PositionalDeleteArrowSchema.Fields()
+       fields = append(fields, arrow.Field{Name: "row", Type: 
arrow.ListOfField(arrow.Field{
+               Name: "element", Type: arrow.BinaryTypes.String,
+               Metadata: fields[0].Metadata,
+       })})
+       schema := arrow.NewSchema(fields, nil)
+       record := mustLoadRecordBatchFromJSON(schema,
+               `[{"file_path":"data.parquet","pos":1,"row":["unrelated"]}]`)
+       defer record.Release()
+       tbl := array.NewTableFromRecords(schema, []arrow.RecordBatch{record})
+       defer tbl.Release()
+
+       fs := iceio.NewMemFS()
+       const deletePath = "mem://bucket/deletes/nested-bloom.parquet"
+       writer, err := fs.Create(deletePath)
+       require.NoError(t, err)
+       require.NoError(t, pqarrow.WriteTable(tbl, writer, 1,
+               parquet.NewWriterProperties(parquet.WithStats(true), 
parquet.WithBloomFilterEnabled(true)),
+               pqarrow.DefaultWriterProps()))
+       require.NoError(t, writer.Close())
+
+       file := newPosDeleteFile(t, deletePath, 1, 128)
+       allDeletes, err := readDeletesForPaths(ctx, fs, file, nil)
+       require.NoError(t, err)
+       defer releasePosDeletes(allDeletes)
+       assert.Equal(t, []int64{1}, int64Values(allDeletes["data.parquet"]))
+
+       filtered, err := readDeletesForPaths(ctx, fs, file, 
map[string]struct{}{"data.parquet": {}})
+       defer releasePosDeletes(filtered)
+       require.ErrorIs(t, err, iceberg.ErrInvalidSchema)
+       assert.Nil(t, filtered)
+}
diff --git a/table/arrow_scanner_posdelete_bench_test.go 
b/table/arrow_scanner_posdelete_bench_test.go
index 91dfcaece..6f2073d1a 100644
--- a/table/arrow_scanner_posdelete_bench_test.go
+++ b/table/arrow_scanner_posdelete_bench_test.go
@@ -35,6 +35,111 @@ import (
        tblutils "github.com/apache/iceberg-go/table/internal"
 )
 
+func BenchmarkReadDeletesWithFilePathFilter(b *testing.B) {
+       const (
+               numPaths    = 1_000
+               rowsPerPath = 1_024
+       )
+
+       memFS, deleteFile, pathNames := benchmarkPositionDeleteFileWithPaths(b, 
numPaths, rowsPerPath)
+       ctx := tblutils.WithTableProperties(context.Background(), 
iceberg.Properties{
+               ParquetBatchSizeKey: "65536",
+       })
+
+       b.Run("all paths", func(b *testing.B) {
+               benchmarkReadDeletesWithFilePathFilter(b, ctx, memFS, 
deleteFile, nil)
+       })
+       for _, targetCount := range []int{1, 10, 100, 200, 201, 1_000} {
+               b.Run(fmt.Sprintf("targets=%d", targetCount), func(b 
*testing.B) {
+                       targets := make(map[string]struct{}, targetCount)
+                       for _, path := range pathNames[:targetCount] {
+                               targets[path] = struct{}{}
+                       }
+
+                       benchmarkReadDeletesWithFilePathFilter(b, ctx, memFS, 
deleteFile, targets)
+               })
+       }
+}
+
+func benchmarkReadDeletesWithFilePathFilter(
+       b *testing.B,
+       ctx context.Context,
+       fs iceio.IO,
+       dataFile iceberg.DataFile,
+       targets map[string]struct{},
+) {
+       b.Helper()
+       b.ReportAllocs()
+       b.ResetTimer()
+
+       for b.Loop() {
+               deletes, err := readDeletesForPaths(ctx, fs, dataFile, targets)
+               if err != nil {
+                       b.Fatal(err)
+               }
+               releasePosDeletes(deletes)
+       }
+}
+
+func benchmarkPositionDeleteFileWithPaths(b *testing.B, numPaths, rowsPerPath 
int) (*iceio.MemFS, iceberg.DataFile, []string) {
+       b.Helper()
+
+       mem := memory.DefaultAllocator
+       pathNames := make([]string, numPaths)
+       for i := range numPaths {
+               pathNames[i] = 
fmt.Sprintf("mem://bucket/data/data-%04d.parquet", i)
+       }
+
+       pathBuilder := array.NewStringBuilder(mem)
+       posBuilder := array.NewInt64Builder(mem)
+       for pathIdx, path := range pathNames {
+               for pos := range rowsPerPath {
+                       pathBuilder.Append(path)
+                       posBuilder.Append(int64(pathIdx*rowsPerPath + pos))
+               }
+       }
+       paths := pathBuilder.NewStringArray()
+       pathBuilder.Release()
+       positions := posBuilder.NewInt64Array()
+       posBuilder.Release()
+       record := array.NewRecordBatch(PositionalDeleteArrowSchema,
+               []arrow.Array{paths, positions}, int64(numPaths*rowsPerPath))
+       paths.Release()
+       positions.Release()
+       defer record.Release()
+       tbl := array.NewTableFromRecords(PositionalDeleteArrowSchema, 
[]arrow.RecordBatch{record})
+       defer tbl.Release()
+
+       deletePath := "mem://bucket/deletes/file-path-filter-benchmark.parquet"
+       memFS := iceio.NewMemFS()
+       fw, err := memFS.Create(deletePath)
+       if err != nil {
+               b.Fatal(err)
+       }
+       if err := pqarrow.WriteTable(tbl, fw, int64(rowsPerPath),
+               parquet.NewWriterProperties(
+                       parquet.WithStats(true),
+                       parquet.WithMaxRowGroupLength(int64(rowsPerPath)),
+               ),
+               pqarrow.DefaultWriterProps()); err != nil {
+               _ = fw.Close()
+               b.Fatal(err)
+       }
+       if err := fw.Close(); err != nil {
+               b.Fatal(err)
+       }
+
+       builder, err := iceberg.NewDataFileBuilder(
+               *iceberg.UnpartitionedSpec, iceberg.EntryContentPosDeletes,
+               deletePath, iceberg.ParquetFile, nil, nil, nil,
+               int64(numPaths*rowsPerPath), 128)
+       if err != nil {
+               b.Fatal(err)
+       }
+
+       return memFS, builder.Build(), pathNames
+}
+
 func BenchmarkReadDeletesProjected(b *testing.B) {
        for _, numRows := range []int{100_000, 1_000_000} {
                b.Run(fmt.Sprintf("rows=%d", numRows), func(b *testing.B) {
@@ -47,7 +152,7 @@ func BenchmarkReadDeletesProjected(b *testing.B) {
                                benchmarkReadDeletes(b, ctx, memFS, dataFile, 
readDeletesBefore)
                        })
                        b.Run("after", func(b *testing.B) {
-                               benchmarkReadDeletes(b, ctx, memFS, dataFile, 
readDeletes)
+                               benchmarkReadDeletes(b, ctx, memFS, dataFile, 
readDeletesAfter)
                        })
                })
        }
@@ -73,6 +178,10 @@ func benchmarkReadDeletes(
        }
 }
 
+func readDeletesAfter(ctx context.Context, fs iceio.IO, dataFile 
iceberg.DataFile) (map[string]*arrow.Chunked, error) {
+       return readDeletesForPaths(ctx, fs, dataFile, nil)
+}
+
 func benchmarkPositionDeleteFile(b *testing.B, numRows int) (*iceio.MemFS, 
iceberg.DataFile) {
        b.Helper()
 
diff --git a/table/arrow_scanner_posdelete_regression_test.go 
b/table/arrow_scanner_posdelete_regression_test.go
index ad26269d3..46a2cb5a0 100644
--- a/table/arrow_scanner_posdelete_regression_test.go
+++ b/table/arrow_scanner_posdelete_regression_test.go
@@ -18,7 +18,9 @@
 package table
 
 import (
+       "bytes"
        "context"
+       "strconv"
        "testing"
 
        "github.com/apache/arrow-go/v18/arrow"
@@ -26,6 +28,8 @@ import (
        "github.com/apache/arrow-go/v18/arrow/compute"
        "github.com/apache/arrow-go/v18/arrow/memory"
        "github.com/apache/arrow-go/v18/parquet"
+       "github.com/apache/arrow-go/v18/parquet/file"
+       "github.com/apache/arrow-go/v18/parquet/metadata"
        "github.com/apache/arrow-go/v18/parquet/pqarrow"
        "github.com/apache/iceberg-go"
        iceio "github.com/apache/iceberg-go/io"
@@ -91,12 +95,379 @@ func TestReadDeletesRejectsMissingFilePath(t *testing.T) {
                pqarrow.DefaultWriterProps()))
        require.NoError(t, fw.Close())
 
-       deletes, err := readDeletes(ctx, memFS, newPosDeleteFile(t, deletePath, 
1, 128))
+       deletes, err := readDeletesForPaths(ctx, memFS, newPosDeleteFile(t, 
deletePath, 1, 128), nil)
        require.ErrorIs(t, err, iceberg.ErrInvalidSchema)
        assert.Nil(t, deletes)
        assert.Contains(t, err.Error(), `exactly one "file_path" column, found 
0`)
 }
 
+func TestReadDeletesForPathsFiltersUnneededRows(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       ctx := compute.WithAllocator(t.Context(), mem)
+       defer mem.AssertSize(t, 0)
+
+       deletePath := "mem://bucket/deletes/filtered.parquet"
+       dataPath := "mem://bucket/data/needed.parquet"
+       otherPath := "mem://bucket/data/unneeded.parquet"
+       memFS := iceio.NewMemFS()
+       writePosDeleteParquetToMemFS(t, memFS, deletePath, `[
+               {"file_path": "`+dataPath+`", "pos": 10},
+               {"file_path": "`+otherPath+`", "pos": 20},
+               {"file_path": "`+dataPath+`", "pos": 30}
+       ]`)
+
+       deletes, err := readDeletesForPaths(ctx, memFS, newPosDeleteFile(t, 
deletePath, 3, 128), map[string]struct{}{dataPath: {}})
+       require.NoError(t, err)
+       defer releasePosDeletes(deletes)
+
+       assert.Equal(t, []int64{10, 30}, int64Values(deletes[dataPath]))
+       assert.NotContains(t, deletes, otherPath)
+}
+
+func TestReadDeletesForPathsTreatsEmptyTargetsAsUnfiltered(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       ctx := compute.WithAllocator(t.Context(), mem)
+       defer mem.AssertSize(t, 0)
+
+       deletePath := "mem://bucket/deletes/empty-targets.parquet"
+       dataPath := "mem://bucket/data/needed.parquet"
+       otherPath := "mem://bucket/data/other.parquet"
+       memFS := iceio.NewMemFS()
+       writePosDeleteParquetToMemFS(t, memFS, deletePath, `[
+               {"file_path": "`+dataPath+`", "pos": 10},
+               {"file_path": "`+otherPath+`", "pos": 20}
+       ]`)
+
+       for _, targets := range []map[string]struct{}{nil, {}} {
+               deletes, err := readDeletesForPaths(ctx, memFS,
+                       newPosDeleteFile(t, deletePath, 2, 128), targets)
+               require.NoError(t, err)
+               assert.Equal(t, []int64{10}, int64Values(deletes[dataPath]))
+               assert.Equal(t, []int64{20}, int64Values(deletes[otherPath]))
+               releasePosDeletes(deletes)
+       }
+}
+
+func TestReadDeletesForPathsMatchesFilePathsExactly(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       ctx := compute.WithAllocator(t.Context(), mem)
+       defer mem.AssertSize(t, 0)
+
+       deletePath := "mem://bucket/deletes/exact-target.parquet"
+       dataPath := "mem://bucket/data/needed.parquet"
+       memFS := iceio.NewMemFS()
+       writePosDeleteParquetToMemFS(t, memFS, deletePath, `[
+               {"file_path": "`+dataPath+`", "pos": 10}
+       ]`)
+
+       deletes, err := readDeletesForPaths(ctx, memFS,
+               newPosDeleteFile(t, deletePath, 1, 128),
+               map[string]struct{}{dataPath + "/": {}})
+       require.NoError(t, err)
+       defer releasePosDeletes(deletes)
+       assert.Empty(t, deletes)
+}
+
+func TestReadDeletesForPathsHandlesDictionaryFilePath(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       ctx := internal.WithTableProperties(
+               compute.WithAllocator(t.Context(), mem),
+               iceberg.Properties{internal.ParquetBatchSizeKey: "2"},
+       )
+       defer mem.AssertSize(t, 0)
+
+       deletePath := "mem://bucket/deletes/dictionary-filtered.parquet"
+       dataPath := "mem://bucket/data/needed.parquet"
+       otherPath := "mem://bucket/data/unneeded.parquet"
+       memFS := iceio.NewMemFS()
+       writePosDeleteParquetToMemFSWithSchema(t, memFS, deletePath, 
PositionalDeleteArrowSchema, `[
+               {"file_path": "`+dataPath+`", "pos": 10},
+               {"file_path": "`+otherPath+`", "pos": 20},
+               {"file_path": "`+dataPath+`", "pos": 30},
+               {"file_path": "`+dataPath+`", "pos": 40}
+       ]`)
+
+       deletes, err := readDeletesForPaths(ctx, memFS, newPosDeleteFile(t, 
deletePath, 4, 128), map[string]struct{}{dataPath: {}})
+       require.NoError(t, err)
+       defer releasePosDeletes(deletes)
+
+       assert.Equal(t, []int64{10, 30, 40}, int64Values(deletes[dataPath]))
+       assert.NotContains(t, deletes, otherPath)
+}
+
+func TestReadDeletesForPathsHandlesReversedPhysicalSchema(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       ctx := compute.WithAllocator(t.Context(), mem)
+       defer mem.AssertSize(t, 0)
+
+       deleteSchema := arrow.NewSchema([]arrow.Field{
+               {Name: "pos", Type: arrow.PrimitiveTypes.Int64, Nullable: 
false},
+               {Name: "file_path", Type: arrow.BinaryTypes.String, Nullable: 
false},
+       }, nil)
+       deletePath := "mem://bucket/deletes/reversed-filtered.parquet"
+       dataPath := "mem://bucket/data/needed.parquet"
+       otherPath := "mem://bucket/data/unneeded.parquet"
+       memFS := iceio.NewMemFS()
+       writePosDeleteParquetToMemFSWithSchema(t, memFS, deletePath, 
deleteSchema, `[
+               {"pos": 10, "file_path": "`+dataPath+`"},
+               {"pos": 20, "file_path": "`+otherPath+`"},
+               {"pos": 30, "file_path": "`+dataPath+`"}
+       ]`)
+
+       deletes, err := readDeletesForPaths(ctx, memFS, newPosDeleteFile(t, 
deletePath, 3, 128), map[string]struct{}{dataPath: {}})
+       require.NoError(t, err)
+       defer releasePosDeletes(deletes)
+
+       assert.Equal(t, []int64{10, 30}, int64Values(deletes[dataPath]))
+       assert.NotContains(t, deletes, otherPath)
+}
+
+func TestReadAllDeleteFilesUsesTaskDataFilePaths(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       ctx := compute.WithAllocator(t.Context(), mem)
+       defer mem.AssertSize(t, 0)
+
+       deletePath := "mem://bucket/deletes/task-target.parquet"
+       dataPath := "mem://bucket/data/needed.parquet"
+       otherPath := "mem://bucket/data/unneeded.parquet"
+       memFS := iceio.NewMemFS()
+       writePosDeleteParquetToMemFS(t, memFS, deletePath, `[
+               {"file_path": "`+dataPath+`", "pos": 10},
+               {"file_path": "`+otherPath+`", "pos": 20}
+       ]`)
+
+       dataBuilder, err := iceberg.NewDataFileBuilder(
+               *iceberg.UnpartitionedSpec, iceberg.EntryContentData,
+               dataPath, iceberg.ParquetFile, nil, nil, nil, 1, 128)
+       require.NoError(t, err)
+
+       deletes, err := readAllDeleteFiles(ctx, memFS, []FileScanTask{{
+               File: dataBuilder.Build(),
+               DeleteFiles: []iceberg.DataFile{
+                       newPosDeleteFile(t, deletePath, 2, 128),
+               },
+       }}, 1)
+       require.NoError(t, err)
+       defer releasePerFilePosDeletes(deletes)
+
+       assert.Equal(t, []int64{10}, int64Values(deletes[dataPath][0]))
+       assert.NotContains(t, deletes, otherPath)
+}
+
+func TestReadAllDeleteFilesFallsBackForMixedTasks(t *testing.T) {
+       for _, missingFile := range []struct {
+               name string
+               file iceberg.DataFile
+       }{
+               {name: "nil file"},
+               {name: "empty path", file: &mockDataFile{}},
+       } {
+               for _, missingFirst := range []bool{false, true} {
+                       t.Run(missingFile.name+"/missing 
first="+strconv.FormatBool(missingFirst), func(t *testing.T) {
+                               mem := 
memory.NewCheckedAllocator(memory.DefaultAllocator)
+                               ctx := compute.WithAllocator(t.Context(), mem)
+                               defer mem.AssertSize(t, 0)
+
+                               deletePath := 
"mem://bucket/deletes/mixed-tasks.parquet"
+                               dataPath := "mem://bucket/data/data-A.parquet"
+                               otherPath := "mem://bucket/data/data-B.parquet"
+                               memFS := iceio.NewMemFS()
+                               writePosDeleteParquetToMemFS(t, memFS, 
deletePath, `[
+                                       {"file_path": "`+dataPath+`", "pos": 
10},
+                                       {"file_path": "`+otherPath+`", "pos": 
20}
+                               ]`)
+                               deleteFile := newPosDeleteFile(t, deletePath, 
2, 128)
+                               tasks := []FileScanTask{
+                                       {File: &mockDataFile{path: dataPath}, 
DeleteFiles: []iceberg.DataFile{deleteFile}},
+                                       {File: missingFile.file, DeleteFiles: 
[]iceberg.DataFile{deleteFile}},
+                               }
+                               if missingFirst {
+                                       tasks[0], tasks[1] = tasks[1], tasks[0]
+                               }
+
+                               deletes, err := readAllDeleteFiles(ctx, memFS, 
tasks, 2)
+                               require.NoError(t, err)
+                               defer releasePerFilePosDeletes(deletes)
+                               require.Len(t, deletes, 2)
+                               require.Len(t, deletes[dataPath], 1)
+                               require.Len(t, deletes[otherPath], 1)
+                               assert.Equal(t, []int64{10}, 
int64Values(deletes[dataPath][0]))
+                               assert.Equal(t, []int64{20}, 
int64Values(deletes[otherPath][0]))
+                       })
+               }
+       }
+}
+
+func TestReadDeletesForPathsAtPredicateLimit(t *testing.T) {
+       for _, targetCount := range []int{inPredicateLimit - 1, 
inPredicateLimit, inPredicateLimit + 1} {
+               t.Run(strconv.Itoa(targetCount), func(t *testing.T) {
+                       mem := 
memory.NewCheckedAllocator(memory.DefaultAllocator)
+                       ctx := compute.WithAllocator(t.Context(), mem)
+                       defer mem.AssertSize(t, 0)
+
+                       targets := make(map[string]struct{}, targetCount)
+                       for i := range targetCount {
+                               
targets["mem://bucket/data/target-"+strconv.Itoa(i)+".parquet"] = struct{}{}
+                       }
+                       tester, err := 
newPositionDeleteRowGroupTester(PositionalDeleteArrowSchema, targets)
+                       require.NoError(t, err)
+                       if targetCount > inPredicateLimit {
+                               assert.Nil(t, tester)
+                       } else {
+                               require.NotNil(t, tester)
+                       }
+
+                       deletePath := 
"mem://bucket/deletes/predicate-limit.parquet"
+                       dataPath := "mem://bucket/data/target-0.parquet"
+                       memFS := iceio.NewMemFS()
+                       writePosDeleteParquetToMemFS(t, memFS, deletePath, `[
+                               {"file_path": "`+dataPath+`", "pos": 10},
+                               {"file_path": 
"mem://bucket/data/unneeded.parquet", "pos": 20},
+                               {"file_path": "`+dataPath+`", "pos": 30}
+                       ]`)
+                       deletes, err := readDeletesForPaths(ctx, memFS, 
newPosDeleteFile(t, deletePath, 3, 128), targets)
+                       require.NoError(t, err)
+                       defer releasePosDeletes(deletes)
+                       require.Len(t, deletes, 1)
+                       require.Contains(t, deletes, dataPath)
+                       assert.Equal(t, []int64{10, 30}, 
int64Values(deletes[dataPath]))
+               })
+       }
+}
+
+func TestReadDeletesForPathsFallsBackForPartialFieldIDs(t *testing.T) {
+       for _, tc := range []struct {
+               name    string
+               missing []int
+       }{
+               {name: "file_path ID absent", missing: []int{0}},
+               {name: "pos ID absent", missing: []int{1}},
+               {name: "IDs only on row", missing: []int{0, 1}},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       mem := 
memory.NewCheckedAllocator(memory.DefaultAllocator)
+                       ctx := compute.WithAllocator(t.Context(), mem)
+                       defer mem.AssertSize(t, 0)
+
+                       fields := PositionalDeleteArrowSchema.Fields()
+                       for _, index := range tc.missing {
+                               fields[index].Metadata = arrow.Metadata{}
+                       }
+                       fields = append(fields, arrow.Field{
+                               Name: "row", Type: arrow.StructOf(arrow.Field{
+                                       Name: "id", Type: 
arrow.PrimitiveTypes.Int64,
+                                       Metadata: 
arrow.MetadataFrom(map[string]string{ArrowParquetFieldIDKey: "101"}),
+                               }),
+                               Metadata: 
arrow.MetadataFrom(map[string]string{ArrowParquetFieldIDKey: "100"}),
+                       })
+                       schema := arrow.NewSchema(fields, nil)
+                       deletePath := "mem://bucket/deletes/partial-ids.parquet"
+                       dataPath := "mem://bucket/data/needed.parquet"
+                       targets := map[string]struct{}{dataPath: {}}
+                       tester, err := newPositionDeleteRowGroupTester(schema, 
targets)
+                       require.NoError(t, err)
+                       assert.Nil(t, tester)
+
+                       memFS := iceio.NewMemFS()
+                       writePosDeleteParquetToMemFSWithSchema(t, memFS, 
deletePath, schema, `[
+                               {"file_path": "`+dataPath+`", "pos": 10, "row": 
{"id": 1}},
+                               {"file_path": 
"mem://bucket/data/unneeded.parquet", "pos": 20, "row": {"id": 2}},
+                               {"file_path": "`+dataPath+`", "pos": 30, "row": 
{"id": 3}}
+                       ]`, parquet.WithBloomFilterEnabled(true))
+                       deletes, err := readDeletesForPaths(ctx, memFS, 
newPosDeleteFile(t, deletePath, 3, 128), targets)
+                       require.NoError(t, err)
+                       defer releasePosDeletes(deletes)
+                       require.Len(t, deletes, 1)
+                       require.Contains(t, deletes, dataPath)
+                       assert.Equal(t, []int64{10, 30}, 
int64Values(deletes[dataPath]))
+               })
+       }
+}
+
+func TestReadDeletesForPathsFallsBackForNoncanonicalFieldIDs(t *testing.T) {
+       filePathField, _ := 
iceberg.PositionalDeleteSchema.FindFieldByName("file_path")
+       posField, _ := iceberg.PositionalDeleteSchema.FindFieldByName("pos")
+       for _, tc := range []struct {
+               name       string
+               filePathID int
+               posID      int
+       }{
+               {name: "both IDs renumbered", filePathID: 1, posID: 2},
+               {name: "file_path ID renumbered", filePathID: 1, posID: 
posField.ID},
+               {name: "pos ID renumbered", filePathID: filePathField.ID, 
posID: 2},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       mem := 
memory.NewCheckedAllocator(memory.DefaultAllocator)
+                       ctx := compute.WithAllocator(t.Context(), mem)
+                       defer mem.AssertSize(t, 0)
+
+                       deletePath := 
"mem://bucket/deletes/noncanonical-ids.parquet"
+                       dataPath := "mem://bucket/data/needed.parquet"
+                       otherPath := "mem://bucket/data/unneeded.parquet"
+                       targets := map[string]struct{}{dataPath: {}}
+                       schema := 
positionDeleteSchemaWithFieldIDs(tc.filePathID, tc.posID)
+                       tester, err := newPositionDeleteRowGroupTester(schema, 
targets)
+                       require.NoError(t, err)
+                       assert.Nil(t, tester)
+
+                       memFS := iceio.NewMemFS()
+                       writePosDeleteParquetToMemFSWithSchema(t, memFS, 
deletePath, schema, `[
+                               {"file_path": "`+dataPath+`", "pos": 10},
+                               {"file_path": "`+otherPath+`", "pos": 20},
+                               {"file_path": "`+dataPath+`", "pos": 30}
+                       ]`)
+                       deletes, err := readDeletesForPaths(ctx, memFS,
+                               newPosDeleteFile(t, deletePath, 3, 128), 
targets)
+                       require.NoError(t, err)
+                       defer releasePosDeletes(deletes)
+                       assert.Equal(t, []int64{10, 30}, 
int64Values(deletes[dataPath]))
+                       assert.NotContains(t, deletes, otherPath)
+               })
+       }
+}
+
+func TestPositionDeleteRowGroupTesterUsesFilePathStats(t *testing.T) {
+       dataPath := "mem://bucket/data/needed.parquet"
+       otherPath := "mem://bucket/data/unneeded.parquet"
+       rec := mustLoadRecordBatchFromJSON(PositionalDeleteArrowSchema, `[
+               {"file_path": "`+otherPath+`", "pos": 10},
+               {"file_path": "`+otherPath+`", "pos": 20},
+               {"file_path": "`+dataPath+`", "pos": 30},
+               {"file_path": "`+dataPath+`", "pos": 40}
+       ]`)
+       defer rec.Release()
+
+       var buf bytes.Buffer
+       writer, err := pqarrow.NewFileWriter(
+               PositionalDeleteArrowSchema, &buf,
+               parquet.NewWriterProperties(
+                       parquet.WithStats(true),
+                       parquet.WithMaxRowGroupLength(2),
+               ),
+               pqarrow.DefaultWriterProps(),
+       )
+       require.NoError(t, err)
+       require.NoError(t, writer.Write(rec))
+       require.NoError(t, writer.Close())
+
+       reader, err := file.NewParquetReader(bytes.NewReader(buf.Bytes()))
+       require.NoError(t, err)
+       defer reader.Close()
+       assert.Equal(t, 2, reader.NumRowGroups())
+
+       tester, err := 
newPositionDeleteRowGroupTester(PositionalDeleteArrowSchema, 
map[string]struct{}{dataPath: {}})
+       require.NoError(t, err)
+       require.NotNil(t, tester)
+
+       use, err := tester.StatsFn(reader.MetaData().RowGroup(0), []int{0, 1})
+       require.NoError(t, err)
+       assert.False(t, use)
+
+       use, err = tester.StatsFn(reader.MetaData().RowGroup(1), []int{0, 1})
+       require.NoError(t, err)
+       assert.True(t, use)
+}
+
 func TestReadDeletesProjectsColumnsAndAccumulatesBatches(t *testing.T) {
        mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
        ctx := compute.WithAllocator(t.Context(), mem)
@@ -150,7 +521,7 @@ func TestReadDeletesProjectsColumnsAndAccumulatesBatches(t 
*testing.T) {
        projected.Release()
        assert.Equal(t, 3, batchCount)
 
-       deletes, err := readDeletes(ctx, memFS, dataFile)
+       deletes, err := readDeletesForPaths(ctx, memFS, dataFile, nil)
        require.NoError(t, err)
        defer releasePosDeletes(deletes)
 
@@ -198,7 +569,7 @@ func TestReadDeletesHandlesDictionaryEncodedFilePath(t 
*testing.T) {
        require.NoError(t, records.Err())
        assert.Greater(t, dictionaryBatches, 0)
 
-       deletes, err := readDeletes(ctx, memFS, dataFile)
+       deletes, err := readDeletesForPaths(ctx, memFS, dataFile, nil)
        require.NoError(t, err)
        defer releasePosDeletes(deletes)
 
@@ -225,7 +596,7 @@ func TestReadDeletesHandlesReversedPhysicalSchema(t 
*testing.T) {
                {"pos": 30, "file_path": "`+dataPath+`"}
        ]`)
 
-       deletes, err := readDeletes(ctx, memFS, newPosDeleteFile(t, deletePath, 
3, 128))
+       deletes, err := readDeletesForPaths(ctx, memFS, newPosDeleteFile(t, 
deletePath, 3, 128), nil)
        require.NoError(t, err)
        defer releasePosDeletes(deletes)
 
@@ -234,7 +605,7 @@ func TestReadDeletesHandlesReversedPhysicalSchema(t 
*testing.T) {
 }
 
 func TestPosDeleteAccumulatorFinishAfterReleasePanics(t *testing.T) {
-       acc := newPosDeleteAccumulator(t.Context())
+       acc := newPosDeleteAccumulator(t.Context(), nil)
        acc.release()
 
        assert.PanicsWithValue(t, "position delete accumulator is already 
finished or released", func() {
@@ -242,6 +613,248 @@ func TestPosDeleteAccumulatorFinishAfterReleasePanics(t 
*testing.T) {
        })
 }
 
+func TestPositionDeleteRowGroupTesterUsesFilePathBloomFilters(t *testing.T) {
+       dataPath := "mem://bucket/data/needed.parquet"
+       otherPath := "mem://bucket/data/unneeded.parquet"
+       deletePath := "mem://bucket/deletes/bloom-filtered.parquet"
+       memFS := iceio.NewMemFS()
+       writePosDeleteParquetToMemFSWithSchema(t, memFS, deletePath, 
PositionalDeleteArrowSchema, `[
+               {"file_path": "`+otherPath+`", "pos": 10},
+               {"file_path": "`+otherPath+`", "pos": 20},
+               {"file_path": "`+dataPath+`", "pos": 30},
+               {"file_path": "`+dataPath+`", "pos": 40}
+       ]`, parquet.WithStats(false), 
parquet.WithBloomFilterEnabledFor("file_path", true),
+               parquet.WithMaxRowGroupLength(2))
+       deleteFile := newPosDeleteFile(t, deletePath, 4, 128)
+
+       for _, useBloom := range []bool{false, true} {
+               t.Run("bloom="+strconv.FormatBool(useBloom), func(t *testing.T) 
{
+                       ctx := t.Context()
+                       source, err := internal.GetFile(ctx, memFS, deleteFile, 
true)
+                       require.NoError(t, err)
+                       reader, err := source.GetReader(ctx)
+                       require.NoError(t, err)
+                       defer reader.Close()
+                       fileMetadata := 
reader.Metadata().(*metadata.FileMetaData)
+                       require.Len(t, fileMetadata.RowGroups, 2)
+                       schema, err := reader.Schema()
+                       require.NoError(t, err)
+                       tester, err := newPositionDeleteRowGroupTester(schema, 
map[string]struct{}{dataPath: {}})
+                       require.NoError(t, err)
+                       require.NotNil(t, tester)
+                       require.NotEmpty(t, tester.BloomPreds)
+                       if !useBloom {
+                               tester.BloomPreds = nil
+                       }
+                       for i := range fileMetadata.RowGroups {
+                               use, err := 
tester.StatsFn(fileMetadata.RowGroup(i), []int{0, 1})
+                               require.NoError(t, err)
+                               require.True(t, use, "statistics must not prune 
either row group")
+                       }
+
+                       var survivors []internal.RowGroupSpan
+                       tester.Survivors = &survivors
+                       records, err := reader.GetRecords(ctx, []int{0, 1}, 
tester)
+                       require.NoError(t, err)
+                       defer records.Release()
+                       var positions []int64
+                       for records.Next() {
+                               positions = append(positions, 
records.RecordBatch().Column(1).(*array.Int64).Int64Values()...)
+                       }
+                       require.NoError(t, records.Err())
+                       if useBloom {
+                               assert.Equal(t, []int64{30, 40}, positions)
+                               assert.Equal(t, 
[]internal.RowGroupSpan{{FirstRowPos: 2, NumRows: 2}}, survivors)
+                               deletes, err := readDeletesForPaths(ctx, memFS, 
deleteFile, map[string]struct{}{dataPath: {}})
+                               require.NoError(t, err)
+                               defer releasePosDeletes(deletes)
+                               require.Len(t, deletes, 1)
+                               require.Contains(t, deletes, dataPath)
+                               assert.Equal(t, []int64{30, 40}, 
int64Values(deletes[dataPath]))
+                       } else {
+                               assert.Equal(t, []int64{10, 20, 30, 40}, 
positions)
+                               assert.Nil(t, survivors)
+                       }
+               })
+       }
+}
+
+func TestPositionDeleteRowGroupTesterValidatesPartialFieldIDs(t *testing.T) {
+       for _, missingIndex := range []int{0, 1} {
+               for _, tc := range []struct {
+                       name    string
+                       fields  func([]arrow.Field) []arrow.Field
+                       wantErr bool
+               }{
+                       {
+                               name:    "reserved ID on another column",
+                               wantErr: true,
+                               fields: func(fields []arrow.Field) 
[]arrow.Field {
+                                       return append(fields, arrow.Field{
+                                               Name: "row", Type: 
arrow.BinaryTypes.String,
+                                               Metadata: 
PositionalDeleteArrowSchema.Field(missingIndex).Metadata,
+                                       })
+                               },
+                       },
+                       {
+                               name:    "duplicate reserved ID",
+                               wantErr: true,
+                               fields: func(fields []arrow.Field) 
[]arrow.Field {
+                                       return append(fields, arrow.Field{
+                                               Name: "row", Type: 
arrow.BinaryTypes.String,
+                                               Metadata: 
fields[1-missingIndex].Metadata,
+                                       })
+                               },
+                       },
+                       {
+                               name: "noncanonical ID on other delete column",
+                               fields: func(fields []arrow.Field) 
[]arrow.Field {
+                                       fields[1-missingIndex].Metadata = 
arrow.MetadataFrom(map[string]string{ArrowParquetFieldIDKey: "100"})
+
+                                       return fields
+                               },
+                       },
+               } {
+                       
t.Run("missing="+strconv.Itoa(missingIndex)+"/"+tc.name, func(t *testing.T) {
+                               fields := PositionalDeleteArrowSchema.Fields()
+                               fields[missingIndex].Metadata = arrow.Metadata{}
+                               tester, err := newPositionDeleteRowGroupTester(
+                                       arrow.NewSchema(tc.fields(fields), 
nil), map[string]struct{}{"data.parquet": {}})
+                               if !tc.wantErr {
+                                       require.NoError(t, err)
+                                       assert.Nil(t, tester)
+
+                                       return
+                               }
+
+                               require.ErrorIs(t, err, 
iceberg.ErrInvalidSchema)
+                               assert.Nil(t, tester)
+                       })
+               }
+       }
+}
+
+func TestPositionDeleteRowGroupTesterValidatesPhysicalFieldIDs(t *testing.T) {
+       t.Parallel()
+
+       filePathField, _ := 
iceberg.PositionalDeleteSchema.FindFieldByName("file_path")
+       posField, _ := iceberg.PositionalDeleteSchema.FindFieldByName("pos")
+       dataPath := "mem://bucket/data/needed.parquet"
+
+       tests := []struct {
+               name       string
+               schema     *arrow.Schema
+               wantTester bool
+               wantErr    string
+       }{
+               {
+                       name:       "canonical IDs",
+                       schema:     
positionDeleteSchemaWithFieldIDs(filePathField.ID, posField.ID),
+                       wantTester: true,
+               },
+               {
+                       name:   "IDs absent",
+                       schema: positionDeleteSchemaWithoutFieldIDs(),
+               },
+               {
+                       name:   "noncanonical IDs",
+                       schema: positionDeleteSchemaWithFieldIDs(1, 2),
+               },
+               {
+                       name:    "swapped IDs",
+                       schema:  positionDeleteSchemaWithFieldIDs(posField.ID, 
filePathField.ID),
+                       wantErr: `position delete column "file_path" has field 
ID`,
+               },
+               {
+                       name:    "duplicate IDs",
+                       schema:  
positionDeleteSchemaWithFieldIDs(filePathField.ID, filePathField.ID),
+                       wantErr: "is not unique",
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       tester, err := 
newPositionDeleteRowGroupTester(tt.schema, map[string]struct{}{dataPath: {}})
+                       if tt.wantErr != "" {
+                               require.ErrorIs(t, err, 
iceberg.ErrInvalidSchema)
+                               require.ErrorContains(t, err, tt.wantErr)
+
+                               return
+                       }
+
+                       require.NoError(t, err)
+                       if tt.wantTester {
+                               require.NotNil(t, tester)
+                               assert.NotEmpty(t, tester.BloomPreds)
+                       } else {
+                               assert.Nil(t, tester)
+                       }
+               })
+       }
+}
+
+func TestReadDeletesForPathsRejectsSwappedPhysicalFieldIDs(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       ctx := compute.WithAllocator(t.Context(), mem)
+       defer mem.AssertSize(t, 0)
+
+       filePathField, _ := 
iceberg.PositionalDeleteSchema.FindFieldByName("file_path")
+       posField, _ := iceberg.PositionalDeleteSchema.FindFieldByName("pos")
+       deletePath := "mem://bucket/deletes/swapped-ids.parquet"
+       dataPath := "mem://bucket/data/needed.parquet"
+       memFS := iceio.NewMemFS()
+       writePosDeleteParquetToMemFSWithSchema(t, memFS, deletePath,
+               positionDeleteSchemaWithFieldIDs(posField.ID, filePathField.ID),
+               `[{"file_path": "`+dataPath+`", "pos": 0}]`)
+
+       deletes, err := readDeletesForPaths(ctx, memFS, newPosDeleteFile(t, 
deletePath, 1, 128),
+               map[string]struct{}{dataPath: {}})
+       require.ErrorIs(t, err, iceberg.ErrInvalidSchema)
+       assert.Nil(t, deletes)
+}
+
+func TestReadDeletesForPathsRejectsDuplicatePhysicalFieldIDs(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       ctx := compute.WithAllocator(t.Context(), mem)
+       defer mem.AssertSize(t, 0)
+
+       filePathField, _ := 
iceberg.PositionalDeleteSchema.FindFieldByName("file_path")
+       deletePath := "mem://bucket/deletes/duplicate-ids.parquet"
+       dataPath := "mem://bucket/data/needed.parquet"
+       memFS := iceio.NewMemFS()
+       writePosDeleteParquetToMemFSWithSchema(t, memFS, deletePath,
+               positionDeleteSchemaWithFieldIDs(filePathField.ID, 
filePathField.ID),
+               `[{"file_path": "`+dataPath+`", "pos": 0}]`)
+
+       deletes, err := readDeletesForPaths(ctx, memFS, newPosDeleteFile(t, 
deletePath, 1, 128),
+               map[string]struct{}{dataPath: {}})
+       require.ErrorIs(t, err, iceberg.ErrInvalidSchema)
+       assert.Nil(t, deletes)
+}
+
+func positionDeleteSchemaWithFieldIDs(filePathID, posID int) *arrow.Schema {
+       return arrow.NewSchema([]arrow.Field{
+               {
+                       Name:     "file_path",
+                       Type:     arrow.BinaryTypes.String,
+                       Nullable: false,
+                       Metadata: 
arrow.MetadataFrom(map[string]string{ArrowParquetFieldIDKey: 
strconv.Itoa(filePathID)}),
+               },
+               {
+                       Name:     "pos",
+                       Type:     arrow.PrimitiveTypes.Int64,
+                       Nullable: false,
+                       Metadata: 
arrow.MetadataFrom(map[string]string{ArrowParquetFieldIDKey: 
strconv.Itoa(posID)}),
+               },
+       }, nil)
+}
+
+func positionDeleteSchemaWithoutFieldIDs() *arrow.Schema {
+       return arrow.NewSchema([]arrow.Field{
+               {Name: "file_path", Type: arrow.BinaryTypes.String, Nullable: 
false},
+               {Name: "pos", Type: arrow.PrimitiveTypes.Int64, Nullable: 
false},
+       }, nil)
+}
+
 func TestGroupPosDeletesByFilePathSupportsStringLayouts(t *testing.T) {
        for _, tc := range []struct {
                name                  string
@@ -454,6 +1067,37 @@ func 
TestGroupPosDeletesByFilePathRejectsMismatchedLengths(t *testing.T) {
        assert.Contains(t, err.Error(), "file_path and pos columns have 
different lengths: 2 and 1")
 }
 
+func TestPosDeleteAccumulatorAppendRecordRejectsMismatchedLengths(t 
*testing.T) {
+       for _, tc := range []struct {
+               name      string
+               filePaths []string
+               positions []int64
+       }{
+               {name: "extra positions", filePaths: 
[]string{"file-a.parquet"}, positions: []int64{1, 2}},
+               {name: "extra file paths", filePaths: 
[]string{"file-a.parquet", "file-b.parquet"}, positions: []int64{1}},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       mem := 
memory.NewCheckedAllocator(memory.DefaultAllocator)
+                       defer mem.AssertSize(t, 0)
+                       ctx := compute.WithAllocator(t.Context(), mem)
+
+                       filePathArr := stringArray(mem, tc.filePaths...)
+                       defer filePathArr.Release()
+                       posArr := int64Array(mem, tc.positions...)
+                       defer posArr.Release()
+                       record := 
array.NewRecordBatch(PositionalDeleteArrowSchema,
+                               []arrow.Array{filePathArr, posArr}, 
int64(min(len(tc.filePaths), len(tc.positions))))
+                       defer record.Release()
+
+                       acc := newPosDeleteAccumulator(ctx, nil)
+                       defer acc.release()
+                       err := acc.appendRecord(ctx, record)
+                       require.ErrorIs(t, err, iceberg.ErrInvalidSchema)
+                       assert.Contains(t, err.Error(), "file_path and pos 
columns have different lengths")
+               })
+       }
+}
+
 func TestGroupPosDeletesByFilePathRejectsNegativePositions(t *testing.T) {
        mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
        defer mem.AssertSize(t, 0)
@@ -835,7 +1479,7 @@ func TestReadDeletesRejectsNullPos(t *testing.T) {
                pqarrow.DefaultWriterProps()))
        require.NoError(t, fw.Close())
 
-       deletes, err := readDeletes(ctx, memFS, newPosDeleteFile(t, deletePath, 
1, 128))
+       deletes, err := readDeletesForPaths(ctx, memFS, newPosDeleteFile(t, 
deletePath, 1, 128), nil)
        require.ErrorIs(t, err, iceberg.ErrInvalidSchema)
        assert.Nil(t, deletes)
        assert.Contains(t, err.Error(), "null pos in position delete file")
@@ -1091,10 +1735,36 @@ func 
TestReadDeletesProjectsLeafColumnsAroundNestedRow(t *testing.T) {
                 {"file_path": "other.parquet", "pos": 2, "row": {"id": 20, 
"name": "b"}},
                 {"file_path": "`+dataPath+`", "pos": 3, "row": {"id": 30, 
"name": "c"}}
             ]`)
-                       deletes, err := readDeletes(ctx, memFS, 
newPosDeleteFile(t, deletePath, 3, 128))
+                       deletes, err := readDeletesForPaths(ctx, memFS, 
newPosDeleteFile(t, deletePath, 3, 128), map[string]struct{}{dataPath: {}})
                        require.NoError(t, err)
                        defer releasePosDeletes(deletes)
                        assert.Equal(t, []int64{1, 3}, 
int64Values(deletes[dataPath]))
                })
        }
 }
+
+func TestPositionDeleteRowGroupTesterRejectsNestedDuplicateFieldIDs(t 
*testing.T) {
+       duplicate := arrow.Field{
+               Name: "value", Type: arrow.BinaryTypes.String,
+               Metadata: PositionalDeleteArrowSchema.Field(0).Metadata,
+       }
+       for _, tc := range []struct {
+               name string
+               typ  arrow.DataType
+       }{
+               {"struct", arrow.StructOf(duplicate)},
+               {"list", arrow.ListOfField(duplicate)},
+               {"large list", arrow.LargeListOfField(duplicate)},
+               {"map key", arrow.MapOfFields(duplicate, arrow.Field{Name: 
"value", Type: arrow.BinaryTypes.String})},
+               {"map value", arrow.MapOfFields(arrow.Field{Name: "key", Type: 
arrow.BinaryTypes.String}, duplicate)},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       fields := PositionalDeleteArrowSchema.Fields()
+                       fields = append(fields, arrow.Field{Name: "row", Type: 
tc.typ})
+                       tester, err := newPositionDeleteRowGroupTester(
+                               arrow.NewSchema(fields, nil), 
map[string]struct{}{"data.parquet": {}})
+                       require.ErrorIs(t, err, iceberg.ErrInvalidSchema)
+                       require.Nil(t, tester)
+               })
+       }
+}
diff --git a/table/arrow_scanner_test.go b/table/arrow_scanner_test.go
index e85de0855..c35e41d35 100644
--- a/table/arrow_scanner_test.go
+++ b/table/arrow_scanner_test.go
@@ -305,12 +305,11 @@ func mustLoadRecordBatchFromJSON(schema *arrow.Schema, 
content string) arrow.Rec
 
 func writePosDeleteParquetToMemFS(t *testing.T, memFS *iceio.MemFS, path, 
content string) {
        t.Helper()
-
        writePosDeleteParquetToMemFSWithSchema(t, memFS, path, 
PositionalDeleteArrowSchema, content)
 }
 
 func writePosDeleteParquetToMemFSWithSchema(t *testing.T, memFS *iceio.MemFS, 
path string,
-       schema *arrow.Schema, content string,
+       schema *arrow.Schema, content string, props ...parquet.WriterProperty,
 ) {
        t.Helper()
 
@@ -323,8 +322,12 @@ func writePosDeleteParquetToMemFSWithSchema(t *testing.T, 
memFS *iceio.MemFS, pa
        fw, err := memFS.Create(path)
        require.NoError(t, err)
 
+       props = append([]parquet.WriterProperty{
+               parquet.WithDictionaryDefault(true),
+               parquet.WithStats(true),
+       }, props...)
        require.NoError(t, pqarrow.WriteTable(tbl, fw, rec.NumRows(),
-               
parquet.NewWriterProperties(parquet.WithDictionaryDefault(true), 
parquet.WithStats(true)),
+               parquet.NewWriterProperties(props...),
                pqarrow.DefaultWriterProps()))
        require.NoError(t, fw.Close())
 }
@@ -431,7 +434,7 @@ func TestReleasePerFilePosDeletes(t *testing.T) {
        t.Run("nil chunk in slice does not panic", func(t *testing.T) {
                // Defensive: production code paths never insert a nil 
*arrow.Chunked
                // into the map. This subtest pins the guard so a future caller 
(the
-               // in-flight delete-file merger, or a refactor of readDeletes)
+               // in-flight delete-file merger, or a refactor of 
readDeletesForPaths)
                // can't silently NPE the cleanup path.
                mem := memory.NewCheckedAllocator(memory.NewGoAllocator())
                defer mem.AssertSize(t, 0)

Reply via email to