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 ff7df2e56 perf(table): reuse bitmap indexes for positional deletes
(#2146)
ff7df2e56 is described below
commit ff7df2e56ed3391e810281b772bf27ea74d03256
Author: Minh Vu <[email protected]>
AuthorDate: Mon Oct 12 21:23:20 2026 +0200
perf(table): reuse bitmap indexes for positional deletes (#2146)
---
table/arrow_scanner.go | 384 ++++++++++++++++----
table/arrow_scanner_lazy_delete_bench_test.go | 220 +++++++++++-
table/arrow_scanner_lazy_delete_regression_test.go | 397 +++++++++++++++++++--
table/arrow_scanner_posdelete_bitmap_test.go | 282 +++++++++++++++
table/dv_scanner_end_to_end_test.go | 32 ++
5 files changed, 1217 insertions(+), 98 deletions(-)
diff --git a/table/arrow_scanner.go b/table/arrow_scanner.go
index a482a2964..935a949eb 100644
--- a/table/arrow_scanner.go
+++ b/table/arrow_scanner.go
@@ -172,19 +172,19 @@ func readAllDeleteFiles(ctx context.Context, fs iceio.IO,
tasks []FileScanTask,
// lazyPositionDeleteLoader indexes positional-delete metadata for a scan, but
// waits to open each delete file until a worker reaches a task that references
-// it. A delete file can apply to more than one data file, so the cache keeps
-// the complete grouped result for the delete file rather than caching only one
-// task's positions.
+// it. Delete-file reads are cached by delete path, while the merged position
+// bitmap is cached by target data file plus its delete-file set. Split tasks
+// therefore share one immutable bitmap instead of rebuilding a hash set per
+// task. Unions of multiple non-empty inputs are retained alongside the
+// per-file bitmaps until release.
//
-// The grouped Arrow chunks are owned by the loader until release. The iterator
-// calls release after all workers have stopped, which keeps shared chunks
alive
-// while multiple tasks use them and also covers early iterator termination.
-// The loader has the lifetime of exactly one scan. Each file's first load also
-// locks in its result, including context errors, for every caller; a loader
-// must not be reused for a retry with a different context.
+// The loader has the lifetime of exactly one scan. Each delete file and merged
+// bitmap locks in its first result, including context errors, for every
caller;
+// a loader must not be reused for a retry with a different context.
type lazyPositionDeleteLoader struct {
- fs iceio.IO
- files map[string]*lazyPositionDeleteFile
+ fs iceio.IO
+ files map[string]*lazyPositionDeleteFile
+ indexes []positionDeleteTaskIndex
releaseOnce sync.Once
released atomic.Bool
@@ -197,15 +197,73 @@ type lazyPositionDeleteFile struct {
targets map[string]struct{}
once sync.Once
- deletes map[string]*arrow.Chunked
+ bitmaps map[string]*dv.RoaringPositionBitmap
err error
}
+type lazyPositionDeleteIndex struct {
+ targetPath string
+ deletePaths []string
+
+ once sync.Once
+ bitmap *dv.RoaringPositionBitmap
+ err error
+}
+
+type positionDeleteTaskIndex struct {
+ targetPath string
+ index *lazyPositionDeleteIndex
+}
+
+func positionDeleteIndexKey(task FileScanTask) (key, targetPath string,
deletePaths []string) {
+ if task.File == nil {
+ return "", "", nil
+ }
+ targetPath = task.File.FilePath()
+ if targetPath == "" {
+ return "", "", nil
+ }
+ // An applicable DV contains all earlier position deletes for this file.
+ // Skip superseded files before loading or validating their contents.
+ if len(task.DeletionVectorFiles) > 0 {
+ return "", targetPath, nil
+ }
+
+ for _, deleteFile := range task.DeleteFiles {
+ if deleteFile.ContentType() == iceberg.EntryContentPosDeletes {
+ deletePaths = append(deletePaths, deleteFile.FilePath())
+ }
+ }
+ if len(deletePaths) == 0 {
+ return "", targetPath, nil
+ }
+ slices.Sort(deletePaths)
+ deletePaths = slices.Compact(deletePaths)
+
+ var builder strings.Builder
+ writePart := func(part string) {
+ builder.WriteString(strconv.Itoa(len(part)))
+ builder.WriteByte(':')
+ builder.WriteString(part)
+ }
+ writePart(targetPath)
+ for _, path := range deletePaths {
+ builder.WriteByte('|')
+ writePart(path)
+ }
+
+ return builder.String(), targetPath, deletePaths
+}
+
func newLazyPositionDeleteLoader(fs iceio.IO, tasks []FileScanTask)
*lazyPositionDeleteLoader {
uniqueDeletes, targetsByDelete :=
collectPositionDeleteFilesAndTargets(tasks)
+ if len(uniqueDeletes) == 0 {
+ return nil
+ }
loader := &lazyPositionDeleteLoader{
- fs: fs,
- files: make(map[string]*lazyPositionDeleteFile,
len(uniqueDeletes)),
+ fs: fs,
+ files: make(map[string]*lazyPositionDeleteFile,
len(uniqueDeletes)),
+ indexes: make([]positionDeleteTaskIndex, len(tasks)),
}
for path, deleteFile := range uniqueDeletes {
@@ -214,69 +272,124 @@ 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)
+ loader.indexes[i].targetPath = targetPath
+ if key == "" {
+ continue
+ }
+ index := indexesByKey[key]
+ if index == nil {
+ index = &lazyPositionDeleteIndex{
+ targetPath: targetPath,
+ deletePaths: deletePaths,
+ }
+ indexesByKey[key] = index
+ }
+ loader.indexes[i].index = index
+ }
return loader
}
-func (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. The target path
rejects
+// ordinals paired with another data file after reordering or filtering tasks,
+// including ordinals that originally had no position deletes.
+//
+// The returned bitmap is shared and read-only. Callers must not mutate it with
+// Set, Or, or RunLengthEncode; other split tasks may be reading the same
bitmap.
+func (l *lazyPositionDeleteLoader) load(ctx context.Context, taskIndex int,
targetPath string) (*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
+ task := l.indexes[taskIndex]
+ if task.targetPath != targetPath {
+ return nil, fmt.Errorf("%w: position delete task index %d
targets %q, got %q",
+ iceberg.ErrInvalidArgument, taskIndex, task.targetPath,
targetPath)
+ }
+ index := task.index
+ 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
}
func (l *lazyPositionDeleteLoader) release() {
@@ -287,8 +400,12 @@ func (l *lazyPositionDeleteLoader) release() {
l.releaseOnce.Do(func() {
l.released.Store(true)
for _, cached := range l.files {
- releasePosDeletes(cached.deletes)
- cached.deletes = nil
+ cached.bitmaps = nil
+ }
+ for _, task := range l.indexes {
+ if task.index != nil {
+ task.index.bitmap = nil
+ }
}
})
}
@@ -727,6 +844,8 @@ func groupPosDeletesByFilePath(ctx context.Context,
filePathCol, posCol *arrow.C
const maxPositionalDeletePreallocation = 64 * 1024
+// collectPosDeletePositions serves producePosDeletesFromTask when a
+// transaction creates new delete records. Scan tasks use cached bitmaps
instead.
func collectPosDeletePositions(positionalDeletes positionDeletes) (set[int64],
error) {
totalPositions := 0
for _, chunk := range positionalDeletes {
@@ -785,6 +904,64 @@ func releasePosDeletes(deletes map[string]*arrow.Chunked) {
}
}
+func collectPosDeleteBitmap(positionalDeletes positionDeletes)
(*dv.RoaringPositionBitmap, error) {
+ bitmap := dv.NewRoaringPositionBitmap()
+ for _, chunk := range positionalDeletes {
+ if chunk == nil {
+ return nil, fmt.Errorf("%w: nil pos column chunk in
position delete file",
+ iceberg.ErrInvalidSchema)
+ }
+ if chunk.DataType().ID() != arrow.INT64 {
+ return nil, fmt.Errorf("%w: unsupported pos column type
%s in position delete file",
+ iceberg.ErrInvalidSchema, chunk.DataType())
+ }
+ for _, arr := range chunk.Chunks() {
+ posArr, ok := arr.(*array.Int64)
+ if !ok {
+ return nil, fmt.Errorf("%w: unsupported pos
chunk array type %T in position delete file",
+ iceberg.ErrInvalidSchema, arr)
+ }
+ if posArr.NullN() > 0 {
+ return nil, fmt.Errorf("%w: null pos in
position delete file",
+ iceberg.ErrInvalidSchema)
+ }
+ for _, position := range posArr.Int64Values() {
+ if position < 0 {
+ return nil, fmt.Errorf("%w: negative
pos %d in position delete file",
+ iceberg.ErrInvalidSchema,
position)
+ }
+ bitmap.Set(uint64(position))
+ }
+ }
+ }
+
+ return bitmap, nil
+}
+
+func readPositionDeleteBitmapsForPaths(
+ ctx context.Context,
+ fs iceio.IO,
+ dataFile iceberg.DataFile,
+ targets map[string]struct{},
+) (map[string]*dv.RoaringPositionBitmap, error) {
+ deletes, err := readDeletesForPaths(ctx, fs, dataFile, targets)
+ if err != nil {
+ return nil, err
+ }
+ defer releasePosDeletes(deletes)
+
+ bitmaps := make(map[string]*dv.RoaringPositionBitmap, len(deletes))
+ for path, positions := range deletes {
+ bitmap, err :=
collectPosDeleteBitmap(positionDeletes{positions})
+ if err != nil {
+ return nil, err
+ }
+ bitmaps[path] = bitmap
+ }
+
+ return bitmaps, nil
+}
+
func readDeletes(ctx context.Context, fs iceio.IO, dataFile iceberg.DataFile)
(map[string]*arrow.Chunked, error) {
return readDeletesForPaths(ctx, fs, dataFile, nil)
}
@@ -1063,6 +1240,77 @@ func combinePositionalDeletes(mem memory.Allocator,
deletes set[int64], cursor *
type recProcessFn func(arrow.RecordBatch) (arrow.RecordBatch, error)
+func combinePositionalDeleteBitmap(
+ mem memory.Allocator,
+ deletes *dv.RoaringPositionBitmap,
+ cursor *rowPositionCursor,
+ nrows int64,
+) arrow.Array {
+ var bldr *array.Int64Builder
+
+ for i := range nrows {
+ if deletes.Contains(uint64(cursor.next())) {
+ if bldr == nil {
+ bldr = array.NewInt64Builder(mem)
+ if i > 0 {
+ // This row is deleted. Resize avoids
rounding the maximum
+ // survivor count up to a power of two
as Reserve would.
+ bldr.Resize(int(nrows - 1))
+ }
+ for j := range i {
+ bldr.UnsafeAppend(j)
+ }
+ }
+
+ continue
+ }
+
+ if bldr != nil {
+ // A leading run of deletes needs no values buffer
until a row
+ // survives. Size it once for the remaining possible
survivors.
+ if bldr.Cap() == 0 {
+ bldr.Resize(int(nrows - i))
+ }
+ bldr.UnsafeAppend(i)
+ }
+ }
+
+ if bldr == nil {
+ return nil
+ }
+ defer bldr.Release()
+
+ return bldr.NewArray()
+}
+
+func processPositionalDeleteBitmap(
+ ctx context.Context,
+ deletes *dv.RoaringPositionBitmap,
+ cursor *rowPositionCursor,
+) recProcessFn {
+ mem := compute.GetAllocator(ctx)
+
+ return func(r arrow.RecordBatch) (arrow.RecordBatch, error) {
+ defer r.Release()
+
+ indices := combinePositionalDeleteBitmap(mem, deletes, cursor,
r.NumRows())
+ if indices == nil {
+ r.Retain()
+
+ return r, nil
+ }
+ defer indices.Release()
+
+ out, err := compute.Take(ctx, *compute.DefaultTakeOptions(),
+ compute.NewDatumWithoutOwning(r),
compute.NewDatumWithoutOwning(indices))
+ if err != nil {
+ return nil, err
+ }
+
+ return out.(*compute.RecordDatum).Value, nil
+ }
+}
+
func processPositionalDeletes(ctx context.Context, deletes set[int64], cursor
*rowPositionCursor) recProcessFn {
mem := compute.GetAllocator(ctx)
@@ -2154,7 +2402,7 @@ func pruningFilterHasMissingInitialDefault(
return false, nil
}
-func (as *arrowScan) recordsFromTask(ctx context.Context, task
tblutils.Enumerated[FileScanTask], sink *recordSink, positionalDeletes
positionDeletes, dvBitmap *dv.RoaringPositionBitmap, eqDeleteSets
[]*equalityDeleteSet, invariants *arrowScanInvariants) (err error) {
+func (as *arrowScan) recordsFromTask(ctx context.Context, task
tblutils.Enumerated[FileScanTask], sink *recordSink, positionalDeleteBitmap
*dv.RoaringPositionBitmap, dvBitmap *dv.RoaringPositionBitmap, eqDeleteSets
[]*equalityDeleteSet, invariants *arrowScanInvariants) (err error) {
defer func() {
if err != nil {
sink.fail(task, err)
@@ -2215,7 +2463,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
var posSource *rowPositionSource
if synthesizeRowID || applyPosDeletes || hasDV {
posSource = &rowPositionSource{}
@@ -2239,12 +2487,8 @@ func (as *arrowScan) recordsFromTask(ctx
context.Context, task tblutils.Enumerat
}
if applyPosDeletes {
- deletes, err := collectPosDeletePositions(positionalDeletes)
- if err != nil {
- return err
- }
-
- pipeline = append(pipeline, processPositionalDeletes(ctx,
deletes, posSource.cursor()))
+ pipeline = append(pipeline,
+ processPositionalDeleteBitmap(ctx,
positionalDeleteBitmap, posSource.cursor()))
}
if hasDV {
@@ -2526,10 +2770,10 @@ func (as *arrowScan)
recordBatchesFromTasksAndDeletes(ctx context.Context, tasks
}
filePath :=
task.Value.File.FilePath()
- var positionalDeletes
positionDeletes
+ var positionalDeleteBitmap
*dv.RoaringPositionBitmap
if positionDeleteLoader != nil {
var err error
- positionalDeletes, err
= positionDeleteLoader.load(scanCtx, task.Value)
+ positionalDeleteBitmap,
err = positionDeleteLoader.load(scanCtx, task.Index, filePath)
if err != nil {
sink.fail(task,
err)
cancel(err)
@@ -2554,7 +2798,7 @@ func (as *arrowScan) recordBatchesFromTasksAndDeletes(ctx
context.Context, tasks
}
if err :=
as.recordsFromTask(scanCtx, task, sink,
- positionalDeletes,
+ positionalDeleteBitmap,
dvBitmap,
eqDeleteSets,
invariants); err != nil
{
diff --git a/table/arrow_scanner_lazy_delete_bench_test.go
b/table/arrow_scanner_lazy_delete_bench_test.go
index b25243a87..0ad127398 100644
--- a/table/arrow_scanner_lazy_delete_bench_test.go
+++ b/table/arrow_scanner_lazy_delete_bench_test.go
@@ -28,6 +28,7 @@ import (
"github.com/apache/arrow-go/v18/parquet/pqarrow"
"github.com/apache/iceberg-go"
iceio "github.com/apache/iceberg-go/io"
+ "github.com/apache/iceberg-go/table/dv"
)
const (
@@ -146,14 +147,227 @@ func BenchmarkLazyPositionDeleteLoading(b *testing.B) {
b.ResetTimer()
for b.Loop() {
loader := newLazyPositionDeleteLoader(fixture.fs,
fixture.tasks)
- deletes, err := loader.load(b.Context(),
fixture.tasks[0])
+ bitmap, err := loader.load(b.Context(), 0,
fixture.tasks[0].File.FilePath())
if err != nil {
b.Fatal(err)
}
- if len(deletes) != 1 {
- b.Fatalf("expected one delete chunk for the
first task, got %d", len(deletes))
+ if bitmap == nil || bitmap.Cardinality() != 1 {
+ b.Fatalf("expected one indexed delete for the
first task")
}
loader.release()
}
})
}
+
+var positionDeleteSplitReuseBenchmarkSink int64
+
+func BenchmarkPositionDeleteBitmapNoDeletes(b *testing.B) {
+ for _, taskCount := range []int{1, 10_000} {
+ b.Run(fmt.Sprintf("tasks=%d", taskCount), func(b *testing.B) {
+ tasks := make([]FileScanTask, taskCount)
+ dataFile := &mockDataFile{path:
"mem://benchmark/data/no-deletes.parquet"}
+ for i := range tasks {
+ tasks[i].File = dataFile
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ loader := newLazyPositionDeleteLoader(nil,
tasks)
+ if loader != nil {
+ b.Fatal("expected no position delete
loader")
+ }
+ loader.release()
+ }
+ })
+ }
+}
+
+// BenchmarkPositionDeleteBitmapUnion isolates merging already decoded delete
+// files. Separate high-bit buckets make repeated copies of the growing union
+// visible as the number of applicable delete files increases.
+func BenchmarkPositionDeleteBitmapUnion(b *testing.B) {
+ const (
+ dataPath = "mem://benchmark/data/union.parquet"
+ positionsPerFile = 16_384
+ )
+
+ for _, fileCount := range []int{1, 4, 16} {
+ b.Run(fmt.Sprintf("files=%d", fileCount), func(b *testing.B) {
+ loader := &lazyPositionDeleteLoader{
+ files: make(map[string]*lazyPositionDeleteFile,
fileCount),
+ }
+ paths := make([]string, fileCount)
+ for i := range paths {
+ paths[i] = fmt.Sprintf("delete-%d.parquet", i)
+ bitmap := dv.NewRoaringPositionBitmap()
+ for position := range positionsPerFile {
+ bitmap.Set(uint64(i)<<32 |
uint64(position*37))
+ }
+ cached := &lazyPositionDeleteFile{
+ bitmaps:
map[string]*dv.RoaringPositionBitmap{dataPath: bitmap},
+ }
+ cached.once.Do(func() {})
+ loader.files[paths[i]] = cached
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ bitmap, err := loader.buildIndex(b.Context(),
dataPath, paths)
+ if err != nil {
+ b.Fatal(err)
+ }
+ if bitmap.Cardinality() !=
int64(fileCount*positionsPerFile) {
+ b.Fatal("merged bitmap has unexpected
cardinality")
+ }
+ positionDeleteSplitReuseBenchmarkSink =
bitmap.Cardinality()
+ }
+ })
+ }
+}
+
+// BenchmarkPositionDeleteBitmapCachedLoad measures the per-split lookup after
+// the delete files and merged bitmap are already cached. Each operation visits
+// 16 splits of the same data file without any Parquet decoding or row
filtering.
+func BenchmarkPositionDeleteBitmapCachedLoad(b *testing.B) {
+ const (
+ dataPath =
"mem://benchmark/data/cached-position-delete-index.parquet"
+ splits = 16
+ )
+
+ for _, fileCount := range []int{1, 4, 16} {
+ b.Run(fmt.Sprintf("files=%d", fileCount), func(b *testing.B) {
+ deleteFiles := make([]iceberg.DataFile, fileCount)
+ for i := range deleteFiles {
+ deleteFiles[i] = &mockDataFile{
+ path:
fmt.Sprintf("mem://benchmark/deletes/cached-index-%02d.parquet", i),
+ contentType:
iceberg.EntryContentPosDeletes,
+ }
+ }
+ tasks := make([]FileScanTask, splits)
+ for i := range tasks {
+ tasks[i] = FileScanTask{
+ File: &mockDataFile{path: dataPath},
DeleteFiles: deleteFiles,
+ Start: int64(i * 128), Length: 128,
+ }
+ }
+ loader := newLazyPositionDeleteLoader(nil, tasks)
+ defer loader.release()
+ for i, deleteFile := range deleteFiles {
+ cached := loader.files[deleteFile.FilePath()]
+ bitmap := dv.NewRoaringPositionBitmap()
+ bitmap.Set(uint64(i))
+ cached.bitmaps =
map[string]*dv.RoaringPositionBitmap{dataPath: bitmap}
+ cached.once.Do(func() {})
+ }
+ if _, err := loader.load(b.Context(), 0, dataPath); err
!= nil {
+ b.Fatal(err)
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ for taskIndex := range tasks {
+ bitmap, err := loader.load(b.Context(),
taskIndex, dataPath)
+ if err != nil {
+ b.Fatal(err)
+ }
+ positionDeleteSplitReuseBenchmarkSink =
bitmap.Cardinality()
+ }
+ }
+ })
+ }
+}
+
+func BenchmarkPositionDeleteBitmapReuseAcrossSplits(b *testing.B) {
+ const deleteRows = 100_000
+
+ deletePath := "mem://benchmark/deletes/split-reuse.parquet"
+ dataPath := "mem://benchmark/data/split-reuse.parquet"
+ fs := iceio.NewMemFS()
+
+ var content strings.Builder
+ content.Grow(deleteRows * 72)
+ content.WriteByte('[')
+ for pos := range deleteRows {
+ if pos > 0 {
+ content.WriteByte(',')
+ }
+ fmt.Fprintf(&content, `{"file_path":"%s","pos":%d}`, dataPath,
pos)
+ }
+ content.WriteByte(']')
+ benchmarkWritePosDeleteParquet(b, fs, deletePath, content.String())
+
+ deleteBuilder, err := iceberg.NewDataFileBuilder(
+ *iceberg.UnpartitionedSpec, iceberg.EntryContentPosDeletes,
+ deletePath, iceberg.ParquetFile, nil, nil, nil, deleteRows, 128)
+ if err != nil {
+ b.Fatal(err)
+ }
+ deleteFile := deleteBuilder.Build()
+
+ dataBuilder, err := iceberg.NewDataFileBuilder(
+ *iceberg.UnpartitionedSpec, iceberg.EntryContentData,
+ dataPath, iceberg.ParquetFile, nil, nil, nil, deleteRows*2,
16*128)
+ if err != nil {
+ b.Fatal(err)
+ }
+ dataFile := dataBuilder.Build()
+ targets := map[string]struct{}{dataPath: {}}
+
+ for _, splitCount := range []int{1, 4, 16} {
+ b.Run(fmt.Sprintf("splits=%d", splitCount), func(b *testing.B) {
+ tasks := make([]FileScanTask, splitCount)
+ for i := range tasks {
+ tasks[i] = FileScanTask{
+ File: dataFile,
+ DeleteFiles:
[]iceberg.DataFile{deleteFile},
+ Start: int64(i * 128),
+ Length: 128,
+ }
+ }
+
+ b.Run("task_local_hash_sets", func(b *testing.B) {
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ deletes, err :=
readDeletesForPaths(b.Context(), fs, deleteFile, targets)
+ if err != nil {
+ b.Fatal(err)
+ }
+ positions :=
positionDeletes{deletes[dataPath]}
+ for range splitCount {
+ set, err :=
collectPosDeletePositions(positions)
+ if err != nil {
+
releasePosDeletes(deletes)
+ b.Fatal(err)
+ }
+
positionDeleteSplitReuseBenchmarkSink = int64(len(set))
+ }
+ releasePosDeletes(deletes)
+ }
+ })
+
+ b.Run("shared_roaring_bitmap", func(b *testing.B) {
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ loader :=
newLazyPositionDeleteLoader(fs, tasks)
+ for taskIndex := range tasks {
+ bitmap, err :=
loader.load(b.Context(), taskIndex, dataPath)
+ if err != nil {
+ loader.release()
+ b.Fatal(err)
+ }
+ if bitmap == nil {
+ loader.release()
+ b.Fatal("expected
position delete bitmap")
+ }
+
positionDeleteSplitReuseBenchmarkSink = bitmap.Cardinality()
+ }
+ loader.release()
+ }
+ })
+ })
+ }
+}
diff --git a/table/arrow_scanner_lazy_delete_regression_test.go
b/table/arrow_scanner_lazy_delete_regression_test.go
index a1ba540e3..87ff58848 100644
--- a/table/arrow_scanner_lazy_delete_regression_test.go
+++ b/table/arrow_scanner_lazy_delete_regression_test.go
@@ -35,6 +35,7 @@ import (
"github.com/apache/arrow-go/v18/parquet/pqarrow"
"github.com/apache/iceberg-go"
iceio "github.com/apache/iceberg-go/io"
+ "github.com/apache/iceberg-go/table/dv"
"github.com/apache/iceberg-go/table/internal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -141,27 +142,358 @@ func
TestLazyPositionDeleteLoaderDefersReadsAndSharesResults(t *testing.T) {
assert.Zero(t, memFS.opens.Load(), "constructing the scan loader must
not open delete files")
- gotA, err := loader.load(ctx, tasks[0])
+ gotA, err := loader.load(ctx, 0, dataPathA)
require.NoError(t, err)
- require.Len(t, gotA, 1, "duplicate delete references must be read once
per task")
- assert.Equal(t, []int64{1}, int64Values(gotA[0]))
+ require.NotNil(t, gotA)
+ assert.Equal(t, int64(1), gotA.Cardinality(),
+ "duplicate delete references must be indexed once per task")
+ assert.True(t, gotA.Contains(1))
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,
+ assert.NotContains(t, cached.bitmaps, unrelatedPath,
"lazy delete reads must filter paths that are not referenced by
scan tasks")
- gotB, err := loader.load(ctx, tasks[1])
+ gotB, err := loader.load(ctx, 1, dataPathB)
require.NoError(t, err)
- require.Len(t, gotB, 1)
- assert.Equal(t, []int64{3}, int64Values(gotB[0]))
+ require.NotNil(t, gotB)
+ assert.Equal(t, int64(1), gotB.Cardinality())
+ assert.True(t, gotB.Contains(3))
assert.Equal(t, int64(1), memFS.opens.Load(), "shared delete files must
use one read")
loader.release()
}
+func TestLazyPositionDeleteLoaderSkipsPlansWithoutPositionDeletes(t
*testing.T) {
+ for _, tc := range []struct {
+ name string
+ equalityDeleteFiles []iceberg.DataFile
+ }{
+ {name: "no deletes"},
+ {name: "equality deletes only", equalityDeleteFiles:
[]iceberg.DataFile{&mockDataFile{
+ path: "mem://bucket/deletes/equality.parquet",
contentType: iceberg.EntryContentEqDeletes,
+ }}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ tasks := make([]FileScanTask, 10_000)
+ dataFile := newLazyDataFile(t,
"mem://bucket/data/a.parquet")
+ for i := range tasks {
+ tasks[i] = FileScanTask{File: dataFile,
EqualityDeleteFiles: tc.equalityDeleteFiles}
+ }
+ loader := newLazyPositionDeleteLoader(nil, tasks)
+ require.Nil(t, loader, "a scan without position deletes
must not allocate a task-sized index")
+ loader.release()
+ })
+ }
+}
+
+func TestLazyPositionDeleteLoaderKeepsOriginalTaskOrdinals(t *testing.T) {
+ mem := memory.NewCheckedAllocator(memory.NewGoAllocator())
+ defer mem.AssertSize(t, 0)
+ ctx := compute.WithAllocator(t.Context(), mem)
+ const (
+ dataPathA = "mem://bucket/data/a.parquet"
+ dataPathB = "mem://bucket/data/b.parquet"
+ deletePath = "mem://bucket/deletes/task-ordinals.parquet"
+ )
+ fs := &countingOpenMemFS{MemFS: iceio.NewMemFS()}
+ writePosDeleteParquetToMemFS(t, fs.MemFS, deletePath, `[
+ {"file_path":"`+dataPathA+`","pos":1},
+ {"file_path":"`+dataPathB+`","pos":2}
+ ]`)
+ deleteFile := newPosDeleteFile(t, deletePath, 2, 128)
+ tasks := []FileScanTask{
+ {File: newLazyDataFile(t, dataPathA)},
+ {File: newLazyDataFile(t, dataPathB), DeleteFiles:
[]iceberg.DataFile{deleteFile}},
+ {File: newLazyDataFile(t, dataPathA), DeleteFiles:
[]iceberg.DataFile{deleteFile}},
+ {File: newLazyDataFile(t, dataPathB), DeleteFiles:
[]iceberg.DataFile{deleteFile}},
+ }
+ loader := newLazyPositionDeleteLoader(fs, tasks)
+ defer loader.release()
+ for _, taskIndex := range []int{3, 0, 2, 1} {
+ bitmap, err := loader.load(ctx, taskIndex,
tasks[taskIndex].File.FilePath())
+ require.NoError(t, err)
+ if taskIndex == 0 {
+ assert.Nil(t, bitmap, "tasks without deletes must keep
their place in the index slice")
+
+ continue
+ }
+ require.NotNil(t, bitmap)
+ assert.Equal(t, int64(1), bitmap.Cardinality())
+ if taskIndex == 2 {
+ assert.True(t, bitmap.Contains(1))
+ } else {
+ assert.True(t, bitmap.Contains(2))
+ }
+ }
+ assert.Same(t, loader.indexes[1].index, loader.indexes[3].index, "split
tasks must share the same cached index")
+ assert.Equal(t, int64(1), fs.opens.Load())
+ for _, taskIndex := range []int{-1, len(tasks)} {
+ bitmap, err := loader.load(ctx, taskIndex, dataPathA)
+ require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+ assert.Nil(t, bitmap)
+ }
+ for _, taskIndex := range []int{0, 2} {
+ bitmap, err := loader.load(ctx, taskIndex, dataPathB)
+ require.ErrorIs(t, err, iceberg.ErrInvalidArgument,
+ "a reordered task must fail even when its original
ordinal had no deletes")
+ assert.Nil(t, bitmap)
+ assert.ErrorContains(t, err, dataPathA)
+ assert.ErrorContains(t, err, dataPathB)
+ }
+}
+
+func TestLazyPositionDeleteLoaderSkipsDVSupersededFiles(t *testing.T) {
+ const (
+ dataPath = "mem://bucket/data/dv.parquet"
+ deletePath = "mem://bucket/deletes/superseded.parquet"
+ )
+ fs := &countingOpenMemFS{MemFS: iceio.NewMemFS()}
+ writePosDeleteParquetToMemFS(t, fs.MemFS, deletePath,
+ `[{"file_path":"`+dataPath+`","pos":-1}]`)
+ task := FileScanTask{
+ File: newLazyDataFile(t, dataPath),
+ DeleteFiles: []iceberg.DataFile{newPosDeleteFile(t,
deletePath, 1, 128)},
+ DeletionVectorFiles: []iceberg.DataFile{&mockDataFile{path:
"mem://bucket/deletes/dv.puffin"}},
+ }
+ loader := newLazyPositionDeleteLoader(fs, []FileScanTask{task})
+ defer loader.release()
+ bitmap, err := loader.load(t.Context(), 0, dataPath)
+ require.NoError(t, err)
+ assert.Nil(t, bitmap)
+ assert.Zero(t, fs.opens.Load(), "superseded position deletes must not
be opened or validated")
+
+ // Prove that the fixture really fails validation without DV precedence.
+ task.DeletionVectorFiles = nil
+ activeLoader := newLazyPositionDeleteLoader(fs, []FileScanTask{task})
+ defer activeLoader.release()
+ _, err = activeLoader.load(t.Context(), 0, dataPath)
+ require.ErrorIs(t, err, iceberg.ErrInvalidSchema)
+ assert.Equal(t, int64(1), fs.opens.Load())
+}
+
+func TestLazyPositionDeleteLoaderConcurrentReadsWhileBuildingUnions(t
*testing.T) {
+ const (
+ dataPath = "mem://bucket/data/shared-bitmap.parquet"
+ positions = 8_192
+ readers = 4
+ )
+ deleteFiles := []iceberg.DataFile{
+ newPosDeleteFile(t, "mem://bucket/deletes/first.parquet",
positions, 128),
+ newPosDeleteFile(t, "mem://bucket/deletes/second.parquet",
positions, 128),
+ }
+ tasks := []FileScanTask{
+ {File: newLazyDataFile(t, dataPath), DeleteFiles:
deleteFiles[:1]},
+ {File: newLazyDataFile(t, dataPath), DeleteFiles:
deleteFiles[1:]},
+ {File: newLazyDataFile(t, dataPath), DeleteFiles: deleteFiles},
+ }
+ loader := newLazyPositionDeleteLoader(nil, tasks)
+ defer loader.release()
+ paths := []string{deleteFiles[0].FilePath(), deleteFiles[1].FilePath()}
+ for i, path := range paths {
+ bitmap := dv.NewRoaringPositionBitmap()
+ for position := range positions {
+ bitmap.Set(uint64(position*2 + i))
+ }
+ cached := loader.files[path]
+ cached.bitmaps = map[string]*dv.RoaringPositionBitmap{dataPath:
bitmap}
+ cached.once.Do(func() {})
+ }
+ first, err := loader.load(t.Context(), 0, dataPath)
+ require.NoError(t, err)
+ second, err := loader.load(t.Context(), 1, dataPath)
+ require.NoError(t, err)
+ require.Same(t, loader.files[paths[0]].bitmaps[dataPath], first)
+ require.Same(t, loader.files[paths[1]].bitmaps[dataPath], second)
+
+ start := make(chan struct{})
+ var wg sync.WaitGroup
+ wg.Add(readers + 1)
+ for range readers {
+ go func() {
+ defer wg.Done()
+ <-start
+ for i := range positions * 16 {
+ position := uint64(i % positions * 2)
+ if !first.Contains(position) ||
first.Contains(position+1) ||
+ second.Contains(position) ||
!second.Contains(position+1) {
+ t.Error("building a union mutated a
shared input bitmap")
+
+ return
+ }
+ }
+ }()
+ }
+ go func() {
+ defer wg.Done()
+ <-start
+ for range 32 {
+ merged, err := loader.buildIndex(t.Context(), dataPath,
paths)
+ if !assert.NoError(t, err) {
+ return
+ }
+ assert.Equal(t, int64(positions*2),
merged.Cardinality())
+ }
+ }()
+ close(start)
+ wg.Wait()
+ assert.Equal(t, int64(positions), first.Cardinality())
+ assert.Equal(t, int64(positions), second.Cardinality())
+}
+
+func TestLazyPositionDeleteLoaderReusesMergedBitmapAcrossSplitTasks(t
*testing.T) {
+ mem := memory.NewCheckedAllocator(memory.NewGoAllocator())
+ defer mem.AssertSize(t, 0)
+ ctx := compute.WithAllocator(t.Context(), mem)
+
+ const (
+ deletePathA = "mem://bucket/deletes/split-a.parquet"
+ deletePathB = "mem://bucket/deletes/split-b.parquet"
+ deletePathC = "mem://bucket/deletes/split-c.parquet"
+ dataPath = "mem://bucket/data/split.parquet"
+ )
+ fs := &countingOpenMemFS{MemFS: iceio.NewMemFS()}
+ writePosDeleteParquetToMemFS(t, fs.MemFS, deletePathA,
+ `[{"file_path":"`+dataPath+`","pos":1}]`)
+ writePosDeleteParquetToMemFS(t, fs.MemFS, deletePathB,
+ `[{"file_path":"`+dataPath+`","pos":3}]`)
+ writePosDeleteParquetToMemFS(t, fs.MemFS, deletePathC,
+ `[{"file_path":"`+dataPath+`","pos":5}]`)
+
+ deleteA := newPosDeleteFile(t, deletePathA, 1, 128)
+ deleteB := newPosDeleteFile(t, deletePathB, 1, 128)
+ deleteC := newPosDeleteFile(t, deletePathC, 1, 128)
+ dataFile := newLazyDataFile(t, dataPath)
+ tasks := []FileScanTask{
+ {File: dataFile, DeleteFiles: []iceberg.DataFile{deleteA,
deleteB, deleteC}, Start: 0, Length: 64},
+ {File: dataFile, DeleteFiles: []iceberg.DataFile{deleteC,
deleteB, deleteA}, Start: 64, Length: 64},
+ {File: dataFile, DeleteFiles: []iceberg.DataFile{deleteA}},
+ {File: dataFile, DeleteFiles: []iceberg.DataFile{deleteB,
deleteC}},
+ }
+ loader := newLazyPositionDeleteLoader(fs, tasks)
+
+ first, err := loader.load(ctx, 0, dataPath)
+ require.NoError(t, err)
+ require.NotNil(t, first)
+ second, err := loader.load(ctx, 1, dataPath)
+ require.NoError(t, err)
+ require.NotNil(t, second)
+
+ assert.Same(t, first, second, "split tasks must reuse the merged
position index")
+ assert.Equal(t, int64(3), first.Cardinality())
+ assert.True(t, first.Contains(1))
+ assert.True(t, first.Contains(3))
+ assert.True(t, first.Contains(5))
+
+ // Merging three or more files must not change cached source bitmaps
used
+ // by another task with a smaller set of applicable delete files.
+ single, err := loader.load(ctx, 2, dataPath)
+ require.NoError(t, err)
+ require.NotNil(t, single)
+ assert.Equal(t, int64(1), single.Cardinality())
+ assert.True(t, single.Contains(1))
+ subset, err := loader.load(ctx, 3, dataPath)
+ require.NoError(t, err)
+ require.NotNil(t, subset)
+ assert.Equal(t, int64(2), subset.Cardinality())
+ assert.True(t, subset.Contains(3))
+ assert.True(t, subset.Contains(5))
+ assert.Equal(t, int64(3), fs.opens.Load(), "each delete file must be
read once")
+
+ loader.release()
+}
+
+func TestLazyPositionDeleteLoaderUnionSkipsEmptyTargetsWithoutMutatingInputs(t
*testing.T) {
+ for _, emptyIndex := range []int{0, 1} {
+ t.Run(fmt.Sprintf("empty file %d", emptyIndex), func(t
*testing.T) {
+ mem :=
memory.NewCheckedAllocator(memory.NewGoAllocator())
+ defer mem.AssertSize(t, 0)
+ ctx := compute.WithAllocator(t.Context(), mem)
+ const dataPath = "mem://bucket/data/union.parquet"
+ fs := &countingOpenMemFS{MemFS: iceio.NewMemFS()}
+ deleteFiles := make([]iceberg.DataFile, 3)
+ var want []uint64
+ for i := range deleteFiles {
+ path := dataPath
+ if i == emptyIndex {
+ path =
"mem://bucket/data/unrelated.parquet"
+ } else {
+ want = append(want, uint64(i))
+ }
+ deletePath :=
fmt.Sprintf("mem://bucket/deletes/union-%d.parquet", i)
+ writePosDeleteParquetToMemFS(t, fs.MemFS,
deletePath,
+
fmt.Sprintf(`[{"file_path":%q,"pos":%d}]`, path, i))
+ deleteFiles[i] = newPosDeleteFile(t,
deletePath, 1, 128)
+ }
+ firstPresent := int(want[0])
+ tasks := []FileScanTask{
+ {File: newLazyDataFile(t, dataPath),
DeleteFiles: deleteFiles},
+ {File: newLazyDataFile(t, dataPath),
DeleteFiles: []iceberg.DataFile{deleteFiles[firstPresent]}},
+ }
+ loader := newLazyPositionDeleteLoader(fs, tasks)
+ defer loader.release()
+
+ single, err := loader.load(ctx, 1, dataPath)
+ require.NoError(t, err)
+ merged, err := loader.load(ctx, 0, dataPath)
+ require.NoError(t, err)
+ require.NotNil(t, merged)
+ assert.Equal(t, int64(len(want)), merged.Cardinality())
+ for _, position := range want {
+ assert.True(t, merged.Contains(position))
+ }
+ cached :=
loader.files[deleteFiles[firstPresent].FilePath()].bitmaps[dataPath]
+ assert.Same(t, single, cached)
+ assert.Equal(t, int64(1), cached.Cardinality(), "the
first non-empty input must stay immutable")
+ assert.True(t, cached.Contains(uint64(firstPresent)))
+ assert.Equal(t, int64(3), fs.opens.Load())
+ })
+ }
+}
+
+func TestLazyPositionDeleteLoaderPreservesOtherTargetValidation(t *testing.T) {
+ for _, invalidPos := range []string{"-1", "null"} {
+ t.Run(invalidPos, func(t *testing.T) {
+ mem :=
memory.NewCheckedAllocator(memory.NewGoAllocator())
+ defer mem.AssertSize(t, 0)
+ ctx := compute.WithAllocator(t.Context(), mem)
+ const (
+ dataPathA = "mem://bucket/data/a.parquet"
+ dataPathB = "mem://bucket/data/b.parquet"
+ deletePath =
"mem://bucket/deletes/invalid-other-target.parquet"
+ )
+ fs := &countingOpenMemFS{MemFS: iceio.NewMemFS()}
+ fields := PositionalDeleteArrowSchema.Fields()
+ fields[1].Nullable = true
+ writePosDeleteParquetToMemFSWithSchema(t, fs.MemFS,
deletePath, arrow.NewSchema(fields, nil), `[
+ {"file_path":"`+dataPathA+`","pos":1},
+
{"file_path":"`+dataPathB+`","pos":`+invalidPos+`}
+ ]`)
+ deleteFile := newPosDeleteFile(t, deletePath, 2, 128)
+ tasks := []FileScanTask{{
+ File: newLazyDataFile(t, dataPathA),
DeleteFiles: []iceberg.DataFile{deleteFile},
+ }}
+
+ // The existing Arrow reader validates every decoded
row before target
+ // filtering. Bitmap conversion must preserve that
error behavior.
+ deletes, err := readDeletesForPaths(ctx, fs,
deleteFile, map[string]struct{}{dataPathA: {}})
+ require.ErrorIs(t, err, iceberg.ErrInvalidSchema)
+ assert.Nil(t, deletes)
+
+ loader := newLazyPositionDeleteLoader(fs, tasks)
+ defer loader.release()
+ bitmap, err := loader.load(ctx, 0, dataPathA)
+ require.ErrorIs(t, err, iceberg.ErrInvalidSchema)
+ assert.Nil(t, bitmap)
+ _, cachedErr := loader.load(ctx, 0, dataPathA)
+ assert.ErrorIs(t, cachedErr, err)
+ assert.Equal(t, int64(2), fs.opens.Load(), "one read
per loader, including the legacy reader")
+ })
+ }
+}
+
func TestLazyPositionDeleteLoaderSingleflightsConcurrentLoads(t *testing.T) {
mem := memory.NewCheckedAllocator(memory.NewGoAllocator())
defer mem.AssertSize(t, 0)
@@ -182,14 +514,14 @@ func
TestLazyPositionDeleteLoaderSingleflightsConcurrentLoads(t *testing.T) {
loader := newLazyPositionDeleteLoader(fs, []FileScanTask{task})
const callers = 8
- results := make(chan positionDeletes, callers)
+ results := make(chan *dv.RoaringPositionBitmap, callers)
errs := make(chan error, callers)
var wg sync.WaitGroup
wg.Add(callers)
for range callers {
go func() {
defer wg.Done()
- deletes, err := loader.load(ctx, task)
+ deletes, err := loader.load(ctx, 0, dataPath)
results <- deletes
errs <- err
}()
@@ -201,9 +533,16 @@ func
TestLazyPositionDeleteLoaderSingleflightsConcurrentLoads(t *testing.T) {
for err := range errs {
require.NoError(t, err)
}
- for deletes := range results {
- require.Len(t, deletes, 1)
- assert.Equal(t, []int64{7}, int64Values(deletes[0]))
+ var first *dv.RoaringPositionBitmap
+ for bitmap := range results {
+ require.NotNil(t, bitmap)
+ assert.Equal(t, int64(1), bitmap.Cardinality())
+ assert.True(t, bitmap.Contains(7))
+ if first == nil {
+ first = bitmap
+ } else {
+ assert.Same(t, first, bitmap, "concurrent loads must
share one position index")
+ }
}
assert.Equal(t, int64(1), fs.opens.Load(), "concurrent users must share
one delete-file read")
@@ -219,13 +558,13 @@ func TestLazyPositionDeleteLoaderCachesErrors(t
*testing.T) {
}
loader := newLazyPositionDeleteLoader(fs, []FileScanTask{task})
- first, err := loader.load(context.Background(), task)
+ first, err := loader.load(context.Background(), 0, task.File.FilePath())
require.Error(t, err)
assert.Nil(t, first)
assert.ErrorContains(t, err, "read position deletes from
"+deleteFile.FilePath())
assert.Equal(t, int64(1), fs.opens.Load())
- second, secondErr := loader.load(context.Background(), task)
+ second, secondErr := loader.load(context.Background(), 0,
task.File.FilePath())
require.Error(t, secondErr)
assert.Nil(t, second)
assert.ErrorIs(t, secondErr, err)
@@ -244,7 +583,7 @@ func
TestLazyPositionDeleteLoaderWrapsErrorsWithoutFilePath(t *testing.T) {
}
loader := newLazyPositionDeleteLoader(fs, []FileScanTask{task})
- _, err := loader.load(context.Background(), task)
+ _, err := loader.load(context.Background(), 0, task.File.FilePath())
require.Error(t, err)
assert.ErrorContains(t, err, "read position deletes from "+deletePath)
loader.release()
@@ -266,9 +605,9 @@ func TestLazyPositionDeleteLoaderCachesCancellation(t
*testing.T) {
}
loader := newLazyPositionDeleteLoader(fs, []FileScanTask{task})
- _, firstErr := loader.load(ctx, task)
+ _, firstErr := loader.load(ctx, 0, task.File.FilePath())
require.ErrorIs(t, firstErr, context.Canceled)
- _, secondErr := loader.load(context.Background(), task)
+ _, secondErr := loader.load(context.Background(), 0,
task.File.FilePath())
require.ErrorIs(t, secondErr, context.Canceled)
assert.Equal(t, int64(1), fs.opens.Load(), "cancellation must not cause
a second read")
@@ -448,17 +787,22 @@ func
TestArrowScanRowLimitStopsBeforeLazyPositionDeleteError(t *testing.T) {
"a row limit may finish before a later task's positional-delete
error")
}
-func TestLazyPositionDeleteLoaderReleasesChunksWhenIteratorStops(t *testing.T)
{
+func TestLazyPositionDeleteLoaderClearsBitmapsWhenIteratorStops(t *testing.T) {
mem := memory.NewCheckedAllocator(memory.NewGoAllocator())
defer mem.AssertSize(t, 0)
- loader := &lazyPositionDeleteLoader{files:
map[string]*lazyPositionDeleteFile{
- "mem://bucket/deletes/one.parquet": {
- deletes: map[string]*arrow.Chunked{
- "mem://bucket/data/a.parquet":
chunkedPosDelete(t, mem, []int64{1}),
+ bitmap := dv.NewRoaringPositionBitmap()
+ bitmap.Set(1)
+ loader := &lazyPositionDeleteLoader{
+ files: map[string]*lazyPositionDeleteFile{
+ "mem://bucket/deletes/one.parquet": {
+ bitmaps: map[string]*dv.RoaringPositionBitmap{
+ "mem://bucket/data/a.parquet": bitmap,
+ },
},
},
- }}
+ indexes: []positionDeleteTaskIndex{{index:
&lazyPositionDeleteIndex{bitmap: bitmap}}},
+ }
batch := checkedInt64RecordBatch(mem, 1)
records := make(chan enumeratedRecord, 1)
@@ -482,7 +826,10 @@ func
TestLazyPositionDeleteLoaderReleasesChunksWhenIteratorStops(t *testing.T) {
}
for _, cached := range loader.files {
- assert.Nil(t, cached.deletes, "release must clear cached delete
chunks")
+ assert.Nil(t, cached.bitmaps, "release must clear cached delete
bitmaps")
+ }
+ for _, index := range loader.indexes {
+ assert.Nil(t, index.index.bitmap, "release must clear merged
position indexes")
}
}
@@ -490,7 +837,7 @@ func TestLazyPositionDeleteLoaderRejectsLoadAfterRelease(t
*testing.T) {
loader := &lazyPositionDeleteLoader{}
loader.release()
- _, err := loader.load(context.Background(), FileScanTask{})
+ _, err := loader.load(context.Background(), 0, "")
require.ErrorIs(t, err, errPositionDeleteLoaderReleased)
}
diff --git a/table/arrow_scanner_posdelete_bitmap_test.go
b/table/arrow_scanner_posdelete_bitmap_test.go
new file mode 100644
index 000000000..0db2b23f2
--- /dev/null
+++ b/table/arrow_scanner_posdelete_bitmap_test.go
@@ -0,0 +1,282 @@
+// 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 (
+ "math"
+ "slices"
+ "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/iceberg-go"
+ "github.com/apache/iceberg-go/table/dv"
+ "github.com/apache/iceberg-go/table/internal"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCombinePositionalDeleteBitmap(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ deletes []uint64
+ spans []internal.RowGroupSpan
+ batches []int64
+ want [][]int64
+ wantNext int64
+ }{
+ {name: "empty batch", batches: []int64{0}, want:
[][]int64{nil}},
+ {name: "no deletes", batches: []int64{4}, want: [][]int64{nil},
wantNext: 4},
+ {name: "deletes outside batch", deletes: []uint64{10}, batches:
[]int64{4}, want: [][]int64{nil}, wantNext: 4},
+ {name: "first row", deletes: []uint64{0}, batches: []int64{4},
want: [][]int64{{1, 2, 3}}, wantNext: 4},
+ {name: "backfill", deletes: []uint64{2}, batches: []int64{4},
want: [][]int64{{0, 1, 3}}, wantNext: 4},
+ {name: "last row", deletes: []uint64{3}, batches: []int64{4},
want: [][]int64{{0, 1, 2}}, wantNext: 4},
+ {name: "all rows", deletes: []uint64{0, 1, 2}, batches:
[]int64{3}, want: [][]int64{{}}, wantNext: 3},
+ {name: "across batches", deletes: []uint64{3}, batches:
[]int64{3, 2}, want: [][]int64{nil, {1}}, wantNext: 5},
+ {
+ name: "pruned row groups", deletes: []uint64{4},
+ spans: []internal.RowGroupSpan{{FirstRowPos: 0,
NumRows: 2}, {FirstRowPos: 4, NumRows: 2}},
+ batches: []int64{2, 2}, want: [][]int64{nil, {1}},
wantNext: 6,
+ },
+ {
+ name: "64-bit positions", deletes: []uint64{1 << 32},
+ spans: []internal.RowGroupSpan{{FirstRowPos: 1<<32 -
1, NumRows: 3}},
+ batches: []int64{3}, want: [][]int64{{0, 2}}, wantNext:
1<<32 + 2,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ mem :=
memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+ bitmap := dv.NewRoaringPositionBitmap()
+ for _, position := range tc.deletes {
+ bitmap.Set(position)
+ }
+ cursor := (&rowPositionSource{spans: tc.spans}).cursor()
+ for i, nrows := range tc.batches {
+ indices := combinePositionalDeleteBitmap(mem,
bitmap, cursor, nrows)
+ if tc.want[i] == nil {
+ require.Nil(t, indices, "a clean batch
must use the passthrough path")
+
+ continue
+ }
+ require.NotNil(t, indices, "an empty array must
still remove an entirely deleted batch")
+ if len(tc.want[i]) == 0 {
+ assert.Zero(t, indices.Len())
+ } else {
+ assert.Equal(t, tc.want[i],
indices.(*array.Int64).Int64Values())
+ }
+ indices.Release()
+ }
+ assert.Equal(t, tc.wantNext, cursor.next(), "every
input row must advance the shared position source")
+ })
+ }
+}
+
+func TestProcessPositionalDeleteBitmapAcrossBatches(t *testing.T) {
+ mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+ ctx := compute.WithAllocator(t.Context(), mem)
+ bitmap := dv.NewRoaringPositionBitmap()
+ bitmap.Set(3)
+ process := processPositionalDeleteBitmap(ctx, bitmap,
(&rowPositionSource{}).cursor())
+
+ for i, values := range [][]int64{{10, 11, 12}, {13, 14}} {
+ batch := checkedInt64RecordBatch(mem, values...)
+ out, err := process(batch)
+ require.NoError(t, err)
+ if i == 0 {
+ assert.Same(t, batch, out, "the clean first batch must
retain its input")
+ assert.Equal(t, values,
out.Column(0).(*array.Int64).Int64Values())
+ } else {
+ assert.Equal(t, []int64{14},
out.Column(0).(*array.Int64).Int64Values())
+ }
+ out.Release()
+ }
+}
+
+func TestCombinePositionalDeleteBitmapLargeBatches(t *testing.T) {
+ const nrows = 1_024
+ for _, tc := range []struct {
+ name string
+ start, end uint64
+ }{
+ {name: "first row", start: 0, end: 1},
+ {name: "leading run", start: 0, end: 512},
+ {name: "middle row", start: 512, end: 513},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ mem :=
memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+ bitmap := dv.NewRoaringPositionBitmap()
+ bitmap.SetRange(tc.start, tc.end)
+ var want []int64
+ for i := range uint64(nrows) {
+ if i < tc.start || i >= tc.end {
+ want = append(want, int64(i))
+ }
+ }
+
+ indices := combinePositionalDeleteBitmap(mem, bitmap,
(&rowPositionSource{}).cursor(), nrows)
+ require.NotNil(t, indices)
+ defer indices.Release()
+ assert.Equal(t, want,
indices.(*array.Int64).Int64Values())
+ })
+ }
+}
+
+type positionDeleteAllocationTracker struct {
+ memory.Allocator
+ largest int
+}
+
+func (a *positionDeleteAllocationTracker) Allocate(size int) []byte {
+ a.largest = max(a.largest, size)
+
+ return a.Allocator.Allocate(size)
+}
+
+func (a *positionDeleteAllocationTracker) Reallocate(size int, b []byte)
[]byte {
+ a.largest = max(a.largest, size)
+
+ return a.Allocator.Reallocate(size, b)
+}
+
+func TestCombinePositionalDeleteBitmapAllocationBounds(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ nrows int64
+ start, end uint64
+ }{
+ {name: "clean", nrows: 1_024},
+ {name: "first row", nrows: 1_024, start: 0, end: 1},
+ {name: "middle row", nrows: 1_024, start: 512, end: 513},
+ {name: "first row non-power-of-two batch", nrows: 1_025, start:
0, end: 1},
+ {name: "middle row non-power-of-two batch", nrows: 1_025,
start: 512, end: 513},
+ {name: "leading run", nrows: 1_024, start: 0, end: 512},
+ {name: "all rows", nrows: 1_024, start: 0, end: 1_024},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ checked :=
memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer checked.AssertSize(t, 0)
+ mem := &positionDeleteAllocationTracker{Allocator:
checked}
+ bitmap := dv.NewRoaringPositionBitmap()
+ bitmap.SetRange(tc.start, tc.end)
+ indices := combinePositionalDeleteBitmap(mem, bitmap,
(&rowPositionSource{}).cursor(), tc.nrows)
+ if tc.start == tc.end {
+ require.Nil(t, indices)
+ assert.Zero(t, mem.largest, "a clean batch must
not allocate Arrow buffers")
+
+ return
+ }
+ require.NotNil(t, indices)
+ defer indices.Release()
+ survivors := int(tc.nrows) - int(tc.end-tc.start)
+ assert.Equal(t, survivors, indices.Len())
+ if survivors == 0 {
+ assert.Zero(t, mem.largest, "a fully deleted
batch must not allocate Arrow buffers")
+ } else {
+ // Arrow aligns buffers to 64 bytes. Check the
largest request,
+ // before NewArray can shrink an oversized
values buffer.
+ assert.LessOrEqual(t, mem.largest,
arrow.Int64Traits.BytesRequired(survivors)+63)
+ }
+ })
+ }
+}
+
+func TestCollectPosDeleteBitmapRejectsInvalidPositions(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ column func(memory.Allocator) arrow.Array
+ want string
+ }{
+ {name: "nil chunk", want: "nil pos column chunk"},
+ {name: "wrong type", column: func(mem memory.Allocator)
arrow.Array {
+ return int32Array(mem, 1)
+ }, want: "unsupported pos column type"},
+ {name: "negative", column: func(mem memory.Allocator)
arrow.Array {
+ return int64Array(mem, -1)
+ }, want: "negative pos -1"},
+ {name: "null", column: func(mem memory.Allocator) arrow.Array {
+ builder := array.NewInt64Builder(mem)
+ defer builder.Release()
+ builder.AppendNull()
+
+ return builder.NewArray()
+ }, want: "null pos in position delete file"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ mem :=
memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+ var chunk *arrow.Chunked
+ if tc.column != nil {
+ column := tc.column(mem)
+ defer column.Release()
+ chunk = arrow.NewChunked(column.DataType(),
[]arrow.Array{column})
+ defer chunk.Release()
+ }
+
+ bitmap, err :=
collectPosDeleteBitmap(positionDeletes{chunk})
+ require.ErrorIs(t, err, iceberg.ErrInvalidSchema)
+ assert.ErrorContains(t, err, tc.want)
+ assert.Nil(t, bitmap)
+ })
+ }
+}
+
+func TestCollectPosDeleteBitmapDeduplicates64BitPositions(t *testing.T) {
+ mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+ first := int64Array(mem, 0, 2, 2, 1<<32)
+ defer first.Release()
+ second := int64Array(mem, 1<<32, math.MaxInt64)
+ defer second.Release()
+ column := arrow.NewChunked(arrow.PrimitiveTypes.Int64,
[]arrow.Array{first, second})
+ defer column.Release()
+
+ bitmap, err := collectPosDeleteBitmap(positionDeletes{column, column})
+ require.NoError(t, err)
+ assert.Equal(t, []uint64{0, 2, 1 << 32, math.MaxInt64},
slices.Collect(bitmap.Positions()))
+}
+
+func BenchmarkCombinePositionalDeleteBitmap(b *testing.B) {
+ const nrows = 65_536
+ for _, name := range []string{"clean", "first", "middle", "all"} {
+ b.Run(name, func(b *testing.B) {
+ bitmap := dv.NewRoaringPositionBitmap()
+ switch name {
+ case "first":
+ bitmap.Set(0)
+ case "middle":
+ bitmap.Set(nrows / 2)
+ case "all":
+ bitmap.SetRange(0, nrows)
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ indices :=
combinePositionalDeleteBitmap(memory.DefaultAllocator, bitmap,
(&rowPositionSource{}).cursor(), nrows)
+ if indices != nil {
+ positionDeleteSplitReuseBenchmarkSink =
int64(indices.Len())
+ indices.Release()
+ }
+ }
+ })
+ }
+}
diff --git a/table/dv_scanner_end_to_end_test.go
b/table/dv_scanner_end_to_end_test.go
index 1506ca980..807d01bc7 100644
--- a/table/dv_scanner_end_to_end_test.go
+++ b/table/dv_scanner_end_to_end_test.go
@@ -19,6 +19,7 @@ package table
import (
"context"
+ "fmt"
"path/filepath"
"slices"
"strconv"
@@ -216,6 +217,37 @@ func TestDVScanEndToEnd(t *testing.T) {
"first and last positions must be removable")
})
+ for _, position := range []string{"0", "-1", "null"} {
+ t.Run("DV supersedes position delete "+position, func(t
*testing.T) {
+ fs, tmp, tbl := newDVScanTestEnv(t)
+ dataPath := filepath.Join(tmp,
"data-with-both-deletes.parquet")
+ df := writeIntParquetWithFieldID(t, fs, dataPath, 0, 5)
+ deletePath := filepath.Join(tmp,
"position-deletes.parquet")
+ fields := PositionalDeleteArrowSchema.Fields()
+ fields[1].Nullable = true
+ deleteSchema := arrow.NewSchema(fields, nil)
+ record := mustLoadRecordBatchFromJSON(deleteSchema,
+ fmt.Sprintf(`[{"file_path":%q,"pos":%s}]`,
dataPath, position))
+ defer record.Release()
+ deleteTable := array.NewTableFromRecords(deleteSchema,
[]arrow.RecordBatch{record})
+ defer deleteTable.Release()
+ deleteFile, err := fs.Create(deletePath)
+ require.NoError(t, err)
+ defer deleteFile.Close()
+ require.NoError(t, pqarrow.WriteTable(deleteTable,
deleteFile, 1, nil, pqarrow.DefaultWriterProps()))
+
+ // A DV takes precedence for hand-built tasks too.
Superseded
+ // position files must not affect rows or fail
validation.
+ puffinPath, offset, length, card :=
writeDVPuffinFixture(t, []uint64{3}, dataPath)
+ got := collectDVScanRows(t, tbl.Scan(), []FileScanTask{{
+ File: df,
+ DeleteFiles:
[]iceberg.DataFile{newPosDeleteFile(t, deletePath, 1, 128)},
+ DeletionVectorFiles:
[]iceberg.DataFile{newDVMockDataFile(puffinPath, dataPath, offset, length,
card)},
+ }})
+ assert.Equal(t, []int64{0, 1, 2, 4}, got)
+ })
+ }
+
t.Run("DV deletes all rows", func(t *testing.T) {
fs, tmp, tbl := newDVScanTestEnv(t)