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

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


The following commit(s) were added to refs/heads/master by this push:
     new 788e488237d [fix](show) apply SHOW TABLETS ordering and LIMIT 
independently (#66713)
788e488237d is described below

commit 788e488237d3e07f6f63a7d0d7e27e5902da6832
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Fri Aug 14 12:00:53 2026 +0800

    [fix](show) apply SHOW TABLETS ordering and LIMIT independently (#66713)
    
    ### What problem does this PR solve?
    
    Issue Number: related to #65871
    
    Related PR: follow-up to #66116
    
    Problem Summary:
    
    #66116 fixed `SHOW TABLETS ... ORDER BY ... LIMIT n` so that the sort
    sees the whole tablet set instead of an arbitrary prefix of the scan.
    That part is correct and is kept as is. But the fix routed both the
    ORDER BY branch and the branch without ORDER BY through one
    `SortAndLimit` call, and that helper needs a comparator even when no
    ordering was asked for. Three problems follow from that, plus one that
    was never covered by a test.
    
    **1. Without ORDER BY, an arbitrary subset was sorted and presented as
    if it were a global top-N.**
    
    When no ORDER BY is given, the scan stops as soon as enough rows are
    gathered, so the collected rows are an arbitrary subset of the table.
    Which partition is walked first comes from `ConcurrentHashMap` iteration
    order (`OlapTable#getPartitions` returns `idToPartition.values()`), so
    it is neither id order nor creation order, and it shifts when partitions
    are added or dropped. Sorting that subset by `(TabletId, ReplicaId)`
    produced a clean ascending column that looks like the globally smallest
    tablet ids but is not.
    
    The trap is that it *is* correct on a table with a single partition and
    no rollup: one index is materialized in full before the size check, so
    the collected set is the whole table. The discrepancy only shows up on
    partitioned tables, which is exactly where it will not be noticed during
    testing.
    
    ```sql
    -- p1 = 10001..10003, p2 = 10004..10006, p3 = 10007..10009
    SHOW TABLETS FROM t LIMIT 3;
    -- returns e.g. 10007, 10008, 10009 -- neatly ascending, but not the 3 
smallest,
    -- and a later ADD PARTITION can change which three come back
    ```
    
    This PR returns those rows in scan order and bounds only their number.
    Sorting an arbitrary subset cannot be made meaningful, so it is better
    not to imply an order that is not there. Without a LIMIT every row is
    collected anyway, so that case keeps the `(TabletId, ReplicaId)`
    ordering the command has always returned.
    
    **2. `LIMIT 0` returned the whole table.**
    
    `LogicalPlanBuilder#visitShowTabletsFromTable` used `0` both for "the
    statement has no LIMIT clause" and for an explicit `LIMIT 0`, so the
    command could not tell them apart and fell back to "no limit at all".
    
    ```sql
    SHOW TABLETS FROM t LIMIT 0;      -- returned every tablet, MySQL semantics 
say no row
    SHOW TABLETS FROM t LIMIT 5, 0;   -- returned everything past the offset
    ```
    
    The parser now passes `-1` for a missing LIMIT clause, so `limit == 0`
    bounds the result to nothing and the scan is skipped entirely.
    
    **3. `limit + offset` overflowed.**
    
    Both operands come from `Long.parseLong`, and the sum wrapped into a
    negative size that later reached `List#subList`:
    
    ```sql
    SHOW TABLETS FROM t LIMIT 9223372036854775807, 9223372036854775807;
    -- IndexOutOfBoundsException
    ```
    
    Each operand is now clamped to `Integer.MAX_VALUE` before they are
    added, which restores a guard that #66116 had removed. This is the case
    @morrySnow raised on #66116 with `Utils#addOverflows`.
    
    **4. The layer where these bugs keep landing had no test.**
    
    Every `sizeLimit` bug so far has been in the mapping from LIMIT/OFFSET
    onto the number of rows to keep. `SortAndLimitTest` only covers the
    utility, and the command test only covers `validate()`, so that layer
    sat between two test suites with none of its own. It is extracted into
    `computeSizeLimit()` and covered directly.
    
    **Additionally**: sorting and formatting move out of
    `olapTable.readLock()`. `TabletsProcDir#fetchComparableResult` builds
    rows out of copied longs and strings and keeps no reference to catalog
    objects, so the lock is only needed for the scan. This matters because
    an explicit ORDER BY now has to collect the whole tablet set: on a table
    with hundreds of thousands of tablets times replicas, `SHOW TABLETS ...
    ORDER BY LocalDataSize DESC LIMIT 10` materializes every row, and
    holding the table read lock across the sort and the string conversion
    blocks schema change and partition DDL on that table for the whole time.
    
    ### Resulting semantics
    
    | statement | result |
    |---|---|
    | `SHOW TABLETS FROM t` | every row, ordered by (TabletId, ReplicaId) |
    | `... ORDER BY k` | every row, ordered by k |
    | `... ORDER BY k LIMIT n` | global top-n by k |
    | `... ORDER BY k LIMIT m, n` | global rank m..m+n-1 by k |
    | `... LIMIT n` | n arbitrary rows, no ordering promised |
    | `... LIMIT m, n` | n arbitrary rows, no ordering promised |
    | `... LIMIT 0` / `... LIMIT m, 0` | no row |
    
    ### Release note
    
    Fix `SHOW TABLETS ... LIMIT 0`, which returned the whole table instead
    of an empty result. Without an explicit `ORDER BY`, `SHOW TABLETS ...
    LIMIT n` no longer sorts its result: the rows it returns are an
    arbitrary subset of the table, and sorting them suggested a global order
    that was never there. The unbounded `SHOW TABLETS FROM tbl` keeps
    returning every row ordered by (TabletId, ReplicaId).
---
 .../doris/nereids/parser/LogicalPlanBuilder.java   |   9 +-
 .../commands/ShowTabletsFromTableCommand.java      | 101 +++++++++++++--------
 .../commands/ShowTabletsFromTableCommandTest.java  |  73 +++++++++++++++
 .../suites/show_p0/test_show_tablet.groovy         |  32 +++++--
 4 files changed, 164 insertions(+), 51 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java
index 8663577809f..05336a7771e 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java
@@ -7842,12 +7842,13 @@ public class LogicalPlanBuilder extends 
DorisParserBaseVisitor<Object> {
         if (ctx.sortClause() != null) {
             orderKeys = visit(ctx.sortClause().sortItem(), OrderKey.class);
         }
-        long limit = 0;
+        // -1 means the statement carries no LIMIT clause at all, which is not 
the same as an
+        // explicit LIMIT 0: the former returns every row, the latter returns 
none.
+        long limit = -1;
         long offset = 0;
         if (ctx.limitClause() != null) {
-            limit = ctx.limitClause().limit != null
-                    ? Long.parseLong(ctx.limitClause().limit.getText())
-                    : 0;
+            // every alternative of the limitClause rule binds `limit`, so it 
is never null here
+            limit = Long.parseLong(ctx.limitClause().limit.getText());
             if (limit < 0) {
                 throw new ParseException("Limit requires non-negative number", 
ctx.limitClause());
             }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowTabletsFromTableCommand.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowTabletsFromTableCommand.java
index 6c6ffca87ab..33535f80300 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowTabletsFromTableCommand.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowTabletsFromTableCommand.java
@@ -54,6 +54,7 @@ import org.apache.doris.qe.ShowResultSet;
 import org.apache.doris.qe.ShowResultSetMetaData;
 import org.apache.doris.qe.StmtExecutor;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Lists;
 
 import java.util.ArrayList;
@@ -70,7 +71,8 @@ public class ShowTabletsFromTableCommand extends ShowCommand {
     private PartitionNamesInfo partitionNames;
     private Expression whereClause;
     private List<OrderKey> orderKeys;
-    private long limit = 0;
+    // -1 means no LIMIT clause was given; 0 means an explicit LIMIT 0
+    private long limit = -1;
     private long offset = 0;
 
     private long version;
@@ -193,6 +195,29 @@ public class ShowTabletsFromTableCommand extends 
ShowCommand {
         throw new AnalysisException("Title name[" + columnName + "] does not 
exist");
     }
 
+    /**
+     * Maps the parsed LIMIT/OFFSET pair onto how many rows have to be kept 
before the OFFSET is
+     * applied, that is the LIMIT rows plus the OFFSET rows that are skipped 
afterwards.
+     *
+     * <p>A negative {@code limit} means the statement carried no LIMIT clause 
at all
+     * (see {@link 
org.apache.doris.nereids.parser.LogicalPlanBuilder#visitShowTabletsFromTable}),
+     * so the result is unbounded; {@code limit == 0} is an explicit LIMIT 0 
and bounds the result
+     * to nothing. Each operand is clamped to {@link Integer#MAX_VALUE} before 
they are added,
+     * because a huge LIMIT/OFFSET pair would otherwise overflow long and end 
up as a negative
+     * size.
+     *
+     * @return the number of rows to keep, or {@link Optional#empty()} for "no 
bound at all"
+     */
+    @VisibleForTesting
+    static Optional<Integer> computeSizeLimit(long limit, long offset) {
+        if (limit < 0) {
+            return Optional.empty();
+        }
+        long capped = Math.min(limit, Integer.MAX_VALUE)
+                + Math.min(Math.max(offset, 0), Integer.MAX_VALUE);
+        return Optional.of((int) Math.min(capped, Integer.MAX_VALUE));
+    }
+
     @Override
     public ShowResultSet doRun(ConnectContext ctx, StmtExecutor executor) 
throws Exception {
         validate(ctx);
@@ -200,18 +225,12 @@ public class ShowTabletsFromTableCommand extends 
ShowCommand {
         Env env = Env.getCurrentEnv();
         Database db = 
env.getInternalCatalog().getDbOrAnalysisException(dbTableName.getDb());
         OlapTable olapTable = 
db.getOlapTableOrAnalysisException(dbTableName.getTbl());
+
+        Optional<Integer> sizeLimit = computeSizeLimit(limit, offset);
+
+        List<List<Comparable>> tabletInfos = new ArrayList<>();
         olapTable.readLock();
         try {
-            // The parser passes limit = 0 when the statement carries no LIMIT 
clause
-            // (see LogicalPlanBuilder#visitShowTabletsFromTable), so only a 
positive limit
-            // bounds the result. sizeLimit is how many sorted rows have to be 
kept: the LIMIT
-            // rows plus the OFFSET rows that are skipped afterwards.
-            Optional<Integer> sizeLimit = Optional.empty();
-            if (limit > 0) {
-                long capped = limit + Math.max(offset, 0);
-                sizeLimit = Optional.of((int) Math.min(capped, 
Integer.MAX_VALUE));
-            }
-
             Collection<Partition> partitions = new ArrayList<Partition>();
             if (partitionNames != null) {
                 List<String> paNames = partitionNames.getPartitionNames();
@@ -230,10 +249,10 @@ public class ShowTabletsFromTableCommand extends 
ShowCommand {
             // With an explicit ORDER BY every tablet has to be collected 
before the result can be
             // truncated, otherwise the sort only sees an arbitrary prefix of 
the scan and returns
             // the wrong rows -- the bug reported in #65871. Without ORDER BY 
the scan still stops
-            // as soon as enough rows are gathered, as it did before: LIMIT 
then returns a prefix
-            // of the scan, ordered by (tabletId, replicaId) among itself.
-            boolean stop = false;
-            List<List<Comparable>> tabletInfos = new ArrayList<>();
+            // as soon as enough rows are gathered, as it did before.
+            // An explicit LIMIT 0 cannot return any row, so nothing has to be 
fetched at all,
+            // whether or not an OFFSET was given.
+            boolean stop = limit == 0;
             for (Partition partition : partitions) {
                 if (stop) {
                     break;
@@ -248,31 +267,39 @@ public class ShowTabletsFromTableCommand extends 
ShowCommand {
                     }
                 }
             }
+        } finally {
+            olapTable.readUnlock();
+        }
 
-            ListComparator<List<Comparable>> comparator;
-            if (orderByPairs != null) {
-                // order by the keys given by the user
-                OrderByPair[] orderByPairArr = new 
OrderByPair[orderByPairs.size()];
-                comparator = new 
ListComparator<>(orderByPairs.toArray(orderByPairArr));
-            } else {
-                // order by tabletId, replicaId
-                comparator = new ListComparator<>(0, 1);
-            }
-            List<List<Comparable>> orderedTabletInfos = 
SortAndLimit.sortAndLimit(tabletInfos, comparator, sizeLimit);
+        // Every row holds values copied out of the catalog, so sorting and 
formatting them no
+        // longer needs the table lock.
+        List<List<Comparable>> resultInfos;
+        if (orderByPairs != null) {
+            // the ORDER BY given by the user applies to the whole tablet set 
of the table
+            OrderByPair[] orderByPairArr = new 
OrderByPair[orderByPairs.size()];
+            resultInfos = SortAndLimit.sortAndLimit(tabletInfos,
+                    new 
ListComparator<>(orderByPairs.toArray(orderByPairArr)), sizeLimit);
+        } else if (sizeLimit.isPresent()) {
+            // No ORDER BY and a LIMIT: the scan stopped as soon as enough 
rows were gathered, so
+            // what was collected is an arbitrary subset of the table. Sorting 
it here would only
+            // make that subset look like the globally smallest rows, so the 
rows are left in scan
+            // order and only their number is bounded.
+            resultInfos = tabletInfos.subList(0, Math.min(sizeLimit.get(), 
tabletInfos.size()));
+        } else {
+            // No ORDER BY and no LIMIT: every row is collected anyway, so 
keep the
+            // (tabletId, replicaId) ordering this command has always returned 
in that case.
+            resultInfos = SortAndLimit.sortAndLimit(tabletInfos, new 
ListComparator<>(0, 1), Optional.empty());
+        }
 
-            // If offset is beyond the end of the result, subList yields an 
empty list and no row
-            // is returned.
-            int resultOffset = (int) Math.min(offset, 
orderedTabletInfos.size());
-            for (List<Comparable> tabletInfo
-                    : orderedTabletInfos.subList(resultOffset, 
orderedTabletInfos.size())) {
-                List<String> oneTablet = new 
ArrayList<String>(tabletInfo.size());
-                for (Comparable column : tabletInfo) {
-                    oneTablet.add(column.toString());
-                }
-                rows.add(oneTablet);
+        // If offset is beyond the end of the result, subList yields an empty 
list and no row
+        // is returned.
+        int resultOffset = (int) Math.min(offset, resultInfos.size());
+        for (List<Comparable> tabletInfo : resultInfos.subList(resultOffset, 
resultInfos.size())) {
+            List<String> oneTablet = new ArrayList<String>(tabletInfo.size());
+            for (Comparable column : tabletInfo) {
+                oneTablet.add(column.toString());
             }
-        } finally {
-            olapTable.readUnlock();
+            rows.add(oneTablet);
         }
 
         return new ShowResultSet(getMetaData(), rows);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ShowTabletsFromTableCommandTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ShowTabletsFromTableCommandTest.java
new file mode 100644
index 00000000000..d471e182afc
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ShowTabletsFromTableCommandTest.java
@@ -0,0 +1,73 @@
+// 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.doris.nereids.trees.plans.commands;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+
+/**
+ * Covers the mapping from the parsed LIMIT/OFFSET pair onto the number of 
rows SHOW TABLETS has
+ * to keep. The end-to-end row selection is covered by the 
show_p0/test_show_tablet regression
+ * suite; this test pins down the arithmetic, including the two cases that are 
easy to get wrong:
+ * "no LIMIT clause" vs "LIMIT 0", and the overflow of LIMIT + OFFSET.
+ */
+public class ShowTabletsFromTableCommandTest {
+
+    @Test
+    public void testNoLimitClauseIsUnbounded() {
+        // the parser passes -1 when the statement carries no LIMIT clause at 
all
+        Assertions.assertEquals(Optional.empty(), 
ShowTabletsFromTableCommand.computeSizeLimit(-1, 0));
+    }
+
+    @Test
+    public void testExplicitZeroLimitKeepsNoRow() {
+        Assertions.assertEquals(Optional.of(0), 
ShowTabletsFromTableCommand.computeSizeLimit(0, 0));
+    }
+
+    @Test
+    public void testZeroLimitWithOffsetStillKeepsNoRow() {
+        // LIMIT 5, 0 keeps 5 rows here, but all of them are dropped by the 
OFFSET afterwards
+        Assertions.assertEquals(Optional.of(5), 
ShowTabletsFromTableCommand.computeSizeLimit(0, 5));
+    }
+
+    @Test
+    public void testLimitWithoutOffset() {
+        Assertions.assertEquals(Optional.of(10), 
ShowTabletsFromTableCommand.computeSizeLimit(10, 0));
+    }
+
+    @Test
+    public void testLimitAndOffsetAreAddedUp() {
+        Assertions.assertEquals(Optional.of(13), 
ShowTabletsFromTableCommand.computeSizeLimit(3, 10));
+    }
+
+    @Test
+    public void testLargeLimitIsClampedToIntRange() {
+        Assertions.assertEquals(Optional.of(Integer.MAX_VALUE),
+                ShowTabletsFromTableCommand.computeSizeLimit(3000000000L, 0));
+    }
+
+    @Test
+    public void testHugeLimitAndOffsetDoNotOverflow() {
+        // both operands come from Long.parseLong, so adding them before 
clamping would wrap
+        // around into a negative size and later blow up in List#subList
+        Assertions.assertEquals(Optional.of(Integer.MAX_VALUE),
+                ShowTabletsFromTableCommand.computeSizeLimit(Long.MAX_VALUE, 
Long.MAX_VALUE));
+    }
+}
diff --git a/regression-test/suites/show_p0/test_show_tablet.groovy 
b/regression-test/suites/show_p0/test_show_tablet.groovy
index 51e5e4fae46..b691fb9a982 100644
--- a/regression-test/suites/show_p0/test_show_tablet.groovy
+++ b/regression-test/suites/show_p0/test_show_tablet.groovy
@@ -89,7 +89,9 @@ suite("test_show_tablet") {
     def descIds = new ArrayList(ascIds)
     Collections.reverse(descIds)
 
-    // without ORDER BY the rows come back ordered by (TabletId, ReplicaId)
+    // Without ORDER BY and without LIMIT every tablet is collected anyway, so 
the whole result
+    // is returned ordered by (TabletId, ReplicaId). Note this holds only for 
the unbounded
+    // result -- see the LIMIT cases below, where no ordering is promised.
     assertEquals(ascIds, allIds)
 
     // ORDER BY without LIMIT returns every tablet
@@ -105,27 +107,37 @@ suite("test_show_tablet") {
     res = sql """SHOW TABLETS FROM show_tablets_multi_part_t ORDER BY TabletId 
DESC LIMIT 2, 3"""
     assertEquals(descIds.subList(2, 5), res.collect { it[0] as long })
 
-    // Without ORDER BY the scan stops as soon as enough rows are gathered, so 
LIMIT returns a
-    // prefix of the scan rather than the globally smallest tablet ids. Only 
the row count and
-    // the ordering inside the returned prefix are guaranteed.
-    def assertPrefixOfTable = { rows, expectedSize ->
+    // Without ORDER BY the scan stops as soon as enough rows are gathered, so 
what comes back is
+    // an arbitrary subset of the table -- which partition is walked first is 
not defined. The
+    // rows are deliberately not sorted either, because sorting an arbitrary 
subset would make it
+    // look like the globally smallest tablet ids. Only the row count and the 
fact that the rows
+    // belong to this table are guaranteed.
+    def assertAnySubsetOfTable = { rows, expectedSize ->
         assertEquals(expectedSize, rows.size())
         def ids = rows.collect { it[0] as long }
-        def sortedIds = new ArrayList(ids)
-        Collections.sort(sortedIds)
-        assertEquals(sortedIds, ids)
         assertTrue(allIds.containsAll(ids))
     }
 
     res = sql """SHOW TABLETS FROM show_tablets_multi_part_t LIMIT 3"""
-    assertPrefixOfTable(res, 3)
+    assertAnySubsetOfTable(res, 3)
 
     res = sql """SHOW TABLETS FROM show_tablets_multi_part_t LIMIT 2, 3"""
-    assertPrefixOfTable(res, 3)
+    assertAnySubsetOfTable(res, 3)
 
     // an offset past the end of the result yields no row
     res = sql """SHOW TABLETS FROM show_tablets_multi_part_t LIMIT 
${allTablets.size()}, 3"""
     assertTrue(res.isEmpty())
 
+    // LIMIT 0 asks for no row and must not fall back to "no limit at all", 
with or without
+    // an OFFSET and with or without an ORDER BY
+    res = sql """SHOW TABLETS FROM show_tablets_multi_part_t LIMIT 0"""
+    assertTrue(res.isEmpty())
+
+    res = sql """SHOW TABLETS FROM show_tablets_multi_part_t LIMIT 2, 0"""
+    assertTrue(res.isEmpty())
+
+    res = sql """SHOW TABLETS FROM show_tablets_multi_part_t ORDER BY TabletId 
DESC LIMIT 0"""
+    assertTrue(res.isEmpty())
+
     sql """drop table if exists show_tablets_multi_part_t;"""
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to