laskoviymishka commented on code in PR #2146:
URL: https://github.com/apache/iceberg-go/pull/2146#discussion_r4242741613


##########
table/arrow_scanner.go:
##########
@@ -2188,7 +2415,7 @@ func (as *arrowScan) recordsFromTask(ctx context.Context, 
task tblutils.Enumerat
        // the planner (scanner.go) and Java's DeleteFileIndex, and keeps at 
most one
        // row-dropping position step in the pipeline so cursors never run over 
a
        // sequence an earlier step already shortened.
-       applyPosDeletes := len(positionalDeletes) > 0 && !hasDV
+       applyPosDeletes := positionalDeleteBitmap != nil && 
!positionalDeleteBitmap.IsEmpty() && !hasDV

Review Comment:
   This is the DV-superseded half of the validation thread from last round, 
still open. By the time we drop the bitmap here, the loader has already read, 
converted and validated the superseded delete file, so a malformed row in a 
file a DV supersedes still fails the scan, and we pay the I/O for deletes we 
throw away.
   
   Now that the loader owns the key, `positionDeleteIndexKey` can return an 
empty key when `len(task.DeletionVectorFiles) > 0` and skip the whole read. I'd 
either do that, or add a line saying the eager validation is intentional, and 
pin the DV-plus-malformed-superseded case with a test either way. That's the 
decision-plus-test I was after in round 1.



##########
table/arrow_scanner.go:
##########
@@ -214,69 +262,113 @@ func newLazyPositionDeleteLoader(fs iceio.IO, tasks 
[]FileScanTask) *lazyPositio
                        targets:  targetsByDelete[path],
                }
        }
+       indexesByKey := make(map[string]*lazyPositionDeleteIndex)
+       for i, task := range tasks {
+               key, targetPath, deletePaths := positionDeleteIndexKey(task)
+               if key == "" {
+                       continue
+               }
+               index := indexesByKey[key]
+               if index == nil {
+                       index = &lazyPositionDeleteIndex{
+                               targetPath:  targetPath,
+                               deletePaths: deletePaths,
+                       }
+                       indexesByKey[key] = index
+               }
+               loader.indexes[i] = index
+       }
 
        return loader
 }
 
-func (l *lazyPositionDeleteLoader) load(ctx context.Context, task 
FileScanTask) (positionDeletes, error) {
-       if l.released.Load() {
-               return nil, errPositionDeleteLoaderReleased
-       }
-
-       if len(task.DeleteFiles) == 0 {
+func (l *lazyPositionDeleteLoader) loadDeleteFile(
+       ctx context.Context, path string,
+) (*lazyPositionDeleteFile, error) {
+       cached, ok := l.files[path]
+       if !ok {
                return nil, nil
        }
 
-       targetPath := task.File.FilePath()
-       deletes := make(positionDeletes, 0, len(task.DeleteFiles))
-       // Most scan tasks carry one positional delete file. Avoid allocating a
-       // deduplication map unless there can actually be duplicate entries.
-       var seen map[string]struct{}
-       if len(task.DeleteFiles) > 1 {
-               seen = make(map[string]struct{}, len(task.DeleteFiles))
+       cached.once.Do(func() {
+               cached.bitmaps, cached.err = readPositionDeleteBitmapsForPaths(
+                       ctx, l.fs, cached.dataFile, cached.targets)
+               if cached.err != nil {
+                       cached.bitmaps = nil
+                       cached.err = fmt.Errorf("read position deletes from %s: 
%w",
+                               cached.dataFile.FilePath(), cached.err)
+               }
+       })
+       if cached.err != nil {
+               return nil, cached.err
        }
-       for _, deleteFile := range task.DeleteFiles {
-               if deleteFile.ContentType() != iceberg.EntryContentPosDeletes {
+
+       return cached, nil
+}
+
+func (l *lazyPositionDeleteLoader) buildIndex(
+       ctx context.Context, targetPath string, deletePaths []string,
+) (*dv.RoaringPositionBitmap, error) {
+       var bitmap *dv.RoaringPositionBitmap
+       owned := false
+       for _, path := range deletePaths {
+               cached, err := l.loadDeleteFile(ctx, path)
+               if err != nil {
+                       return nil, err
+               }
+               if cached == nil {
                        continue
                }
 
-               path := deleteFile.FilePath()
-               if seen != nil {
-                       if _, ok := seen[path]; ok {
-                               continue
-                       }
-                       seen[path] = struct{}{}
+               deleteBitmap := cached.bitmaps[targetPath]
+               if deleteBitmap == nil || deleteBitmap.IsEmpty() {
+                       continue
                }
+               if bitmap == nil {
+                       bitmap = deleteBitmap
 
-               cached, ok := l.files[path]
-               if !ok {
-                       // The loader is normally built from the same task 
slice supplied to
-                       // this method. Keep this guard so a malformed caller 
cannot panic a
-                       // scan if it changes a task after loader construction.
                        continue
                }
 
-               cached.once.Do(func() {
-                       cached.deletes, cached.err = readDeletesForPaths(ctx, 
l.fs, cached.dataFile, cached.targets)
-                       if cached.err != nil {
-                               // readDeletesForPaths currently returns nil on 
errors. Release defensively
-                               // in case a future reader returns partial 
Arrow ownership.
-                               releasePosDeletes(cached.deletes)
-                               cached.deletes = nil
-                               cached.err = fmt.Errorf("read position deletes 
from %s: %w",
-                                       cached.dataFile.FilePath(), cached.err)
-                       }
-               })
-               if cached.err != nil {
-                       return nil, cached.err
+               // Detach from the first cached bitmap once, then accumulate 
into the
+               // private union. Cached inputs stay immutable for other split 
tasks.
+               if !owned {
+                       merged := dv.NewRoaringPositionBitmap()
+                       merged.Or(bitmap)
+                       bitmap = merged
+                       owned = true
                }
+               bitmap.Or(deleteBitmap)
+       }
 
-               if chunk := cached.deletes[targetPath]; chunk != nil {
-                       deletes = append(deletes, chunk)
-               }
+       return bitmap, nil
+}
+
+// load uses the ordinal in the task slice passed to 
newLazyPositionDeleteLoader.
+// Workers preserve that ordinal even when they finish out of order, so this
+// lookup does not rebuild or hash a key for every split.
+func (l *lazyPositionDeleteLoader) load(ctx context.Context, taskIndex int) 
(*dv.RoaringPositionBitmap, error) {

Review Comment:
   The ordinal is now the only thing tying a task to its index, and nothing 
checks it against the task's own file. Today `recordBatchesFromTasksAndDeletes` 
passes the same slice it built the loader from, so it holds, but a future 
refactor that filters, sorts or limit-trims tasks between construction and the 
scan loop would silently hand back another data file's bitmap: wrong rows 
dropped, no error. The old `load(task)` was self-validating because it read 
`task.File.FilePath()` itself.
   
   I'd pass the target path into `load` and compare it to `index.targetPath` 
(one string compare), returning `ErrInvalidArgument` on mismatch, with a test 
for the mismatch. Cheap insurance against a silent-wrong-results seam.



##########
table/arrow_scanner.go:
##########
@@ -785,6 +883,64 @@ func releasePosDeletes(deletes map[string]*arrow.Chunked) {
        }
 }
 
+func collectPosDeleteBitmap(positionalDeletes positionDeletes) 
(*dv.RoaringPositionBitmap, error) {

Review Comment:
   The nil / type / null / negative checks here are a near-verbatim copy of 
`collectPosDeletePositions`, so the two validators will drift on the next 
change. Not blocking, but I'd either factor out one validator both consume, or 
move `producePosDeletesFromTask` onto the bitmap and drop the set path, which 
was the round-1 cleanup. The doc note pointing at `producePosDeletesFromTask` 
helps either way.



##########
table/arrow_scanner.go:
##########
@@ -214,69 +262,113 @@ func newLazyPositionDeleteLoader(fs iceio.IO, tasks 
[]FileScanTask) *lazyPositio
                        targets:  targetsByDelete[path],
                }
        }
+       indexesByKey := make(map[string]*lazyPositionDeleteIndex)
+       for i, task := range tasks {
+               key, targetPath, deletePaths := positionDeleteIndexKey(task)
+               if key == "" {
+                       continue
+               }
+               index := indexesByKey[key]
+               if index == nil {
+                       index = &lazyPositionDeleteIndex{
+                               targetPath:  targetPath,
+                               deletePaths: deletePaths,
+                       }
+                       indexesByKey[key] = index
+               }
+               loader.indexes[i] = index
+       }
 
        return loader
 }
 
-func (l *lazyPositionDeleteLoader) load(ctx context.Context, task 
FileScanTask) (positionDeletes, error) {
-       if l.released.Load() {
-               return nil, errPositionDeleteLoaderReleased
-       }
-
-       if len(task.DeleteFiles) == 0 {
+func (l *lazyPositionDeleteLoader) loadDeleteFile(
+       ctx context.Context, path string,
+) (*lazyPositionDeleteFile, error) {
+       cached, ok := l.files[path]
+       if !ok {
                return nil, nil
        }
 
-       targetPath := task.File.FilePath()
-       deletes := make(positionDeletes, 0, len(task.DeleteFiles))
-       // Most scan tasks carry one positional delete file. Avoid allocating a
-       // deduplication map unless there can actually be duplicate entries.
-       var seen map[string]struct{}
-       if len(task.DeleteFiles) > 1 {
-               seen = make(map[string]struct{}, len(task.DeleteFiles))
+       cached.once.Do(func() {
+               cached.bitmaps, cached.err = readPositionDeleteBitmapsForPaths(
+                       ctx, l.fs, cached.dataFile, cached.targets)
+               if cached.err != nil {
+                       cached.bitmaps = nil
+                       cached.err = fmt.Errorf("read position deletes from %s: 
%w",
+                               cached.dataFile.FilePath(), cached.err)
+               }
+       })
+       if cached.err != nil {
+               return nil, cached.err
        }
-       for _, deleteFile := range task.DeleteFiles {
-               if deleteFile.ContentType() != iceberg.EntryContentPosDeletes {
+
+       return cached, nil
+}
+
+func (l *lazyPositionDeleteLoader) buildIndex(
+       ctx context.Context, targetPath string, deletePaths []string,
+) (*dv.RoaringPositionBitmap, error) {
+       var bitmap *dv.RoaringPositionBitmap
+       owned := false
+       for _, path := range deletePaths {
+               cached, err := l.loadDeleteFile(ctx, path)
+               if err != nil {
+                       return nil, err
+               }
+               if cached == nil {
                        continue
                }
 
-               path := deleteFile.FilePath()
-               if seen != nil {
-                       if _, ok := seen[path]; ok {
-                               continue
-                       }
-                       seen[path] = struct{}{}
+               deleteBitmap := cached.bitmaps[targetPath]
+               if deleteBitmap == nil || deleteBitmap.IsEmpty() {
+                       continue
                }
+               if bitmap == nil {
+                       bitmap = deleteBitmap
 
-               cached, ok := l.files[path]
-               if !ok {
-                       // The loader is normally built from the same task 
slice supplied to
-                       // this method. Keep this guard so a malformed caller 
cannot panic a
-                       // scan if it changes a task after loader construction.
                        continue
                }
 
-               cached.once.Do(func() {
-                       cached.deletes, cached.err = readDeletesForPaths(ctx, 
l.fs, cached.dataFile, cached.targets)
-                       if cached.err != nil {
-                               // readDeletesForPaths currently returns nil on 
errors. Release defensively
-                               // in case a future reader returns partial 
Arrow ownership.
-                               releasePosDeletes(cached.deletes)
-                               cached.deletes = nil
-                               cached.err = fmt.Errorf("read position deletes 
from %s: %w",
-                                       cached.dataFile.FilePath(), cached.err)
-                       }
-               })
-               if cached.err != nil {
-                       return nil, cached.err
+               // Detach from the first cached bitmap once, then accumulate 
into the
+               // private union. Cached inputs stay immutable for other split 
tasks.
+               if !owned {
+                       merged := dv.NewRoaringPositionBitmap()
+                       merged.Or(bitmap)
+                       bitmap = merged
+                       owned = true
                }
+               bitmap.Or(deleteBitmap)
+       }
 
-               if chunk := cached.deletes[targetPath]; chunk != nil {
-                       deletes = append(deletes, chunk)
-               }
+       return bitmap, nil
+}
+
+// load uses the ordinal in the task slice passed to 
newLazyPositionDeleteLoader.
+// Workers preserve that ordinal even when they finish out of order, so this
+// lookup does not rebuild or hash a key for every split.
+func (l *lazyPositionDeleteLoader) load(ctx context.Context, taskIndex int) 
(*dv.RoaringPositionBitmap, error) {
+       if l.released.Load() {
+               return nil, errPositionDeleteLoaderReleased
+       }
+       if taskIndex < 0 || taskIndex >= len(l.indexes) {
+               return nil, fmt.Errorf("%w: position delete task index %d out 
of range [0, %d)",
+                       iceberg.ErrInvalidArgument, taskIndex, len(l.indexes))
        }
 
-       return deletes, nil
+       index := l.indexes[taskIndex]
+       if index == nil {
+               return nil, nil
+       }
+
+       index.once.Do(func() {
+               index.bitmap, index.err = l.buildIndex(ctx, index.targetPath, 
index.deletePaths)
+       })
+       if index.err != nil {
+               return nil, index.err
+       }
+
+       return index.bitmap, nil

Review Comment:
   With 0 or 1 non-empty inputs this returns the cached per-file bitmap 
directly, so several split tasks share one `*RoaringPositionBitmap`, and it's 
mutable and not safe for concurrent writes. Nothing enforces read-only use: 
`recordsFromTask` takes it right next to `dvBitmap`, so a later edit that runs 
`Or` / `Set` / `RunLengthEncode` on it would corrupt other splits, and neither 
the compiler nor `-race` would catch it until a test happened to hit the window.
   
   The copy-on-first-merge logic itself is correct, this is just about the 
contract. I'd put a doc comment on `load` stating the returned bitmap is shared 
and read-only, and ideally a `-race` test where several workers read `Contains` 
while another builds a union from the same cached inputs.



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

To unsubscribe, e-mail: [email protected]

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


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

Reply via email to