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 832bdede3 perf(table): index equality delete metric ranges (#2148)
832bdede3 is described below

commit 832bdede3f3d989a8cfc00e322482017c6aeacbe
Author: Minh Vu <[email protected]>
AuthorDate: Mon Oct 12 20:29:08 2026 +0200

    perf(table): index equality delete metric ranges (#2148)
---
 table/equality_delete_index.go                     |  70 ++-
 table/equality_delete_range_index.go               | 310 ++++++++++++++
 .../equality_delete_range_index_properties_test.go | 476 +++++++++++++++++++++
 table/equality_delete_range_index_test.go          | 249 +++++++++++
 4 files changed, 1095 insertions(+), 10 deletions(-)

diff --git a/table/equality_delete_index.go b/table/equality_delete_index.go
index a9716cfe4..1a3f0287e 100644
--- a/table/equality_delete_index.go
+++ b/table/equality_delete_index.go
@@ -34,9 +34,11 @@ import (
 // Unpartitioned equality deletes are global and apply across partition specs;
 // partitioned deletes only apply to data files in the same spec and partition.
 type equalityDeleteIndex struct {
-       global      []equalityDeleteIndexEntry
-       byPartition map[equalityDeletePartitionKey][]equalityDeleteIndexEntry
-       schema      *iceberg.Schema
+       global                  []equalityDeleteIndexEntry
+       byPartition             
map[equalityDeletePartitionKey][]equalityDeleteIndexEntry
+       globalRangeIndex        *equalityDeleteRangeIndex
+       rangeIndexesByPartition 
map[equalityDeletePartitionKey]*equalityDeleteRangeIndex
+       schema                  *iceberg.Schema
 }
 
 type equalityDeleteIndexEntry struct {
@@ -497,8 +499,15 @@ func buildEqualityDeleteIndex(
                })
        }
        sortBySequence(idx.global)
-       for _, partitionEntries := range idx.byPartition {
+       idx.globalRangeIndex = newEqualityDeleteRangeIndex(idx.global)
+       for key, partitionEntries := range idx.byPartition {
                sortBySequence(partitionEntries)
+               if rangeIndex := newEqualityDeleteRangeIndex(partitionEntries); 
rangeIndex != nil {
+                       if idx.rangeIndexesByPartition == nil {
+                               idx.rangeIndexesByPartition = 
make(map[equalityDeletePartitionKey]*equalityDeleteRangeIndex)
+                       }
+                       idx.rangeIndexesByPartition[key] = rangeIndex
+               }
        }
 
        return idx, nil
@@ -513,6 +522,7 @@ func (idx *equalityDeleteIndex) forDataFile(dataEntry 
iceberg.ManifestEntry) ([]
        }
 
        partitionEntries := []equalityDeleteIndexEntry(nil)
+       var partitionRangeIndex *equalityDeleteRangeIndex
        if len(idx.byPartition) > 0 {
                dataFile := dataEntry.DataFile()
                partition := dataFilePartition(dataFile)
@@ -522,6 +532,7 @@ func (idx *equalityDeleteIndex) forDataFile(dataEntry 
iceberg.ManifestEntry) ([]
                                return nil, fmt.Errorf("matching equality 
deletes to data file %s: %w", dataFile.FilePath(), err)
                        }
                        partitionEntries = idx.byPartition[key]
+                       partitionRangeIndex = idx.rangeIndexesByPartition[key]
                }
        }
 
@@ -532,21 +543,60 @@ func (idx *equalityDeleteIndex) forDataFile(dataEntry 
iceberg.ManifestEntry) ([]
                dataStats = &stats
        }
 
-       out := appendEqualityDeletesAfter(nil, idx.global, dataSeqNum, 
dataStats)
-       out = appendEqualityDeletesAfter(out, partitionEntries, dataSeqNum, 
dataStats)
+       out := appendEqualityDeletesAfterIndexed(
+               nil, idx.global, dataSeqNum, dataStats, idx.globalRangeIndex)
+       out = appendEqualityDeletesAfterIndexed(
+               out, partitionEntries, dataSeqNum, dataStats, 
partitionRangeIndex)
 
        return out, nil
 }
 
-func appendEqualityDeletesAfter(
+func firstEqualityDeleteAfter(
+       entries []equalityDeleteIndexEntry,
+       dataSeqNum int64,
+) int {
+       return sort.Search(len(entries), func(i int) bool {
+               return entries[i].entry.SequenceNum() > dataSeqNum
+       })
+}
+
+func appendEqualityDeletesAfterIndexed(
        out []iceberg.DataFile,
        entries []equalityDeleteIndexEntry,
        dataSeqNum int64,
        dataStats *equalityDeleteDataFileStats,
+       rangeIndex *equalityDeleteRangeIndex,
+) []iceberg.DataFile {
+       start := firstEqualityDeleteAfter(entries, dataSeqNum)
+       if rangeIndex == nil || dataStats == nil ||
+               len(dataStats.lowerBounds) == 0 || len(dataStats.upperBounds) 
== 0 ||
+               len(entries)-start < equalityDeleteRangeIndexMinLookupEntries {
+               return appendEqualityDeletesFrom(out, entries, start, dataStats)
+       }
+
+       candidates, ok := rangeIndex.candidates(start, dataSeqNum, dataStats)
+       if !ok {
+               return appendEqualityDeletesFrom(out, entries, start, dataStats)
+       }
+
+       out = slices.Grow(out, len(candidates))
+       for _, entryIndex := range candidates {
+               entry := entries[entryIndex]
+               if !equalityDeleteCanContainData(dataStats, entry.fields) {
+                       continue
+               }
+               out = append(out, entry.entry.DataFile())
+       }
+
+       return out
+}
+
+func appendEqualityDeletesFrom(
+       out []iceberg.DataFile,
+       entries []equalityDeleteIndexEntry,
+       start int,
+       dataStats *equalityDeleteDataFileStats,
 ) []iceberg.DataFile {
-       start := sort.Search(len(entries), func(i int) bool {
-               return entries[i].entry.SequenceNum() > dataSeqNum
-       })
        for _, entry := range entries[start:] {
                if !equalityDeleteCanContainData(dataStats, entry.fields) {
                        continue
diff --git a/table/equality_delete_range_index.go 
b/table/equality_delete_range_index.go
new file mode 100644
index 000000000..d71b53494
--- /dev/null
+++ b/table/equality_delete_range_index.go
@@ -0,0 +1,310 @@
+// 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 (
+       "cmp"
+       "slices"
+       "sort"
+       "sync"
+)
+
+// These floors do not guarantee that the first lookup amortizes construction.
+// BenchmarkEqualityDeleteRangeIndexThreshold measures the cold and repeated
+// lookup tradeoff against linear matching.
+const (
+       // Small buckets rarely amortize tree construction; keep them linear.
+       equalityDeleteRangeIndexMinEntries = 256
+       // Avoid a separate tree for a field represented by only a few deletes.
+       equalityDeleteRangeIndexMinGroupEntries = 64
+       // Even a large bucket should use its sequence-sorted suffix directly 
once
+       // fewer than 128 entries remain. At that floor, the half-remaining 
limit
+       // below allows fewer than 64 candidates.
+       equalityDeleteRangeIndexMinLookupEntries = 128
+)
+
+type equalityDeleteRangeIndex struct {
+       // Entries and their decoded bounds are immutable after index 
construction.
+       // Build the tree only when a lookup has a large enough sequence 
suffix, so
+       // unvisited partitions do not pay for sorting or allocating range 
nodes.
+       entries  []equalityDeleteIndexEntry
+       once     sync.Once
+       groups   []equalityDeleteRangeGroup
+       fallback []int
+}
+
+type equalityDeleteRangeItem struct {
+       entryIndex int
+       lower      equalityDeleteMetricValue
+       upper      equalityDeleteMetricValue
+       sequence   int64
+}
+
+type equalityDeleteRangeNode struct {
+       entryIndex  int
+       left        int
+       right       int
+       lower       equalityDeleteMetricValue
+       upper       equalityDeleteMetricValue
+       maxUpper    equalityDeleteMetricValue
+       sequence    int64
+       maxSequence int64
+}
+
+type equalityDeleteRangeGroup struct {
+       field equalityDeleteFieldMetrics
+       root  int
+       nodes []equalityDeleteRangeNode
+}
+
+func newEqualityDeleteRangeIndex(entries []equalityDeleteIndexEntry) 
*equalityDeleteRangeIndex {
+       if len(entries) < equalityDeleteRangeIndexMinEntries {
+               return nil
+       }
+
+       return &equalityDeleteRangeIndex{entries: entries}
+}
+
+func (idx *equalityDeleteRangeIndex) build() {
+       entries := idx.entries
+       itemsByField := make(map[int][]equalityDeleteRangeItem)
+       fieldsByID := make(map[int]equalityDeleteFieldMetrics)
+       fallback := make([]int, 0)
+       for entryIndex := range entries {
+               field, ok := 
equalityDeleteRangeIndexField(entries[entryIndex].fields)
+               if !ok {
+                       fallback = append(fallback, entryIndex)
+
+                       continue
+               }
+
+               itemsByField[field.fieldID] = 
append(itemsByField[field.fieldID], equalityDeleteRangeItem{
+                       entryIndex: entryIndex,
+                       lower:      field.lowerValue,
+                       upper:      field.upperValue,
+                       sequence:   entries[entryIndex].entry.SequenceNum(),
+               })
+               if _, found := fieldsByID[field.fieldID]; !found {
+                       fieldsByID[field.fieldID] = *field
+               }
+       }
+
+       fieldIDs := make([]int, 0, len(itemsByField))
+       for fieldID := range itemsByField {
+               fieldIDs = append(fieldIDs, fieldID)
+       }
+       slices.Sort(fieldIDs)
+
+       groups := make([]equalityDeleteRangeGroup, 0, len(fieldIDs))
+       for _, fieldID := range fieldIDs {
+               items := itemsByField[fieldID]
+               if len(items) < equalityDeleteRangeIndexMinGroupEntries {
+                       for _, item := range items {
+                               fallback = append(fallback, item.entryIndex)
+                       }
+
+                       continue
+               }
+
+               groups = append(groups, 
newEqualityDeleteRangeGroup(fieldsByID[fieldID], items))
+       }
+
+       slices.Sort(fallback)
+       idx.groups = groups
+       idx.fallback = fallback
+}
+
+// The index may exclude only deletes that equalityDeleteCanContainData would
+// also reject. A ranged field must prove it cannot match null or NaN keys 
before
+// the tree can use its bounds. Anything uncertain stays on the linear path.
+func equalityDeleteRangeIndexField(
+       fields []equalityDeleteFieldMetrics,
+) (*equalityDeleteFieldMetrics, bool) {
+       for i := range fields {
+               field := &fields[i]
+               if !field.primitive || !field.canUseRange || 
!field.hasDecodedBounds {
+                       continue
+               }
+               if !field.required && (!field.hasNullCount || field.nullCount 
!= 0) {
+                       continue
+               }
+               if field.floatType && (!field.hasNaNCount || field.nanCount != 
0) {
+                       continue
+               }
+
+               // Index one field per delete to keep candidates unique. The 
first safe
+               // field may be less selective than a later composite-key 
field; the
+               // candidate limit falls back to linear matching in that case.
+               return field, true
+       }
+
+       return nil, false
+}
+
+func newEqualityDeleteRangeGroup(
+       field equalityDeleteFieldMetrics,
+       items []equalityDeleteRangeItem,
+) equalityDeleteRangeGroup {
+       slices.SortFunc(items, func(a, b equalityDeleteRangeItem) int {
+               if order := equalityDeleteMetricValueCompare(&a.lower, 
&b.lower); order != 0 {
+                       return order
+               }
+
+               return cmp.Compare(a.entryIndex, b.entryIndex)
+       })
+
+       group := equalityDeleteRangeGroup{
+               field: field,
+               root:  -1,
+               nodes: make([]equalityDeleteRangeNode, len(items)),
+       }
+       var build func(int, int) int
+       build = func(start, end int) int {
+               if start >= end {
+                       return -1
+               }
+
+               middle := start + (end-start)/2
+               item := items[middle]
+               nodeIndex := middle
+
+               left := build(start, middle)
+               right := build(middle+1, end)
+               // Bound values copy scalars or references to bytes already 
owned by
+               // entries. Nodes do not clone the string/binary payload again.
+               node := equalityDeleteRangeNode{
+                       entryIndex:  item.entryIndex,
+                       left:        left,
+                       right:       right,
+                       lower:       item.lower,
+                       upper:       item.upper,
+                       maxUpper:    item.upper,
+                       sequence:    item.sequence,
+                       maxSequence: item.sequence,
+               }
+               for _, childIndex := range [...]int{left, right} {
+                       if childIndex < 0 {
+                               continue
+                       }
+                       child := &group.nodes[childIndex]
+                       if equalityDeleteMetricValueCompare(&child.maxUpper, 
&node.maxUpper) > 0 {
+                               node.maxUpper = child.maxUpper
+                       }
+                       if child.maxSequence > node.maxSequence {
+                               node.maxSequence = child.maxSequence
+                       }
+               }
+               group.nodes[nodeIndex] = node
+
+               return nodeIndex
+       }
+
+       group.root = build(0, len(items))
+
+       return group
+}
+
+func (idx *equalityDeleteRangeIndex) candidates(
+       start int,
+       dataSeqNum int64,
+       dataStats *equalityDeleteDataFileStats,
+) ([]int, bool) {
+       idx.once.Do(idx.build)
+       if len(idx.groups) == 0 {
+               return nil, false
+       }
+
+       remaining := len(idx.entries) - start
+       // Use the linear path once candidates include at least half the 
applicable entries.
+       // Stop at that threshold instead of allocating a full candidate list 
that
+       // will be discarded before the same entries are scanned again.
+       candidateLimit := remaining / 2
+       fallbackStart := sort.Search(len(idx.fallback), func(i int) bool {
+               return idx.fallback[i] >= start
+       })
+       if len(idx.fallback)-fallbackStart >= candidateLimit {
+               return nil, false
+       }
+
+       candidates := make([]int, 0, min(candidateLimit, 128))
+       candidates = append(candidates, idx.fallback[fallbackStart:]...)
+
+       for i := range idx.groups {
+               var ok bool
+               candidates, ok = idx.groups[i].appendCandidates(candidates, 
dataStats, dataSeqNum, candidateLimit)
+               if !ok || len(candidates) >= candidateLimit {
+                       return nil, false
+               }
+       }
+
+       slices.Sort(candidates)
+
+       return candidates, true
+}
+
+func (group *equalityDeleteRangeGroup) appendCandidates(
+       out []int,
+       dataStats *equalityDeleteDataFileStats,
+       dataSeqNum int64,
+       candidateLimit int,
+) ([]int, bool) {
+       field := &group.field
+       if field.floatType && !equalityDeleteFloatRangesAreKnown(*dataStats, 
field) {
+               return out, false
+       }
+
+       dataBounds := equalityDeleteDataFileBoundsFor(dataStats, field)
+       if !dataBounds.usable {
+               return out, false
+       }
+
+       return group.appendNodeCandidates(out, group.root, dataBounds, 
dataSeqNum, candidateLimit), true
+}
+
+func (group *equalityDeleteRangeGroup) appendNodeCandidates(
+       out []int,
+       nodeIndex int,
+       dataBounds *equalityDeleteDataFileBounds,
+       dataSeqNum int64,
+       candidateLimit int,
+) []int {
+       if nodeIndex < 0 || len(out) >= candidateLimit {
+               return out
+       }
+
+       node := &group.nodes[nodeIndex]
+       // These strict disjoint-range checks must remain a subset of the 
rejects
+       // in equalityDeleteCanContainData. Equal endpoints still overlap, and 
the
+       // strict sequence comparison retains only deletes newer than the data.
+       if node.maxSequence <= dataSeqNum ||
+               equalityDeleteMetricValueCompare(&node.maxUpper, 
&dataBounds.lower) < 0 {
+               return out
+       }
+
+       out = group.appendNodeCandidates(out, node.left, dataBounds, 
dataSeqNum, candidateLimit)
+       if len(out) >= candidateLimit ||
+               equalityDeleteMetricValueCompare(&node.lower, 
&dataBounds.upper) > 0 {
+               return out
+       }
+
+       if node.sequence > dataSeqNum &&
+               equalityDeleteMetricValueCompare(&node.upper, 
&dataBounds.lower) >= 0 {
+               out = append(out, node.entryIndex)
+       }
+
+       return group.appendNodeCandidates(out, node.right, dataBounds, 
dataSeqNum, candidateLimit)
+}
diff --git a/table/equality_delete_range_index_properties_test.go 
b/table/equality_delete_range_index_properties_test.go
new file mode 100644
index 000000000..34b910770
--- /dev/null
+++ b/table/equality_delete_range_index_properties_test.go
@@ -0,0 +1,476 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+       "fmt"
+       "math/rand/v2"
+       "sync"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+func equalityDeleteRangeTestSpecs() equalityDeleteIndexTestSpecLookup {
+       specs := equalityDeleteIndexTestSpecs()
+       for _, id := range []int{1, 2} {
+               // Partition on a separate field so equality bounds may vary 
within it.
+               specs[id] = iceberg.NewPartitionSpecID(id, 
iceberg.PartitionField{
+                       SourceIDs: []int{3}, FieldID: 1000, Name: "partition",
+                       Transform: iceberg.IdentityTransform{},
+               })
+       }
+
+       return specs
+}
+
+func equalityDeleteRangeTestBucket(t testing.TB, count int, specID int32, 
partition map[int]any) []iceberg.ManifestEntry {
+       t.Helper()
+       entries := make([]iceberg.ManifestEntry, count)
+       for i := range entries {
+               lower, upper := equalityDeleteMetricsTestBounds(t, int32(i*10), 
int32(i*10+4))
+               entries[i] = newEqualityDeleteMetricsTestEntry(
+                       fmt.Sprintf("delete-%d.parquet", i), specID, partition, 
iceberg.EntryContentEqDeletes, int64(i+2),
+                       []int{1}, map[int]int64{1: 1}, map[int]int64{1: 0}, 
nil, lower, upper)
+       }
+
+       return entries
+}
+
+func TestEqualityDeleteRangeIndexCandidateLimit(t *testing.T) {
+       entries := equalityDeleteRangeTestBucket(t, 
equalityDeleteRangeIndexMinEntries, 0, nil)
+       schema := equalityDeleteMetricsTestSchema(iceberg.PrimitiveTypes.Int32, 
true)
+       idx, err := buildEqualityDeleteIndex(entries, 
equalityDeleteIndexTestSpecs(), schema)
+       require.NoError(t, err)
+       limit := len(entries) / 2
+       for _, matches := range []int{limit - 1, limit} {
+               lower, upper := equalityDeleteMetricsTestBounds(t, int32(0), 
int32((matches-1)*10+4))
+               data := newEqualityDeleteMetricsTestEntry("data.parquet", 0, 
nil, iceberg.EntryContentData, 0,
+                       nil, nil, nil, nil, lower, upper)
+               stats := equalityDeleteDataFileStatsFor(data.DataFile())
+               candidates, ok := idx.globalRangeIndex.candidates(0, 0, &stats)
+               assert.Equal(t, matches < limit, ok, "candidate count %d, limit 
%d", matches, limit)
+               if ok {
+                       assert.Len(t, candidates, matches)
+               }
+               got, err := idx.forDataFile(data)
+               require.NoError(t, err)
+               assert.Len(t, got, matches, "falling back must preserve the 
matching deletes")
+       }
+}
+
+func TestEqualityDeleteRangeIndexDefersUnusableLookups(t *testing.T) {
+       count := equalityDeleteRangeIndexMinEntries
+       entries := equalityDeleteRangeTestBucket(t, count, 0, nil)
+       schema := equalityDeleteMetricsTestSchema(iceberg.PrimitiveTypes.Int32, 
true)
+       for _, remaining := range []int{
+               equalityDeleteRangeIndexMinLookupEntries - 1,
+               equalityDeleteRangeIndexMinLookupEntries,
+               equalityDeleteRangeIndexMinLookupEntries + 1,
+       } {
+               for _, bounds := range []bool{false, true} {
+                       t.Run(fmt.Sprintf("remaining=%d/bounds=%t", remaining, 
bounds), func(t *testing.T) {
+                               idx, err := buildEqualityDeleteIndex(entries, 
equalityDeleteIndexTestSpecs(), schema)
+                               require.NoError(t, err)
+                               require.Empty(t, idx.globalRangeIndex.groups, 
"construction must defer the tree")
+                               var lower, upper []byte
+                               if bounds {
+                                       lower, upper = 
equalityDeleteMetricsTestBounds(t, int32((count-1)*10), int32((count-1)*10+4))
+                               }
+                               seq := int64(count - remaining + 1)
+                               data := 
newEqualityDeleteMetricsTestEntry("data.parquet", 0, nil, 
iceberg.EntryContentData, seq,
+                                       nil, nil, nil, nil, lower, upper)
+                               got, err := idx.forDataFile(data)
+                               require.NoError(t, err)
+                               stats := 
equalityDeleteDataFileStatsFor(data.DataFile())
+                               want := appendEqualityDeletesAfterLinear(nil, 
idx.global, seq, &stats)
+                               assert.Equal(t, 
equalityDeleteMetricPaths(want), equalityDeleteMetricPaths(got))
+                               assert.Equal(t, bounds && remaining >= 
equalityDeleteRangeIndexMinLookupEntries,
+                                       len(idx.globalRangeIndex.groups) > 0, 
"only an eligible lookup should build the tree")
+                       })
+               }
+       }
+}
+
+func TestEqualityDeleteRangeIndexMultipleFieldsAndFallback(t *testing.T) {
+       schema := iceberg.NewSchema(1,
+               iceberg.NestedField{ID: 1, Name: "first", Type: 
iceberg.PrimitiveTypes.Int32},
+               iceberg.NestedField{ID: 2, Name: "second", Type: 
iceberg.PrimitiveTypes.Int32},
+               iceberg.NestedField{ID: 3, Name: "small_group", Type: 
iceberg.PrimitiveTypes.Int32})
+       count := equalityDeleteRangeIndexMinEntries
+       groupSize := equalityDeleteRangeIndexMinGroupEntries
+       entries := equalityDeleteRangeTestBucket(t, count, 0, nil)
+       for i, entry := range entries {
+               file := entry.DataFile().(*equalityDeleteMetricsTestFile)
+               switch {
+               case i < count-2*groupSize:
+               case i < count-groupSize:
+                       file.equalityFieldIDs = []int{1, 2}
+                       file.lowerBounds[2], file.upperBounds[2] = 
file.lowerBounds[1], file.upperBounds[1]
+                       delete(file.lowerBounds, 1)
+                       file.nullCounts[2] = 0
+               case i < count-1:
+                       file.equalityFieldIDs = []int{3}
+                       file.lowerBounds[3], file.upperBounds[3] = 
file.lowerBounds[1], file.upperBounds[1]
+                       file.nullCounts[3] = 0
+               default:
+                       file.equalityFieldIDs = []int{99}
+               }
+       }
+       idx, err := buildEqualityDeleteIndex(entries, 
equalityDeleteIndexTestSpecs(), schema)
+       require.NoError(t, err)
+       data := entries[0].DataFile().(*equalityDeleteMetricsTestFile)
+       stats := equalityDeleteDataFileStatsFor(data)
+       stats.lowerBounds = map[int][]byte{1: data.lowerBounds[1], 2: 
data.lowerBounds[1]}
+       stats.upperBounds = map[int][]byte{1: data.upperBounds[1], 2: 
data.upperBounds[1]}
+       candidates, ok := idx.globalRangeIndex.candidates(0, 0, &stats)
+       require.True(t, ok)
+       require.Len(t, idx.globalRangeIndex.groups, 2)
+       assert.Equal(t, 1, idx.globalRangeIndex.groups[0].field.fieldID)
+       assert.Equal(t, 2, idx.globalRangeIndex.groups[1].field.fieldID,
+               "a composite key must skip an unusable first field")
+       assert.Len(t, idx.globalRangeIndex.fallback, groupSize,
+               "a sub-threshold group and an unknown field must stay in the 
fallback list")
+       assert.Len(t, candidates, groupSize+1)
+       got := appendEqualityDeletesAfterIndexed(nil, idx.global, 0, &stats, 
idx.globalRangeIndex)
+       want := appendEqualityDeletesAfterLinear(nil, idx.global, 0, &stats)
+       assert.Equal(t, equalityDeleteMetricPaths(want), 
equalityDeleteMetricPaths(got))
+}
+
+func equalityDeleteRandomRangeFile(t testing.TB, rng *rand.Rand, fields 
[]iceberg.NestedField, safe bool) *equalityDeleteMetricsTestFile {
+       t.Helper()
+       file := &equalityDeleteMetricsTestFile{mockDataFile: &mockDataFile{
+               format: iceberg.ParquetFile, count: 100,
+               valueCounts: make(map[int]int64), nullCounts: 
make(map[int]int64), nanCounts: make(map[int]int64),
+               lowerBounds: make(map[int][]byte), upperBounds: 
make(map[int][]byte),
+       }}
+       for _, field := range fields {
+               id := field.ID
+               lo := rng.IntN(4096)
+               hi := lo + rng.IntN(8)
+               var lower, upper []byte
+               switch field.Type.(type) {
+               case iceberg.Int32Type:
+                       lower, upper = equalityDeleteMetricsTestBounds(t, 
int32(lo), int32(hi))
+               case iceberg.Int64Type:
+                       lower, upper = equalityDeleteMetricsTestBounds(t, 
int64(lo), int64(hi))
+               case iceberg.Float32Type:
+                       lower, upper = equalityDeleteMetricsTestBounds(t, 
float32(lo), float32(hi))
+               case iceberg.Float64Type:
+                       lower, upper = equalityDeleteMetricsTestBounds(t, 
float64(lo), float64(hi))
+               case iceberg.StringType:
+                       lower, upper = equalityDeleteMetricsTestBounds(t, 
fmt.Sprintf("%04d", lo), fmt.Sprintf("%04d", hi))
+               case iceberg.BinaryType:
+                       lower, upper = equalityDeleteMetricsTestBounds(t, 
[]byte(fmt.Sprintf("%04d", lo)), []byte(fmt.Sprintf("%04d", hi)))
+               default:
+                       t.Fatalf("unsupported test type %s", field.Type)
+               }
+               file.valueCounts[id], file.nullCounts[id], file.nanCounts[id] = 
100, 0, 0
+               file.lowerBounds[id], file.upperBounds[id] = lower, upper
+               if safe {
+                       continue
+               }
+               switch rng.IntN(40) {
+               case 0:
+                       delete(file.lowerBounds, id)
+               case 1:
+                       delete(file.upperBounds, id)
+               case 2:
+                       file.lowerBounds[id], file.upperBounds[id] = upper, 
lower
+               case 3:
+                       delete(file.nullCounts, id)
+               case 4:
+                       file.nullCounts[id] = 1
+               case 5:
+                       delete(file.nanCounts, id)
+               case 6:
+                       file.nanCounts[id] = 1
+               case 7:
+                       delete(file.valueCounts, id)
+               case 8:
+                       file.nullCounts[id] = 100
+               }
+       }
+
+       return file
+}
+
+func TestEqualityDeleteRangeIndexRandomizedMatchesLinearReference(t 
*testing.T) {
+       for _, types := range [][2]iceberg.Type{
+               {iceberg.PrimitiveTypes.Int32, iceberg.PrimitiveTypes.Int64},
+               {iceberg.PrimitiveTypes.Float32, 
iceberg.PrimitiveTypes.Float64},
+               {iceberg.PrimitiveTypes.String, iceberg.PrimitiveTypes.Binary},
+       } {
+               t.Run(fmt.Sprintf("%s-%s", types[0], types[1]), func(t 
*testing.T) {
+                       fields := []iceberg.NestedField{
+                               {ID: 1, Name: "first", Type: types[0], 
Required: true},
+                               {ID: 2, Name: "second", Type: types[1]},
+                       }
+                       schema := iceberg.NewSchema(1, fields[0], fields[1], 
iceberg.NestedField{
+                               ID: 3, Name: "partition", Type: 
iceberg.PrimitiveTypes.Int32, Required: true,
+                       })
+                       fastLookups := 0
+                       for _, count := range []int{63, 64, 65, 127, 128, 129, 
255, 256, 257, 511, 512, 513} {
+                               rng := rand.New(rand.NewPCG(uint64(count), 
2148))
+                               var entries []iceberg.ManifestEntry
+                               for bucket := range 3 {
+                                       for i := range count {
+                                               file := 
equalityDeleteRandomRangeFile(t, rng, fields, false)
+                                               file.path = 
fmt.Sprintf("bucket-%d/delete-%d.parquet", bucket, i)
+                                               file.contentType = 
iceberg.EntryContentEqDeletes
+                                               if bucket > 0 {
+                                                       file.specid = 1
+                                                       file.partition = 
map[int]any{1000: int32(bucket - 1)}
+                                               }
+                                               file.equalityFieldIDs = []int{1 
+ rng.IntN(2)}
+                                               if i%4 == 0 {
+                                                       file.equalityFieldIDs = 
[]int{1, 2}
+                                                       if i%16 == 0 {
+                                                               
delete(file.lowerBounds, 1)
+                                                       }
+                                               }
+                                               if i%37 == 0 {
+                                                       file.equalityFieldIDs = 
[]int{99}
+                                               }
+                                               seq := int64(1 + rng.IntN(8))
+                                               entries = append(entries, 
iceberg.NewManifestEntry(iceberg.EntryStatusADDED, nil, &seq, nil, file))
+                                       }
+                               }
+                               // Input order must not supply hidden sequence 
or range ordering.
+                               rng.Shuffle(len(entries), func(i, j int) { 
entries[i], entries[j] = entries[j], entries[i] })
+                               idx, err := buildEqualityDeleteIndex(entries, 
equalityDeleteRangeTestSpecs(), schema)
+                               require.NoError(t, err)
+                               for query := range 64 {
+                                       file := 
equalityDeleteRandomRangeFile(t, rng, fields, query%8 == 0)
+                                       file.path, file.contentType = 
"data.parquet", iceberg.EntryContentData
+                                       file.specid = int32(query % 3)
+                                       if file.specid != 0 {
+                                               file.partition = 
map[int]any{1000: int32(rng.IntN(3))}
+                                       }
+                                       seq := int64(rng.IntN(9))
+                                       if query%8 == 0 {
+                                               seq = 0
+                                       }
+                                       data := 
iceberg.NewManifestEntry(iceberg.EntryStatusADDED, nil, &seq, nil, file)
+                                       got, err := idx.forDataFile(data)
+                                       require.NoError(t, err)
+                                       key, err := 
newEqualityDeletePartitionKey(file.specid, file.partition)
+                                       require.NoError(t, err)
+                                       stats := 
equalityDeleteDataFileStatsFor(file)
+                                       want := 
appendEqualityDeletesAfterLinear(nil, idx.global, seq, &stats)
+                                       want = 
appendEqualityDeletesAfterLinear(want, idx.byPartition[key], seq, &stats)
+                                       require.Equal(t, 
equalityDeleteMetricPaths(want), equalityDeleteMetricPaths(got),
+                                               "seed=%d/2148 query=%d 
sequence=%d", count, query, seq)
+                                       for _, index := range 
[]*equalityDeleteRangeIndex{idx.globalRangeIndex, 
idx.rangeIndexesByPartition[key]} {
+                                               if index == nil {
+                                                       continue
+                                               }
+                                               start := 
firstEqualityDeleteAfter(index.entries, seq)
+                                               if len(index.entries)-start < 
equalityDeleteRangeIndexMinLookupEntries {
+                                                       continue
+                                               }
+                                               if candidates, ok := 
index.candidates(start, seq, &stats); ok {
+                                                       fastLookups++
+                                                       assert.Less(t, 
len(candidates), len(index.entries)-start)
+                                               }
+                                       }
+                               }
+                       }
+                       assert.Positive(t, fastLookups, "differential coverage 
must exercise the range index")
+               })
+       }
+}
+
+func TestEqualityDeleteRangeIndexConcurrentFirstLookups(t *testing.T) {
+       partition := map[int]any{1000: int32(0)}
+       otherPartition := map[int]any{1000: int32(1)}
+       entries := equalityDeleteRangeTestBucket(t, 512, 0, nil)
+       entries = append(entries, equalityDeleteRangeTestBucket(t, 512, 1, 
partition)...)
+       entries = append(entries, equalityDeleteRangeTestBucket(t, 512, 1, 
otherPartition)...)
+       for i, entry := range entries {
+               entry.DataFile().(*equalityDeleteMetricsTestFile).path = 
fmt.Sprintf("delete-%d.parquet", i)
+       }
+       schema := iceberg.NewSchema(1,
+               iceberg.NestedField{ID: 1, Name: "id", Type: 
iceberg.PrimitiveTypes.Int32, Required: true},
+               iceberg.NestedField{ID: 3, Name: "partition", Type: 
iceberg.PrimitiveTypes.Int32, Required: true})
+       idx, err := buildEqualityDeleteIndex(entries, 
equalityDeleteRangeTestSpecs(), schema)
+       require.NoError(t, err)
+       lower, upper := equalityDeleteMetricsTestBounds(t, int32(2000), 
int32(2004))
+       data := newEqualityDeleteMetricsTestEntry("data.parquet", 1, partition, 
iceberg.EntryContentData, 0,
+               nil, nil, nil, nil, lower, upper)
+       start := make(chan struct{})
+       var wg sync.WaitGroup
+       for range 8 {
+               wg.Go(func() {
+                       <-start
+                       got, err := idx.forDataFile(data)
+                       if assert.NoError(t, err) {
+                               assert.Equal(t, []string{"delete-200.parquet", 
"delete-712.parquet"}, equalityDeleteMetricPaths(got))
+                       }
+               })
+       }
+       close(start)
+       wg.Wait()
+       otherKey, err := newEqualityDeletePartitionKey(1, otherPartition)
+       require.NoError(t, err)
+       assert.Empty(t, idx.rangeIndexesByPartition[otherKey].groups, "unprobed 
partitions must not allocate a tree")
+       assert.Len(t, idx.globalRangeIndex.groups[0].nodes, 512)
+}
+
+func TestEqualityDeleteRangeIndexFloatNaNGuards(t *testing.T) {
+       for _, typ := range []iceberg.Type{iceberg.PrimitiveTypes.Float32, 
iceberg.PrimitiveTypes.Float64} {
+               for _, deleteNaNs := range []int64{-1, 0, 1} {
+                       for _, dataNaNs := range []int64{-1, 0, 1} {
+                               
t.Run(fmt.Sprintf("%s/deleteNaNs=%d/dataNaNs=%d", typ, deleteNaNs, dataNaNs), 
func(t *testing.T) {
+                                       fields := []iceberg.NestedField{{ID: 1, 
Name: "id", Type: typ, Required: true}}
+                                       rng := rand.New(rand.NewPCG(2148, 1))
+                                       file := 
equalityDeleteRandomRangeFile(t, rng, fields, true)
+                                       file.equalityFieldIDs = []int{1}
+                                       file.nanCounts[1] = deleteNaNs
+                                       if deleteNaNs < 0 {
+                                               delete(file.nanCounts, 1)
+                                       }
+                                       var dataLower, dataUpper []byte
+                                       if 
typ.Equals(iceberg.PrimitiveTypes.Float32) {
+                                               file.lowerBounds[1], 
file.upperBounds[1] = equalityDeleteMetricsTestBounds(t, float32(20), 
float32(30))
+                                               dataLower, dataUpper = 
equalityDeleteMetricsTestBounds(t, float32(0), float32(10))
+                                       } else {
+                                               file.lowerBounds[1], 
file.upperBounds[1] = equalityDeleteMetricsTestBounds(t, float64(20), 
float64(30))
+                                               dataLower, dataUpper = 
equalityDeleteMetricsTestBounds(t, float64(0), float64(10))
+                                       }
+                                       seq := int64(2)
+                                       entries := 
make([]iceberg.ManifestEntry, equalityDeleteRangeIndexMinEntries)
+                                       for i := range entries {
+                                               deleteFile := *file
+                                               deleteFile.path = 
fmt.Sprintf("delete-%d.parquet", i)
+                                               deleteFile.contentType = 
iceberg.EntryContentEqDeletes
+                                               entries[i] = 
iceberg.NewManifestEntry(iceberg.EntryStatusADDED, nil, &seq, nil, &deleteFile)
+                                       }
+                                       idx, err := 
buildEqualityDeleteIndex(entries, equalityDeleteIndexTestSpecs(), 
iceberg.NewSchema(1, fields...))
+                                       require.NoError(t, err)
+                                       nanCounts := map[int]int64{1: dataNaNs}
+                                       if dataNaNs < 0 {
+                                               nanCounts = nil
+                                       }
+                                       data := 
newEqualityDeleteMetricsTestEntry("data.parquet", 0, nil, 
iceberg.EntryContentData, 0,
+                                               nil, nil, nil, nanCounts, 
dataLower, dataUpper)
+                                       stats := 
equalityDeleteDataFileStatsFor(data.DataFile())
+                                       candidates, indexed := 
idx.globalRangeIndex.candidates(0, 0, &stats)
+                                       assert.Equal(t, deleteNaNs == 0 && 
dataNaNs == 0, indexed)
+                                       got, err := idx.forDataFile(data)
+                                       require.NoError(t, err)
+                                       if indexed {
+                                               assert.Empty(t, candidates)
+                                               assert.Empty(t, got)
+                                       } else {
+                                               assert.Len(t, got, 
len(entries), "unknown NaNs cannot prove disjointness")
+                                       }
+                               })
+                       }
+               }
+       }
+}
+
+func TestEqualityDeleteRangeIndexOptionalParent(t *testing.T) {
+       schema := iceberg.NewSchema(1, iceberg.NestedField{
+               ID: 2, Name: "parent", Type: &iceberg.StructType{FieldList: 
[]iceberg.NestedField{
+                       {ID: 1, Name: "child", Type: 
iceberg.PrimitiveTypes.Int32, Required: true},
+               }},
+       })
+       entries := equalityDeleteRangeTestBucket(t, 
equalityDeleteRangeIndexMinEntries, 0, nil)
+       for _, entry := range entries {
+               
delete(entry.DataFile().(*equalityDeleteMetricsTestFile).nullCounts, 1)
+       }
+       idx, err := buildEqualityDeleteIndex(entries, 
equalityDeleteIndexTestSpecs(), schema)
+       require.NoError(t, err)
+       lower, upper := equalityDeleteMetricsTestBounds(t, int32(20_000), 
int32(20_004))
+       data := newEqualityDeleteMetricsTestEntry("data.parquet", 0, nil, 
iceberg.EntryContentData, 0,
+               nil, map[int]int64{1: 1}, map[int]int64{1: 1}, nil, lower, 
upper)
+       got, err := idx.forDataFile(data)
+       require.NoError(t, err)
+       assert.Len(t, got, len(entries), "a required child may be null under an 
optional parent")
+       assert.Empty(t, idx.globalRangeIndex.groups, "unknown null keys must 
stay off the range tree")
+}
+
+// BenchmarkEqualityDeleteRangeIndexThreshold includes range-tree construction
+// and repeated lookups over already-decoded delete metrics. A single lookup
+// exposes cold-start cost; repeated lookups show when that cost is amortized.
+func BenchmarkEqualityDeleteRangeIndexThreshold(b *testing.B) {
+       schema := equalityDeleteMetricsTestSchema(iceberg.PrimitiveTypes.Int32, 
true)
+       for _, count := range []int{255, 256, 257, 511, 512} {
+               manifestEntries := equalityDeleteRangeTestBucket(b, count, 0, 
nil)
+               entries := make([]equalityDeleteIndexEntry, count)
+               for i, entry := range manifestEntries {
+                       entries[i] = newEqualityDeleteIndexEntry(entry, schema)
+               }
+               for _, lookups := range []int{1, 16, 128} {
+                       for _, broad := range []bool{false, true} {
+                               lower, upper := 
equalityDeleteMetricsTestBounds(b, int32(100), int32(104))
+                               if broad {
+                                       lower, upper = 
equalityDeleteMetricsTestBounds(b, int32(0), int32(count*10))
+                               }
+                               data := 
newEqualityDeleteMetricsTestEntry("data.parquet", 0, nil, 
iceberg.EntryContentData, 0,
+                                       nil, nil, nil, nil, lower, upper)
+                               for _, indexed := range []bool{false, true} {
+                                       
b.Run(fmt.Sprintf("entries=%d/lookups=%d/broad=%t/indexed=%t", count, lookups, 
broad, indexed), func(b *testing.B) {
+                                               b.ReportAllocs()
+                                               for b.Loop() {
+                                                       var rangeIndex 
*equalityDeleteRangeIndex
+                                                       if indexed {
+                                                               rangeIndex = 
newEqualityDeleteRangeIndex(entries)
+                                                       }
+                                                       for range lookups {
+                                                               stats := 
equalityDeleteDataFileStatsFor(data.DataFile())
+                                                               files := 
appendEqualityDeletesAfterIndexed(nil, entries, 0, &stats, rangeIndex)
+                                                               
equalityDeleteBenchmarkSink = len(files)
+                                                       }
+                                                       want := 1
+                                                       if broad {
+                                                               want = count
+                                                       }
+                                                       if 
equalityDeleteBenchmarkSink != want {
+                                                               
b.Fatalf("expected %d deletes, got %d", want, equalityDeleteBenchmarkSink)
+                                                       }
+                                               }
+                                       })
+                               }
+                       }
+               }
+       }
+}
+
+var equalityDeleteRangeUnprobedSink *equalityDeleteRangeIndex
+
+func BenchmarkEqualityDeleteRangeIndexUnprobed(b *testing.B) {
+       schema := equalityDeleteMetricsTestSchema(iceberg.PrimitiveTypes.Int32, 
true)
+       for _, count := range []int{256, 512, 4096} {
+               manifestEntries := equalityDeleteRangeTestBucket(b, count, 0, 
nil)
+               entries := make([]equalityDeleteIndexEntry, count)
+               for i, entry := range manifestEntries {
+                       entries[i] = newEqualityDeleteIndexEntry(entry, schema)
+               }
+               b.Run(fmt.Sprintf("entries=%d", count), func(b *testing.B) {
+                       b.ReportAllocs()
+                       for b.Loop() {
+                               equalityDeleteRangeUnprobedSink = 
newEqualityDeleteRangeIndex(entries)
+                       }
+               })
+       }
+}
diff --git a/table/equality_delete_range_index_test.go 
b/table/equality_delete_range_index_test.go
new file mode 100644
index 000000000..61f0fa42b
--- /dev/null
+++ b/table/equality_delete_range_index_test.go
@@ -0,0 +1,249 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may not use this file
+// except in compliance with the License.  You may obtain a copy of the
+// License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package table
+
+import (
+       "fmt"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// appendEqualityDeletesAfterLinear is an independent reference matcher. It
+// scans every entry so neither the sequence search nor range tree can hide a
+// false negative in the production path.
+func appendEqualityDeletesAfterLinear(
+       out []iceberg.DataFile,
+       entries []equalityDeleteIndexEntry,
+       dataSeqNum int64,
+       dataStats *equalityDeleteDataFileStats,
+) []iceberg.DataFile {
+       for _, entry := range entries {
+               if entry.entry.SequenceNum() > dataSeqNum && 
equalityDeleteCanContainData(dataStats, entry.fields) {
+                       out = append(out, entry.entry.DataFile())
+               }
+       }
+
+       return out
+}
+
+func TestEqualityDeleteRangeIndexMatchesLinearReference(t *testing.T) {
+       const deleteFileCount = 512
+
+       schema := equalityDeleteMetricsTestSchema(iceberg.PrimitiveTypes.Int32, 
true)
+       partition := map[int]any{1000: int32(0)}
+       deleteEntries := make([]iceberg.ManifestEntry, deleteFileCount)
+       for i := range deleteEntries {
+               var lower, upper []byte
+               if i%31 != 0 {
+                       lower, upper = equalityDeleteMetricsTestBounds(
+                               t, int32(i*10), int32(i*10+4))
+               }
+               deleteEntries[i] = newEqualityDeleteMetricsTestEntry(
+                       fmt.Sprintf("delete-%d.parquet", i),
+                       1,
+                       partition,
+                       iceberg.EntryContentEqDeletes,
+                       int64(i+2),
+                       []int{1},
+                       map[int]int64{1: 1},
+                       map[int]int64{1: 0},
+                       map[int]int64{1: 0},
+                       lower,
+                       upper,
+               )
+       }
+
+       idx, err := buildEqualityDeleteIndex(
+               deleteEntries, equalityDeleteIndexTestSpecs(), schema)
+       require.NoError(t, err)
+
+       key, err := newEqualityDeletePartitionKey(1, partition)
+       require.NoError(t, err)
+       require.NotNil(t, idx.rangeIndexesByPartition[key])
+       entries := idx.byPartition[key]
+
+       tests := []struct {
+               name     string
+               sequence int64
+               lower    int32
+               upper    int32
+               indexed  bool
+       }{
+               {name: "selective middle", sequence: 0, lower: 2000, upper: 
2099, indexed: true},
+               {name: "selective with sequence pruning", sequence: 200, lower: 
4000, upper: 4099, indexed: true},
+               {name: "no ranged overlap keeps fallback", sequence: 0, lower: 
9000, upper: 9100, indexed: true},
+               {name: "unselective range uses linear fallback", sequence: 0, 
lower: 0, upper: 5200},
+               {name: "unselective sequence suffix", sequence: 200, lower: 0, 
upper: 5200},
+               {name: "small sequence suffix", sequence: 450, lower: 0, upper: 
5200},
+               {name: "no applicable sequences", sequence: 513, lower: 0, 
upper: 5200},
+               {name: "equal lower endpoint overlaps", sequence: 0, lower: 
2000, upper: 2000, indexed: true},
+               {name: "equal upper endpoint overlaps", sequence: 0, lower: 
2004, upper: 2004, indexed: true},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       lower, upper := equalityDeleteMetricsTestBounds(t, 
tt.lower, tt.upper)
+                       dataEntry := newEqualityDeleteMetricsTestEntry(
+                               "data.parquet",
+                               1,
+                               partition,
+                               iceberg.EntryContentData,
+                               tt.sequence,
+                               nil,
+                               map[int]int64{1: 1},
+                               map[int]int64{1: 0},
+                               map[int]int64{1: 0},
+                               lower,
+                               upper,
+                       )
+
+                       got, err := idx.forDataFile(dataEntry)
+                       require.NoError(t, err)
+
+                       stats := 
equalityDeleteDataFileStatsFor(dataEntry.DataFile())
+                       want := appendEqualityDeletesAfterLinear(
+                               nil, entries, dataEntry.SequenceNum(), &stats)
+                       assert.Equal(t, equalityDeleteMetricPaths(want), 
equalityDeleteMetricPaths(got))
+                       start := firstEqualityDeleteAfter(entries, 
dataEntry.SequenceNum())
+                       if len(entries)-start >= 
equalityDeleteRangeIndexMinLookupEntries {
+                               candidates, ok := 
idx.rangeIndexesByPartition[key].candidates(start, dataEntry.SequenceNum(), 
&stats)
+                               assert.Equal(t, tt.indexed, ok, "the expected 
fast path must actually run")
+                               if ok {
+                                       assert.Less(t, len(candidates), 
len(entries)-start)
+                               }
+                       }
+               })
+       }
+}
+
+func BenchmarkEqualityDeleteRangeIndexLookup(b *testing.B) {
+       const (
+               dataFileCount   = 10_000
+               deleteFileCount = 10_000
+               groupCount      = 100
+               filesPerGroup   = deleteFileCount / groupCount
+       )
+
+       schema := equalityDeleteMetricsTestSchema(iceberg.PrimitiveTypes.Int32, 
true)
+       partition := map[int]any{1000: int32(0)}
+       lowerBounds := make([][]byte, groupCount)
+       upperBounds := make([][]byte, groupCount)
+       for group := range groupCount {
+               lowerBounds[group], upperBounds[group] = 
equalityDeleteMetricsTestBounds(
+                       b, int32(group*filesPerGroup), 
int32((group+1)*filesPerGroup-1))
+       }
+
+       deleteEntries := make([]iceberg.ManifestEntry, deleteFileCount)
+       for i := range deleteEntries {
+               group := i / filesPerGroup
+               deleteEntries[i] = newEqualityDeleteMetricsTestEntry(
+                       fmt.Sprintf("delete-%d.parquet", i),
+                       1,
+                       partition,
+                       iceberg.EntryContentEqDeletes,
+                       int64(i+1),
+                       []int{1},
+                       map[int]int64{1: 1},
+                       map[int]int64{1: 0},
+                       map[int]int64{1: 0},
+                       lowerBounds[group],
+                       upperBounds[group],
+               )
+       }
+
+       dataEntries := make([]iceberg.ManifestEntry, dataFileCount)
+       for i := range dataEntries {
+               group := i / filesPerGroup
+               dataEntries[i] = newEqualityDeleteMetricsTestEntry(
+                       fmt.Sprintf("data-%d.parquet", i),
+                       1,
+                       partition,
+                       iceberg.EntryContentData,
+                       0,
+                       nil,
+                       map[int]int64{1: 1},
+                       map[int]int64{1: 0},
+                       map[int]int64{1: 0},
+                       lowerBounds[group],
+                       upperBounds[group],
+               )
+       }
+
+       idx, err := buildEqualityDeleteIndex(
+               deleteEntries, equalityDeleteIndexTestSpecs(), schema)
+       if err != nil {
+               b.Fatal(err)
+       }
+       key, err := newEqualityDeletePartitionKey(1, partition)
+       if err != nil {
+               b.Fatal(err)
+       }
+       entries := idx.byPartition[key]
+       rangeIndex := idx.rangeIndexesByPartition[key]
+       if rangeIndex == nil {
+               b.Fatal("range index was not built")
+       }
+
+       // This benchmark isolates warm lookups; construction is measured 
separately.
+       rangeIndex.once.Do(rangeIndex.build)
+       benchmark := func(b *testing.B, indexed bool, dataEntries 
[]iceberg.ManifestEntry) {
+               b.ReportAllocs()
+               matched := 0
+               b.ResetTimer()
+               for range b.N {
+                       matched = 0
+                       for _, dataEntry := range dataEntries {
+                               stats := 
equalityDeleteDataFileStatsFor(dataEntry.DataFile())
+                               var files []iceberg.DataFile
+                               if indexed {
+                                       files = 
appendEqualityDeletesAfterIndexed(
+                                               nil, entries, 
dataEntry.SequenceNum(), &stats, rangeIndex)
+                               } else {
+                                       files = 
appendEqualityDeletesAfterIndexed(
+                                               nil, entries, 
dataEntry.SequenceNum(), &stats, nil)
+                               }
+                               matched += len(files)
+                       }
+                       equalityDeleteBenchmarkSink = matched
+               }
+               b.StopTimer()
+               b.ReportMetric(float64(matched)/float64(len(dataEntries)), 
"attached_deletes_per_data_file")
+       }
+
+       b.Run("linear", func(b *testing.B) {
+               benchmark(b, false, dataEntries)
+       })
+       b.Run("range_index", func(b *testing.B) {
+               benchmark(b, true, dataEntries)
+       })
+
+       // A broad data range exercises the cost of abandoning an unselective 
index.
+       lower, upper := equalityDeleteMetricsTestBounds(b, int32(0), 
int32(deleteFileCount))
+       unselectiveEntries := 
[]iceberg.ManifestEntry{newEqualityDeleteMetricsTestEntry(
+               "unselective.parquet", 1, partition, iceberg.EntryContentData, 
0, nil,
+               map[int]int64{1: 1}, map[int]int64{1: 0}, map[int]int64{1: 0}, 
lower, upper,
+       )}
+       b.Run("unselective_linear", func(b *testing.B) {
+               benchmark(b, false, unselectiveEntries)
+       })
+       b.Run("unselective_range_index", func(b *testing.B) {
+               benchmark(b, true, unselectiveEntries)
+       })
+}

Reply via email to