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

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


The following commit(s) were added to refs/heads/master by this push:
     new 98bda679fd3 Add planning budget tests for large IN lists (#19745)
98bda679fd3 is described below

commit 98bda679fd39f71b37dc5e4a99b478586ec76350
Author: Yash Mayya <[email protected]>
AuthorDate: Mon Oct 5 21:00:25 2026 -0700

    Add planning budget tests for large IN lists (#19745)
---
 .../pinot/query/LargeInListPlanningBudgetTest.java | 198 +++++++++++++++++++++
 1 file changed, 198 insertions(+)

diff --git 
a/pinot-query-planner/src/test/java/org/apache/pinot/query/LargeInListPlanningBudgetTest.java
 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/LargeInListPlanningBudgetTest.java
new file mode 100644
index 00000000000..c7ac630221b
--- /dev/null
+++ 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/LargeInListPlanningBudgetTest.java
@@ -0,0 +1,198 @@
+/**
+ * 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.pinot.query;
+
+import com.sun.management.ThreadMXBean;
+import java.lang.management.ManagementFactory;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.apache.pinot.query.planner.physical.DispatchablePlanFragment;
+import org.apache.pinot.query.planner.serde.PlanNodeSerializer;
+import org.testng.SkipException;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertTrue;
+
+
+/// Planning budgets for queries with large IN lists.
+///
+/// A planner rule or a Calcite upgrade can make planning much more expensive 
for these queries without any test
+/// failing, because each rule that simplifies a predicate rebuilds the lists 
in it. These tests plan the shapes that
+/// were expensive and compare their cost with a baseline query that holds the 
same lists in a single-table `WHERE`:
+/// - Planner work, measured as the bytes that the planning thread allocates. 
The bytes change with the JIT state, but
+///   their ratio to the baseline does not, so the budgets are ratios.
+/// - Plan size, measured as the serialized bytes of the stages.
+///
+/// Measured ratios:
+/// - Join shapes: 1.0x to 1.1x with sealed IN lists (see `SearchSealer`), 
2.7x to 3.6x without.
+/// - A list outside `WHERE`: 0.9x to 1.7x with sealed IN lists, about 400x 
without.
+/// - A list next to a range: 1.0x the plan bytes, because 
`RexExpressionUtils` ships the values as one `IN` or
+///   `NOT_IN`. With one range per value, it was 10x to 12x.
+///
+/// The ratios do not catch a cost per value that the baseline has too, so 
[#testBaselineGrowsLinearly] checks the
+/// baseline itself. `SealedInListPlanningTest` checks that sealing does not 
change the plans.
+public class LargeInListPlanningBudgetTest extends QueryEnvironmentTestBase {
+  private static final ThreadMXBean THREAD_MX_BEAN = 
ManagementFactory.getPlatformMXBean(ThreadMXBean.class);
+  private static final String THREE_LISTS = "SELECT col1 FROM a WHERE col3 IN 
(L1) AND col6 IN (L2) AND col7 IN (L3)";
+  private static final String ONE_LIST = "SELECT col1 FROM a WHERE col3 IN 
(L1)";
+  private static final String JOINS = "SELECT a.col1, a.col2, b.col2, c.col1, 
COUNT(*) FILTER (WHERE a.col5), "
+      + "SUM(a.col3) FILTER (WHERE a.col6 > 0), MAX(a.col7) FROM a LEFT JOIN b 
ON a.col1 = b.col1 "
+      + "LEFT JOIN c ON a.col2 = c.col2 WHERE a.col3 IN (L1) AND a.col6 IN 
(L2) AND a.col7 IN (L3) AND a.ts > 10 "
+      + "GROUP BY a.col1, a.col2, b.col2, c.col1 ORDER BY 5 DESC LIMIT 50";
+  private static final String CASE = "SELECT SUM(CASE WHEN col3 IN (L1) THEN 1 
ELSE 0 END) FROM a";
+  private static final int JOIN_VALUES = 10_000;
+  private static final double JOIN_BUDGET = 1.5;
+  private static final int OUTSIDE_WHERE_VALUES = 300;
+  private static final double OUTSIDE_WHERE_BUDGET = 5;
+
+  /// Joins, aggregates and sorts above filters with 3 lists, which makes many 
rules pull the predicates up and simplify
+  /// them.
+  @DataProvider(name = "joinShapes")
+  public Object[][] joinShapes() {
+    return new Object[][]{
+        {"LEFT JOINs", JOINS, THREE_LISTS},
+        {"LEFT JOINs with NOT IN", JOINS.replace(" IN (", " NOT IN ("), 
THREE_LISTS.replace(" IN (", " NOT IN (")},
+        {"INNER JOINs", JOINS.replace("LEFT JOIN", "JOIN"), THREE_LISTS},
+        {"3 LEFT JOINs", JOINS.replace("ON a.col2 = c.col2", "ON a.col2 = 
c.col2 LEFT JOIN d ON a.col1 = d.col2"),
+            THREE_LISTS}
+    };
+  }
+
+  @Test(dataProvider = "joinShapes", timeOut = 120_000)
+  public void testJoinShapeAllocation(String name, String query, String 
baseline) {
+    double ratio = allocationRatio(withLists(query, JOIN_VALUES), 
withLists(baseline, JOIN_VALUES));
+    assertTrue(ratio <= JOIN_BUDGET, message(name, "planning allocates", 
ratio, JOIN_BUDGET));
+  }
+
+  /// A list outside the `WHERE` clause, which `SqlToRelConverter` used to 
expand into an `OR` of equalities and
+  /// simplify against itself.
+  @DataProvider(name = "listOutsideWhereShapes")
+  public Object[][] listOutsideWhereShapes() {
+    return new Object[][]{
+        {"JOIN ON", "SELECT a.col1 FROM a JOIN b ON a.col1 = b.col1 AND b.col3 
IN (L1)"},
+        {"LEFT JOIN ON", "SELECT a.col1 FROM a LEFT JOIN b ON a.col1 = b.col1 
AND a.col3 IN (L1)"},
+        {"CASE", CASE},
+        {"CASE with NOT IN", CASE.replace(" IN (", " NOT IN (")},
+        {"FILTER", "SELECT COUNT(*) FILTER (WHERE col3 IN (L1)) FROM a"},
+        {"SELECT list", "SELECT col3 IN (L1) FROM a"},
+        {"GROUP BY", "SELECT CASE WHEN col3 IN (L1) THEN 'x' ELSE 'y' END, 
COUNT(*) FROM a "
+            + "GROUP BY CASE WHEN col3 IN (L1) THEN 'x' ELSE 'y' END"},
+        {"window", "SELECT col1, ROW_NUMBER() OVER (PARTITION BY CASE WHEN 
col3 IN (L1) THEN 1 ELSE 0 END "
+            + "ORDER BY col6) FROM a"},
+        {"CASE in HAVING", "SELECT col1, COUNT(*) FROM a GROUP BY col1 "
+            + "HAVING SUM(CASE WHEN col3 IN (L1) THEN 1 ELSE 0 END) > 0"}
+    };
+  }
+
+  @Test(dataProvider = "listOutsideWhereShapes", timeOut = 120_000)
+  public void testListOutsideWhereAllocation(String name, String query) {
+    double ratio =
+        allocationRatio(withLists(query, OUTSIDE_WHERE_VALUES), 
withLists(ONE_LIST, OUTSIDE_WHERE_VALUES));
+    assertTrue(ratio <= OUTSIDE_WHERE_BUDGET, message(name, "planning 
allocates", ratio, OUTSIDE_WHERE_BUDGET));
+  }
+
+  /// The budgets fail without sealed IN lists. If this test fails, the 
budgets above no longer catch the regressions
+  /// that they are meant for.
+  @Test(timeOut = 120_000)
+  public void testBudgetsFailWithoutSealing() {
+    String unsealed = "SET sealedInListThreshold=0; ";
+    double joinRatio = allocationRatio(unsealed + withLists(JOINS, 
JOIN_VALUES),
+        unsealed + withLists(THREE_LISTS, JOIN_VALUES));
+    assertTrue(joinRatio > JOIN_BUDGET, message("LEFT JOINs without sealing, 
which must exceed the budget",
+        "planning allocates", joinRatio, JOIN_BUDGET));
+    double caseRatio = allocationRatio(unsealed + withLists(CASE, 
OUTSIDE_WHERE_VALUES),
+        unsealed + withLists(ONE_LIST, OUTSIDE_WHERE_VALUES));
+    assertTrue(caseRatio > OUTSIDE_WHERE_BUDGET, message("CASE without 
sealing, which must exceed the budget",
+        "planning allocates", caseRatio, OUTSIDE_WHERE_BUDGET));
+  }
+
+  /// The baseline itself allocates in proportion to the number of values.
+  @Test(timeOut = 120_000)
+  public void testBaselineGrowsLinearly() {
+    double ratio = allocationRatio(withLists(THREE_LISTS, 20_000), 
withLists(THREE_LISTS, 5_000));
+    assertTrue(ratio <= 6, message("4x the values", "planning allocates", 
ratio, 6));
+  }
+
+  /// A list next to a range on the same column, which fold into one Sarg with 
single values and a range.
+  @DataProvider(name = "listAndRangeShapes")
+  public Object[][] listAndRangeShapes() {
+    return new Object[][]{
+        {"IN or a range", "SELECT col1 FROM a WHERE col3 IN (L1) OR col3 > 
100000000"},
+        {"NOT IN and a range", "SELECT col1 FROM a WHERE col3 NOT IN (L1) AND 
col3 > 0"}
+    };
+  }
+
+  @Test(dataProvider = "listAndRangeShapes")
+  public void testListAndRangePlanSize(String name, String query) {
+    double ratio = (double) planBytes(withLists(query, 20_000)) / 
planBytes(withLists(ONE_LIST, 20_000));
+    assertTrue(ratio <= 2, message(name, "the plan has", ratio, 2));
+  }
+
+  private static String message(String name, String what, double ratio, double 
budget) {
+    return String.format("%s: %s %.2fx the bytes of the baseline (budget 
%.2fx)", name, what, ratio, budget);
+  }
+
+  /// Returns the bytes that planning the query allocates, divided by the 
bytes that planning the baseline allocates.
+  private double allocationRatio(String query, String baseline) {
+    if (!THREAD_MX_BEAN.isThreadAllocatedMemorySupported() || 
!THREAD_MX_BEAN.isThreadAllocatedMemoryEnabled()) {
+      throw new SkipException("The JVM does not measure the bytes that a 
thread allocates");
+    }
+    return (double) allocatedBytes(query) / allocatedBytes(baseline);
+  }
+
+  /// Returns the bytes that the current thread allocates to plan the query: 
the smallest of 2 runs after a warm-up run.
+  private long allocatedBytes(String query) {
+    long smallest = Long.MAX_VALUE;
+    for (int run = 0; run < 3; run++) {
+      long before = THREAD_MX_BEAN.getCurrentThreadAllocatedBytes();
+      try (QueryEnvironment.CompiledQuery compiledQuery = 
_queryEnvironment.compile(query)) {
+        compiledQuery.planQuery(1);
+      }
+      long allocated = THREAD_MX_BEAN.getCurrentThreadAllocatedBytes() - 
before;
+      if (run > 0) {
+        smallest = Math.min(smallest, allocated);
+      }
+    }
+    return smallest;
+  }
+
+  /// Plans the query and returns the serialized size of its stages.
+  private long planBytes(String query) {
+    try (QueryEnvironment.CompiledQuery compiledQuery = 
_queryEnvironment.compile(query)) {
+      long bytes = 0;
+      for (DispatchablePlanFragment fragment : 
compiledQuery.planQuery(1).getQueryPlan().getQueryStagesWithoutRoot()) {
+        bytes += 
PlanNodeSerializer.process(fragment.getPlanFragment().getFragmentRoot()).getSerializedSize();
+      }
+      return bytes;
+    }
+  }
+
+  /// Replaces `L1`, `L2` and `L3` with lists of distinct integers.
+  private static String withLists(String query, int numValues) {
+    return query.replace("(L1)", "(" + values(numValues, 7, 3) + ")")
+        .replace("(L2)", "(" + values(numValues, 5, 1) + ")")
+        .replace("(L3)", "(" + values(numValues, 11, 2) + ")");
+  }
+
+  private static String values(int numValues, int step, int offset) {
+    return IntStream.range(0, numValues).mapToObj(i -> Long.toString((long) i 
* step + offset))
+        .collect(Collectors.joining(", "));
+  }
+}


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

Reply via email to