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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 289e9265e1 [core] Support composite BTree prefix and range queries 
(#10346)
289e9265e1 is described below

commit 289e9265e11cb2174e6a08985676db9f9b3749af
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 3 16:35:33 2026 +0800

    [core] Support composite BTree prefix and range queries (#10346)
---
 docs/docs/multimodal-table/global-index/btree.mdx  |  36 ++-
 .../globalindex/SortedFileGlobalIndexReader.java   |   2 +-
 .../btree/BTreeGlobalIndexerFactory.java           |  28 +-
 .../paimon/globalindex/btree/BTreeIndexReader.java |  33 +++
 .../globalindex/btree/CompositeBTreePredicate.java | 310 ++++++++++++++++++---
 .../globalindex/btree/LazyFilteredBTreeReader.java |  27 +-
 .../java/org/apache/paimon/sst/BlockIterator.java  |   8 +-
 .../java/org/apache/paimon/sst/SstFileReader.java  |   9 +-
 .../globalindex/btree/CompositeBTreeIndexTest.java |   7 +-
 .../btree/CompositeBTreePredicateTest.java         |  90 +++---
 .../globalindex/btree/CompositeBTreeRangeTest.java | 236 ++++++++++++++++
 .../org/apache/paimon/sst/BlockIteratorTest.java   |  21 ++
 .../paimon/globalindex/DataEvolutionBatchScan.java |   3 +-
 .../DataEvolutionGlobalIndexScanner.java           |   6 +-
 .../paimon/globalindex/GlobalIndexQuery.java       | 188 ++++++++-----
 .../paimon/globalindex/GlobalIndexQueryTest.java   |  89 ++++--
 .../paimon/table/CompositeBTreeTableTest.java      | 165 ++++++++++-
 .../paimon/flink/SortedGlobalIndexITCase.java      |  11 +
 .../CompositeBTreeIndexProcedureTest.scala         |   9 +
 19 files changed, 1067 insertions(+), 211 deletions(-)

diff --git a/docs/docs/multimodal-table/global-index/btree.mdx 
b/docs/docs/multimodal-table/global-index/btree.mdx
index 5a31c6b005..7da34552eb 100644
--- a/docs/docs/multimodal-table/global-index/btree.mdx
+++ b/docs/docs/multimodal-table/global-index/btree.mdx
@@ -165,18 +165,40 @@ The index stores typed tuples and one row-ID posting list 
for each distinct tupl
 Equality conditions on every key column use a composite point lookup, 
regardless
 of their order in the SQL predicate. This avoids expanding each column's 
posting
 list before intersecting them. Additional predicates remain row filters, and OR
-branches can each select a composite lookup. When several definitions match,
-selection prefers the one with more key columns.
+branches can each select a composite lookup.
 
-Eligible composite point queries honor `global-index.query-in-reader.enabled`.
+Composite indexes also support an equality prefix followed by a range on the 
next
+key column. For an index on `(category, item_number)`, the following 
conditions can
+use the index:
+
+| Condition | Index scan |
+| --- | --- |
+| `category = 'category-a'` | All tuples with this category prefix |
+| `category = 'category-a' AND item_number > 7` | A range within the category 
prefix |
+| `category = 'category-a' AND item_number BETWEEN 7 AND 9` | A range with 
inclusive endpoints within the prefix |
+| `category > 'category-a' AND item_number = 7` | A category range; the 
item-number condition remains a data filter |
+
+The range may use `>`, `>=`, `<`, `<=` or `BETWEEN`. Conditions after the first
+range column remain data filters. A condition only on `item_number` cannot use
+this composite index's left prefix.
+
+When several composite definitions match, selection prefers more bounded key
+columns, then more equality columns, then fewer total key columns. If only the
+leading column is bounded, a dedicated single-column index is preferred when
+its coverage includes the composite definition's coverage.
+
+Eligible composite queries honor `global-index.query-in-reader.enabled`.
 With reader evaluation enabled, supported index conditions narrow the candidate
 rows and remaining conditions are evaluated against the data.
 
 Composite indexes can coexist with single-column indexes on the same columns.
-Composite lookup currently requires equality on all key columns; conditions on
-only some key columns, ranges and IN predicates use available single-column
-indexes or ordinary scans. Vector and full-text search pre-filters use
-single-column indexes or their data fallback.
+Full-key equalities use point probes. Prefix and range scans use
+`btree-index.fallback-scan-max-size`, applied to the selected files in each 
index
+group. If any group exceeds the budget, the whole composite definition is
+declined and planning uses other indexes or an ordinary scan. Point probes are
+unaffected by this budget. Dedicated composite `IN` and `IS NULL` optimization 
is
+not supported yet. Vector and full-text search pre-filters use single-column
+indexes or their data fallback.
 
 In `full` and `detail` modes, rows outside the selected composite index's 
coverage
 remain eligible for data filtering. Calling the procedure again with the same
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/SortedFileGlobalIndexReader.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/SortedFileGlobalIndexReader.java
index 3c4d80c441..71731aa07b 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/SortedFileGlobalIndexReader.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/SortedFileGlobalIndexReader.java
@@ -327,7 +327,7 @@ public abstract class SortedFileGlobalIndexReader<R extends 
Closeable>
         return visitSelectedFiles(selector.get(), visitor);
     }
 
-    private CompletableFuture<Optional<GlobalIndexResult>> visitSelectedFiles(
+    protected CompletableFuture<Optional<GlobalIndexResult>> 
visitSelectedFiles(
             Optional<List<GlobalIndexIOMeta>> selectedOpt,
             Function<R, Optional<GlobalIndexResult>> visitor) {
         if (!selectedOpt.isPresent()) {
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/BTreeGlobalIndexerFactory.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/BTreeGlobalIndexerFactory.java
index 75ae2a762b..84b403b46e 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/BTreeGlobalIndexerFactory.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/BTreeGlobalIndexerFactory.java
@@ -18,21 +18,15 @@
 
 package org.apache.paimon.globalindex.btree;
 
-import org.apache.paimon.data.GenericRow;
-import org.apache.paimon.globalindex.CompositeKeySerializer;
 import org.apache.paimon.globalindex.GlobalIndexIOMeta;
 import org.apache.paimon.globalindex.GlobalIndexer;
 import org.apache.paimon.globalindex.GlobalIndexerFactory;
 import org.apache.paimon.globalindex.KeySerializer;
 import org.apache.paimon.globalindex.SortedFileMetaSelector;
 import org.apache.paimon.options.Options;
-import org.apache.paimon.predicate.FieldRef;
-import org.apache.paimon.predicate.LeafPredicate;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.types.DataField;
-import org.apache.paimon.types.RowType;
 
-import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 
@@ -50,25 +44,9 @@ public class BTreeGlobalIndexerFactory implements 
GlobalIndexerFactory {
     public List<GlobalIndexIOMeta> selectFiles(
             List<DataField> indexFields, Predicate predicate, 
List<GlobalIndexIOMeta> files) {
         if (indexFields.size() > 1) {
-            Optional<List<LeafPredicate>> matched =
-                    CompositeBTreePredicate.match(indexFields, predicate);
-            if (!matched.isPresent()) {
-                return files;
-            }
-            if (CompositeBTreePredicate.isContradictory(indexFields, 
predicate)) {
-                return Collections.emptyList();
-            }
-            if (files.stream().anyMatch(file -> file.metadata() == null)) {
-                return files;
-            }
-            RowType keyType = new RowType(indexFields);
-            KeySerializer serializer = new CompositeKeySerializer(keyType);
-            Object[] values = matched.get().stream().map(leaf -> 
leaf.literals().get(0)).toArray();
-            return new SortedFileMetaSelector(files, serializer)
-                    .visitEqual(
-                            new FieldRef(0, indexFields.get(0).name(), 
keyType),
-                            GenericRow.of(values))
-                    .orElse(files);
+            Optional<CompositeBTreePredicate.Plan> plan =
+                    CompositeBTreePredicate.plan(indexFields, predicate);
+            return plan.map(value -> value.selectFiles(files)).orElse(files);
         }
         return SortedFileMetaSelector.selectFiles(
                 predicate, files, 
KeySerializer.create(indexFields.get(0).type()));
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/BTreeIndexReader.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/BTreeIndexReader.java
index 174df07042..26584d4260 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/BTreeIndexReader.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/BTreeIndexReader.java
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.globalindex.btree;
 
+import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.fs.SeekableInputStream;
 import org.apache.paimon.globalindex.GlobalIndexIOMeta;
@@ -339,6 +340,38 @@ public class BTreeIndexReader implements Closeable {
         return createResult(() -> pointQuery(literal));
     }
 
+    public Optional<GlobalIndexResult> 
visitComposite(CompositeBTreePredicate.Plan plan) {
+        return createResult(
+                () -> {
+                    if (plan.isEmpty()) {
+                        return new RoaringNavigableMap64();
+                    }
+                    if (plan.isPointLookup()) {
+                        return pointQuery(plan.pointKey());
+                    }
+                    RoaringNavigableMap64 result = new RoaringNavigableMap64();
+                    SstFileReader.SstFileIterator iterator = 
reader.createIterator();
+                    iterator.seekTo(
+                            key ->
+                                    plan.lower()
+                                            .compareKey(
+                                                    (InternalRow) 
keySerializer.deserialize(key)));
+                    BlockIterator batch;
+                    while ((batch = iterator.readBatch()) != null) {
+                        while (batch.hasNext()) {
+                            Map.Entry<MemorySlice, MemorySlice> entry = 
batch.next();
+                            InternalRow key =
+                                    (InternalRow) 
keySerializer.deserialize(entry.getKey());
+                            if (plan.upper().compareKey(key) > 0) {
+                                return result;
+                            }
+                            addRowIdsTo(entry.getValue(), result);
+                        }
+                    }
+                    return result;
+                });
+    }
+
     public Optional<GlobalIndexResult> visitGreaterThan(Object literal) {
         return createResult(() -> rangeQuery(literal, maxKey, false, true));
     }
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/CompositeBTreePredicate.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/CompositeBTreePredicate.java
index 02b71aec89..3acccc79a6 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/CompositeBTreePredicate.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/CompositeBTreePredicate.java
@@ -18,71 +18,303 @@
 
 package org.apache.paimon.globalindex.btree;
 
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.globalindex.CompositeKeySerializer;
+import org.apache.paimon.globalindex.GlobalIndexIOMeta;
 import org.apache.paimon.globalindex.KeySerializer;
+import org.apache.paimon.globalindex.SortedIndexFileMeta;
+import org.apache.paimon.memory.MemorySlice;
+import org.apache.paimon.predicate.Between;
 import org.apache.paimon.predicate.Equal;
+import org.apache.paimon.predicate.GreaterOrEqual;
+import org.apache.paimon.predicate.GreaterThan;
 import org.apache.paimon.predicate.LeafPredicate;
+import org.apache.paimon.predicate.LessOrEqual;
+import org.apache.paimon.predicate.LessThan;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.predicate.PredicateBuilder;
 import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.RowType;
 
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Optional;
 
-/** Matches a full conjunction of equalities in physical composite-key order. 
*/
+/** An equality prefix followed by an optional range in physical composite-key 
order. */
 public final class CompositeBTreePredicate {
 
     private CompositeBTreePredicate() {}
 
-    /** SQL equalities with NULL, or distinct values for the same key field, 
cannot match. */
-    public static boolean isContradictory(List<DataField> fields, Predicate 
predicate) {
-        List<LeafPredicate> matched = match(fields, predicate).get();
-        for (int i = 0; i < fields.size(); i++) {
-            Object value = matched.get(i).literals().get(0);
-            if (value == null) {
-                return true;
+    public static Optional<Plan> plan(List<DataField> fields, Predicate 
predicate) {
+        List<Predicate> conjuncts = PredicateBuilder.splitAnd(predicate);
+        List<Predicate> covered = new ArrayList<>();
+        List<Object> prefix = new ArrayList<>();
+        List<LeafPredicate> range = Collections.emptyList();
+        boolean empty = false;
+        for (DataField field : fields) {
+            List<LeafPredicate> column = new ArrayList<>();
+            LeafPredicate equality = null;
+            for (Predicate child : conjuncts) {
+                if (!(child instanceof LeafPredicate)) {
+                    continue;
+                }
+                LeafPredicate leaf = (LeafPredicate) child;
+                if (!leaf.fieldRefOptional().isPresent()
+                        || 
!field.name().equals(leaf.fieldRefOptional().get().name())
+                        || !field.type().equalsIgnoreNullable(leaf.type())
+                        || (!(leaf.function() instanceof Equal) && 
!isRange(leaf))) {
+                    continue;
+                }
+                column.add(leaf);
+                if (leaf.function() instanceof Equal) {
+                    equality = leaf;
+                }
+            }
+            if (equality == null) {
+                range = column;
+                covered.addAll(range);
+                break;
             }
-            Comparator<Object> comparator =
-                    
KeySerializer.create(fields.get(i).type()).createComparator();
-            for (Predicate child : PredicateBuilder.splitAnd(predicate)) {
-                if (child instanceof LeafPredicate) {
-                    LeafPredicate leaf = (LeafPredicate) child;
-                    if (leaf.function() instanceof Equal
-                            && 
leaf.fieldRefOptional().equals(matched.get(i).fieldRefOptional())) {
-                        Object other = leaf.literals().get(0);
-                        if (other == null || comparator.compare(value, other) 
!= 0) {
-                            return true;
-                        }
+            Object value = equality.literals().get(0);
+            for (LeafPredicate leaf : column) {
+                // Validate all bounds before using a full-key point lookup or 
a prefix.
+                empty |=
+                        value == null || !leaf.function().test(leaf.type(), 
value, leaf.literals());
+            }
+            prefix.add(value);
+            covered.addAll(column);
+        }
+        int equalColumns = prefix.size();
+        int boundColumns = equalColumns + (range.isEmpty() ? 0 : 1);
+        if (boundColumns == 0) {
+            return Optional.empty();
+        }
+        Object[] values = prefix.toArray();
+        Bound lower = new Bound(fields, values, false);
+        Bound upper = new Bound(fields, values, true);
+        if (!range.isEmpty()) {
+            // Comparisons cannot match a NULL component, even with an 
unbounded lower endpoint.
+            lower = new Bound(fields, append(values, null), true);
+            for (LeafPredicate leaf : range) {
+                if (leaf.literals().contains(null)) {
+                    empty = true;
+                    continue;
+                }
+                if (leaf.function() instanceof GreaterThan
+                        || leaf.function() instanceof GreaterOrEqual
+                        || leaf.function() instanceof Between) {
+                    Bound next =
+                            new Bound(
+                                    fields,
+                                    append(values, leaf.literals().get(0)),
+                                    leaf.function() instanceof GreaterThan);
+                    if (next.compareTo(lower) > 0) {
+                        lower = next;
+                    }
+                }
+                if (leaf.function() instanceof LessThan
+                        || leaf.function() instanceof LessOrEqual
+                        || leaf.function() instanceof Between) {
+                    Object value = leaf.literals().get(leaf.function() 
instanceof Between ? 1 : 0);
+                    Bound next =
+                            new Bound(
+                                    fields,
+                                    append(values, value),
+                                    !(leaf.function() instanceof LessThan));
+                    if (next.compareTo(upper) < 0) {
+                        upper = next;
                     }
                 }
             }
         }
-        return false;
+        return Optional.of(
+                new Plan(
+                        fields,
+                        covered,
+                        lower,
+                        upper,
+                        boundColumns,
+                        equalColumns,
+                        empty || lower.compareTo(upper) >= 0));
     }
 
-    public static Optional<List<LeafPredicate>> match(List<DataField> fields, 
Predicate predicate) {
-        List<Predicate> conjuncts = PredicateBuilder.splitAnd(predicate);
-        List<LeafPredicate> matched = new ArrayList<>();
-        for (DataField field : fields) {
-            LeafPredicate equality = null;
-            for (Predicate conjunct : conjuncts) {
-                if (conjunct instanceof LeafPredicate) {
-                    LeafPredicate leaf = (LeafPredicate) conjunct;
-                    if (leaf.function() instanceof Equal
-                            && leaf.fieldRefOptional().isPresent()
-                            && 
field.name().equals(leaf.fieldRefOptional().get().name())
-                            && field.type().equalsIgnoreNullable(leaf.type())) 
{
-                        equality = leaf;
-                        break;
-                    }
+    private static boolean isRange(LeafPredicate leaf) {
+        return leaf.function() instanceof GreaterThan
+                || leaf.function() instanceof GreaterOrEqual
+                || leaf.function() instanceof LessThan
+                || leaf.function() instanceof LessOrEqual
+                || leaf.function() instanceof Between;
+    }
+
+    private static Object[] append(Object[] prefix, Object value) {
+        Object[] result = Arrays.copyOf(prefix, prefix.length + 1);
+        result[prefix.length] = value;
+        return result;
+    }
+
+    /** A virtual boundary immediately before or after every key with the 
specified prefix. */
+    public static final class Bound {
+        private final Object[] values;
+        private final boolean after;
+        private final InternalRow.FieldGetter[] getters;
+        private final Comparator<Object>[] comparators;
+
+        @SuppressWarnings("unchecked")
+        private Bound(List<DataField> fields, Object[] values, boolean after) {
+            this.values = values;
+            this.after = after;
+            getters = new InternalRow.FieldGetter[values.length];
+            comparators = new Comparator[values.length];
+            for (int i = 0; i < values.length; i++) {
+                getters[i] = 
InternalRow.createFieldGetter(fields.get(i).type().copy(true), i);
+                comparators[i] = 
KeySerializer.create(fields.get(i).type()).createComparator();
+            }
+        }
+
+        /** Compare a persisted key to this virtual endpoint. */
+        public int compareKey(InternalRow key) {
+            for (int i = 0; i < values.length; i++) {
+                int comparison = compare(i, getters[i].getFieldOrNull(key), 
values[i]);
+                if (comparison != 0) {
+                    return comparison;
                 }
             }
-            if (equality == null) {
-                return Optional.empty();
+            return after ? -1 : 1;
+        }
+
+        private int compare(int position, Object left, Object right) {
+            return left == null
+                    ? (right == null ? 0 : -1)
+                    : right == null ? 1 : comparators[position].compare(left, 
right);
+        }
+
+        private int compareTo(Bound other) {
+            int count = Math.min(values.length, other.values.length);
+            for (int i = 0; i < count; i++) {
+                int comparison = compare(i, values[i], other.values[i]);
+                if (comparison != 0) {
+                    return comparison;
+                }
+            }
+            if (values.length != other.values.length) {
+                return values.length < other.values.length
+                        ? (after ? 1 : -1)
+                        : (other.after ? -1 : 1);
+            }
+            return Boolean.compare(after, other.after);
+        }
+    }
+
+    /** One tuple interval, with its covered query predicates and point-lookup 
classification. */
+    public static final class Plan {
+        private final List<DataField> fields;
+        private final List<Predicate> predicates;
+        private final Bound lower;
+        private final Bound upper;
+        private final int boundColumns;
+        private final int equalColumns;
+        private final boolean empty;
+
+        private Plan(
+                List<DataField> fields,
+                List<Predicate> predicates,
+                Bound lower,
+                Bound upper,
+                int boundColumns,
+                int equalColumns,
+                boolean empty) {
+            this.fields = fields;
+            this.predicates = predicates;
+            this.lower = lower;
+            this.upper = upper;
+            this.boundColumns = boundColumns;
+            this.equalColumns = equalColumns;
+            this.empty = empty;
+        }
+
+        public List<Predicate> predicates() {
+            return predicates;
+        }
+
+        public Bound lower() {
+            return lower;
+        }
+
+        public Bound upper() {
+            return upper;
+        }
+
+        public int boundColumns() {
+            return boundColumns;
+        }
+
+        public int equalColumns() {
+            return equalColumns;
+        }
+
+        public boolean isEmpty() {
+            return empty;
+        }
+
+        public boolean isPointLookup() {
+            return equalColumns == fields.size();
+        }
+
+        public GenericRow pointKey() {
+            return GenericRow.of(lower.values);
+        }
+
+        /**
+         * Point probes are unrestricted; other scans use the existing 
selected-file byte budget.
+         */
+        public boolean canScan(List<GlobalIndexIOMeta> selected, long budget) {
+            if (isPointLookup() || selected.isEmpty()) {
+                return true;
+            }
+            if (budget <= 0) {
+                return false;
+            }
+            for (GlobalIndexIOMeta file : selected) {
+                if (file.fileSize() > budget) {
+                    return false;
+                }
+                budget -= file.fileSize();
+            }
+            return true;
+        }
+
+        public List<GlobalIndexIOMeta> selectFiles(List<GlobalIndexIOMeta> 
files) {
+            if (empty) {
+                return Collections.emptyList();
+            }
+            // Preserve conservative pruning when metadata is unavailable.
+            if (files.stream().anyMatch(file -> file.metadata() == null)) {
+                return files;
+            }
+            CompositeKeySerializer serializer = new CompositeKeySerializer(new 
RowType(fields));
+            List<GlobalIndexIOMeta> result = new ArrayList<>();
+            for (GlobalIndexIOMeta file : files) {
+                SortedIndexFileMeta meta = 
SortedIndexFileMeta.deserialize(file.metadata());
+                if (meta.firstKey() == null
+                        || meta.lastKey() == null
+                        || (lower.compareKey(
+                                                (InternalRow)
+                                                        serializer.deserialize(
+                                                                
MemorySlice.wrap(meta.lastKey())))
+                                        > 0
+                                && upper.compareKey(
+                                                (InternalRow)
+                                                        serializer.deserialize(
+                                                                
MemorySlice.wrap(meta.firstKey())))
+                                        < 0)) {
+                    result.add(file);
+                }
             }
-            matched.add(equality);
+            return result;
         }
-        return Optional.of(matched);
     }
 }
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/LazyFilteredBTreeReader.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/LazyFilteredBTreeReader.java
index b14cf376c5..6ec85d813e 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/LazyFilteredBTreeReader.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/LazyFilteredBTreeReader.java
@@ -18,7 +18,6 @@
 
 package org.apache.paimon.globalindex.btree;
 
-import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.globalindex.GlobalIndexIOMeta;
 import org.apache.paimon.globalindex.GlobalIndexResult;
 import org.apache.paimon.globalindex.KeySerializer;
@@ -28,7 +27,6 @@ import org.apache.paimon.globalindex.io.GlobalIndexFileReader;
 import org.apache.paimon.io.cache.CacheManager;
 import org.apache.paimon.memory.MemorySlice;
 import org.apache.paimon.predicate.FieldRef;
-import org.apache.paimon.predicate.LeafPredicate;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.predicate.TopN;
 import org.apache.paimon.types.DataField;
@@ -60,6 +58,8 @@ public class LazyFilteredBTreeReader extends 
SortedFileGlobalIndexReader<BTreeIn
     @Nullable private final RoaringNavigableMap64 rowIdFilter;
     private final Comparator<Object> comparator;
     private final long totalRowCount;
+    private final List<GlobalIndexIOMeta> indexFiles;
+    private final long fallbackScanMaxSize;
     @Nullable private final Pair<Object, Object> fullRangeBounds;
 
     public LazyFilteredBTreeReader(
@@ -77,6 +77,8 @@ public class LazyFilteredBTreeReader extends 
SortedFileGlobalIndexReader<BTreeIn
         this.fileReader = fileReader;
         this.keySerializer = keySerializer;
         this.indexFields = indexFields;
+        this.indexFiles = files;
+        this.fallbackScanMaxSize = fallbackScanMaxSize;
         this.rowIdFilter =
                 rowRanges == null ? null : 
GlobalIndexResult.fromRanges(rowRanges).results();
         this.comparator = keySerializer.createComparator();
@@ -99,6 +101,9 @@ public class LazyFilteredBTreeReader extends 
SortedFileGlobalIndexReader<BTreeIn
                 return null;
             }
             remaining -= file.rowCount();
+            if (file.metadata() == null) {
+                return null;
+            }
             SortedIndexFileMeta meta = 
SortedIndexFileMeta.deserialize(file.metadata());
             if (meta.hasNulls() || meta.firstKey() == null || meta.lastKey() 
== null) {
                 return null;
@@ -120,16 +125,20 @@ public class LazyFilteredBTreeReader extends 
SortedFileGlobalIndexReader<BTreeIn
         if (indexFields.size() < 2) {
             return CompletableFuture.completedFuture(Optional.empty());
         }
-        Optional<List<LeafPredicate>> matched =
-                CompositeBTreePredicate.match(indexFields, predicate);
-        if (!matched.isPresent()) {
+        Optional<CompositeBTreePredicate.Plan> planned =
+                CompositeBTreePredicate.plan(indexFields, predicate);
+        if (!planned.isPresent()) {
             return CompletableFuture.completedFuture(Optional.empty());
         }
-        if (CompositeBTreePredicate.isContradictory(indexFields, predicate)) {
-            return 
CompletableFuture.completedFuture(Optional.of(GlobalIndexResult.createEmpty()));
+        CompositeBTreePredicate.Plan plan = planned.get();
+        if (plan.isPointLookup() && !plan.isEmpty()) {
+            return visitEqual((FieldRef) null, plan.pointKey());
+        }
+        List<GlobalIndexIOMeta> selected = plan.selectFiles(indexFiles);
+        if (!plan.canScan(selected, fallbackScanMaxSize)) {
+            return CompletableFuture.completedFuture(Optional.empty());
         }
-        Object[] values = matched.get().stream().map(leaf -> 
leaf.literals().get(0)).toArray();
-        return visitEqual((FieldRef) null, GenericRow.of(values));
+        return visitSelectedFiles(Optional.of(selected), reader -> 
reader.visitComposite(plan));
     }
 
     @Override
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/sst/BlockIterator.java 
b/paimon-common/src/main/java/org/apache/paimon/sst/BlockIterator.java
index b477f8547d..861f892f81 100644
--- a/paimon-common/src/main/java/org/apache/paimon/sst/BlockIterator.java
+++ b/paimon-common/src/main/java/org/apache/paimon/sst/BlockIterator.java
@@ -24,6 +24,7 @@ import org.apache.paimon.memory.MemorySliceInput;
 import java.util.Iterator;
 import java.util.Map;
 import java.util.NoSuchElementException;
+import java.util.function.ToIntFunction;
 
 /** An {@link Iterator} for a block. */
 public class BlockIterator implements Iterator<Map.Entry<MemorySlice, 
MemorySlice>> {
@@ -64,6 +65,11 @@ public class BlockIterator implements 
Iterator<Map.Entry<MemorySlice, MemorySlic
     }
 
     public boolean seekTo(MemorySlice targetKey) {
+        return seekTo(key -> reader.comparator().compare(key, targetKey));
+    }
+
+    /** Seek using a monotone comparison to a key or virtual prefix boundary. 
*/
+    public boolean seekTo(ToIntFunction<MemorySlice> compareToTarget) {
         int left = 0;
         int right = reader.recordCount() - 1;
         polledPosition = -1;
@@ -72,7 +78,7 @@ public class BlockIterator implements 
Iterator<Map.Entry<MemorySlice, MemorySlic
         while (left <= right) {
             int mid = left + (right - left) / 2;
 
-            int compare = reader.comparator().compare(readKey(mid), targetKey);
+            int compare = compareToTarget.applyAsInt(readKey(mid));
 
             if (compare == 0) {
                 polledPosition = mid;
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/sst/SstFileReader.java 
b/paimon-common/src/main/java/org/apache/paimon/sst/SstFileReader.java
index 8346464f3b..ea0575457e 100644
--- a/paimon-common/src/main/java/org/apache/paimon/sst/SstFileReader.java
+++ b/paimon-common/src/main/java/org/apache/paimon/sst/SstFileReader.java
@@ -32,6 +32,7 @@ import javax.annotation.Nullable;
 import java.io.Closeable;
 import java.io.IOException;
 import java.util.Comparator;
+import java.util.function.ToIntFunction;
 
 import static org.apache.paimon.sst.SstFileUtils.crc32c;
 import static org.apache.paimon.utils.Preconditions.checkArgument;
@@ -279,15 +280,19 @@ public class SstFileReader implements Closeable {
          */
         public void seekTo(byte[] key) {
             MemorySlice keySlice = MemorySlice.wrap(key);
+            seekTo(candidate -> comparator.compare(candidate, keySlice));
+        }
 
-            indexIterator.seekTo(keySlice);
+        /** Seek to a virtual boundary without encoding it as a persisted key. 
*/
+        public void seekTo(ToIntFunction<MemorySlice> compareToTarget) {
+            indexIterator.seekTo(compareToTarget);
             if (indexIterator.hasNext()) {
                 seekedDataBlock = getNextBlock(indexIterator);
                 // The index block entry key is the last key of the 
corresponding data block.
                 // If there is some index entry key >= targetKey, the related 
data block must
                 // also contain some key >= target key, which means 
seekedDataBlock.hasNext()
                 // must be true
-                seekedDataBlock.seekTo(keySlice);
+                seekedDataBlock.seekTo(compareToTarget);
                 Preconditions.checkState(seekedDataBlock.hasNext());
             } else {
                 seekedDataBlock = null;
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/CompositeBTreeIndexTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/CompositeBTreeIndexTest.java
index 1c05959f13..b642583541 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/CompositeBTreeIndexTest.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/CompositeBTreeIndexTest.java
@@ -194,11 +194,8 @@ class CompositeBTreeIndexTest {
                             BinaryString.fromString("tag"));
             for (Predicate unsupported :
                     Arrays.asList(
-                            builder.equal(0, 
BinaryString.fromString("category-a")),
-                            PredicateBuilder.and(
-                                    builder.equal(0, 
BinaryString.fromString("category-a")),
-                                    builder.greaterThan(1, 7),
-                                    builder.equal(2, 
BinaryString.fromString("tag"))),
+                            builder.equal(1, 7),
+                            builder.isNull(0),
                             PredicateBuilder.and(
                                     builder.in(
                                             0,
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/CompositeBTreePredicateTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/CompositeBTreePredicateTest.java
index 6172018d29..4a63cdd80a 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/CompositeBTreePredicateTest.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/CompositeBTreePredicateTest.java
@@ -25,7 +25,6 @@ import org.apache.paimon.globalindex.CompositeKeySerializer;
 import org.apache.paimon.globalindex.GlobalIndexIOMeta;
 import org.apache.paimon.globalindex.KeySerializer;
 import org.apache.paimon.globalindex.SortedIndexFileMeta;
-import org.apache.paimon.predicate.LeafPredicate;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.predicate.PredicateBuilder;
 import org.apache.paimon.types.DataField;
@@ -37,11 +36,10 @@ import org.junit.jupiter.api.Test;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
-import java.util.stream.Collectors;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
-/** Complete tuple matching and conservative manifest metadata pruning. */
+/** Typed interval planning and conservative manifest metadata pruning. */
 class CompositeBTreePredicateTest {
 
     private final List<DataField> fields =
@@ -57,42 +55,36 @@ class CompositeBTreePredicateTest {
         Predicate predicate =
                 PredicateBuilder.and(
                         builder.equal(1, -3), builder.equal(0, 
BinaryString.fromString("a")));
-        List<LeafPredicate> matched = CompositeBTreePredicate.match(fields, 
predicate).get();
+        assertThat(CompositeBTreePredicate.plan(fields, 
predicate).get().pointKey())
+                .isEqualTo(GenericRow.of(BinaryString.fromString("a"), -3));
+        assertThat(CompositeBTreePredicate.plan(fields, 
predicate).get().isEmpty()).isFalse();
         assertThat(
-                        matched.stream()
-                                .map(leaf -> leaf.literals().get(0))
-                                .collect(Collectors.toList()))
-                .containsExactly(BinaryString.fromString("a"), -3);
-        assertThat(CompositeBTreePredicate.isContradictory(fields, 
predicate)).isFalse();
-        assertThat(
-                        CompositeBTreePredicate.match(
+                        CompositeBTreePredicate.plan(
                                         Arrays.asList(fields.get(1), 
fields.get(0)), predicate)
-                                .get().stream()
-                                .map(leaf -> leaf.literals().get(0))
-                                .collect(Collectors.toList()))
-                .containsExactly(-3, BinaryString.fromString("a"));
+                                .get()
+                                .pointKey())
+                .isEqualTo(GenericRow.of(-3, BinaryString.fromString("a")));
     }
 
     @Test
-    void testRequiresCompleteConjunctiveEqualitiesAndMatchingTypes() {
-        assertThat(
-                        CompositeBTreePredicate.match(
-                                fields, builder.equal(0, 
BinaryString.fromString("a"))))
-                .isEmpty();
+    void testRequiresLeadingConstraintAndMatchingTypes() {
+        assertThat(CompositeBTreePredicate.plan(fields, builder.equal(1, 
7))).isEmpty();
         assertThat(
-                        CompositeBTreePredicate.match(
+                        CompositeBTreePredicate.plan(
                                 fields,
                                 PredicateBuilder.or(
                                         builder.equal(0, 
BinaryString.fromString("a")),
                                         builder.equal(1, 7))))
                 .isEmpty();
         assertThat(
-                        CompositeBTreePredicate.match(
-                                fields,
-                                PredicateBuilder.and(
-                                        builder.equal(0, 
BinaryString.fromString("a")),
-                                        builder.greaterThan(1, 7))))
-                .isEmpty();
+                        CompositeBTreePredicate.plan(
+                                        fields,
+                                        PredicateBuilder.and(
+                                                builder.equal(0, 
BinaryString.fromString("a")),
+                                                builder.greaterThan(1, 7)))
+                                .get()
+                                .boundColumns())
+                .isEqualTo(2);
         PredicateBuilder incompatible =
                 new PredicateBuilder(
                         new RowType(
@@ -100,7 +92,7 @@ class CompositeBTreePredicateTest {
                                         new DataField(10, "category", 
DataTypes.INT()),
                                         fields.get(1))));
         assertThat(
-                        CompositeBTreePredicate.match(
+                        CompositeBTreePredicate.plan(
                                 fields,
                                 PredicateBuilder.and(
                                         incompatible.equal(0, 1), 
incompatible.equal(1, 7))))
@@ -134,16 +126,16 @@ class CompositeBTreePredicateTest {
                         PredicateBuilder.and(
                                 builder.equal(0, BinaryString.fromString("a")),
                                 builder.equal(1, null)))) {
-            assertThat(CompositeBTreePredicate.isContradictory(fields, 
predicate)).isTrue();
+            assertThat(CompositeBTreePredicate.plan(fields, 
predicate).get().isEmpty()).isTrue();
             assertThat(factory.selectFiles(fields, predicate, 
files)).isEmpty();
         }
         Predicate redundant = PredicateBuilder.and(equal("a", 7), 
builder.equal(1, 7));
-        assertThat(CompositeBTreePredicate.isContradictory(fields, 
redundant)).isFalse();
+        assertThat(CompositeBTreePredicate.plan(fields, 
redundant).get().isEmpty()).isFalse();
         assertThat(factory.selectFiles(fields, redundant, 
files)).containsExactlyElementsOf(files);
     }
 
     @Test
-    void testRetainsFilesWhenCompleteKeyOrMetadataIsUnavailable() {
+    void testRetainsFilesWhenLeadingConstraintOrMetadataIsUnavailable() {
         GlobalIndexIOMeta file = file("first", "a", -3, "a", 7);
         List<GlobalIndexIOMeta> files = Collections.singletonList(file);
         assertThat(factory.selectFiles(fields, builder.equal(1, 100), 
files)).isSameAs(files);
@@ -154,7 +146,7 @@ class CompositeBTreePredicateTest {
                                         builder.equal(0, 
BinaryString.fromString("a")),
                                         builder.greaterThan(1, 100)),
                                 files))
-                .isSameAs(files);
+                .isEmpty();
         List<GlobalIndexIOMeta> unknown =
                 Arrays.asList(file, new GlobalIndexIOMeta(new Path("unknown"), 
1, null));
         assertThat(factory.selectFiles(fields, equal("z", 7), 
unknown)).isSameAs(unknown);
@@ -166,6 +158,40 @@ class CompositeBTreePredicateTest {
                 .isEmpty();
     }
 
+    @Test
+    void testPrefixAndRangePruneTypedEndpoints() {
+        GlobalIndexIOMeta first = file("first", "a", -3, "a", 7);
+        GlobalIndexIOMeta second = file("second", "a", 10, "b", 2);
+        GlobalIndexIOMeta third = file("third", "b", 3, "c", 4);
+        List<GlobalIndexIOMeta> files = Arrays.asList(first, second, third);
+        Predicate prefix = builder.equal(0, BinaryString.fromString("a"));
+        assertThat(factory.selectFiles(fields, prefix, 
files)).containsExactly(first, second);
+        assertThat(
+                        factory.selectFiles(
+                                fields,
+                                PredicateBuilder.and(prefix, 
builder.greaterThan(1, 7)),
+                                files))
+                .containsExactly(second);
+        assertThat(
+                        factory.selectFiles(
+                                fields,
+                                PredicateBuilder.and(prefix, 
builder.greaterOrEqual(1, 7)),
+                                files))
+                .containsExactly(first, second);
+        assertThat(
+                        factory.selectFiles(
+                                fields,
+                                PredicateBuilder.and(prefix, 
builder.lessThan(1, -3)),
+                                files))
+                .isEmpty();
+        assertThat(
+                        factory.selectFiles(
+                                fields,
+                                PredicateBuilder.and(prefix, 
builder.lessOrEqual(1, -3)),
+                                files))
+                .containsExactly(first);
+    }
+
     private Predicate equal(String category, int number) {
         return PredicateBuilder.and(
                 builder.equal(1, number), builder.equal(0, 
BinaryString.fromString(category)));
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/CompositeBTreeRangeTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/CompositeBTreeRangeTest.java
new file mode 100644
index 0000000000..58a1602851
--- /dev/null
+++ 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/CompositeBTreeRangeTest.java
@@ -0,0 +1,236 @@
+/*
+ * 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 org.apache.paimon.globalindex.btree;
+
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.globalindex.CompositeKeySerializer;
+import org.apache.paimon.globalindex.GlobalIndexIOMeta;
+import org.apache.paimon.globalindex.GlobalIndexReader;
+import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
+import org.apache.paimon.globalindex.GlobalIndexer;
+import org.apache.paimon.globalindex.ResultEntry;
+import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
+import org.apache.paimon.options.MemorySize;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RoaringNavigableMap64;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.ExecutorService;
+
+import static 
org.apache.paimon.shade.guava30.com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Prefix and range seeks must include entire boundary suffixes across SST 
blocks. */
+class CompositeBTreeRangeTest {
+
+    @TempDir java.nio.file.Path tempPath;
+
+    private final RowType type =
+            RowType.of(
+                    new DataField(10, "category", DataTypes.INT()),
+                    new DataField(20, "item_number", DataTypes.INT()),
+                    new DataField(30, "suffix", DataTypes.INT()));
+    private final PredicateBuilder builder = new PredicateBuilder(type);
+    private final List<GenericRow> rows = new ArrayList<>();
+    private final LocalFileIO io = LocalFileIO.create();
+
+    @ParameterizedTest
+    @ValueSource(ints = {1, 2})
+    void testPrefixAndRangeSeeksAcrossBlocks(int version) throws Exception {
+        Options options = options(version);
+        List<GlobalIndexIOMeta> files = write(options);
+        List<Predicate> queries =
+                Arrays.asList(
+                        builder.equal(0, 1),
+                        PredicateBuilder.and(builder.equal(0, 1), 
builder.equal(1, 7)),
+                        PredicateBuilder.and(builder.equal(0, 1), 
builder.greaterThan(1, 7)),
+                        PredicateBuilder.and(builder.equal(0, 1), 
builder.greaterOrEqual(1, 7)),
+                        PredicateBuilder.and(builder.equal(0, 1), 
builder.lessThan(1, 7)),
+                        PredicateBuilder.and(builder.equal(0, 1), 
builder.lessOrEqual(1, 7)),
+                        PredicateBuilder.and(builder.equal(0, 1), 
builder.between(1, -1, 7)),
+                        PredicateBuilder.and(
+                                builder.equal(0, 1),
+                                builder.greaterThan(1, -1),
+                                builder.lessThan(1, 7)),
+                        PredicateBuilder.and(
+                                builder.equal(0, 1),
+                                builder.greaterOrEqual(1, -1),
+                                builder.lessOrEqual(1, 7)),
+                        builder.greaterThan(0, 0),
+                        builder.lessOrEqual(0, 0),
+                        builder.between(0, -1, 1),
+                        PredicateBuilder.and(
+                                builder.equal(0, 1), builder.greaterThan(1, 
Integer.MAX_VALUE)),
+                        PredicateBuilder.and(
+                                builder.equal(0, 1), builder.lessThan(1, 
Integer.MIN_VALUE)),
+                        PredicateBuilder.and(
+                                builder.equal(0, 1),
+                                builder.greaterThan(1, 7),
+                                builder.lessOrEqual(1, 7)),
+                        PredicateBuilder.and(
+                                builder.equal(0, 1), builder.equal(1, 7), 
builder.lessThan(1, 7)),
+                        PredicateBuilder.and(builder.equal(0, null), 
builder.greaterThan(1, 7)),
+                        PredicateBuilder.and(builder.equal(0, 1), 
builder.lessThan(1, null)));
+        ExecutorService executor = newDirectExecutorService();
+        try {
+            for (List<Range> ranges :
+                    Arrays.asList(null, Collections.singletonList(new 
Range(20, 150)))) {
+                try (GlobalIndexReader reader =
+                        GlobalIndexer.create("btree", type.getFields(), 
options)
+                                .createReader(
+                                        file -> 
io.newInputStream(file.filePath()),
+                                        files,
+                                        rows.size(),
+                                        ranges,
+                                        executor)) {
+                    for (Predicate query : queries) {
+                        RoaringNavigableMap64 expected = new 
RoaringNavigableMap64();
+                        for (int i = 0; i < rows.size(); i++) {
+                            if ((ranges == null || (i >= 20 && i <= 150))
+                                    && query.test(rows.get(i))) {
+                                expected.add(i);
+                            }
+                        }
+                        
assertThat(reader.visitComposite(query).join()).isPresent();
+                        assertThat(
+                                        reader.visitComposite(query)
+                                                .join()
+                                                .get()
+                                                .results()
+                                                .toRangeList())
+                                .as("%s, local ranges %s", query, ranges)
+                                
.containsExactlyElementsOf(expected.toRangeList());
+                    }
+                }
+            }
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    void testBudgetFallbackDoesNotDisablePointLookupsOrProvenEmptyRanges() 
throws Exception {
+        Options options = options(2);
+        List<GlobalIndexIOMeta> files = write(options);
+        options.set(BTreeIndexOptions.BTREE_INDEX_FALLBACK_SCAN_MAX_SIZE, 
MemorySize.ofBytes(0));
+        ExecutorService executor = newDirectExecutorService();
+        try (GlobalIndexReader reader =
+                GlobalIndexer.create("btree", type.getFields(), options)
+                        .createReader(
+                                file -> io.newInputStream(file.filePath()),
+                                files,
+                                rows.size(),
+                                null,
+                                executor)) {
+            assertThat(reader.visitComposite(builder.equal(0, 
1)).join()).isEmpty();
+            Predicate point =
+                    PredicateBuilder.and(
+                            builder.equal(0, 1), builder.equal(1, 7), 
builder.equal(2, 0));
+            
assertThat(reader.visitComposite(point).join().get().results().isEmpty()).isFalse();
+            assertThat(
+                            reader.visitComposite(builder.greaterThan(0, 
Integer.MAX_VALUE))
+                                    .join()
+                                    .get()
+                                    .results()
+                                    .isEmpty())
+                    .isTrue();
+            for (Predicate unsupported :
+                    Arrays.asList(
+                            builder.equal(1, 7),
+                            builder.isNull(0),
+                            builder.in(0, Arrays.asList(-1, 0, 1)))) {
+                
assertThat(reader.visitComposite(unsupported).join()).isEmpty();
+            }
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
+    private Options options(int version) {
+        Options options = new Options();
+        options.set(BTreeIndexOptions.BTREE_INDEX_FILE_VERSION, version);
+        options.set(BTreeIndexOptions.BTREE_INDEX_BLOCK_SIZE, 
MemorySize.ofBytes(128));
+        options.set(BTreeIndexOptions.BTREE_INDEX_COMPRESSION, "lz4");
+        options.set(BTreeIndexOptions.BTREE_INDEX_BLOOM_FILTER_ENABLED, true);
+        return options;
+    }
+
+    private List<GlobalIndexIOMeta> write(Options options) throws Exception {
+        for (Integer category :
+                Arrays.asList(null, Integer.MIN_VALUE, -1, 0, 1, 
Integer.MAX_VALUE)) {
+            for (Integer number :
+                    Arrays.asList(null, Integer.MIN_VALUE, -1, 0, 7, 8, 
Integer.MAX_VALUE)) {
+                for (int suffix = 0; suffix < 5; suffix++) {
+                    rows.add(GenericRow.of(category, number, suffix));
+                }
+            }
+        }
+        rows.add(GenericRow.of(1, 7, 0));
+        rows.add(GenericRow.of(1, 7, null));
+        CompositeKeySerializer serializer = new CompositeKeySerializer(type);
+        rows.sort((left, right) -> serializer.createComparator().compare(left, 
right));
+        Path directory = new Path(tempPath.toUri());
+        GlobalIndexFileWriter files =
+                new GlobalIndexFileWriter() {
+                    @Override
+                    public String newFileName(String prefix) {
+                        return prefix + UUID.randomUUID();
+                    }
+
+                    @Override
+                    public PositionOutputStream newOutputStream(String name) 
throws IOException {
+                        return io.newOutputStream(new Path(directory, name), 
false);
+                    }
+                };
+        GlobalIndexSingleColumnWriter writer =
+                (GlobalIndexSingleColumnWriter)
+                        GlobalIndexer.create("btree", type.getFields(), 
options)
+                                .createWriter(files);
+        for (int i = 0; i < rows.size(); i++) {
+            writer.write(rows.get(i), i);
+        }
+        List<GlobalIndexIOMeta> result = new ArrayList<>();
+        for (ResultEntry entry : writer.finish()) {
+            Path path = new Path(directory, entry.fileName());
+            result.add(
+                    new GlobalIndexIOMeta(
+                            path, io.getFileSize(path), entry.rowCount(), 
entry.meta()));
+        }
+        return result;
+    }
+}
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/sst/BlockIteratorTest.java 
b/paimon-common/src/test/java/org/apache/paimon/sst/BlockIteratorTest.java
index f40b464399..47c7e6573d 100644
--- a/paimon-common/src/test/java/org/apache/paimon/sst/BlockIteratorTest.java
+++ b/paimon-common/src/test/java/org/apache/paimon/sst/BlockIteratorTest.java
@@ -45,6 +45,27 @@ public class BlockIteratorTest {
         innerTest(false);
     }
 
+    @Test
+    void testSeekToVirtualPrefixBoundaries() throws IOException {
+        BlockIterator iterator = BlockReader.create(writeBlock(true), 
COMPARATOR).iterator();
+        iterator.seekTo(
+                key -> {
+                    int comparison = Integer.compare(key.readInt(0) / 10, 3);
+                    return comparison == 0 ? 1 : comparison;
+                });
+        Assertions.assertEquals(30, iterator.next().getKey().readInt(0));
+        iterator.seekTo(
+                key -> {
+                    int comparison = Integer.compare(key.readInt(0) / 10, 3);
+                    return comparison == 0 ? -1 : comparison;
+                });
+        Assertions.assertEquals(40, iterator.next().getKey().readInt(0));
+        iterator.seekTo(key -> -1);
+        Assertions.assertFalse(iterator.hasNext());
+        iterator.seekTo(key -> 1);
+        Assertions.assertEquals(0, iterator.next().getKey().readInt(0));
+    }
+
     public void innerTest(boolean aligned) throws IOException {
         MemorySlice data = writeBlock(aligned);
         BlockReader reader = BlockReader.create(data, COMPARATOR);
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionBatchScan.java
 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionBatchScan.java
index a1cdc71ebe..4fdae682e5 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionBatchScan.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionBatchScan.java
@@ -388,7 +388,8 @@ public class DataEvolutionBatchScan implements 
DataTableScan {
                         table.rowType(),
                         indexFilter,
                         indexFiles,
-                        table.store().pathFactory().globalIndexFileFactory());
+                        table.store().pathFactory().globalIndexFileFactory(),
+                        table.coreOptions().toConfiguration());
         if (indexQuery == null) {
             return dataPlan;
         }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexScanner.java
 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexScanner.java
index a6ef515ceb..e294cc09d1 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexScanner.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexScanner.java
@@ -408,7 +408,8 @@ public class DataEvolutionGlobalIndexScanner implements 
Closeable {
                                         .noneMatch(
                                                 
DataEvolutionGlobalIndexScanner::isMultiFieldIndex)
                         ? null
-                        : GlobalIndexQuery.create(rowType, predicate, 
indexFiles, indexPathFactory);
+                        : GlobalIndexQuery.create(
+                                rowType, predicate, indexFiles, 
indexPathFactory, options);
         if (query != null && query.hasCompositeQuery()) {
             try {
                 return query.evaluateWithCoverage(
@@ -443,7 +444,8 @@ public class DataEvolutionGlobalIndexScanner implements 
Closeable {
         GlobalIndexQuery query =
                 predicate == null
                         ? null
-                        : GlobalIndexQuery.create(rowType, predicate, 
indexFiles, indexPathFactory);
+                        : GlobalIndexQuery.create(
+                                rowType, predicate, indexFiles, 
indexPathFactory, options);
         if (query != null && query.hasCompositeQuery() && 
query.hasScalarQuery()) {
             // Scalar shards can decline a predicate at runtime, so the legacy 
API must use
             // evaluated coverage as well. Internal callers retain the 
evaluation directly.
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexQuery.java 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexQuery.java
index 18737d69f9..f962282552 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexQuery.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexQuery.java
@@ -22,6 +22,7 @@ import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.fs.Path;
 import 
org.apache.paimon.globalindex.DataEvolutionGlobalIndexScanner.IndexMetaFileGroup;
 import org.apache.paimon.globalindex.GlobalIndexEvaluator.Evaluation;
+import org.apache.paimon.globalindex.btree.BTreeIndexOptions;
 import org.apache.paimon.globalindex.btree.CompositeBTreePredicate;
 import org.apache.paimon.index.IndexFileMeta;
 import org.apache.paimon.index.IndexPathFactory;
@@ -30,7 +31,6 @@ import org.apache.paimon.io.DataOutputView;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.predicate.And;
 import org.apache.paimon.predicate.CompoundPredicate;
-import org.apache.paimon.predicate.Equal;
 import org.apache.paimon.predicate.FieldRef;
 import org.apache.paimon.predicate.GreaterOrEqual;
 import org.apache.paimon.predicate.LeafPredicate;
@@ -106,7 +106,8 @@ class GlobalIndexQuery {
             RowType rowType,
             Predicate predicate,
             List<IndexFileMeta> files,
-            IndexPathFactory pathFactory) {
+            IndexPathFactory pathFactory,
+            Options options) {
         Map<Integer, List<IndexMetaFileGroup>> groupsByField =
                 DataEvolutionGlobalIndexScanner.groupIndexFiles(
                         files.stream()
@@ -124,90 +125,54 @@ class GlobalIndexQuery {
                     }
                     groups.put(fieldId, indexGroups);
                 });
-        return createForPredicate(predicate, rowType, groups);
+        return createForPredicate(predicate, rowType, groups, options);
     }
 
     @Nullable
     private static GlobalIndexQuery createForPredicate(
-            Predicate predicate, RowType rowType, Map<Integer, 
List<IndexGroup>> groups) {
+            Predicate predicate,
+            RowType rowType,
+            Map<Integer, List<IndexGroup>> groups,
+            Options options) {
         if (predicate instanceof LeafPredicate) {
             LeafPredicate leaf = (LeafPredicate) predicate;
             Optional<FieldRef> field = leaf.fieldRefOptional();
             if (!field.isPresent()) {
                 return null;
             }
-            return createForIndexedField(leaf, field.get(), rowType, groups);
+            CompositeCandidate composite = selectComposite(leaf, groups, 
options);
+            return composite == null
+                    ? createForIndexedField(leaf, field.get(), rowType, groups)
+                    : composite.query();
         }
         CompoundPredicate compound = (CompoundPredicate) predicate;
         boolean union = compound.function() instanceof Or;
         List<GlobalIndexQuery> children = new ArrayList<>();
         List<Predicate> predicates = 
GlobalIndexEvaluator.normalizedChildren(compound);
         if (!union) {
-            // Prefer the longest full composite key before any single-column 
posting is read.
             while (!predicates.isEmpty()) {
-                IndexGroup selected = null;
-                List<LeafPredicate> matched = null;
-                Predicate conjunction = PredicateBuilder.and(predicates);
-                for (List<IndexGroup> fieldGroups : groups.values()) {
-                    for (IndexGroup group : fieldGroups) {
-                        if (!group.isCompositeBTree()
-                                || (selected != null
-                                        && group.extraFields.size()
-                                                <= 
selected.extraFields.size())) {
-                            continue;
-                        }
-                        Optional<List<LeafPredicate>> match =
-                                
CompositeBTreePredicate.match(group.indexFields(), conjunction);
-                        if (match.isPresent()) {
-                            selected = group;
-                            matched = match.get();
-                        }
-                    }
-                }
+                CompositeCandidate selected =
+                        selectComposite(PredicateBuilder.and(predicates), 
groups, options);
                 if (selected == null) {
                     break;
                 }
-                List<Predicate> covered = new ArrayList<>();
-                for (Predicate child : predicates) {
-                    if (child instanceof LeafPredicate) {
-                        LeafPredicate leaf = (LeafPredicate) child;
-                        if (leaf.function() instanceof Equal
-                                && matched.stream()
-                                        .anyMatch(
-                                                equality ->
-                                                        
equality.fieldRefOptional()
-                                                                
.equals(leaf.fieldRefOptional()))) {
-                            covered.add(child);
-                        }
-                    }
-                }
-                Predicate composite = PredicateBuilder.and(covered);
-                List<IndexGroup> selectedGroups = new ArrayList<>();
-                for (IndexGroup group : groups.get(selected.field.id())) {
-                    if (group.type.equals(selected.type)
-                            && 
group.indexFields().equals(selected.indexFields())) {
-                        selectedGroups.add(group.selectFiles(composite));
-                    }
-                }
-                children.add(
-                        new GlobalIndexQuery(
-                                composite, false, Collections.emptyList(), 
selectedGroups));
-                predicates.removeAll(covered);
-                // The tuple equality already narrows these columns to one 
value. Leave their
-                // remaining conditions as data filters instead of reading 
scalar postings.
-                List<LeafPredicate> keyEqualities = matched;
+                children.add(selected.query());
+                predicates.removeAll(selected.plan.predicates());
+                // Later key conditions remain data filters; do not expand 
scalar postings for them.
+                Set<String> keyColumns =
+                        selected.group.indexFields().stream()
+                                .map(DataField::name)
+                                .collect(Collectors.toSet());
                 predicates.removeIf(
-                        child -> {
-                            if (!(child instanceof LeafPredicate)) {
-                                return false;
-                            }
-                            Optional<FieldRef> field = ((LeafPredicate) 
child).fieldRefOptional();
-                            return keyEqualities.stream()
-                                    .anyMatch(
-                                            equality -> 
equality.fieldRefOptional().equals(field));
-                        });
+                        child ->
+                                child instanceof LeafPredicate
+                                        && ((LeafPredicate) child)
+                                                .fieldRefOptional()
+                                                .filter(field -> 
keyColumns.contains(field.name()))
+                                                .isPresent());
             }
         }
+
         for (int i = 0; i < predicates.size(); i++) {
             Predicate child = predicates.get(i);
             GlobalIndexQuery query = null;
@@ -238,7 +203,7 @@ class GlobalIndexQuery {
                 }
             }
             if (query == null) {
-                query = createForPredicate(child, rowType, groups);
+                query = createForPredicate(child, rowType, groups, options);
             }
             if (query == null) {
                 if (union) {
@@ -253,6 +218,101 @@ class GlobalIndexQuery {
                 : new GlobalIndexQuery(null, union, children, 
Collections.emptyList());
     }
 
+    @Nullable
+    private static CompositeCandidate selectComposite(
+            Predicate predicate, Map<Integer, List<IndexGroup>> groups, 
Options options) {
+        CompositeCandidate selected = null;
+        Set<List<DataField>> seen = new HashSet<>();
+        long budget = 
options.get(BTreeIndexOptions.BTREE_INDEX_FALLBACK_SCAN_MAX_SIZE).getBytes();
+        for (List<IndexGroup> fieldGroups : groups.values()) {
+            for (IndexGroup group : fieldGroups) {
+                if (!group.isCompositeBTree() || 
!seen.add(group.indexFields())) {
+                    continue;
+                }
+                Optional<CompositeBTreePredicate.Plan> planned =
+                        CompositeBTreePredicate.plan(group.indexFields(), 
predicate);
+                if (!planned.isPresent()) {
+                    continue;
+                }
+                CompositeBTreePredicate.Plan plan = planned.get();
+                List<IndexGroup> definition =
+                        groups.get(group.field.id()).stream()
+                                .filter(
+                                        other ->
+                                                other.type.equals(group.type)
+                                                        && other.indexFields()
+                                                                
.equals(group.indexFields()))
+                                .collect(Collectors.toList());
+                Predicate covered = PredicateBuilder.and(plan.predicates());
+                List<IndexGroup> selectedGroups =
+                        definition.stream()
+                                .map(part -> part.selectFiles(covered))
+                                .collect(Collectors.toList());
+                // Decline the entire definition when any shard exceeds the 
same reader budget.
+                // Selecting only some shards would change FAST coverage 
between eager and reader
+                // queries.
+                if (selectedGroups.stream().anyMatch(part -> 
!plan.canScan(part.files, budget))) {
+                    continue;
+                }
+                if (plan.boundColumns() == 1) {
+                    List<Range> scalarCoverage =
+                            Range.sortAndMergeOverlap(
+                                    fieldGroups.stream()
+                                            .filter(other -> 
other.extraFields.isEmpty())
+                                            .map(other -> other.range)
+                                            .collect(Collectors.toList()),
+                                    true);
+                    List<Range> compositeCoverage =
+                            Range.sortAndMergeOverlap(
+                                    definition.stream()
+                                            .map(other -> other.range)
+                                            .collect(Collectors.toList()),
+                                    true);
+                    // A dedicated leading-field lookup avoids expanding the 
tuple suffix.
+                    if (Range.and(compositeCoverage, 
scalarCoverage).equals(compositeCoverage)) {
+                        continue;
+                    }
+                }
+                CompositeCandidate candidate = new CompositeCandidate(group, 
plan, selectedGroups);
+                if (selected == null || candidate.preferredTo(selected)) {
+                    selected = candidate;
+                }
+            }
+        }
+        return selected;
+    }
+
+    private static class CompositeCandidate {
+        private final IndexGroup group;
+        private final CompositeBTreePredicate.Plan plan;
+        private final List<IndexGroup> groups;
+
+        private CompositeCandidate(
+                IndexGroup group, CompositeBTreePredicate.Plan plan, 
List<IndexGroup> groups) {
+            this.group = group;
+            this.plan = plan;
+            this.groups = groups;
+        }
+
+        private boolean preferredTo(CompositeCandidate other) {
+            if (plan.boundColumns() != other.plan.boundColumns()) {
+                return plan.boundColumns() > other.plan.boundColumns();
+            }
+            if (plan.equalColumns() != other.plan.equalColumns()) {
+                return plan.equalColumns() > other.plan.equalColumns();
+            }
+            return group.indexFields().size() < 
other.group.indexFields().size();
+        }
+
+        private GlobalIndexQuery query() {
+            return new GlobalIndexQuery(
+                    PredicateBuilder.and(plan.predicates()),
+                    false,
+                    Collections.emptyList(),
+                    groups);
+        }
+    }
+
     @Nullable
     private static GlobalIndexQuery createForIndexedField(
             Predicate predicate,
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/globalindex/GlobalIndexQueryTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/globalindex/GlobalIndexQueryTest.java
index 79727b7b3e..c315a73b20 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/globalindex/GlobalIndexQueryTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/globalindex/GlobalIndexQueryTest.java
@@ -124,7 +124,8 @@ class GlobalIndexQueryTest {
                         builder.equal(0, 25),
                         PredicateBuilder.and(
                                 builder.greaterThan(0, 15), 
builder.lessThan(0, 35)))) {
-            GlobalIndexQuery query = GlobalIndexQuery.create(rowType, 
predicate, files, paths);
+            GlobalIndexQuery query =
+                    GlobalIndexQuery.create(rowType, predicate, files, paths, 
new Options());
             assertThat(query).isNotNull();
             DataOutputSerializer out = new DataOutputSerializer(256);
             query.forRanges(Collections.singletonList(new Range(0, 
99))).serialize(out);
@@ -152,7 +153,8 @@ class GlobalIndexQueryTest {
                         invocation ->
                                 new 
Path(invocation.<IndexFileMeta>getArgument(0).fileName()));
         Options options = new Options();
-        GlobalIndexQuery plan = GlobalIndexQuery.create(rowType, range, files, 
paths);
+        GlobalIndexQuery plan =
+                GlobalIndexQuery.create(rowType, range, files, paths, new 
Options());
         assertThat(plan).isNotNull();
         IndexQuerySplit split =
                 new IndexQuerySplit(dataSplit(), plan, options.toMap(), 
Collections.emptyList());
@@ -189,7 +191,11 @@ class GlobalIndexQueryTest {
 
         assertThat(
                         GlobalIndexQuery.create(
-                                rowType, PredicateBuilder.or(lower, upper), 
files, paths))
+                                rowType,
+                                PredicateBuilder.or(lower, upper),
+                                files,
+                                paths,
+                                new Options()))
                 .isNotNull();
     }
 
@@ -249,7 +255,8 @@ class GlobalIndexQueryTest {
                 Arrays.asList(b.isNotNull(0), b.notEqual(0, 1), b.notIn(0, 
Arrays.asList(1, 3)));
         for (int i = 0; i < predicates.size(); i++) {
             GlobalIndexQuery plan =
-                    GlobalIndexQuery.create(rowType, predicates.get(i), files, 
paths);
+                    GlobalIndexQuery.create(
+                            rowType, predicates.get(i), files, paths, new 
Options());
             List<Range> ranges = Collections.singletonList(new Range(100, 
102));
             assertThat(plan).isNotNull();
             assertThat(plan.evaluate(fileIO, new Options(), 
ranges).results().toRangeList())
@@ -608,7 +615,8 @@ class GlobalIndexQueryTest {
                         rowType,
                         PredicateBuilder.and(supportedLeaf, unsupportedLeaf),
                         files,
-                        paths);
+                        paths,
+                        new Options());
         assertThat(andPlan).isNotNull();
         
assertThat(andPlan.contributingFieldIds(rowType)).containsExactlyInAnyOrder(0, 
1);
         IndexQuerySplit split =
@@ -620,20 +628,22 @@ class GlobalIndexQueryTest {
                                 rowType,
                                 PredicateBuilder.or(supportedLeaf, 
unsupportedLeaf),
                                 files,
-                                paths))
+                                paths,
+                                new Options()))
                 .isNotNull();
         assertThat(
                         GlobalIndexQuery.create(
                                 rowType,
                                 supportedLeaf,
                                 Arrays.asList(supported, indexFile("fm", "fm", 
0, 99, 0, null)),
-                                paths))
+                                paths,
+                                new Options()))
                 .isNotNull();
     }
 
     @ParameterizedTest
     @ValueSource(strings = {"btree", "custom-index"})
-    void testScalarPlansUseOnlySingleFieldDefinitions(String indexType) {
+    void testCompositePrefixEligibilityIsSeparateFromScalarDefinitions(String 
indexType) {
         RowType rowType = RowType.of(DataTypes.INT(), DataTypes.INT());
         PredicateBuilder builder = new PredicateBuilder(rowType);
         IndexFileMeta multi = indexFile(indexType, "multi", 0, 99, 0, new 
int[] {1});
@@ -644,10 +654,19 @@ class GlobalIndexQueryTest {
                                 new 
Path(invocation.<IndexFileMeta>getArgument(0).fileName()));
         for (int field : new int[] {0, 1}) {
             Predicate predicate = builder.equal(field, 7);
-            assertThat(
-                            GlobalIndexQuery.create(
-                                    rowType, predicate, 
Collections.singletonList(multi), paths))
-                    .isNull();
+            GlobalIndexQuery prefix =
+                    GlobalIndexQuery.create(
+                            rowType,
+                            predicate,
+                            Collections.singletonList(multi),
+                            paths,
+                            new Options());
+            if (field == 0 && "btree".equals(indexType)) {
+                assertThat(prefix).isNotNull();
+                assertThat(prefix.hasCompositeQuery()).isTrue();
+            } else {
+                assertThat(prefix).isNull();
+            }
             Range dedicatedRange = new Range(20 + field * 20, 39 + field * 20);
             IndexFileMeta dedicated =
                     indexFile(
@@ -659,13 +678,50 @@ class GlobalIndexQueryTest {
                             null);
             GlobalIndexQuery query =
                     GlobalIndexQuery.create(
-                            rowType, predicate, Arrays.asList(multi, 
dedicated), paths);
+                            rowType,
+                            predicate,
+                            Arrays.asList(multi, dedicated),
+                            paths,
+                            new Options());
             assertThat(query).isNotNull();
-            assertThat(query.coveredRanges()).containsExactly(dedicatedRange);
-            assertThat(query.hasCompositeQuery()).isFalse();
+            if (field == 0 && "btree".equals(indexType)) {
+                // Partial scalar coverage must not replace a fully covered 
composite prefix.
+                assertThat(query.coveredRanges()).containsExactly(new Range(0, 
99));
+                assertThat(query.hasCompositeQuery()).isTrue();
+            } else {
+                
assertThat(query.coveredRanges()).containsExactly(dedicatedRange);
+                assertThat(query.hasCompositeQuery()).isFalse();
+            }
         }
     }
 
+    @Test
+    void testDedicatedLeadingIndexPreferredOnlyWithCompleteCoverage() {
+        RowType rowType = RowType.of(DataTypes.INT(), DataTypes.INT());
+        Predicate predicate = new PredicateBuilder(rowType).equal(0, 7);
+        IndexPathFactory paths = mock(IndexPathFactory.class);
+        when(paths.toPath(any(IndexFileMeta.class)))
+                .thenAnswer(
+                        invocation ->
+                                new 
Path(invocation.<IndexFileMeta>getArgument(0).fileName()));
+        IndexFileMeta composite = indexFile("btree", "composite", 0, 99, 0, 
new int[] {1});
+        IndexFileMeta first = indexFile("btree", "first", 0, 49, 0, null);
+        IndexFileMeta second = indexFile("bitmap", "second", 50, 99, 0, null);
+        GlobalIndexQuery partial =
+                GlobalIndexQuery.create(
+                        rowType, predicate, Arrays.asList(composite, first), 
paths, new Options());
+        assertThat(partial.hasCompositeQuery()).isTrue();
+        GlobalIndexQuery complete =
+                GlobalIndexQuery.create(
+                        rowType,
+                        predicate,
+                        Arrays.asList(composite, first, second),
+                        paths,
+                        new Options());
+        assertThat(complete.hasCompositeQuery()).isFalse();
+        assertThat(complete.coveredRanges()).containsExactly(new Range(0, 99));
+    }
+
     private IndexFileMeta indexFile(
             String type, String name, long start, long end, int fieldId, int[] 
extraFields) {
         return new IndexFileMeta(
@@ -720,6 +776,7 @@ class GlobalIndexQueryTest {
                         return false;
                     }
                 };
-        return GlobalIndexQuery.create(rowType, predicate, 
Collections.singletonList(file), paths);
+        return GlobalIndexQuery.create(
+                rowType, predicate, Collections.singletonList(file), paths, 
new Options());
     }
 }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/CompositeBTreeTableTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/CompositeBTreeTableTest.java
index 9976fe8479..9e6361a82b 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/CompositeBTreeTableTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/CompositeBTreeTableTest.java
@@ -67,6 +67,159 @@ import static org.assertj.core.api.Assertions.assertThat;
 /** Composite lookups must avoid single-column postings and retain uncovered 
data. */
 class CompositeBTreeTableTest extends DataEvolutionTestBase {
 
+    @Test
+    void testPrefixAndRangeQueriesAcrossReaderModes() throws Exception {
+        createTableDefault();
+        FileStoreTable table =
+                
table().copy(Collections.singletonMap("sorted-index.records-per-file", "13"));
+        append(table, 0, 60);
+        BatchWriteBuilder writes = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = writes.newWrite();
+                BatchTableCommit commit = writes.newCommit()) {
+            write.write(
+                    GenericRow.of(
+                            null,
+                            BinaryString.fromString("category-a"),
+                            BinaryString.fromString("null-number")));
+            write.write(GenericRow.of(7, null, 
BinaryString.fromString("null-category")));
+            commit.commit(write.prepareCommit());
+        }
+        build(table, "f1", "f0", "f2");
+        FileStoreTable withoutIndex =
+                table.copy(
+                        
Collections.singletonMap(CoreOptions.GLOBAL_INDEX_ENABLED.key(), "false"));
+        PredicateBuilder b = new PredicateBuilder(table.rowType());
+        Predicate prefix = b.equal(1, BinaryString.fromString("category-a"));
+        List<Predicate> queries =
+                Arrays.asList(
+                        prefix,
+                        PredicateBuilder.and(prefix, b.equal(0, 7)),
+                        PredicateBuilder.and(prefix, b.greaterThan(0, 7)),
+                        PredicateBuilder.and(prefix, b.greaterOrEqual(0, 7)),
+                        PredicateBuilder.and(prefix, b.lessThan(0, 7)),
+                        PredicateBuilder.and(prefix, b.lessOrEqual(0, 7)),
+                        PredicateBuilder.and(prefix, b.between(0, 6, 8)),
+                        PredicateBuilder.and(prefix, b.greaterThan(0, 6), 
b.lessThan(0, 8)),
+                        PredicateBuilder.and(prefix, b.greaterThan(0, 7), 
b.lessOrEqual(0, 7)),
+                        PredicateBuilder.and(
+                                prefix,
+                                b.greaterThan(0, 7),
+                                b.startsWith(2, 
BinaryString.fromString("p2"))),
+                        PredicateBuilder.and(
+                                b.greaterThan(1, 
BinaryString.fromString("category-a")),
+                                b.equal(0, 7)),
+                        b.equal(0, 7),
+                        PredicateBuilder.or(
+                                PredicateBuilder.and(prefix, b.greaterThan(0, 
7)),
+                                PredicateBuilder.and(
+                                        b.equal(1, 
BinaryString.fromString("category-b")),
+                                        b.lessOrEqual(0, 1))));
+        for (Predicate query : queries) {
+            List<String> expected = read(withoutIndex, query);
+            for (boolean inReader : Arrays.asList(false, true)) {
+                for (String mode : Arrays.asList("fast", "full", "detail")) {
+                    assertThat(read(configured(table, mode, inReader), query))
+                            .as("%s, reader=%s, mode=%s", query, inReader, 
mode)
+                            .containsExactlyInAnyOrderElementsOf(expected);
+                }
+            }
+        }
+        append(table, 60, 80);
+        Predicate query = PredicateBuilder.and(prefix, b.greaterThan(0, 7));
+        for (boolean inReader : Arrays.asList(false, true)) {
+            assertThat(read(configured(table, "fast", inReader), 
query)).hasSize(6);
+            for (String mode : Arrays.asList("full", "detail")) {
+                assertThat(read(configured(table, mode, inReader), query))
+                        
.containsExactlyInAnyOrderElementsOf(read(withoutIndex, query));
+            }
+        }
+    }
+
+    @Test
+    void testRangesAvoidSingleColumnPostingsAndKeepResidualConditions() throws 
Exception {
+        createTableDefault();
+        FileStoreTable table = table();
+        append(table, 0, 100);
+        build(table, "f1");
+        build(table, "f0");
+        build(table, "f1", "f0");
+        deleteIndexFiles(table, 1);
+        PredicateBuilder b = new PredicateBuilder(table.rowType());
+        Predicate range =
+                PredicateBuilder.and(
+                        b.equal(1, BinaryString.fromString("category-a")), 
b.between(0, 6, 8));
+        Predicate residual = PredicateBuilder.and(range, b.notEqual(0, 7));
+        Predicate union =
+                PredicateBuilder.or(
+                        residual,
+                        PredicateBuilder.and(
+                                b.equal(1, 
BinaryString.fromString("category-b")),
+                                b.greaterThan(0, 8)));
+        FileStoreTable withoutIndex =
+                table.copy(
+                        
Collections.singletonMap(CoreOptions.GLOBAL_INDEX_ENABLED.key(), "false"));
+        for (boolean inReader : Arrays.asList(false, true)) {
+            for (Predicate query : Arrays.asList(range, residual, union)) {
+                assertThat(read(configured(table, "fast", inReader), query))
+                        
.containsExactlyInAnyOrderElementsOf(read(withoutIndex, query));
+            }
+        }
+    }
+
+    @Test
+    void testOneOverBudgetShardFallsBackForTheWholeDefinition() throws 
Exception {
+        createTableDefault();
+        FileStoreTable table = table();
+        append(table, 0, 20);
+        build(table, "f1", "f0", "f2");
+        append(table, 20, 2200);
+        build(table, "f1", "f0", "f2");
+        List<IndexFileMeta> files =
+                table.store().newIndexFileHandler().scanEntries().stream()
+                        .map(IndexManifestEntry::indexFile)
+                        .collect(Collectors.toList());
+        long small =
+                files.stream()
+                        .filter(file -> file.globalIndexMeta().rowRange().to 
== 19)
+                        .mapToLong(IndexFileMeta::fileSize)
+                        .sum();
+        long large =
+                files.stream()
+                        .filter(file -> file.globalIndexMeta().rowRange().from 
== 20)
+                        .mapToLong(IndexFileMeta::fileSize)
+                        .sum();
+        assertThat(small).isPositive();
+        assertThat(large).isGreaterThan(small);
+        FileStoreTable budgeted =
+                table.copy(
+                        Collections.singletonMap(
+                                "btree-index.fallback-scan-max-size", small + 
" b"));
+        FileStoreTable withoutIndex =
+                table.copy(
+                        
Collections.singletonMap(CoreOptions.GLOBAL_INDEX_ENABLED.key(), "false"));
+        PredicateBuilder b = new PredicateBuilder(table.rowType());
+        Predicate range =
+                PredicateBuilder.and(
+                        b.equal(1, BinaryString.fromString("category-a")), 
b.between(0, 7, 8));
+        Predicate conjunction =
+                PredicateBuilder.and(range, b.startsWith(2, 
BinaryString.fromString("p")));
+        Predicate union =
+                PredicateBuilder.or(
+                        range,
+                        PredicateBuilder.and(
+                                b.equal(1, 
BinaryString.fromString("category-b")),
+                                b.greaterThan(0, 8)));
+        for (Predicate query : Arrays.asList(range, conjunction, union)) {
+            List<String> expected = read(withoutIndex, query);
+            for (boolean inReader : Arrays.asList(false, true)) {
+                for (String mode : Arrays.asList("fast", "full", "detail")) {
+                    assertThat(read(configured(budgeted, mode, inReader), 
query))
+                            .containsExactlyInAnyOrderElementsOf(expected);
+                }
+            }
+        }
+    }
+
     @Test
     void testCoexistsAndAvoidsSingleColumnPostings() throws Exception {
         createTableDefault();
@@ -395,7 +548,7 @@ class CompositeBTreeTableTest extends DataEvolutionTestBase 
{
                 DataEvolutionGlobalIndexScanner.create(table, files).get()) {
             for (Predicate predicate :
                     Arrays.asList(
-                            predicates.equal(0, 7),
+                            predicates.notEqual(0, 7),
                             predicates.equal(1, 
BinaryString.fromString("category-a")))) {
                 assertThat(scanner.scan(predicate)).isEmpty();
                 
assertThat(scanner.unindexedRows(predicate).results().toRangeList())
@@ -619,17 +772,15 @@ class CompositeBTreeTableTest extends 
DataEvolutionTestBase {
         createTableDefault();
         FileStoreTable table = table();
         append(table, 0, 20);
-        build(table, "f1");
+        build(table, "f0");
         append(table, 20, 40);
         build(table, "f1", "f0");
-        Predicate scalar =
-                new PredicateBuilder(table.rowType())
-                        .equal(1, BinaryString.fromString("category-a"));
+        Predicate scalar = new PredicateBuilder(table.rowType()).equal(0, 7);
         for (boolean inReader : Arrays.asList(false, true)) {
-            assertThat(read(configured(table, "fast", inReader), 
scalar)).hasSize(10);
+            assertThat(read(configured(table, "fast", inReader), 
scalar)).hasSize(2);
             for (String mode : Arrays.asList("full", "detail")) {
                 assertThat(read(configured(table, mode, inReader), scalar))
-                        .hasSize(20)
+                        .hasSize(4)
                         .contains("p27");
             }
         }
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/SortedGlobalIndexITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/SortedGlobalIndexITCase.java
index 0ae2afbe7a..d409a8955e 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/SortedGlobalIndexITCase.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/SortedGlobalIndexITCase.java
@@ -152,6 +152,17 @@ public class SortedGlobalIndexITCase extends 
CatalogITCaseBase {
                             sql(
                                     "SELECT id FROM T_COMPOSITE WHERE category 
= 'absent' AND item_number = 7"))
                     .isEmpty();
+            assertThat(sql("SELECT id FROM T_COMPOSITE WHERE item_number = 7"))
+                    .containsExactlyInAnyOrder(
+                            Row.of(7), Row.of(17), Row.of(27), Row.of(37), 
Row.of(100));
+            assertThat(
+                            sql(
+                                    "SELECT id FROM T_COMPOSITE WHERE 
item_number = 7 AND category > 'category-a'"))
+                    .containsExactlyInAnyOrder(Row.of(17), Row.of(37));
+            assertThat(
+                            sql(
+                                    "SELECT id FROM T_COMPOSITE WHERE 
item_number = 7 AND category BETWEEN 'category-a' AND 'category-b'"))
+                    .containsExactlyInAnyOrder(Row.of(7), Row.of(17), 
Row.of(27), Row.of(37));
         }
         insertCompositeRows(40, 60);
         buildBTreeIndexForTable("T_COMPOSITE", "item_number,category");
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CompositeBTreeIndexProcedureTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CompositeBTreeIndexProcedureTest.scala
index 334b68471c..ed820634da 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CompositeBTreeIndexProcedureTest.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CompositeBTreeIndexProcedureTest.scala
@@ -99,6 +99,15 @@ class CompositeBTreeIndexProcedureTest extends 
PaimonSparkTestBase {
         checkAnswer(
           sql("SELECT id FROM T WHERE category = 'absent' AND item_number = 
7"),
           Seq.empty)
+        checkAnswer(
+          sql("SELECT id FROM T WHERE category = 'category-a'"),
+          ((0 until 10) ++ (20 until 30) :+ 101).map(Row(_)))
+        checkAnswer(
+          sql("SELECT id FROM T WHERE category = 'category-a' AND item_number 
> 7"),
+          Seq(Row(8), Row(9), Row(28), Row(29)))
+        checkAnswer(
+          sql("SELECT id FROM T WHERE category = 'category-a' AND item_number 
BETWEEN 6 AND 8"),
+          Seq(Row(6), Row(7), Row(8), Row(26), Row(27), Row(28)))
       }
       insert(40, 60)
       build("category,item_number")

Reply via email to