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 ed02a55dea [core] Sort provably all-null splits last in TopN pushdown 
with NULLS LAST (#10020)
ed02a55dea is described below

commit ed02a55dea9b9a5246935b1fe8c1b0761789dec2
Author: YangJie <[email protected]>
AuthorDate: Sun Sep 20 22:39:55 2026 -0400

    [core] Sort provably all-null splits last in TopN pushdown with NULLS LAST 
(#10020)
---
 .../table/source/TopNDataSplitEvaluator.java       |  32 +++--
 .../apache/paimon/table/source/TableScanTest.java  | 136 +++++++++++++++++++++
 2 files changed, 160 insertions(+), 8 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/TopNDataSplitEvaluator.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/TopNDataSplitEvaluator.java
index d6bf3d3215..c67c05f094 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/TopNDataSplitEvaluator.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/TopNDataSplitEvaluator.java
@@ -90,6 +90,14 @@ public class TopNDataSplitEvaluator {
         return results;
     }
 
+    /**
+     * Orders splits by their best row under the query's sort order and keeps 
the first {@code
+     * limit} ones. In the NULLS LAST branches a split whose sort column is 
provably all null
+     * ({@link RichSplit#allNull}, i.e. the null count equals the row count) 
is the worst candidate
+     * and sorts last. A null min/max that is not provably all-null means the 
bound is unknown — for
+     * example files written with {@code stats.mode=counts} — and {@link 
#ascCompare}/{@link
+     * #descCompare} order such a split first so it is read, conservatively.
+     */
     private List<DataSplit> pickTopNSplits(
             List<RichSplit> splits,
             DataType fieldType,
@@ -107,9 +115,12 @@ public class TopNDataSplitEvaluator {
                                 result = ascCompare(fieldType, x.min, y.min);
                             }
                         } else {
-                            result = ascCompare(fieldType, x.min, y.min);
+                            result = Boolean.compare(x.allNull, y.allNull);
                             if (result == 0) {
-                                result = nullsLastCompare(x.nullCount, 
y.nullCount);
+                                result = ascCompare(fieldType, x.min, y.min);
+                                if (result == 0) {
+                                    result = nullsLastCompare(x.nullCount, 
y.nullCount);
+                                }
                             }
                         }
                         return result;
@@ -124,9 +135,12 @@ public class TopNDataSplitEvaluator {
                                 result = descCompare(fieldType, x.max, y.max);
                             }
                         } else {
-                            result = descCompare(fieldType, x.max, y.max);
+                            result = Boolean.compare(x.allNull, y.allNull);
                             if (result == 0) {
-                                result = nullsLastCompare(x.nullCount, 
y.nullCount);
+                                result = descCompare(fieldType, x.max, y.max);
+                                if (result == 0) {
+                                    result = nullsLastCompare(x.nullCount, 
y.nullCount);
+                                }
                             }
                         }
                         return result;
@@ -141,7 +155,7 @@ public class TopNDataSplitEvaluator {
 
     private int nullsFirstCompare(Long left, Long right) {
         if (left == null) {
-            return -1;
+            return right == null ? 0 : -1;
         } else if (right == null) {
             return 1;
         } else {
@@ -151,7 +165,7 @@ public class TopNDataSplitEvaluator {
 
     private int nullsLastCompare(Long left, Long right) {
         if (left == null) {
-            return -1;
+            return right == null ? 0 : -1;
         } else if (right == null) {
             return 1;
         } else {
@@ -161,7 +175,7 @@ public class TopNDataSplitEvaluator {
 
     private int ascCompare(DataType type, Object left, Object right) {
         if (left == null) {
-            return -1;
+            return right == null ? 0 : -1;
         } else if (right == null) {
             return 1;
         } else {
@@ -171,7 +185,7 @@ public class TopNDataSplitEvaluator {
 
     private int descCompare(DataType type, Object left, Object right) {
         if (left == null) {
-            return -1;
+            return right == null ? 0 : -1;
         } else if (right == null) {
             return 1;
         } else {
@@ -191,12 +205,14 @@ public class TopNDataSplitEvaluator {
         private final Object min;
         private final Object max;
         private final Long nullCount;
+        private final boolean allNull;
 
         private RichSplit(DataSplit split, Object min, Object max, Long 
nullCount) {
             this.split = split;
             this.min = min;
             this.max = max;
             this.nullCount = nullCount;
+            this.allNull = nullCount != null && nullCount.longValue() == 
split.rowCount();
         }
 
         private DataSplit split() {
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/source/TableScanTest.java 
b/paimon-core/src/test/java/org/apache/paimon/table/source/TableScanTest.java
index a60893aa65..6d706d5b9a 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/source/TableScanTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/source/TableScanTest.java
@@ -869,6 +869,92 @@ public class TableScanTest extends ScannerTestBase {
         assertThat(result).containsExactly(wideSplit, tightLowSplit);
     }
 
+    @Test
+    public void testPushDownTopNNullsLastSortsAllNullSplitLast() throws 
Exception {
+        createAppendOnlyTable();
+
+        DataField field = table.schema().fields().get(1);
+        FieldRef ref = new FieldRef(1, field.name(), field.type());
+
+        // split whose sort column is entirely null; with NULLS LAST it is the 
worst TopN
+        // candidate and must not take a limit slot from a split with real 
values
+        DataSplit allNullSplit = newAllNullTestSplit("all-null", 5);
+        DataSplit realSplit = newTestSplit("real", 10, 19, null);
+
+        TopN ascTopN = new TopN(ref, ASCENDING, NULLS_LAST, 1);
+        List<Split> ascResult =
+                new TopNDataSplitEvaluator(table.schema(), 
table.schemaManager())
+                        .evaluate(
+                                ascTopN.orders().get(0),
+                                ascTopN.limit(),
+                                Arrays.asList(allNullSplit, realSplit));
+        assertThat(ascResult).containsExactly(realSplit);
+
+        DataSplit realMaxSplit = newTestSplit("real-max", 100, 109, null);
+        TopN descTopN = new TopN(ref, DESCENDING, NULLS_LAST, 1);
+        List<Split> descResult =
+                new TopNDataSplitEvaluator(table.schema(), 
table.schemaManager())
+                        .evaluate(
+                                descTopN.orders().get(0),
+                                descTopN.limit(),
+                                Arrays.asList(allNullSplit, realMaxSplit));
+        assertThat(descResult).containsExactly(realMaxSplit);
+
+        // sanity: NULLS FIRST keeps treating the null-containing split as the 
best candidate
+        TopN nullsFirstTopN = new TopN(ref, DESCENDING, NULLS_FIRST, 1);
+        List<Split> nullsFirstResult =
+                new TopNDataSplitEvaluator(table.schema(), 
table.schemaManager())
+                        .evaluate(
+                                nullsFirstTopN.orders().get(0),
+                                nullsFirstTopN.limit(),
+                                Arrays.asList(allNullSplit, realMaxSplit));
+        assertThat(nullsFirstResult).containsExactly(allNullSplit);
+
+        // two all-null splits tie on every key and both are kept: the 
comparator returns 0 for
+        // equal keys rather than a nonzero value.
+        DataSplit allNullSplit2 = newAllNullTestSplit("all-null-2", 5);
+        TopN nullsFirstTopN2 = new TopN(ref, ASCENDING, NULLS_FIRST, 2);
+        List<Split> tiedResult =
+                new TopNDataSplitEvaluator(table.schema(), 
table.schemaManager())
+                        .evaluate(
+                                nullsFirstTopN2.orders().get(0),
+                                nullsFirstTopN2.limit(),
+                                Arrays.asList(allNullSplit, allNullSplit2));
+        assertThat(tiedResult).containsExactlyInAnyOrder(allNullSplit, 
allNullSplit2);
+    }
+
+    @Test
+    public void testPushDownTopNNullsLastKeepsStatsUnknownSplitFirst() throws 
Exception {
+        createAppendOnlyTable();
+
+        DataField field = table.schema().fields().get(1);
+        FieldRef ref = new FieldRef(1, field.name(), field.type());
+
+        // stats-mode=counts-like split: min/max unknown (null) but only 2 of 
5 rows are null,
+        // so it is NOT provably all-null and must stay ahead of splits with 
known bounds
+        DataSplit unknownSplit = newTestSplitWithField1Stats("unknown", null, 
null, 2L, 5);
+        DataSplit realSplit = newTestSplit("real", 10, 19, null);
+        DataSplit realMaxSplit = newTestSplit("real-max", 100, 109, null);
+
+        TopN ascTopN = new TopN(ref, ASCENDING, NULLS_LAST, 1);
+        List<Split> ascResult =
+                new TopNDataSplitEvaluator(table.schema(), 
table.schemaManager())
+                        .evaluate(
+                                ascTopN.orders().get(0),
+                                ascTopN.limit(),
+                                Arrays.asList(unknownSplit, realSplit));
+        assertThat(ascResult).containsExactly(unknownSplit);
+
+        TopN descTopN = new TopN(ref, DESCENDING, NULLS_LAST, 1);
+        List<Split> descResult =
+                new TopNDataSplitEvaluator(table.schema(), 
table.schemaManager())
+                        .evaluate(
+                                descTopN.orders().get(0),
+                                descTopN.limit(),
+                                Arrays.asList(unknownSplit, realMaxSplit));
+        assertThat(descResult).containsExactly(unknownSplit);
+    }
+
     @Test
     public void testPushDownTopNSchemaEvolution() throws Exception {
         createAppendOnlyTable();
@@ -995,6 +1081,56 @@ public class TableScanTest extends ScannerTestBase {
         return builder.build();
     }
 
+    private DataSplit newAllNullTestSplit(String name, int rowCount) {
+        return newTestSplitWithField1Stats(name, null, null, (long) rowCount, 
rowCount);
+    }
+
+    private DataSplit newTestSplitWithField1Stats(
+            String name, Integer minValue, Integer maxValue, Long nullCount, 
int rowCount) {
+        DataFileMeta file =
+                DataFileMeta.forAppend(
+                        name,
+                        0,
+                        (long) rowCount,
+                        new SimpleStats(
+                                newStatsRowOptionalField1(0, minValue, 0L),
+                                newStatsRowOptionalField1(0, maxValue, 0L),
+                                fromLongArray(new Long[] {0L, nullCount, 0L})),
+                        0,
+                        0,
+                        table.schema().id(),
+                        Collections.emptyList(),
+                        null,
+                        FileSource.APPEND,
+                        null,
+                        null,
+                        null,
+                        null);
+
+        return DataSplit.builder()
+                .withSnapshot(1)
+                .withPartition(BinaryRow.EMPTY_ROW)
+                .withBucket(0)
+                .withBucketPath("dummy")
+                .rawConvertible(true)
+                .withDataFiles(Collections.singletonList(file))
+                .build();
+    }
+
+    private BinaryRow newStatsRowOptionalField1(int pt, Integer a, long c) {
+        BinaryRow row = new BinaryRow(3);
+        BinaryRowWriter writer = new BinaryRowWriter(row);
+        writer.writeInt(0, pt);
+        if (a == null) {
+            writer.setNullAt(1);
+        } else {
+            writer.writeInt(1, a);
+        }
+        writer.writeLong(2, c);
+        writer.complete();
+        return row;
+    }
+
     private BinaryRow newStatsRow(int pt, int a, long b) {
         BinaryRow row = new BinaryRow(3);
         BinaryRowWriter writer = new BinaryRowWriter(row);

Reply via email to