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 d67113eb7b [core] Support TopN pushdown for BTree global indexes 
(#8991)
d67113eb7b is described below

commit d67113eb7b4338d3404de4132ec922c9357341b1
Author: umi <[email protected]>
AuthorDate: Tue Aug 4 19:59:40 2026 +0800

    [core] Support TopN pushdown for BTree global indexes (#8991)
---
 .../paimon/globalindex/GlobalIndexEvaluator.java   |  23 +-
 .../paimon/globalindex/GlobalIndexReader.java      |  11 +
 .../globalindex/OffsetGlobalIndexReader.java       |   6 +
 .../globalindex/SortedFileGlobalIndexReader.java   |   7 +
 .../paimon/globalindex/TopNGlobalIndexResult.java  | 205 ++++++++++++++++
 .../paimon/globalindex/UnionGlobalIndexReader.java |   6 +
 .../paimon/globalindex/btree/BTreeIndexReader.java | 107 ++++++++-
 .../globalindex/btree/LazyFilteredBTreeReader.java |   7 +
 .../java/org/apache/paimon/sst/BlockReader.java    |   4 +
 .../apache/paimon/sst/ReverseBlockIterator.java    |  64 +++++
 .../java/org/apache/paimon/sst/SstFileReader.java  |  36 +++
 .../globalindex/GlobalIndexEvaluatorTest.java      |  43 ++++
 .../globalindex/TopNGlobalIndexResultTest.java     | 169 +++++++++++++
 .../globalindex/btree/BTreeIndexReaderTest.java    | 103 ++++++++
 .../btree/LazyFilteredBTreeIndexReaderTest.java    |  38 ++-
 .../org/apache/paimon/sst/BlockIteratorTest.java   |  15 +-
 .../globalindex/BTreeTopNIndexFileSelector.java    | 164 +++++++++++++
 .../paimon/globalindex/DataEvolutionBatchScan.java |  85 ++++++-
 .../DataEvolutionGlobalIndexScanner.java           | 111 ++++++++-
 .../BTreeTopNIndexFileSelectorTest.java            | 237 +++++++++++++++++++
 .../paimon/table/BtreeGlobalIndexTableTest.java    | 262 +++++++++++++++++++++
 .../paimon/spark/sql/RowTrackingTestBase.scala     |  30 +++
 22 files changed, 1724 insertions(+), 9 deletions(-)

diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexEvaluator.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexEvaluator.java
index 0f8a0e3ccd..49958cc3be 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexEvaluator.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexEvaluator.java
@@ -29,6 +29,7 @@ import org.apache.paimon.predicate.LeafPredicate;
 import org.apache.paimon.predicate.LeafTernaryFunction;
 import org.apache.paimon.predicate.Or;
 import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.TopN;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.IOUtils;
 
@@ -50,6 +51,8 @@ import java.util.concurrent.ExecutionException;
 import java.util.function.IntFunction;
 import java.util.stream.Collectors;
 
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
 /** Predicate for filtering data using global indexes. */
 public class GlobalIndexEvaluator implements Closeable {
 
@@ -68,8 +71,26 @@ public class GlobalIndexEvaluator implements Closeable {
         if (predicate == null) {
             return Optional.empty();
         }
+        return awaitGlobalIndexResult(visitAsync(predicate));
+    }
+
+    public Optional<GlobalIndexResult> evaluateTopN(TopN topN) {
+        FieldRef fieldRef = topN.orders().get(0).field();
+        int fieldId = rowType.getField(fieldRef.name()).id();
+        Collection<GlobalIndexReader> readers =
+                indexReadersCache.computeIfAbsent(fieldId, 
readersFunction::apply);
+
+        if (readers.isEmpty()) {
+            return Optional.empty();
+        }
+        checkArgument(readers.size() == 1, "TopN expects one aggregated global 
index reader.");
+        return 
awaitGlobalIndexResult(readers.iterator().next().visitTopN(topN));
+    }
+
+    private Optional<GlobalIndexResult> awaitGlobalIndexResult(
+            CompletableFuture<Optional<GlobalIndexResult>> future) {
         try {
-            return visitAsync(predicate).get();
+            return future.get();
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
             throw new RuntimeException("Interrupted during index evaluation", 
e);
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexReader.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexReader.java
index 807ecb8aa0..b857fdc1a1 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexReader.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexReader.java
@@ -23,6 +23,7 @@ import org.apache.paimon.predicate.FieldRef;
 import org.apache.paimon.predicate.FullTextSearch;
 import org.apache.paimon.predicate.FunctionVisitor;
 import org.apache.paimon.predicate.LeafPredicate;
+import org.apache.paimon.predicate.TopN;
 import org.apache.paimon.predicate.VectorSearch;
 
 import java.io.Closeable;
@@ -80,6 +81,16 @@ public interface GlobalIndexReader
         throw new UnsupportedOperationException();
     }
 
+    /**
+     * Returns row candidates for the given TopN predicate.
+     *
+     * <p>The result may contain more than {@link TopN#limit()} rows when this 
reader owns multiple
+     * independent index files. Callers must still apply the final TopN 
operation.
+     */
+    default CompletableFuture<Optional<GlobalIndexResult>> visitTopN(TopN 
topN) {
+        return CompletableFuture.completedFuture(Optional.empty());
+    }
+
     /** Batch search; result {@code i} matches vector {@code i}. */
     default CompletableFuture<List<Optional<ScoredGlobalIndexResult>>> 
visitBatchVectorSearch(
             BatchVectorSearch batchVectorSearch) {
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/OffsetGlobalIndexReader.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/OffsetGlobalIndexReader.java
index 7b513a2c39..23d38d3a94 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/OffsetGlobalIndexReader.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/OffsetGlobalIndexReader.java
@@ -21,6 +21,7 @@ package org.apache.paimon.globalindex;
 import org.apache.paimon.predicate.BatchVectorSearch;
 import org.apache.paimon.predicate.FieldRef;
 import org.apache.paimon.predicate.FullTextSearch;
+import org.apache.paimon.predicate.TopN;
 import org.apache.paimon.predicate.VectorSearch;
 
 import java.io.IOException;
@@ -168,6 +169,11 @@ public class OffsetGlobalIndexReader implements 
GlobalIndexReader {
                         });
     }
 
+    @Override
+    public CompletableFuture<Optional<GlobalIndexResult>> visitTopN(TopN topN) 
{
+        return wrapped.visitTopN(topN).thenApply(this::applyOffset);
+    }
+
     private Optional<GlobalIndexResult> 
applyOffset(Optional<GlobalIndexResult> result) {
         return result.map(r -> r.offset(offset));
     }
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 5d0195b3fc..f16c2036fe 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
@@ -49,6 +49,7 @@ public abstract class SortedFileGlobalIndexReader<R extends 
Closeable>
         implements GlobalIndexReader {
 
     private final SortedFileMetaSelector fileSelector;
+    private final List<GlobalIndexIOMeta> files;
     private final long fallbackScanMaxSize;
     private final Map<Path, R> readerCache;
     private final ExecutorService executor;
@@ -59,6 +60,7 @@ public abstract class SortedFileGlobalIndexReader<R extends 
Closeable>
             long fallbackScanMaxSize,
             ExecutorService executor) {
         this.fileSelector = new SortedFileMetaSelector(files, keySerializer);
+        this.files = new ArrayList<>(files);
         this.fallbackScanMaxSize = fallbackScanMaxSize;
         this.readerCache = new ConcurrentHashMap<>();
         this.executor = executor;
@@ -391,6 +393,11 @@ public abstract class SortedFileGlobalIndexReader<R 
extends Closeable>
                 .thenApply(v -> unionResults(futures));
     }
 
+    protected CompletableFuture<Optional<GlobalIndexResult>> visitAllFiles(
+            Function<R, Optional<GlobalIndexResult>> visitor) {
+        return visitSelectedFiles(Optional.of(files), visitor);
+    }
+
     private R getOrCreateReader(GlobalIndexIOMeta meta) {
         return readerCache.computeIfAbsent(meta.filePath(), ignored -> 
openReader(meta));
     }
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/TopNGlobalIndexResult.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/TopNGlobalIndexResult.java
new file mode 100644
index 0000000000..6d4e05b9fb
--- /dev/null
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/TopNGlobalIndexResult.java
@@ -0,0 +1,205 @@
+/*
+ * 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;
+
+import org.apache.paimon.globalindex.btree.BTreeIndexReader.KeyRowIds;
+import org.apache.paimon.predicate.SortValue;
+import org.apache.paimon.utils.Preconditions;
+import org.apache.paimon.utils.RoaringNavigableMap64;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * A bounded global index result which retains sort keys while merging TopN 
candidates.
+ *
+ * <p>Key groups are ordered by the requested sort direction and their row ids 
are ordered
+ * ascending. Merging two compatible results combines equal keys and keeps 
only the globally best
+ * {@code limit} row ids. Merging with a plain bitmap result falls back to a 
conservative bitmap
+ * union because the other result has no sort keys.
+ */
+public final class TopNGlobalIndexResult implements GlobalIndexResult {
+
+    private final List<KeyRowIds> keyRowIds;
+    private final Comparator<Object> keyComparator;
+    private final SortValue.SortDirection direction;
+    private final SortValue.NullOrdering nullOrdering;
+    private final int limit;
+    private final RoaringNavigableMap64 results;
+
+    private TopNGlobalIndexResult(
+            List<KeyRowIds> keyRowIds,
+            Comparator<Object> keyComparator,
+            SortValue.SortDirection direction,
+            SortValue.NullOrdering nullOrdering,
+            int limit) {
+        this.keyComparator = keyComparator;
+        this.direction = direction;
+        this.nullOrdering = nullOrdering;
+        this.limit = limit;
+
+        List<KeyRowIds> sorted = new ArrayList<>(keyRowIds);
+        sorted.sort(keyRowIdsComparator());
+        this.keyRowIds = Collections.unmodifiableList(mergeAndLimit(sorted));
+        this.results = toBitmap(this.keyRowIds);
+    }
+
+    public static TopNGlobalIndexResult create(
+            List<KeyRowIds> keyRowIds,
+            Comparator<Object> keyComparator,
+            SortValue.NullOrdering nullOrdering,
+            int limit) {
+        return create(
+                keyRowIds, keyComparator, SortValue.SortDirection.DESCENDING, 
nullOrdering, limit);
+    }
+
+    public static TopNGlobalIndexResult create(
+            List<KeyRowIds> keyRowIds,
+            Comparator<Object> keyComparator,
+            SortValue.SortDirection direction,
+            SortValue.NullOrdering nullOrdering,
+            int limit) {
+        Preconditions.checkArgument(limit >= 0, "TopN limit must not be 
negative.");
+        return new TopNGlobalIndexResult(keyRowIds, keyComparator, direction, 
nullOrdering, limit);
+    }
+
+    @Override
+    public RoaringNavigableMap64 results() {
+        return results;
+    }
+
+    @Override
+    public TopNGlobalIndexResult offset(long startOffset) {
+        if (startOffset == 0) {
+            return this;
+        }
+
+        List<KeyRowIds> offsetKeyRowIds = new ArrayList<>(keyRowIds.size());
+        for (KeyRowIds keyRowIds : keyRowIds) {
+            long[] rowIds = keyRowIds.rowIds();
+            long[] offsetRowIds = new long[rowIds.length];
+            for (int i = 0; i < rowIds.length; i++) {
+                offsetRowIds[i] = rowIds[i] + startOffset;
+            }
+            offsetKeyRowIds.add(new KeyRowIds(keyRowIds.key(), offsetRowIds));
+        }
+        return create(offsetKeyRowIds, keyComparator, direction, nullOrdering, 
limit);
+    }
+
+    @Override
+    public GlobalIndexResult or(GlobalIndexResult other) {
+        if (!(other instanceof TopNGlobalIndexResult)) {
+            if (other.results().isEmpty()) {
+                return this;
+            }
+            return GlobalIndexResult.super.or(other);
+        }
+
+        TopNGlobalIndexResult sortedOther = (TopNGlobalIndexResult) other;
+        Preconditions.checkArgument(
+                limit == sortedOther.limit,
+                "Cannot merge sorted global index results with different TopN 
limits.");
+        Preconditions.checkArgument(
+                direction == sortedOther.direction,
+                "Cannot merge sorted global index results with different sort 
directions.");
+        Preconditions.checkArgument(
+                nullOrdering == sortedOther.nullOrdering,
+                "Cannot merge sorted global index results with different null 
ordering.");
+
+        if (sortedOther.keyRowIds.isEmpty()) {
+            return this;
+        }
+        if (keyRowIds.isEmpty()) {
+            return sortedOther;
+        }
+
+        List<KeyRowIds> merged = new ArrayList<>(keyRowIds.size() + 
sortedOther.keyRowIds.size());
+        merged.addAll(keyRowIds);
+        merged.addAll(sortedOther.keyRowIds);
+        return new TopNGlobalIndexResult(merged, keyComparator, direction, 
nullOrdering, limit);
+    }
+
+    private List<KeyRowIds> mergeAndLimit(List<KeyRowIds> sorted) {
+        List<KeyRowIds> result = new ArrayList<>(Math.min(limit, 
sorted.size()));
+        int remaining = limit;
+        int position = 0;
+        while (remaining > 0 && position < sorted.size()) {
+            Object key = sorted.get(position).key();
+            RoaringNavigableMap64 sameKeyRowIds = new RoaringNavigableMap64();
+            do {
+                for (long rowId : sorted.get(position).rowIds()) {
+                    sameKeyRowIds.add(rowId);
+                }
+                position++;
+            } while (position < sorted.size() && compareKeys(key, 
sorted.get(position).key()) == 0);
+
+            int count = (int) Math.min((long) remaining, 
sameKeyRowIds.getLongCardinality());
+            if (count > 0) {
+                long[] limitedRowIds = new long[count];
+                int index = 0;
+                for (long rowId : sameKeyRowIds) {
+                    limitedRowIds[index++] = rowId;
+                    if (index == count) {
+                        break;
+                    }
+                }
+                result.add(new KeyRowIds(key, limitedRowIds));
+                remaining -= count;
+            }
+        }
+        return result;
+    }
+
+    private Comparator<KeyRowIds> keyRowIdsComparator() {
+        return (left, right) -> compareKeys(left.key(), right.key());
+    }
+
+    private int compareKeys(@Nullable Object left, @Nullable Object right) {
+        if (left == null && right == null) {
+            return 0;
+        }
+        if (left == null) {
+            return nullOrdering == SortValue.NullOrdering.NULLS_FIRST ? -1 : 1;
+        }
+        if (right == null) {
+            return nullOrdering == SortValue.NullOrdering.NULLS_FIRST ? 1 : -1;
+        }
+        return direction == SortValue.SortDirection.ASCENDING
+                ? keyComparator.compare(left, right)
+                : keyComparator.compare(right, left);
+    }
+
+    private static RoaringNavigableMap64 toBitmap(List<KeyRowIds> keyRowIds) {
+        RoaringNavigableMap64 bitmap = new RoaringNavigableMap64();
+        for (KeyRowIds group : keyRowIds) {
+            for (long rowId : group.rowIds()) {
+                bitmap.add(rowId);
+            }
+        }
+        return bitmap;
+    }
+
+    List<KeyRowIds> keyRowIds() {
+        return keyRowIds;
+    }
+}
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/UnionGlobalIndexReader.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/UnionGlobalIndexReader.java
index 473eb24d0a..0e249b5df7 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/UnionGlobalIndexReader.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/UnionGlobalIndexReader.java
@@ -19,6 +19,7 @@
 package org.apache.paimon.globalindex;
 
 import org.apache.paimon.predicate.FieldRef;
+import org.apache.paimon.predicate.TopN;
 import org.apache.paimon.predicate.VectorSearch;
 import org.apache.paimon.utils.IOUtils;
 
@@ -174,6 +175,11 @@ public class UnionGlobalIndexReader implements 
GlobalIndexReader {
                         });
     }
 
+    @Override
+    public CompletableFuture<Optional<GlobalIndexResult>> visitTopN(TopN topN) 
{
+        return unionAsync(reader -> reader.visitTopN(topN));
+    }
+
     private CompletableFuture<Optional<GlobalIndexResult>> unionAsync(
             Function<GlobalIndexReader, 
CompletableFuture<Optional<GlobalIndexResult>>> visitor) {
         long start = durationConsumer == null ? 0L : System.nanoTime();
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 e9cb833a68..d8183337bd 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
@@ -25,14 +25,18 @@ import org.apache.paimon.globalindex.GlobalIndexResult;
 import org.apache.paimon.globalindex.KeySerializer;
 import org.apache.paimon.globalindex.SortedFileMetaSelector;
 import org.apache.paimon.globalindex.SortedIndexFileMeta;
+import org.apache.paimon.globalindex.TopNGlobalIndexResult;
 import org.apache.paimon.globalindex.io.GlobalIndexFileReader;
 import org.apache.paimon.io.cache.CacheManager;
 import org.apache.paimon.memory.MemorySegment;
 import org.apache.paimon.memory.MemorySlice;
 import org.apache.paimon.memory.MemorySliceInput;
+import org.apache.paimon.predicate.SortValue;
+import org.apache.paimon.predicate.TopN;
 import org.apache.paimon.sst.BlockCache;
 import org.apache.paimon.sst.BlockHandle;
 import org.apache.paimon.sst.BlockIterator;
+import org.apache.paimon.sst.ReverseBlockIterator;
 import org.apache.paimon.sst.SstFileReader;
 import org.apache.paimon.utils.FileBasedBloomFilter;
 import org.apache.paimon.utils.LazyField;
@@ -43,6 +47,7 @@ import javax.annotation.Nullable;
 
 import java.io.Closeable;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
@@ -339,6 +344,20 @@ public class BTreeIndexReader implements Closeable {
         return createResult(() -> rangeQuery(from, to, true, true));
     }
 
+    public Optional<GlobalIndexResult> visitTopN(TopN topN) {
+        List<SortValue> orders = topN.orders();
+        if (orders.size() != 1) {
+            return Optional.empty();
+        }
+        Preconditions.checkArgument(topN.limit() >= 0, "TopN limit must not be 
negative.");
+        SortValue order = orders.get(0);
+        try {
+            return Optional.of(topN(topN.limit(), order.direction(), 
order.nullOrdering()));
+        } catch (IOException e) {
+            throw new RuntimeException("fail to read btree index file.", e);
+        }
+    }
+
     private Optional<GlobalIndexResult> 
createResult(IOSupplier<RoaringNavigableMap64> supplier) {
         try {
             return Optional.of(GlobalIndexResult.create(supplier.get()));
@@ -362,6 +381,84 @@ public class BTreeIndexReader implements Closeable {
         return rangeQuery(minKey, maxKey, true, true);
     }
 
+    private TopNGlobalIndexResult topN(
+            int limit, SortValue.SortDirection direction, 
SortValue.NullOrdering nullOrdering)
+            throws IOException {
+        List<KeyRowIds> result = new ArrayList<>();
+        if (limit == 0) {
+            return TopNGlobalIndexResult.create(result, comparator, direction, 
nullOrdering, limit);
+        }
+
+        int remaining = limit;
+        if (nullOrdering == SortValue.NullOrdering.NULLS_FIRST) {
+            remaining = addNullRows(result, remaining);
+        }
+        if (remaining > 0) {
+            remaining =
+                    direction == SortValue.SortDirection.ASCENDING
+                            ? addAscendingNonNullRows(result, remaining)
+                            : addDescendingNonNullRows(result, remaining);
+        }
+        if (remaining > 0 && nullOrdering == 
SortValue.NullOrdering.NULLS_LAST) {
+            addNullRows(result, remaining);
+        }
+        return TopNGlobalIndexResult.create(result, comparator, direction, 
nullOrdering, limit);
+    }
+
+    private int addNullRows(List<KeyRowIds> result, int remaining) {
+        int count = (int) Math.min(nullBitmap.get().getLongCardinality(), 
remaining);
+        long[] rowIds = new long[count];
+        int position = 0;
+        for (long rowId : nullBitmap.get()) {
+            rowIds[position++] = rowId;
+            if (position == count) {
+                break;
+            }
+        }
+        if (count > 0) {
+            result.add(new KeyRowIds(null, rowIds));
+        }
+        return remaining - count;
+    }
+
+    private int addDescendingNonNullRows(List<KeyRowIds> result, int 
remaining) throws IOException {
+        if (maxKey == null) {
+            return remaining;
+        }
+
+        SstFileReader.SstFileReverseIterator fileIterator = 
reader.createReverseIterator();
+        ReverseBlockIterator dataIterator;
+        while (remaining > 0 && (dataIterator = fileIterator.readBatch()) != 
null) {
+            while (remaining > 0 && dataIterator.hasNext()) {
+                Map.Entry<MemorySlice, MemorySlice> entry = 
dataIterator.next();
+                Object key = keySerializer.deserialize(entry.getKey());
+                long[] rowIds = deserializeRowIds(entry.getValue(), remaining);
+                result.add(new KeyRowIds(key, rowIds));
+                remaining -= rowIds.length;
+            }
+        }
+        return remaining;
+    }
+
+    private int addAscendingNonNullRows(List<KeyRowIds> result, int remaining) 
throws IOException {
+        if (minKey == null) {
+            return remaining;
+        }
+
+        SstFileReader.SstFileIterator fileIterator = reader.createIterator();
+        BlockIterator dataIterator;
+        while (remaining > 0 && (dataIterator = fileIterator.readBatch()) != 
null) {
+            while (remaining > 0 && dataIterator.hasNext()) {
+                Map.Entry<MemorySlice, MemorySlice> entry = 
dataIterator.next();
+                Object key = keySerializer.deserialize(entry.getKey());
+                long[] rowIds = deserializeRowIds(entry.getValue(), remaining);
+                result.add(new KeyRowIds(key, rowIds));
+                remaining -= rowIds.length;
+            }
+        }
+        return remaining;
+    }
+
     /**
      * Range query on underlying SST File.
      *
@@ -402,11 +499,17 @@ public class BTreeIndexReader implements Closeable {
     }
 
     private long[] deserializeRowIds(MemorySlice slice) {
+        return deserializeRowIds(slice, Integer.MAX_VALUE);
+    }
+
+    static long[] deserializeRowIds(MemorySlice slice, int maxRowIds) {
+        Preconditions.checkArgument(maxRowIds >= 0, "Max row id count must not 
be negative.");
         MemorySliceInput sliceInput = slice.toInput();
         int length = sliceInput.readVarLenInt();
         Preconditions.checkState(length > 0, "Invalid row id length: 0");
-        long[] ids = new long[length];
-        for (int i = 0; i < length; i++) {
+        int resultLength = Math.min(length, maxRowIds);
+        long[] ids = new long[resultLength];
+        for (int i = 0; i < resultLength; i++) {
             ids[i] = sliceInput.readVarLenLong();
         }
         return ids;
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 e7e6217e27..d346d868e7 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
@@ -25,11 +25,13 @@ import 
org.apache.paimon.globalindex.SortedFileGlobalIndexReader;
 import org.apache.paimon.globalindex.io.GlobalIndexFileReader;
 import org.apache.paimon.io.cache.CacheManager;
 import org.apache.paimon.predicate.FieldRef;
+import org.apache.paimon.predicate.TopN;
 import org.apache.paimon.utils.RoaringNavigableMap64;
 
 import java.io.IOException;
 import java.util.List;
 import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutorService;
 
 /**
@@ -147,6 +149,11 @@ public class LazyFilteredBTreeReader extends 
SortedFileGlobalIndexReader<BTreeIn
         return bitmap(reader.visitGreaterThan(literal));
     }
 
+    @Override
+    public CompletableFuture<Optional<GlobalIndexResult>> visitTopN(TopN topN) 
{
+        return visitAllFiles(reader -> reader.visitTopN(topN));
+    }
+
     @Override
     protected BTreeIndexReader openReader(GlobalIndexIOMeta meta) {
         try {
diff --git a/paimon-common/src/main/java/org/apache/paimon/sst/BlockReader.java 
b/paimon-common/src/main/java/org/apache/paimon/sst/BlockReader.java
index caee5884d5..6c6d0749b1 100644
--- a/paimon-common/src/main/java/org/apache/paimon/sst/BlockReader.java
+++ b/paimon-common/src/main/java/org/apache/paimon/sst/BlockReader.java
@@ -54,6 +54,10 @@ public abstract class BlockReader {
         return new BlockIterator(this);
     }
 
+    public ReverseBlockIterator reverseIterator() {
+        return new ReverseBlockIterator(this);
+    }
+
     /** Seek to slice position from record position. */
     public abstract int seekTo(int recordPosition);
 
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/sst/ReverseBlockIterator.java 
b/paimon-common/src/main/java/org/apache/paimon/sst/ReverseBlockIterator.java
new file mode 100644
index 0000000000..3ed480e595
--- /dev/null
+++ 
b/paimon-common/src/main/java/org/apache/paimon/sst/ReverseBlockIterator.java
@@ -0,0 +1,64 @@
+/*
+ * 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.sst;
+
+import org.apache.paimon.memory.MemorySlice;
+import org.apache.paimon.memory.MemorySliceInput;
+
+import java.util.Iterator;
+import java.util.Map;
+import java.util.NoSuchElementException;
+
+/** An {@link Iterator} which reads entries in a block from last to first. */
+public class ReverseBlockIterator implements Iterator<Map.Entry<MemorySlice, 
MemorySlice>> {
+
+    private final BlockReader reader;
+    private final MemorySliceInput input;
+    private int recordPosition;
+
+    public ReverseBlockIterator(BlockReader reader) {
+        this.reader = reader;
+        this.input = reader.blockInput();
+        this.recordPosition = reader.recordCount() - 1;
+    }
+
+    @Override
+    public boolean hasNext() {
+        return recordPosition >= 0;
+    }
+
+    @Override
+    public BlockEntry next() {
+        if (!hasNext()) {
+            throw new NoSuchElementException();
+        }
+
+        input.setPosition(reader.seekTo(recordPosition--));
+        int keyLength = input.readVarLenInt();
+        MemorySlice key = input.readSlice(keyLength);
+        int valueLength = input.readVarLenInt();
+        MemorySlice value = input.readSlice(valueLength);
+        return new BlockEntry(key, value);
+    }
+
+    @Override
+    public void remove() {
+        throw new UnsupportedOperationException();
+    }
+}
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 788e926dc7..21b7a421fa 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
@@ -92,6 +92,10 @@ public class SstFileReader implements Closeable {
         return new SstFileIterator(indexBlock.iterator());
     }
 
+    public SstFileReverseIterator createReverseIterator() {
+        return new SstFileReverseIterator(indexBlock.reverseIterator());
+    }
+
     private BlockIterator getNextBlock(BlockIterator indexBlockIterator) {
         // index block handle, point to the key, value position.
         MemorySlice blockHandle = indexBlockIterator.next().getValue();
@@ -100,6 +104,14 @@ public class SstFileReader implements Closeable {
         return dataBlock.iterator();
     }
 
+    private ReverseBlockIterator getPreviousBlock(ReverseBlockIterator 
indexBlockIterator) {
+        // index block handle, point to the key, value position.
+        MemorySlice blockHandle = indexBlockIterator.next().getValue();
+        BlockReader dataBlock =
+                readBlock(BlockHandle.readBlockHandle(blockHandle.toInput()), 
false);
+        return dataBlock.reverseIterator();
+    }
+
     /**
      * @param blockHandle The block handle.
      * @param index Whether read the block as an index.
@@ -214,4 +226,28 @@ public class SstFileReader implements Closeable {
             return getNextBlock(indexIterator);
         }
     }
+
+    /** An iterator which reads an SST file from the largest key to the 
smallest key. */
+    public class SstFileReverseIterator {
+
+        private final ReverseBlockIterator indexIterator;
+
+        SstFileReverseIterator(ReverseBlockIterator indexIterator) {
+            this.indexIterator = indexIterator;
+        }
+
+        /**
+         * Read a batch of records from this SST File and move current record 
position to the
+         * previous batch.
+         *
+         * @return current batch of records, null if reaching file beginning.
+         */
+        @Nullable
+        public ReverseBlockIterator readBatch() throws IOException {
+            if (!indexIterator.hasNext()) {
+                return null;
+            }
+            return getPreviousBlock(indexIterator);
+        }
+    }
 }
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/GlobalIndexEvaluatorTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/GlobalIndexEvaluatorTest.java
index dcd36c8eaa..42ccc570e2 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/GlobalIndexEvaluatorTest.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/GlobalIndexEvaluatorTest.java
@@ -23,6 +23,7 @@ import org.apache.paimon.predicate.CompoundPredicate;
 import org.apache.paimon.predicate.FieldRef;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.predicate.TopN;
 import org.apache.paimon.predicate.VectorSearch;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.DataTypes;
@@ -45,6 +46,8 @@ import java.util.concurrent.Executors;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 
+import static org.apache.paimon.predicate.SortValue.NullOrdering.NULLS_LAST;
+import static org.apache.paimon.predicate.SortValue.SortDirection.DESCENDING;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
@@ -98,6 +101,46 @@ class GlobalIndexEvaluatorTest {
         evaluator.close();
     }
 
+    @Test
+    void testTopNUsesAggregatedReaderAndReusesPredicateCache() {
+        RowType rowType = rowType();
+        AtomicInteger readersCreated = new AtomicInteger();
+        GlobalIndexReader first =
+                new StubGlobalIndexReader(null) {
+                    @Override
+                    public CompletableFuture<Optional<GlobalIndexResult>> 
visitTopN(TopN topN) {
+                        return 
CompletableFuture.completedFuture(Optional.of(resultOf(1, 2)));
+                    }
+                };
+        GlobalIndexReader second =
+                new StubGlobalIndexReader(null) {
+                    @Override
+                    public CompletableFuture<Optional<GlobalIndexResult>> 
visitTopN(TopN topN) {
+                        return 
CompletableFuture.completedFuture(Optional.of(resultOf(2, 3)));
+                    }
+                };
+        GlobalIndexReader union = new 
UnionGlobalIndexReader(Arrays.asList(first, second));
+        GlobalIndexEvaluator evaluator =
+                new GlobalIndexEvaluator(
+                        rowType,
+                        fieldId -> {
+                            readersCreated.incrementAndGet();
+                            return Collections.singletonList(union);
+                        });
+        TopN topN = new TopN(new FieldRef(0, "a", DataTypes.INT()), 
DESCENDING, NULLS_LAST, 2);
+
+        evaluator.evaluate(new PredicateBuilder(rowType).equal(0, 42));
+        Optional<GlobalIndexResult> firstResult = evaluator.evaluateTopN(topN);
+        Optional<GlobalIndexResult> secondResult = 
evaluator.evaluateTopN(topN);
+
+        assertThat(firstResult).isPresent();
+        assertBitmapContainsExactly(firstResult.get().results(), 1L, 2L, 3L);
+        assertThat(secondResult).isPresent();
+        assertBitmapContainsExactly(secondResult.get().results(), 1L, 2L, 3L);
+        assertThat(readersCreated).hasValue(1);
+        evaluator.close();
+    }
+
     @Test
     void testAndParallelMultipleFields() {
         executor = Executors.newFixedThreadPool(2);
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/TopNGlobalIndexResultTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/TopNGlobalIndexResultTest.java
new file mode 100644
index 0000000000..b5e4e00895
--- /dev/null
+++ 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/TopNGlobalIndexResultTest.java
@@ -0,0 +1,169 @@
+/*
+ * 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;
+
+import org.apache.paimon.globalindex.btree.BTreeIndexReader.KeyRowIds;
+import org.apache.paimon.utils.RoaringNavigableMap64;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+
+import static org.apache.paimon.predicate.SortValue.NullOrdering.NULLS_FIRST;
+import static org.apache.paimon.predicate.SortValue.NullOrdering.NULLS_LAST;
+import static org.apache.paimon.predicate.SortValue.SortDirection.ASCENDING;
+import static org.apache.paimon.predicate.SortValue.SortDirection.DESCENDING;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link TopNGlobalIndexResult}. */
+public class TopNGlobalIndexResultTest {
+
+    private static final Comparator<Object> INT_COMPARATOR =
+            (left, right) -> Integer.compare((Integer) left, (Integer) right);
+
+    @Test
+    public void testMergeKeepsGlobalTopN() {
+        TopNGlobalIndexResult first = result(NULLS_LAST, 3, keyRowIds(50, 5), 
keyRowIds(10, 1));
+        TopNGlobalIndexResult second = result(NULLS_LAST, 3, keyRowIds(40, 4), 
keyRowIds(30, 3));
+
+        GlobalIndexResult merged = first.or(second);
+
+        assertThat(merged).isInstanceOf(TopNGlobalIndexResult.class);
+        assertThat(merged.results()).containsExactlyInAnyOrder(3L, 4L, 5L);
+    }
+
+    @Test
+    public void testMergeKeepsGlobalAscendingTopN() {
+        TopNGlobalIndexResult first =
+                result(ASCENDING, NULLS_LAST, 3, keyRowIds(50, 5), 
keyRowIds(10, 1));
+        TopNGlobalIndexResult second =
+                result(ASCENDING, NULLS_LAST, 3, keyRowIds(40, 4), 
keyRowIds(30, 3));
+
+        TopNGlobalIndexResult merged = (TopNGlobalIndexResult) 
first.or(second);
+
+        assertThat(merged.results()).containsExactlyInAnyOrder(1L, 3L, 4L);
+        
assertThat(merged.keyRowIds()).extracting(KeyRowIds::key).containsExactly(10, 
30, 40);
+    }
+
+    @Test
+    public void testCannotMergeDifferentDirections() {
+        TopNGlobalIndexResult ascending = result(ASCENDING, NULLS_LAST, 1, 
keyRowIds(10, 1));
+        TopNGlobalIndexResult descending = result(DESCENDING, NULLS_LAST, 1, 
keyRowIds(10, 1));
+
+        assertThatThrownBy(() -> ascending.or(descending))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage(
+                        "Cannot merge sorted global index results with 
different sort directions.");
+    }
+
+    @Test
+    public void testMergeBreaksBoundaryTiesByRowId() {
+        TopNGlobalIndexResult first = result(NULLS_LAST, 2, keyRowIds(20, 9), 
keyRowIds(10, 3));
+        TopNGlobalIndexResult second = result(NULLS_LAST, 2, keyRowIds(10, 2), 
keyRowIds(5, 1));
+
+        assertThat(first.or(second).results()).containsExactlyInAnyOrder(2L, 
9L);
+    }
+
+    @Test
+    public void testMergeCombinesSameKeyAndLimitsByRowIdCardinality() {
+        TopNGlobalIndexResult first = result(NULLS_LAST, 4, keyRowIds(20, 9, 
3), keyRowIds(10, 1));
+        TopNGlobalIndexResult second = result(NULLS_LAST, 4, keyRowIds(20, 2, 
9), keyRowIds(15, 4));
+
+        TopNGlobalIndexResult merged = (TopNGlobalIndexResult) 
first.or(second);
+
+        assertThat(merged.results()).containsExactlyInAnyOrder(2L, 3L, 4L, 9L);
+        assertThat(merged.keyRowIds()).hasSize(2);
+        assertThat(merged.keyRowIds().get(0).key()).isEqualTo(20);
+        assertThat(merged.keyRowIds().get(0).rowIds()).containsExactly(2L, 3L, 
9L);
+        assertThat(merged.keyRowIds().get(1).key()).isEqualTo(15);
+        assertThat(merged.keyRowIds().get(1).rowIds()).containsExactly(4L);
+    }
+
+    @Test
+    public void testSingleKeyIsLimitedByRowIdCardinality() {
+        TopNGlobalIndexResult result =
+                result(NULLS_LAST, 2, keyRowIds(20, 5, 3, 4), keyRowIds(10, 
1));
+
+        assertThat(result.keyRowIds()).hasSize(1);
+        assertThat(result.keyRowIds().get(0).key()).isEqualTo(20);
+        assertThat(result.keyRowIds().get(0).rowIds()).containsExactly(3L, 4L);
+        assertThat(result.results()).containsExactlyInAnyOrder(3L, 4L);
+    }
+
+    @Test
+    public void testNullOrdering() {
+        TopNGlobalIndexResult first = result(NULLS_FIRST, 2, keyRowIds(null, 
2), keyRowIds(100, 4));
+        TopNGlobalIndexResult second =
+                result(NULLS_FIRST, 2, keyRowIds(null, 1), keyRowIds(200, 3));
+
+        assertThat(first.or(second).results()).containsExactlyInAnyOrder(1L, 
2L);
+
+        first = result(NULLS_LAST, 2, keyRowIds(null, 2), keyRowIds(100, 4));
+        second = result(NULLS_LAST, 2, keyRowIds(null, 1), keyRowIds(200, 3));
+
+        assertThat(first.or(second).results()).containsExactlyInAnyOrder(3L, 
4L);
+    }
+
+    @Test
+    public void testOffsetPreservesSortKeys() {
+        TopNGlobalIndexResult first = result(NULLS_LAST, 2, keyRowIds(20, 1), 
keyRowIds(10, 2));
+        TopNGlobalIndexResult second = result(NULLS_LAST, 2, keyRowIds(30, 
1)).offset(10);
+
+        GlobalIndexResult merged = first.or(second);
+
+        assertThat(merged).isInstanceOf(TopNGlobalIndexResult.class);
+        assertThat(merged.results()).containsExactlyInAnyOrder(1L, 11L);
+    }
+
+    @Test
+    public void testPlainResultUsesConservativeUnion() {
+        TopNGlobalIndexResult sorted = result(NULLS_LAST, 1, keyRowIds(20, 1));
+        RoaringNavigableMap64 unindexedRows = new RoaringNavigableMap64();
+        unindexedRows.add(2);
+
+        GlobalIndexResult merged = 
sorted.or(GlobalIndexResult.create(unindexedRows));
+
+        assertThat(merged).isNotInstanceOf(TopNGlobalIndexResult.class);
+        assertThat(merged.results()).containsExactlyInAnyOrder(1L, 2L);
+    }
+
+    private TopNGlobalIndexResult result(
+            org.apache.paimon.predicate.SortValue.NullOrdering nullOrdering,
+            int limit,
+            KeyRowIds... entries) {
+        return result(DESCENDING, nullOrdering, limit, entries);
+    }
+
+    private TopNGlobalIndexResult result(
+            org.apache.paimon.predicate.SortValue.SortDirection direction,
+            org.apache.paimon.predicate.SortValue.NullOrdering nullOrdering,
+            int limit,
+            KeyRowIds... entries) {
+        List<KeyRowIds> candidates = Arrays.asList(entries);
+        return TopNGlobalIndexResult.create(
+                candidates, INT_COMPARATOR, direction, nullOrdering, limit);
+    }
+
+    private KeyRowIds keyRowIds(Integer key, long... rowIds) {
+        return new KeyRowIds(key, rowIds);
+    }
+}
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/BTreeIndexReaderTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/BTreeIndexReaderTest.java
index 3c5e9b07c0..21a9870a79 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/BTreeIndexReaderTest.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/BTreeIndexReaderTest.java
@@ -20,14 +20,24 @@ package org.apache.paimon.globalindex.btree;
 
 import org.apache.paimon.globalindex.GlobalIndexIOMeta;
 import org.apache.paimon.globalindex.GlobalIndexReader;
+import org.apache.paimon.globalindex.GlobalIndexResult;
+import org.apache.paimon.memory.MemorySliceOutput;
+import org.apache.paimon.predicate.FieldRef;
+import org.apache.paimon.predicate.TopN;
 import 
org.apache.paimon.testutils.junit.parameterized.ParameterizedTestExtension;
 
+import org.junit.jupiter.api.TestTemplate;
 import org.junit.jupiter.api.extension.ExtendWith;
 
 import java.util.Collections;
 import java.util.List;
 
+import static org.apache.paimon.predicate.SortValue.NullOrdering.NULLS_FIRST;
+import static org.apache.paimon.predicate.SortValue.NullOrdering.NULLS_LAST;
+import static org.apache.paimon.predicate.SortValue.SortDirection.ASCENDING;
+import static org.apache.paimon.predicate.SortValue.SortDirection.DESCENDING;
 import static 
org.apache.paimon.shade.guava30.com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
+import static org.assertj.core.api.Assertions.assertThat;
 
 /** Test for {@link BTreeIndexReader} to read a single file. */
 @ExtendWith(ParameterizedTestExtension.class)
@@ -43,4 +53,97 @@ public class BTreeIndexReaderTest extends 
AbstractIndexReaderTest {
         return globalIndexer.createReader(
                 fileReader, Collections.singletonList(written), 
newDirectExecutorService());
     }
+
+    @TestTemplate
+    public void testDescendingTopN() throws Exception {
+        int limit = 20;
+        FieldRef ref = new FieldRef(1, "testField", dataType);
+        Object[] valuesByRowId = valuesByRowId();
+
+        try (GlobalIndexReader reader = prepareDataAndCreateReader()) {
+            GlobalIndexResult result =
+                    reader.visitTopN(new TopN(ref, DESCENDING, NULLS_LAST, 
limit)).join().get();
+            assertThat(result.results().getLongCardinality()).isEqualTo(limit);
+
+            Object boundary = data.get(dataNum - limit).getKey();
+            for (long rowId : result.results()) {
+                assertThat(comparator.compare(valuesByRowId[(int) rowId], 
boundary))
+                        .isGreaterThanOrEqualTo(0);
+            }
+
+            GlobalIndexResult ascending =
+                    reader.visitTopN(new TopN(ref, ASCENDING, NULLS_LAST, 
limit)).join().get();
+            
assertThat(ascending.results().getLongCardinality()).isEqualTo(limit);
+            boundary = data.get(limit - 1).getKey();
+            for (long rowId : ascending.results()) {
+                assertThat(comparator.compare(valuesByRowId[(int) rowId], 
boundary))
+                        .isLessThanOrEqualTo(0);
+            }
+
+            assertThat(
+                            reader.visitTopN(new TopN(ref, DESCENDING, 
NULLS_LAST, 0))
+                                    .join()
+                                    .get()
+                                    .results())
+                    .isEmpty();
+        }
+
+        int nullCount = dataNum / 10;
+        for (int i = dataNum - nullCount; i < dataNum; i++) {
+            data.get(i).setLeft(null);
+        }
+        valuesByRowId = valuesByRowId();
+        try (GlobalIndexReader reader = prepareDataAndCreateReader()) {
+            GlobalIndexResult nullsFirst =
+                    reader.visitTopN(new TopN(ref, DESCENDING, NULLS_FIRST, 
limit)).join().get();
+            
assertThat(nullsFirst.results().getLongCardinality()).isEqualTo(limit);
+            for (long rowId : nullsFirst.results()) {
+                assertThat(valuesByRowId[(int) rowId]).isNull();
+            }
+
+            GlobalIndexResult nullsLast =
+                    reader.visitTopN(new TopN(ref, DESCENDING, NULLS_LAST, 
limit)).join().get();
+            
assertThat(nullsLast.results().getLongCardinality()).isEqualTo(limit);
+            Object boundary = data.get(dataNum - nullCount - limit).getKey();
+            for (long rowId : nullsLast.results()) {
+                Object value = valuesByRowId[(int) rowId];
+                assertThat(value).isNotNull();
+                assertThat(comparator.compare(value, 
boundary)).isGreaterThanOrEqualTo(0);
+            }
+
+            GlobalIndexResult ascendingNullsFirst =
+                    reader.visitTopN(new TopN(ref, ASCENDING, NULLS_FIRST, 
limit)).join().get();
+            
assertThat(ascendingNullsFirst.results().getLongCardinality()).isEqualTo(limit);
+            for (long rowId : ascendingNullsFirst.results()) {
+                assertThat(valuesByRowId[(int) rowId]).isNull();
+            }
+
+            GlobalIndexResult ascendingNullsLast =
+                    reader.visitTopN(new TopN(ref, ASCENDING, NULLS_LAST, 
limit)).join().get();
+            
assertThat(ascendingNullsLast.results().getLongCardinality()).isEqualTo(limit);
+            boundary = data.get(limit - 1).getKey();
+            for (long rowId : ascendingNullsLast.results()) {
+                Object value = valuesByRowId[(int) rowId];
+                assertThat(value).isNotNull();
+                assertThat(comparator.compare(value, 
boundary)).isLessThanOrEqualTo(0);
+            }
+        }
+    }
+
+    @TestTemplate
+    public void testTopNOnlyDeserializesRemainingRowIds() {
+        MemorySliceOutput output = new MemorySliceOutput(16);
+        output.writeVarLenInt(3);
+        output.writeVarLenLong(10);
+        output.writeVarLenLong(20);
+
+        assertThat(BTreeIndexReader.deserializeRowIds(output.toSlice(), 2))
+                .containsExactly(10L, 20L);
+    }
+
+    private Object[] valuesByRowId() {
+        Object[] values = new Object[dataNum];
+        data.forEach(pair -> values[pair.getValue().intValue()] = 
pair.getKey());
+        return values;
+    }
 }
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/LazyFilteredBTreeIndexReaderTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/LazyFilteredBTreeIndexReaderTest.java
index 760c5daa9f..d8321d7684 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/LazyFilteredBTreeIndexReaderTest.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/LazyFilteredBTreeIndexReaderTest.java
@@ -24,9 +24,11 @@ import org.apache.paimon.globalindex.GlobalIndexReader;
 import org.apache.paimon.globalindex.GlobalIndexResult;
 import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
 import org.apache.paimon.globalindex.ResultEntry;
+import org.apache.paimon.globalindex.btree.BTreeIndexReader.KeyRowIds;
 import org.apache.paimon.options.MemorySize;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.predicate.FieldRef;
+import org.apache.paimon.predicate.TopN;
 import 
org.apache.paimon.testutils.junit.parameterized.ParameterizedTestExtension;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.utils.Pair;
@@ -52,6 +54,9 @@ import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 
+import static org.apache.paimon.predicate.SortValue.NullOrdering.NULLS_LAST;
+import static org.apache.paimon.predicate.SortValue.SortDirection.ASCENDING;
+import static org.apache.paimon.predicate.SortValue.SortDirection.DESCENDING;
 import static 
org.apache.paimon.shade.guava30.com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -93,6 +98,37 @@ public class LazyFilteredBTreeIndexReaderTest extends 
AbstractIndexReaderTest {
         return -1;
     }
 
+    @TestTemplate
+    public void testGlobalTopNCandidatesAcrossFiles() throws Exception {
+        int limit = 5;
+        List<GlobalIndexIOMeta> written = writeData();
+        FieldRef ref = new FieldRef(1, "testField", dataType);
+
+        try (GlobalIndexReader reader =
+                globalIndexer.createReader(fileReader, written, 
newDirectExecutorService())) {
+            GlobalIndexResult result =
+                    reader.visitTopN(new TopN(ref, DESCENDING, NULLS_LAST, 
limit)).join().get();
+            assertThat(result.results().getLongCardinality()).isEqualTo(limit);
+
+            Object boundary = data.get(dataNum - limit).getKey();
+            Object[] valuesByRowId = new Object[dataNum];
+            data.forEach(pair -> valuesByRowId[pair.getValue().intValue()] = 
pair.getKey());
+            for (long rowId : result.results()) {
+                assertThat(comparator.compare(valuesByRowId[(int) rowId], 
boundary))
+                        .isGreaterThanOrEqualTo(0);
+            }
+
+            GlobalIndexResult ascending =
+                    reader.visitTopN(new TopN(ref, ASCENDING, NULLS_LAST, 
limit)).join().get();
+            
assertThat(ascending.results().getLongCardinality()).isEqualTo(limit);
+            boundary = data.get(limit - 1).getKey();
+            for (long rowId : ascending.results()) {
+                assertThat(comparator.compare(valuesByRowId[(int) rowId], 
boundary))
+                        .isLessThanOrEqualTo(0);
+            }
+        }
+    }
+
     @TestTemplate
     public void testFallbackScanDisabledByBudget() throws Exception {
         options.set(BTreeIndexOptions.BTREE_INDEX_FALLBACK_SCAN_MAX_SIZE, 
MemorySize.ofBytes(1));
@@ -185,7 +221,7 @@ public class LazyFilteredBTreeIndexReaderTest extends 
AbstractIndexReaderTest {
 
                 // Collect all entries from iterator
                 while (iter.hasNext()) {
-                    BTreeIndexReader.KeyRowIds entry = iter.next();
+                    KeyRowIds entry = iter.next();
                     Object key = entry.key();
                     long[] rowIds = entry.rowIds();
 
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 92e6ae8d2e..f40b464399 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
@@ -47,7 +47,8 @@ public class BlockIteratorTest {
 
     public void innerTest(boolean aligned) throws IOException {
         MemorySlice data = writeBlock(aligned);
-        BlockIterator iterator = BlockReader.create(data, 
COMPARATOR).iterator();
+        BlockReader reader = BlockReader.create(data, COMPARATOR);
+        BlockIterator iterator = reader.iterator();
 
         // 1. test for normal cases:
         final int step = 3;
@@ -92,6 +93,18 @@ public class BlockIteratorTest {
         Assertions.assertTrue(iterator.hasNext());
         entry = iterator.next();
         Assertions.assertEquals(4, entry.getKey().readInt(0));
+
+        ReverseBlockIterator reverseIterator = reader.reverseIterator();
+        int expected = ROW_NUM - 1;
+        while (reverseIterator.hasNext()) {
+            Map.Entry<MemorySlice, MemorySlice> reverseEntry = 
reverseIterator.next();
+            Assertions.assertEquals(expected * 2, 
reverseEntry.getKey().readInt(0));
+            Assertions.assertArrayEquals(
+                    constructValue(valueOut, aligned, expected),
+                    reverseEntry.getValue().copyBytes());
+            expected--;
+        }
+        Assertions.assertEquals(-1, expected);
     }
 
     private MemorySlice writeBlock(boolean aligned) throws IOException {
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/BTreeTopNIndexFileSelector.java
 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/BTreeTopNIndexFileSelector.java
new file mode 100644
index 0000000000..69b52276c4
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/BTreeTopNIndexFileSelector.java
@@ -0,0 +1,164 @@
+/*
+ * 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;
+
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.memory.MemorySlice;
+import org.apache.paimon.predicate.SortValue;
+import org.apache.paimon.predicate.TopN;
+import org.apache.paimon.types.DataField;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
+
+/**
+ * Selects BTree index files which may contain a single-column TopN result.
+ *
+ * <p>Every BTree file must have sorted metadata, matching the predicate 
reader contract. Retaining
+ * the first {@code N} files ordered by their best value is safe because every 
BTree file
+ * contributes at least one row at that value. Fewer files can be retained 
when one file alone
+ * contains at least {@code N} rows and its worst value is not worse than the 
best value of every
+ * remaining file.
+ */
+class BTreeTopNIndexFileSelector {
+
+    private final KeySerializer keySerializer;
+    private final Comparator<Object> keyComparator;
+    private final boolean ascending;
+    private final boolean nullsFirst;
+
+    private BTreeTopNIndexFileSelector(DataField field, TopN topN) {
+        this.keySerializer = KeySerializer.create(field.type());
+        this.keyComparator = keySerializer.createComparator();
+        this.ascending = topN.orders().get(0).direction() == 
SortValue.SortDirection.ASCENDING;
+        this.nullsFirst = topN.orders().get(0).nullOrdering() == 
SortValue.NullOrdering.NULLS_FIRST;
+    }
+
+    static List<IndexFileMeta> select(List<IndexFileMeta> files, DataField 
field, TopN topN) {
+        int limit = topN.limit();
+        if (limit == 0) {
+            return new ArrayList<>();
+        }
+
+        BTreeTopNIndexFileSelector selector = new 
BTreeTopNIndexFileSelector(field, topN);
+        List<IndexFileMeta> selected = new ArrayList<>();
+        List<RankedIndexFile> rankedFiles = new ArrayList<>();
+        for (IndexFileMeta file : files) {
+            rankedFiles.add(selector.rank(file));
+        }
+
+        rankedFiles.sort(selector::compare);
+        for (int i = 0; i < Math.min(limit, rankedFiles.size()); i++) {
+            RankedIndexFile current = rankedFiles.get(i);
+            selected.add(current.file);
+            if (i + 1 < rankedFiles.size()
+                    && current.file.rowCount() >= limit
+                    // Equal boundary keys are safe because this TopN has no 
secondary ordering or
+                    // WITH TIES semantics, and the current file alone 
supplies enough rows.
+                    && selector.compareWorstToBest(current, rankedFiles.get(i 
+ 1)) <= 0) {
+                break;
+            }
+        }
+        return selected;
+    }
+
+    private RankedIndexFile rank(IndexFileMeta file) {
+        GlobalIndexMeta globalIndex =
+                checkNotNull(
+                        file.globalIndexMeta(),
+                        "BTree index file '%s' is missing global index 
metadata.",
+                        file.fileName());
+        byte[] indexMeta =
+                checkNotNull(
+                        globalIndex.indexMeta(),
+                        "BTree index file '%s' is missing sorted metadata.",
+                        file.fileName());
+        SortedIndexFileMeta sortedMeta = 
SortedIndexFileMeta.deserialize(indexMeta);
+        byte[] firstKey = sortedMeta.firstKey();
+        byte[] lastKey = sortedMeta.lastKey();
+        boolean hasNonNulls = lastKey != null;
+
+        byte[] nonNullBestKey = ascending ? firstKey : lastKey;
+        byte[] nonNullWorstKey = ascending ? lastKey : firstKey;
+        boolean bestIsNull = nullsFirst ? sortedMeta.hasNulls() : !hasNonNulls;
+        Object bestKey =
+                bestIsNull ? null : 
keySerializer.deserialize(MemorySlice.wrap(nonNullBestKey));
+        boolean worstIsNull = nullsFirst ? !hasNonNulls : 
sortedMeta.hasNulls();
+        Object worstKey =
+                worstIsNull ? null : 
keySerializer.deserialize(MemorySlice.wrap(nonNullWorstKey));
+        return new RankedIndexFile(file, bestIsNull, bestKey, worstIsNull, 
worstKey);
+    }
+
+    private int compare(RankedIndexFile left, RankedIndexFile right) {
+        int result = compareValues(left.bestIsNull, left.bestKey, 
right.bestIsNull, right.bestKey);
+        if (result != 0) {
+            return result;
+        }
+        return left.file.fileName().compareTo(right.file.fileName());
+    }
+
+    private int compareWorstToBest(RankedIndexFile current, RankedIndexFile 
next) {
+        return compareValues(current.worstIsNull, current.worstKey, 
next.bestIsNull, next.bestKey);
+    }
+
+    private int compareValues(
+            boolean leftIsNull,
+            @Nullable Object left,
+            boolean rightIsNull,
+            @Nullable Object right) {
+        if (leftIsNull != rightIsNull) {
+            if (leftIsNull) {
+                return nullsFirst ? -1 : 1;
+            }
+            return nullsFirst ? 1 : -1;
+        }
+        if (leftIsNull) {
+            return 0;
+        }
+        return ascending ? keyComparator.compare(left, right) : 
keyComparator.compare(right, left);
+    }
+
+    private static class RankedIndexFile {
+
+        private final IndexFileMeta file;
+        private final boolean bestIsNull;
+        @Nullable private final Object bestKey;
+        private final boolean worstIsNull;
+        @Nullable private final Object worstKey;
+
+        private RankedIndexFile(
+                IndexFileMeta file,
+                boolean bestIsNull,
+                @Nullable Object bestKey,
+                boolean worstIsNull,
+                @Nullable Object worstKey) {
+            this.file = file;
+            this.bestIsNull = bestIsNull;
+            this.bestKey = bestKey;
+            this.worstIsNull = worstIsNull;
+            this.worstKey = worstKey;
+        }
+    }
+}
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 6dbbca8e77..059d661608 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
@@ -68,6 +68,8 @@ public class DataEvolutionBatchScan implements DataTableScan {
     private final AppendBatchTableScan batchScan;
 
     private Predicate filter;
+    private TopN topN;
+    private Integer pushDownLimit;
     private RowRangeIndex pushedRowRangeIndex;
     private GlobalIndexResult globalIndexResult;
 
@@ -148,7 +150,7 @@ public class DataEvolutionBatchScan implements 
DataTableScan {
 
     @Override
     public InnerTableScan withTopN(TopN topN) {
-        batchScan.withTopN(topN);
+        this.topN = topN;
         return this;
     }
 
@@ -166,6 +168,7 @@ public class DataEvolutionBatchScan implements 
DataTableScan {
 
     @Override
     public InnerTableScan withLimit(int limit) {
+        this.pushDownLimit = limit;
         batchScan.withLimit(limit);
         return this;
     }
@@ -255,9 +258,16 @@ public class DataEvolutionBatchScan implements 
DataTableScan {
     public Plan plan() {
         RowRangeIndex rowRangeIndex = this.pushedRowRangeIndex;
         ScoreGetter scoreGetter = null;
+        boolean globalIndexTopNCandidatesFound = false;
 
         if (rowRangeIndex == null) {
-            Optional<GlobalIndexResult> indexResult = evalGlobalIndex();
+            Optional<GlobalIndexResult> indexResult;
+            if (canPushDownGlobalIndexTopN()) {
+                indexResult = evalGlobalIndexTopN();
+                globalIndexTopNCandidatesFound = indexResult.isPresent();
+            } else {
+                indexResult = evalGlobalIndex();
+            }
             if (indexResult.isPresent()) {
                 GlobalIndexResult result = indexResult.get();
                 rowRangeIndex = 
RowRangeIndex.create(result.results().toRangeList());
@@ -269,6 +279,10 @@ public class DataEvolutionBatchScan implements 
DataTableScan {
             }
         }
 
+        if (!globalIndexTopNCandidatesFound && topN != null) {
+            batchScan.withTopN(topN);
+        }
+
         if (rowRangeIndex == null) {
             return batchScan.plan();
         }
@@ -328,6 +342,73 @@ public class DataEvolutionBatchScan implements 
DataTableScan {
         }
     }
 
+    private Optional<GlobalIndexResult> evalGlobalIndexTopN() {
+        CoreOptions options = table.coreOptions();
+        PartitionPredicate partitionFilter =
+                batchScan.snapshotReader().manifestsReader().partitionFilter();
+        long totalStart = System.nanoTime();
+        Optional<DataEvolutionGlobalIndexScanner> optionalScanner =
+                DataEvolutionGlobalIndexScanner.createForTopN(table, 
partitionFilter, topN);
+        long metadataDuration = System.nanoTime() - totalStart;
+        if (!optionalScanner.isPresent()) {
+            return Optional.empty();
+        }
+
+        try (DataEvolutionGlobalIndexScanner scanner = optionalScanner.get()) {
+            long lookupStart = System.nanoTime();
+            Optional<GlobalIndexResult> result = scanner.scan(topN);
+            long lookupDuration = System.nanoTime() - lookupStart;
+            if (!result.isPresent()) {
+                return Optional.empty();
+            }
+
+            long coverageStart = System.nanoTime();
+            GlobalIndexResult finalResult = 
result.get().or(scanner.unindexedRows(topN));
+            long coverageDuration = System.nanoTime() - coverageStart;
+            long totalDuration = System.nanoTime() - totalStart;
+            LOG.info(
+                    "Scan table '{}' with BTree global index TopN. 
searchMode='{}', topN='{}', total={} ms, metadata={} ms, lookup={} ms, 
coverage={} ms.",
+                    table.name(),
+                    options.scalarIndexSearchMode(),
+                    topN,
+                    totalDuration / 1_000_000,
+                    metadataDuration / 1_000_000,
+                    lookupDuration / 1_000_000,
+                    coverageDuration / 1_000_000);
+            return Optional.of(finalResult);
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private boolean canPushDownGlobalIndexTopN() {
+        if (topN == null
+                || pushDownLimit != null
+                || globalIndexResult != null
+                || 
!table.rowType().containsField(topN.orders().get(0).field().name())) {
+            return false;
+        }
+        CoreOptions options = table.coreOptions();
+        return supportsGlobalIndexTopN(options)
+                && options.globalIndexEnabled()
+                && !options.deletionVectorsEnabled()
+                && !options.queryAuthEnabled()
+                && !batchScan.snapshotReader().hasNonPartitionFilter();
+    }
+
+    private boolean supportsGlobalIndexTopN(CoreOptions options) {
+        switch (options.startupMode()) {
+            case LATEST_FULL:
+            case LATEST:
+            case FROM_TIMESTAMP:
+            case FROM_SNAPSHOT:
+            case FROM_SNAPSHOT_FULL:
+                return true;
+            default:
+                return false;
+        }
+    }
+
     @VisibleForTesting
     public static Plan wrapToIndexSplits(
             List<Split> splits, RowRangeIndex rowRangeIndex, ScoreGetter 
scoreGetter) {
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 7dbdb06853..498130a11d 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
@@ -21,6 +21,7 @@ package org.apache.paimon.globalindex;
 import org.apache.paimon.Snapshot;
 import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.fs.Path;
+import org.apache.paimon.globalindex.btree.BTreeGlobalIndexerFactory;
 import org.apache.paimon.globalindex.io.GlobalIndexFileReader;
 import org.apache.paimon.index.GlobalIndexMeta;
 import org.apache.paimon.index.IndexFileMeta;
@@ -29,6 +30,7 @@ import org.apache.paimon.manifest.IndexManifestEntry;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.partition.PartitionPredicate;
 import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.TopN;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.RowType;
@@ -66,6 +68,8 @@ import static 
org.apache.paimon.utils.Preconditions.checkNotNull;
 /** Scanner for shard-based global indexes on data-evolution tables. */
 public class DataEvolutionGlobalIndexScanner implements Closeable {
 
+    private static final int MAX_TOP_N_LIMIT = 100;
+
     private static final Logger LOG =
             LoggerFactory.getLogger(DataEvolutionGlobalIndexScanner.class);
 
@@ -86,6 +90,28 @@ public class DataEvolutionGlobalIndexScanner implements 
Closeable {
             FileIO fileIO,
             IndexPathFactory indexPathFactory,
             Collection<IndexFileMeta> indexFiles) {
+        this(
+                table,
+                snapshot,
+                partitionFilter,
+                options,
+                rowType,
+                fileIO,
+                indexPathFactory,
+                indexFiles,
+                indexFiles);
+    }
+
+    private DataEvolutionGlobalIndexScanner(
+            FileStoreTable table,
+            @Nullable Snapshot snapshot,
+            @Nullable PartitionPredicate partitionFilter,
+            Options options,
+            RowType rowType,
+            FileIO fileIO,
+            IndexPathFactory indexPathFactory,
+            Collection<IndexFileMeta> coverageIndexFiles,
+            Collection<IndexFileMeta> indexFiles) {
         this.table = table;
         this.options = options;
         this.rowType = rowType;
@@ -97,7 +123,7 @@ public class DataEvolutionGlobalIndexScanner implements 
Closeable {
                         table,
                         snapshot,
                         partitionFilter,
-                        indexFiles,
+                        coverageIndexFiles,
                         table.coreOptions().scalarIndexSearchMode());
         GlobalIndexFileReader indexFileReader = meta -> 
fileIO.newInputStream(meta.filePath());
         Map<Integer, IndexMetaFileGroup> indexMetas = new HashMap<>();
@@ -128,7 +154,6 @@ public class DataEvolutionGlobalIndexScanner implements 
Closeable {
             }
             group.addFile(indexType, range, indexFile);
         }
-
         IntFunction<Collection<GlobalIndexReader>> readersFunction =
                 fId -> {
                     List<IndexMetaFileGroup> groups = new ArrayList<>();
@@ -244,6 +269,65 @@ public class DataEvolutionGlobalIndexScanner implements 
Closeable {
                         indexFiles));
     }
 
+    /**
+     * Creates a scanner for a single-column TopN backed by a primary BTree 
index.
+     *
+     * <p>Indexes carrying the ordered field only as an extra field are not 
ordered by that field
+     * and cannot serve this scan.
+     */
+    public static Optional<DataEvolutionGlobalIndexScanner> createForTopN(
+            FileStoreTable table, @Nullable PartitionPredicate 
partitionFilter, TopN topN) {
+        if (!isSupportedTopN(topN)) {
+            return Optional.empty();
+        }
+
+        DataField indexField = 
table.rowType().getField(topN.orders().get(0).field().name());
+        int fieldId = indexField.id();
+        @Nullable Snapshot snapshot = tryTravelOrLatest(table);
+        List<IndexFileMeta> indexFiles =
+                table.store().newIndexFileHandler()
+                        .scan(snapshot, topNIndexFileFilter(partitionFilter, 
fieldId)).stream()
+                        .map(IndexManifestEntry::indexFile)
+                        .collect(Collectors.toList());
+        if (indexFiles.isEmpty()) {
+            return Optional.empty();
+        }
+        List<IndexFileMeta> selectedIndexFiles =
+                BTreeTopNIndexFileSelector.select(indexFiles, indexField, 
topN);
+        return Optional.of(
+                new DataEvolutionGlobalIndexScanner(
+                        table,
+                        snapshot,
+                        partitionFilter,
+                        table.coreOptions().toConfiguration(),
+                        table.rowType(),
+                        table.fileIO(),
+                        table.store().pathFactory().globalIndexFileFactory(),
+                        indexFiles,
+                        selectedIndexFiles));
+    }
+
+    private static boolean isSupportedTopN(TopN topN) {
+        return topN != null
+                && topN.limit() >= 0
+                && topN.limit() <= MAX_TOP_N_LIMIT
+                && topN.orders().size() == 1;
+    }
+
+    private static Filter<IndexManifestEntry> topNIndexFileFilter(
+            @Nullable PartitionPredicate partitionFilter, int fieldId) {
+        return entry -> {
+            if (partitionFilter != null && 
!partitionFilter.test(entry.partition())) {
+                return false;
+            }
+            IndexFileMeta indexFile = entry.indexFile();
+            GlobalIndexMeta globalIndex = indexFile.globalIndexMeta();
+            return globalIndex != null
+                    && globalIndex.indexFieldId() == fieldId
+                    && 
BTreeGlobalIndexerFactory.IDENTIFIER.equals(indexFile.indexType());
+        };
+    }
+
     private static Filter<IndexManifestEntry> indexFileFilter(
             FileStoreTable table,
             @Nullable PartitionPredicate partitionFilter,
@@ -288,6 +372,20 @@ public class DataEvolutionGlobalIndexScanner implements 
Closeable {
         return globalIndexEvaluator.evaluate(predicate);
     }
 
+    public Optional<GlobalIndexResult> scan(TopN topN) {
+        if (!isSupportedTopN(topN)) {
+            return Optional.empty();
+        }
+        if (topN.limit() == 0) {
+            return Optional.of(GlobalIndexResult.createEmpty());
+        }
+        String fieldName = topN.orders().get(0).field().name();
+        if (!rowType.containsField(fieldName)) {
+            return Optional.empty();
+        }
+        return globalIndexEvaluator.evaluateTopN(topN);
+    }
+
     public GlobalIndexResult unindexedRows(Predicate predicate) {
         RoaringNavigableMap64 rows = new RoaringNavigableMap64();
         for (Range range : coverage.unindexedRanges(rowType, predicate)) {
@@ -296,6 +394,15 @@ public class DataEvolutionGlobalIndexScanner implements 
Closeable {
         return GlobalIndexResult.create(rows);
     }
 
+    public GlobalIndexResult unindexedRows(TopN topN) {
+        String fieldName = topN.orders().get(0).field().name();
+        RoaringNavigableMap64 rows = new RoaringNavigableMap64();
+        for (Range range : 
coverage.unindexedRanges(rowType.getField(fieldName).id())) {
+            rows.addRange(range);
+        }
+        return GlobalIndexResult.create(rows);
+    }
+
     private Collection<GlobalIndexReader> createReaders(
             GlobalIndexFileReader indexFileReadWrite, IndexMetaFileGroup 
group, RowType rowType) {
         DataField indexField = group.indexField(rowType);
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/globalindex/BTreeTopNIndexFileSelectorTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/globalindex/BTreeTopNIndexFileSelectorTest.java
new file mode 100644
index 0000000000..044f286345
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/globalindex/BTreeTopNIndexFileSelectorTest.java
@@ -0,0 +1,237 @@
+/*
+ * 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;
+
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.predicate.FieldRef;
+import org.apache.paimon.predicate.SortValue;
+import org.apache.paimon.predicate.TopN;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.apache.paimon.predicate.SortValue.NullOrdering.NULLS_FIRST;
+import static org.apache.paimon.predicate.SortValue.NullOrdering.NULLS_LAST;
+import static org.apache.paimon.predicate.SortValue.SortDirection.ASCENDING;
+import static org.apache.paimon.predicate.SortValue.SortDirection.DESCENDING;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link BTreeTopNIndexFileSelector}. */
+public class BTreeTopNIndexFileSelectorTest {
+
+    private static final DataField FIELD = new DataField(1, "score", 
DataTypes.INT());
+    private static final KeySerializer KEY_SERIALIZER = 
KeySerializer.create(FIELD.type());
+
+    @Test
+    public void testSelectDescendingNullsLast() {
+        List<IndexFileMeta> files =
+                Arrays.asList(
+                        file("all-null", null, null, true),
+                        file("max-20", 10, 20, false),
+                        file("max-40", 30, 40, false),
+                        file("max-30", 20, 30, true));
+
+        assertThat(fileNames(select(files, NULLS_LAST, 
2))).containsExactly("max-40", "max-30");
+    }
+
+    @Test
+    public void testSelectDescendingNullsFirst() {
+        List<IndexFileMeta> files =
+                Arrays.asList(
+                        file("nonnull", 100, 1000, false),
+                        file("null-b", null, null, true),
+                        file("null-a", 10, 20, true));
+
+        assertThat(fileNames(select(files, NULLS_FIRST, 
2))).containsExactly("null-a", "null-b");
+    }
+
+    @Test
+    public void testSelectAscendingNullsLast() {
+        List<IndexFileMeta> files =
+                Arrays.asList(
+                        file("all-null", null, null, true),
+                        file("min-10", 10, 20, false),
+                        file("min-30", 30, 40, false),
+                        file("min-20", 20, 30, true));
+
+        assertThat(fileNames(select(files, ASCENDING, NULLS_LAST, 2)))
+                .containsExactly("min-10", "min-20");
+    }
+
+    @Test
+    public void testSelectAscendingNullsFirst() {
+        List<IndexFileMeta> files =
+                Arrays.asList(
+                        file("nonnull", 1, 100, false),
+                        file("null-b", null, null, true),
+                        file("null-a", 10, 20, true));
+
+        assertThat(fileNames(select(files, ASCENDING, NULLS_FIRST, 2)))
+                .containsExactly("null-a", "null-b");
+    }
+
+    @Test
+    public void testAscendingSingleFileCoversTopNAtEqualBoundary() {
+        List<IndexFileMeta> files =
+                Arrays.asList(
+                        file("bottom", 0, 10, false, 100),
+                        file("next", 10, 20, false, 100),
+                        file("upper", 20, 30, false, 100));
+
+        assertThat(fileNames(select(files, ASCENDING, NULLS_LAST, 
100))).containsExactly("bottom");
+    }
+
+    @Test
+    public void testMissingMetadataFailsFast() {
+        assertThatThrownBy(
+                        () -> 
select(Arrays.asList(fileWithoutMetadata("missing")), NULLS_LAST, 1))
+                .isInstanceOf(NullPointerException.class)
+                .hasMessage("BTree index file 'missing' is missing sorted 
metadata.");
+    }
+
+    @Test
+    public void testCorruptMetadataFailsFast() {
+        assertThatThrownBy(
+                        () ->
+                                select(
+                                        Arrays.asList(
+                                                fileWithMetadata(
+                                                        "corrupt", new byte[] 
{-1, -1, -1, -1})),
+                                        NULLS_LAST,
+                                        1))
+                .isInstanceOf(RuntimeException.class);
+    }
+
+    @Test
+    public void testSingleFileCoversTopNAtEqualBoundary() {
+        List<IndexFileMeta> files =
+                Arrays.asList(
+                        file("top", 90, 100, false, 100),
+                        file("next", 80, 90, false, 100),
+                        file("lower", 70, 80, false, 100));
+
+        assertThat(fileNames(select(files, NULLS_LAST, 
100))).containsExactly("top");
+    }
+
+    @Test
+    public void testDoesNotStopWhenSingleFileHasTooFewRows() {
+        List<IndexFileMeta> files =
+                Arrays.asList(
+                        file("top", 90, 100, false, 99),
+                        file("next", 80, 89, false, 100),
+                        file("lower", 70, 79, false, 100));
+
+        assertThat(fileNames(select(files, NULLS_LAST, 
100))).containsExactly("top", "next");
+    }
+
+    @Test
+    public void testDoesNotStopAtOverlappingRange() {
+        List<IndexFileMeta> files =
+                Arrays.asList(
+                        file("top", 0, 100, false, 100),
+                        file("next", 90, 99, false, 99),
+                        file("lower", 80, 89, false, 99));
+
+        assertThat(fileNames(select(files, NULLS_LAST, 100)))
+                .containsExactly("top", "next", "lower");
+    }
+
+    @Test
+    public void testNullsLastPreventsWholeFileCoverage() {
+        List<IndexFileMeta> files =
+                Arrays.asList(
+                        file("top", 90, 100, true, 100),
+                        file("next", 80, 89, false, 100),
+                        file("lower", 70, 79, false, 100));
+
+        assertThat(fileNames(select(files, NULLS_LAST, 
100))).containsExactly("top", "next");
+    }
+
+    @Test
+    public void testLimitBoundaries() {
+        List<IndexFileMeta> files =
+                Arrays.asList(file("max-10", 0, 10, false), file("max-20", 10, 
20, false));
+
+        assertThat(select(files, NULLS_LAST, 0)).isEmpty();
+        assertThat(fileNames(select(files, NULLS_LAST, files.size())))
+                .containsExactly("max-20", "max-10");
+    }
+
+    private List<IndexFileMeta> select(
+            List<IndexFileMeta> files, SortValue.NullOrdering nullOrdering, 
int limit) {
+        return select(files, DESCENDING, nullOrdering, limit);
+    }
+
+    private List<IndexFileMeta> select(
+            List<IndexFileMeta> files,
+            SortValue.SortDirection direction,
+            SortValue.NullOrdering nullOrdering,
+            int limit) {
+        FieldRef fieldRef = new FieldRef(FIELD.id(), FIELD.name(), 
FIELD.type());
+        TopN topN = new TopN(fieldRef, direction, nullOrdering, limit);
+        return BTreeTopNIndexFileSelector.select(files, FIELD, topN);
+    }
+
+    private IndexFileMeta file(
+            String fileName, Integer firstKey, Integer lastKey, boolean 
hasNulls) {
+        return file(fileName, firstKey, lastKey, hasNulls, 1);
+    }
+
+    private IndexFileMeta file(
+            String fileName, Integer firstKey, Integer lastKey, boolean 
hasNulls, long rowCount) {
+        SortedIndexFileMeta sortedMeta =
+                new SortedIndexFileMeta(serialize(firstKey), 
serialize(lastKey), hasNulls);
+        return new IndexFileMeta(
+                "btree",
+                fileName,
+                1,
+                rowCount,
+                new GlobalIndexMeta(0, 0, FIELD.id(), null, 
sortedMeta.serialize()),
+                null);
+    }
+
+    private IndexFileMeta fileWithoutMetadata(String fileName) {
+        return fileWithMetadata(fileName, null);
+    }
+
+    private IndexFileMeta fileWithMetadata(String fileName, byte[] metadata) {
+        return new IndexFileMeta(
+                "btree",
+                fileName,
+                1,
+                1,
+                new GlobalIndexMeta(0, 0, FIELD.id(), null, metadata),
+                null);
+    }
+
+    private byte[] serialize(Integer value) {
+        return value == null ? null : KEY_SERIALIZER.serialize(value);
+    }
+
+    private List<String> fileNames(List<IndexFileMeta> files) {
+        return 
files.stream().map(IndexFileMeta::fileName).collect(Collectors.toList());
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/BtreeGlobalIndexTableTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/BtreeGlobalIndexTableTest.java
index 676a1c305e..375cac3989 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/BtreeGlobalIndexTableTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/BtreeGlobalIndexTableTest.java
@@ -33,8 +33,10 @@ import org.apache.paimon.index.GlobalIndexMeta;
 import org.apache.paimon.index.IndexFileMeta;
 import org.apache.paimon.manifest.IndexManifestEntry;
 import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.predicate.FieldRef;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.predicate.TopN;
 import org.apache.paimon.schema.SchemaChange;
 import org.apache.paimon.table.sink.BatchTableCommit;
 import org.apache.paimon.table.sink.BatchTableWrite;
@@ -63,6 +65,9 @@ import java.util.Collections;
 import java.util.List;
 import java.util.stream.Collectors;
 
+import static org.apache.paimon.predicate.SortValue.NullOrdering.NULLS_LAST;
+import static org.apache.paimon.predicate.SortValue.SortDirection.ASCENDING;
+import static org.apache.paimon.predicate.SortValue.SortDirection.DESCENDING;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 
@@ -146,6 +151,251 @@ public class BtreeGlobalIndexTableTest extends 
DataEvolutionTestBase {
         assertThat(readF1).containsExactly("a200", "a300", "a400", "a56789");
     }
 
+    @Test
+    public void testBTreeGlobalIndexTopNCandidatesAcrossRanges() throws 
Exception {
+        write(100L);
+        createIndex("f1");
+        appendRows(100, 200);
+        createIndexIncremental("f1");
+
+        FileStoreTable table = (FileStoreTable) catalog.getTable(identifier());
+        TopN topN =
+                new TopN(
+                        new FieldRef(1, "f1", table.rowType().getTypeAt(1)),
+                        DESCENDING,
+                        NULLS_LAST,
+                        5);
+
+        try (DataEvolutionGlobalIndexScanner scanner =
+                DataEvolutionGlobalIndexScanner.createForTopN(
+                                table, PartitionPredicate.ALWAYS_TRUE, topN)
+                        .orElseThrow(AssertionError::new)) {
+            
assertThat(scanner.scan(topN).orElseThrow(AssertionError::new).results().toRangeList())
+                    .containsExactly(new Range(95, 99));
+        }
+
+        ReadBuilder readBuilder = table.newReadBuilder().withTopN(topN);
+        TableScan.Plan plan = readBuilder.newScan().plan();
+        assertThat(plan.splits()).allMatch(IndexedSplit.class::isInstance);
+        assertThat(readF1(readBuilder, plan))
+                .containsExactlyInAnyOrder("a95", "a96", "a97", "a98", "a99");
+
+        TopN ascendingTopN =
+                new TopN(
+                        new FieldRef(1, "f1", table.rowType().getTypeAt(1)),
+                        ASCENDING,
+                        NULLS_LAST,
+                        5);
+        try (DataEvolutionGlobalIndexScanner scanner =
+                DataEvolutionGlobalIndexScanner.createForTopN(
+                                table, PartitionPredicate.ALWAYS_TRUE, 
ascendingTopN)
+                        .orElseThrow(AssertionError::new)) {
+            assertThat(
+                            scanner.scan(ascendingTopN)
+                                    .orElseThrow(AssertionError::new)
+                                    .results()
+                                    .toRangeList())
+                    .containsExactly(new Range(0, 1), new Range(10, 10), new 
Range(100, 101));
+        }
+
+        ReadBuilder ascendingReadBuilder = 
table.newReadBuilder().withTopN(ascendingTopN);
+        TableScan.Plan ascendingPlan = ascendingReadBuilder.newScan().plan();
+        
assertThat(ascendingPlan.splits()).allMatch(IndexedSplit.class::isInstance);
+        assertThat(readF1(ascendingReadBuilder, ascendingPlan))
+                .containsExactlyInAnyOrder("a0", "a1", "a10", "a100", "a101");
+    }
+
+    @Test
+    public void testBTreeGlobalIndexTopNCandidatesSkipSplitTopN() throws 
Exception {
+        write(100L);
+        createIndex("f0");
+        appendRows(100, 200);
+        createIndexIncremental("f0");
+
+        FileStoreTable table =
+                ((FileStoreTable) catalog.getTable(identifier()))
+                        .copy(
+                                Collections.singletonMap(
+                                        
CoreOptions.SOURCE_SPLIT_TARGET_SIZE.key(), "1 b"));
+        TopN topN =
+                new TopN(
+                        new FieldRef(0, "f0", table.rowType().getTypeAt(0)),
+                        DESCENDING,
+                        NULLS_LAST,
+                        1);
+
+        try (DataEvolutionGlobalIndexScanner scanner =
+                DataEvolutionGlobalIndexScanner.createForTopN(
+                                table, PartitionPredicate.ALWAYS_TRUE, topN)
+                        .orElseThrow(AssertionError::new)) {
+            
assertThat(scanner.scan(topN).orElseThrow(AssertionError::new).results().toRangeList())
+                    .containsExactly(new Range(199, 199));
+            // Index-file TopN pruning must not make the excluded indexed 
range look unindexed.
+            assertThat(scanner.unindexedRows(topN).results()).isEmpty();
+        }
+
+        TableScan.Plan plan = 
table.newReadBuilder().withTopN(topN).newScan().plan();
+        assertThat(plan.splits()).allMatch(IndexedSplit.class::isInstance);
+        assertThat(
+                        plan.splits().stream()
+                                .map(IndexedSplit.class::cast)
+                                .flatMap(split -> split.rowRanges().stream())
+                                .collect(Collectors.toList()))
+                .containsExactly(new Range(199, 199));
+    }
+
+    @Test
+    public void testBTreeGlobalIndexTopNPartialCoverage() throws Exception {
+        write(100L);
+        createIndex("f1");
+        appendRows(100, 110);
+
+        FileStoreTable table =
+                tableWithSearchMode((FileStoreTable) 
catalog.getTable(identifier()), "full");
+        TopN topN =
+                new TopN(
+                        new FieldRef(1, "f1", table.rowType().getTypeAt(1)),
+                        DESCENDING,
+                        NULLS_LAST,
+                        5);
+
+        try (DataEvolutionGlobalIndexScanner scanner =
+                DataEvolutionGlobalIndexScanner.createForTopN(
+                                table, PartitionPredicate.ALWAYS_TRUE, topN)
+                        .orElseThrow(AssertionError::new)) {
+            
assertThat(scanner.scan(topN).orElseThrow(AssertionError::new).results().toRangeList())
+                    .containsExactly(new Range(95, 99));
+            assertThat(scanner.unindexedRows(topN).results().toRangeList())
+                    .containsExactly(new Range(100, 109));
+        }
+
+        FileStoreTable fastTable = tableWithSearchMode(table, "fast");
+        try (DataEvolutionGlobalIndexScanner scanner =
+                DataEvolutionGlobalIndexScanner.createForTopN(
+                                fastTable, PartitionPredicate.ALWAYS_TRUE, 
topN)
+                        .orElseThrow(AssertionError::new)) {
+            assertThat(scanner.unindexedRows(topN).results()).isEmpty();
+        }
+    }
+
+    @Test
+    public void testBTreeGlobalIndexTopNFallsBackForUnsafeReads() throws 
Exception {
+        write(100L);
+        createIndex("f1");
+
+        FileStoreTable table = (FileStoreTable) catalog.getTable(identifier());
+        TopN topN =
+                new TopN(
+                        new FieldRef(1, "f1", table.rowType().getTypeAt(1)),
+                        DESCENDING,
+                        NULLS_LAST,
+                        5);
+        Predicate filter = new PredicateBuilder(table.rowType()).lessThan(0, 
10);
+        ReadBuilder filtered = 
table.newReadBuilder().withFilter(filter).withTopN(topN);
+        TableScan.Plan filteredPlan = filtered.newScan().plan();
+        
assertThat(filteredPlan.splits()).allMatch(DataSplit.class::isInstance);
+        assertThat(readF1(filtered, filteredPlan))
+                .containsExactlyInAnyOrder(
+                        "a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", 
"a9");
+
+        FileStoreTable modifiableDeletionVectorTable =
+                table.copy(
+                        Collections.singletonMap(
+                                CoreOptions.DELETION_VECTORS_MODIFIABLE.key(), 
"true"));
+        FileStoreTable deletionVectorTable =
+                modifiableDeletionVectorTable.copy(
+                        Collections.singletonMap(
+                                CoreOptions.DELETION_VECTORS_ENABLED.key(), 
"true"));
+        ReadBuilder deletionVectorRead = 
deletionVectorTable.newReadBuilder().withTopN(topN);
+        assertThat(deletionVectorRead.newScan().plan().splits())
+                .allMatch(DataSplit.class::isInstance);
+    }
+
+    @Test
+    public void testBTreeGlobalIndexTopNFallsBackForLargeLimit() throws 
Exception {
+        write(200L);
+        createIndex("f1");
+
+        FileStoreTable table = (FileStoreTable) catalog.getTable(identifier());
+        TopN maxSupportedTopN =
+                new TopN(
+                        new FieldRef(1, "f1", table.rowType().getTypeAt(1)),
+                        DESCENDING,
+                        NULLS_LAST,
+                        100);
+        
assertThat(table.newReadBuilder().withTopN(maxSupportedTopN).newScan().plan().splits())
+                .isNotEmpty()
+                .allMatch(IndexedSplit.class::isInstance);
+
+        TopN topN =
+                new TopN(
+                        new FieldRef(1, "f1", table.rowType().getTypeAt(1)),
+                        DESCENDING,
+                        NULLS_LAST,
+                        101);
+
+        assertThat(
+                        DataEvolutionGlobalIndexScanner.createForTopN(
+                                table, PartitionPredicate.ALWAYS_TRUE, topN))
+                .isEmpty();
+
+        TableScan.Plan plan = 
table.newReadBuilder().withTopN(topN).newScan().plan();
+        
assertThat(plan.splits()).isNotEmpty().allMatch(DataSplit.class::isInstance);
+    }
+
+    @Test
+    public void testBTreeGlobalIndexTopNFallsBackForUnsupportedStartupModes() 
throws Exception {
+        write(100L);
+        createIndex("f1");
+
+        FileStoreTable table = (FileStoreTable) catalog.getTable(identifier());
+        long startSnapshot = table.snapshotManager().latestSnapshotId();
+        appendRows(100, 110);
+        table = (FileStoreTable) catalog.getTable(identifier());
+        long endSnapshot = table.snapshotManager().latestSnapshotId();
+
+        FileStoreTable incrementalTable =
+                table.copy(
+                        Collections.singletonMap(
+                                CoreOptions.INCREMENTAL_BETWEEN.key(),
+                                startSnapshot + "," + endSnapshot));
+        TopN topN =
+                new TopN(
+                        new FieldRef(1, "f1", 
incrementalTable.rowType().getTypeAt(1)),
+                        DESCENDING,
+                        NULLS_LAST,
+                        5);
+
+        List<FileStoreTable> unsupportedTables =
+                Arrays.asList(
+                        table.copy(
+                                Collections.singletonMap(
+                                        CoreOptions.SCAN_MODE.key(),
+                                        
CoreOptions.StartupMode.COMPACTED_FULL.toString())),
+                        table.copy(
+                                Collections.singletonMap(
+                                        
CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key(), "0")),
+                        table.copy(
+                                Collections.singletonMap(
+                                        
CoreOptions.SCAN_CREATION_TIME_MILLIS.key(), "0")),
+                        incrementalTable);
+        for (FileStoreTable unsupportedTable : unsupportedTables) {
+            ReadBuilder unsupportedReadBuilder = 
unsupportedTable.newReadBuilder().withTopN(topN);
+            assertThat(unsupportedReadBuilder.newScan().plan().splits())
+                    .isNotEmpty()
+                    .allMatch(DataSplit.class::isInstance);
+        }
+
+        ReadBuilder readBuilder = 
incrementalTable.newReadBuilder().withTopN(topN);
+        TableScan.Plan plan = readBuilder.newScan().plan();
+
+        
assertThat(plan.splits()).isNotEmpty().allMatch(DataSplit.class::isInstance);
+        assertThat(readF1(readBuilder, plan))
+                .containsExactlyInAnyOrder(
+                        "a100", "a101", "a102", "a103", "a104", "a105", 
"a106", "a107", "a108",
+                        "a109");
+    }
+
     @Test
     public void testMixedRowIdOrSkipsGlobalIndexScan() throws Exception {
         write(10L);
@@ -195,6 +445,14 @@ public class BtreeGlobalIndexTableTest extends 
DataEvolutionTestBase {
                     new PredicateBuilder(table.rowType()).equal(1, 
BinaryString.fromString("a7"));
             table.newReadBuilder().withFilter(predicate).newScan().plan();
 
+            TopN topN =
+                    new TopN(
+                            new FieldRef(1, "f1", 
table.rowType().getTypeAt(1)),
+                            DESCENDING,
+                            NULLS_LAST,
+                            1);
+            table.newReadBuilder().withTopN(topN).newScan().plan();
+
             PredicateBuilder rowIdBuilder =
                     new 
PredicateBuilder(SpecialFields.rowTypeWithRowId(table.rowType()));
             int rowIdIndex = table.rowType().getFieldCount();
@@ -210,6 +468,10 @@ public class BtreeGlobalIndexTableTest extends 
DataEvolutionTestBase {
                             "INFO Scan table '[^']+' with global index\\. "
                                     + "searchMode='fast', total=\\d+ ms, 
metadata=\\d+ ms, "
                                     + "lookup=\\d+ ms, coverage=\\d+ ms\\.")
+                    .containsPattern(
+                            "INFO Scan table '[^']+' with BTree global index 
TopN\\. "
+                                    + "searchMode='fast', topN='[^']+', 
total=\\d+ ms, "
+                                    + "metadata=\\d+ ms, lookup=\\d+ ms, 
coverage=\\d+ ms\\.")
                     .containsPattern(
                             "INFO Global index lookup table='[^']+', 
type='btree', "
                                     + "fields='\\[f1\\]', lookup=\\d+ ms\\.")
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/RowTrackingTestBase.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/RowTrackingTestBase.scala
index f88993dba8..ba66e09697 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/RowTrackingTestBase.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/RowTrackingTestBase.scala
@@ -20,6 +20,7 @@ package org.apache.paimon.spark.sql
 
 import org.apache.paimon.Snapshot.CommitKind
 import org.apache.paimon.errors.ErrorMessages
+import org.apache.paimon.globalindex.IndexedSplit
 import org.apache.paimon.spark.PaimonMetrics.RESULTED_TABLE_FILES
 import org.apache.paimon.spark.PaimonSparkTestBase
 import org.apache.paimon.spark.read.PaimonSplitScan
@@ -1086,6 +1087,35 @@ abstract class RowTrackingTestBase extends 
PaimonSparkTestBase with AdaptiveSpar
     }
   }
 
+  test("Data Evolution: BTree global index TopN with Spark SQL") {
+    assume(gteqSpark3_3)
+    withTable("t") {
+      sql("""
+            |CREATE TABLE t (id INT, name STRING) TBLPROPERTIES (
+            |  'row-tracking.enabled' = 'true',
+            |  'data-evolution.enabled' = 'true')
+            |""".stripMargin)
+      sql("INSERT INTO t VALUES (1, 'a'), (2, 'c'), (3, 'b'), (4, 'e'), (5, 
'd')")
+      sql(
+        "CALL sys.create_global_index(table => 'test.t', index_column => 
'name', " +
+          "index_type => 'btree', options => 
'btree-index.records-per-range=2')")
+
+      val descending = "SELECT id, name FROM t ORDER BY name DESC NULLS LAST 
LIMIT 2"
+      val descendingScan = getPaimonScan(descending)
+      assert(descendingScan.pushedTopN.nonEmpty)
+      assert(descendingScan.inputSplits.nonEmpty)
+      assert(descendingScan.inputSplits.forall(_.isInstanceOf[IndexedSplit]))
+      checkAnswer(sql(descending), Seq(Row(4, "e"), Row(5, "d")))
+
+      val ascending = "SELECT id, name FROM t ORDER BY name ASC NULLS LAST 
LIMIT 2"
+      val ascendingScan = getPaimonScan(ascending)
+      assert(ascendingScan.pushedTopN.nonEmpty)
+      assert(ascendingScan.inputSplits.nonEmpty)
+      assert(ascendingScan.inputSplits.forall(_.isInstanceOf[IndexedSplit]))
+      checkAnswer(sql(ascending), Seq(Row(1, "a"), Row(3, "b")))
+    }
+  }
+
   test("Data Evolution: V1 update table with data-evolution without 
condition") {
     withSparkSQLConf("spark.paimon.write.use-v2-write" -> "false") {
       withTable("t") {

Reply via email to