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 1ea817199aa Support IdSet literals of millions of ids (#19759)
1ea817199aa is described below

commit 1ea817199aa0b29e473c4d6bf49b660b0fbe219c
Author: Yash Mayya <[email protected]>
AuthorDate: Wed Oct 7 16:38:58 2026 -0700

    Support IdSet literals of millions of ids (#19759)
---
 .../MultiStageBrokerRequestHandler.java            |   5 +-
 .../MultiStageBrokerRequestHandlerTest.java        |  19 ++-
 .../apache/pinot/sql/parsers/CalciteSqlParser.java |  15 +--
 .../sql/parsers/parser/GrowingCharStream.java      |  86 +++++++++++++
 .../pinot/sql/parsers/CalciteSqlParserTest.java    |  57 +++++++--
 .../sql/parsers/parser/GrowingCharStreamTest.java  | 138 +++++++++++++++++++++
 .../function/InIdSetTransformFunction.java         |  36 +++++-
 .../transform/function/TransformFunction.java      |  12 ++
 .../function/TransformFunctionFactory.java         |   2 +-
 .../core/query/request/context/QueryContext.java   |   8 +-
 .../pinot/core/util/MemoizedClassAssociation.java  |  42 -------
 .../function/InIdSetTransformFunctionTest.java     |  98 +++++++++++++++
 .../tests/custom/IdSetIntegrationTest.java         |  17 +++
 .../query/service/dispatch/DispatchClient.java     |  18 ++-
 .../query/service/dispatch/QueryDispatcher.java    |  35 +++++-
 .../pinot/query/service/server/QueryServer.java    |  26 ++--
 .../query/service/dispatch/DispatchClientTest.java | 138 +++++++++++++++++++++
 .../query/service/server/QueryServerTest.java      |  55 ++++++++
 .../apache/pinot/spi/utils/CommonConstants.java    |  14 +++
 19 files changed, 738 insertions(+), 83 deletions(-)

diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
index f42c17adbb7..e2708b725e9 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
@@ -212,11 +212,14 @@ public class MultiStageBrokerRequestHandler extends 
BaseBrokerRequestHandler {
         CommonConstants.Broker.DEFAULT_STREAM_STATS_DRAIN_MS);
     boolean enableProtoSegmentList = 
_config.getProperty(CommonConstants.Broker.CONFIG_OF_MSE_ENABLE_PROTO_SEGMENT_LIST,
         CommonConstants.Broker.DEFAULT_MSE_ENABLE_PROTO_SEGMENT_LIST);
+    int dispatchMaxInboundMessageSizeBytes = config.getProperty(
+        
CommonConstants.MultiStageQueryRunner.KEY_OF_DISPATCH_CHANNEL_MAX_INBOUND_MESSAGE_SIZE_BYTES,
+        
CommonConstants.MultiStageQueryRunner.DEFAULT_OF_DISPATCH_CHANNEL_MAX_INBOUND_MESSAGE_SIZE_BYTES);
     _mailboxService = new MailboxService(hostname, port, InstanceType.BROKER, 
config, tlsConfig);
     _queryDispatcher =
         new QueryDispatcher(_mailboxService, failureDetector, tlsConfig, 
isQueryCancellationEnabled(), cancelTimeout,
             dispatchKeepAliveTimeMs, dispatchKeepAliveTimeoutMs, 
dispatchKeepAliveWithoutCalls, _streamStatsDefault,
-            streamStatsDrainMs, enableProtoSegmentList);
+            streamStatsDrainMs, enableProtoSegmentList, 
dispatchMaxInboundMessageSizeBytes);
     LOGGER.info("Initialized MultiStageBrokerRequestHandler on host: {}, port: 
{} with broker id: {}, timeout: {}ms, "
             + "query log max length: {}, query log max rate: {}, query 
cancellation enabled: {}", hostname, port,
         _brokerId, _brokerTimeoutMs, _queryLogger.getMaxQueryLengthToLog(), 
_queryLogger.getLogRateLimit(),
diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandlerTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandlerTest.java
index fc36e0cf2cb..d2d99c2ebbd 100644
--- 
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandlerTest.java
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandlerTest.java
@@ -210,6 +210,16 @@ public class MultiStageBrokerRequestHandlerTest extends 
QueryEnvironmentTestBase
         "No option should be injected when the broker default is unset");
   }
 
+  @Test
+  public void testDispatchMaxInboundMessageSizeFromConfig()
+      throws Exception {
+    assertEquals(newHandler(new 
PinotConfiguration()).getQueryDispatcher().getDispatchMaxInboundMessageSizeBytes(),
+        
MultiStageQueryRunner.DEFAULT_OF_DISPATCH_CHANNEL_MAX_INBOUND_MESSAGE_SIZE_BYTES);
+    PinotConfiguration config = new PinotConfiguration();
+    
config.setProperty(MultiStageQueryRunner.KEY_OF_DISPATCH_CHANNEL_MAX_INBOUND_MESSAGE_SIZE_BYTES,
 "12345678");
+    
assertEquals(newHandler(config).getQueryDispatcher().getDispatchMaxInboundMessageSizeBytes(),
 12_345_678);
+  }
+
   @Test
   public void testDefaultDisabledPlannerRulesFromClusterConfig() {
     // ServiceStartableUtils.applyClusterConfig applies cluster configs with 
setProperty, which doesn't split lists
@@ -247,8 +257,6 @@ public class MultiStageBrokerRequestHandlerTest extends 
QueryEnvironmentTestBase
       @Nullable String streamingGroupByFlushThreshold, @Nullable String 
streamingDistinctFlushThreshold)
       throws Exception {
     PinotConfiguration config = new PinotConfiguration();
-    config.setProperty(MultiStageQueryRunner.KEY_OF_QUERY_RUNNER_HOSTNAME, 
"localhost");
-    config.setProperty(MultiStageQueryRunner.KEY_OF_QUERY_RUNNER_PORT, 
Integer.toString(NetUtils.findOpenPort()));
     if (streamingGroupByFlushThreshold != null) {
       
config.setProperty(CommonConstants.Broker.CONFIG_OF_MSE_STREAMING_GROUP_BY_FLUSH_THRESHOLD,
           streamingGroupByFlushThreshold);
@@ -257,6 +265,13 @@ public class MultiStageBrokerRequestHandlerTest extends 
QueryEnvironmentTestBase
       
config.setProperty(CommonConstants.Broker.CONFIG_OF_MSE_STREAMING_DISTINCT_FLUSH_THRESHOLD,
           streamingDistinctFlushThreshold);
     }
+    return newHandler(config);
+  }
+
+  private static MultiStageBrokerRequestHandler newHandler(PinotConfiguration 
config)
+      throws Exception {
+    config.setProperty(MultiStageQueryRunner.KEY_OF_QUERY_RUNNER_HOSTNAME, 
"localhost");
+    config.setProperty(MultiStageQueryRunner.KEY_OF_QUERY_RUNNER_PORT, 
Integer.toString(NetUtils.findOpenPort()));
     BrokerQueryEventListenerFactory.init(config);
     BrokerMetrics.register(mock(BrokerMetrics.class));
 
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java 
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java
index 0d85fb9b03d..d1f8f9bef86 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java
@@ -21,7 +21,6 @@ package org.apache.pinot.sql.parsers;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableSet;
-import java.io.StringReader;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -72,6 +71,7 @@ import 
org.apache.pinot.common.utils.config.QueryOptionsUtils.SqlOptionsMode;
 import org.apache.pinot.common.utils.request.RequestUtils;
 import org.apache.pinot.segment.spi.AggregationFunctionType;
 import org.apache.pinot.sql.FilterKind;
+import org.apache.pinot.sql.parsers.parser.GrowingCharStream;
 import org.apache.pinot.sql.parsers.parser.SqlInsertFromFile;
 import org.apache.pinot.sql.parsers.parser.SqlParserImpl;
 import org.apache.pinot.sql.parsers.parser.SqlPinotCreateMaterializedView;
@@ -137,8 +137,8 @@ public class CalciteSqlParser {
       }
     }
 
-    try (StringReader inStream = new StringReader(sql)) {
-      SqlParserImpl sqlParser = newSqlParser(inStream);
+    try {
+      SqlParserImpl sqlParser = newSqlParser(sql);
       SqlNodeList sqlNodeList = sqlParser.parseSqlStmtList();
       sqlNodeList = (SqlNodeList) PostgreSqlCastRewriter.rewrite(sqlNodeList);
       // Extract OPTION statements from sql.
@@ -667,8 +667,8 @@ public class CalciteSqlParser {
   /// @throws SqlCompilationException if String is not a valid expression.
   public static Expression compileToExpression(String expression) {
     SqlNode sqlNode;
-    try (StringReader inStream = new StringReader(expression)) {
-      SqlParserImpl sqlParser = newSqlParser(inStream);
+    try {
+      SqlParserImpl sqlParser = newSqlParser(expression);
       sqlNode = sqlParser.parseSqlExpressionEof();
     } catch (Throwable e) {
       throw new SqlCompilationException("Caught exception while parsing 
expression: " + expression, e);
@@ -679,8 +679,9 @@ public class CalciteSqlParser {
   }
 
   @VisibleForTesting
-  static SqlParserImpl newSqlParser(StringReader inStream) {
-    SqlParserImpl sqlParser = new SqlParserImpl(inStream);
+  static SqlParserImpl newSqlParser(String sql) {
+    // Reads long tokens, e.g. a multi-MB IdSet literal, in linear time
+    SqlParserImpl sqlParser = GrowingCharStream.newParser(sql);
     sqlParser.switchTo(SqlAbstractParserImpl.LexicalState.DQID);
     // TODO: convert to MySQL conformance once we retired most of the 
un-tested BABEL tokens
     sqlParser.setConformance(SqlConformanceEnum.BABEL);
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/parser/GrowingCharStream.java
 
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/parser/GrowingCharStream.java
new file mode 100644
index 00000000000..2171a2a98c7
--- /dev/null
+++ 
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/parser/GrowingCharStream.java
@@ -0,0 +1,86 @@
+/**
+ * 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.sql.parsers.parser;
+
+import java.io.StringReader;
+
+
+/// A [SimpleCharStream] over a SQL string that grows its buffer to fit the 
whole SQL when a token outgrows it.
+///
+/// The generated stream keeps the current token in a buffer of 4,096 chars, 
which it grows by 2,048 chars at a time.
+/// Each growth copies the buffer and its line and column arrays (10 bytes per 
char), so reading a token of n chars
+/// costs O(n^2) time and memory, e.g. 2.6 GB of allocation for a string 
literal of 1M chars such as a serialized IdSet.
+/// Growing the buffer once, to a size that fits any token of the SQL, makes 
it O(n). Tokens of up to 2,048 chars never
+/// grow the buffer, so most queries, including those with long IN lists, are 
not affected.
+///
+/// The cost is in a long SQL with one longer token: the buffer grows to the 
length of the whole SQL. A token of more
+/// than 2,048 chars can grow it, depending on where the token starts in the 
buffer, and a token of more than 4,096
+/// chars always does. For example, a 5,000-char literal in an IN list query 
of 8M chars allocates 80 MB. Doubling the
+/// buffer would avoid that, but a literal of n chars would then allocate 2 to 
3 times as much, and such literals are
+/// the reason for this class.
+///
+/// Not thread-safe, like the generated stream.
+public class GrowingCharStream extends SimpleCharStream {
+  // Fits the whole SQL, so the buffer grows at most once
+  private final int _maxBufferSize;
+
+  private GrowingCharStream(String sql) {
+    super(new StringReader(sql), 1, 1);
+    _maxBufferSize = sql.length() + 1;
+  }
+
+  /// Returns a parser that reads the SQL through a [GrowingCharStream].
+  public static SqlParserImpl newParser(String sql) {
+    GrowingCharStream charStream = new GrowingCharStream(sql);
+    SqlParserImpl sqlParser = new SqlParserImpl(new 
SqlParserImplTokenManager(charStream));
+    // The parser also uses the stream directly, e.g. to set the tab size
+    sqlParser.jj_input_stream = charStream;
+    return sqlParser;
+  }
+
+  /// Same as [SimpleCharStream#ExpandBuff], except for the size of the new 
buffer.
+  @Override
+  protected void ExpandBuff(boolean wrapAround) {
+    int newBufsize = Math.max(bufsize + 2048, _maxBufferSize);
+    char[] newBuffer = new char[newBufsize];
+    int[] newBufline = new int[newBufsize];
+    int[] newBufcolumn = new int[newBufsize];
+    if (wrapAround) {
+      System.arraycopy(buffer, tokenBegin, newBuffer, 0, bufsize - tokenBegin);
+      System.arraycopy(buffer, 0, newBuffer, bufsize - tokenBegin, bufpos);
+      System.arraycopy(bufline, tokenBegin, newBufline, 0, bufsize - 
tokenBegin);
+      System.arraycopy(bufline, 0, newBufline, bufsize - tokenBegin, bufpos);
+      System.arraycopy(bufcolumn, tokenBegin, newBufcolumn, 0, bufsize - 
tokenBegin);
+      System.arraycopy(bufcolumn, 0, newBufcolumn, bufsize - tokenBegin, 
bufpos);
+      bufpos += bufsize - tokenBegin;
+    } else {
+      System.arraycopy(buffer, tokenBegin, newBuffer, 0, bufsize - tokenBegin);
+      System.arraycopy(bufline, tokenBegin, newBufline, 0, bufsize - 
tokenBegin);
+      System.arraycopy(bufcolumn, tokenBegin, newBufcolumn, 0, bufsize - 
tokenBegin);
+      bufpos -= tokenBegin;
+    }
+    maxNextCharInd = bufpos;
+    buffer = newBuffer;
+    bufline = newBufline;
+    bufcolumn = newBufcolumn;
+    bufsize = newBufsize;
+    available = bufsize;
+    tokenBegin = 0;
+  }
+}
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/sql/parsers/CalciteSqlParserTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/sql/parsers/CalciteSqlParserTest.java
index 552aeea604c..2e3d93f30d5 100644
--- 
a/pinot-common/src/test/java/org/apache/pinot/sql/parsers/CalciteSqlParserTest.java
+++ 
b/pinot-common/src/test/java/org/apache/pinot/sql/parsers/CalciteSqlParserTest.java
@@ -18,7 +18,6 @@
  */
 package org.apache.pinot.sql.parsers;
 
-import java.io.StringReader;
 import java.util.List;
 import org.apache.calcite.sql.SqlBinaryStringLiteral;
 import org.apache.calcite.sql.SqlCall;
@@ -32,8 +31,10 @@ import org.apache.pinot.common.request.Expression;
 import org.apache.pinot.common.request.Function;
 import org.apache.pinot.common.request.PinotQuery;
 import org.apache.pinot.common.utils.request.RequestUtils;
+import org.apache.pinot.spi.accounting.ThreadResourceUsageProvider;
 import org.apache.pinot.sql.parsers.parser.SqlPhysicalExplain;
 import org.apache.pinot.sql.parsers.parser.SqlPinotCreateMaterializedView;
+import org.testng.SkipException;
 import org.testng.annotations.AfterMethod;
 import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
@@ -171,8 +172,8 @@ public class CalciteSqlParserTest {
   @Test
   public void testPostgreSqlByteaLiteralUnderNodeWithoutSetOperandIsRejected()
       throws Exception {
-    SqlNode query = CalciteSqlParser.newSqlParser(new StringReader("SELECT a 
FROM t")).parseSqlStmtEof();
-    SqlNode bytea = CalciteSqlParser.newSqlParser(new 
StringReader("'\\x01'::bytea")).parseSqlExpressionEof();
+    SqlNode query = CalciteSqlParser.newSqlParser("SELECT a FROM 
t").parseSqlStmtEof();
+    SqlNode bytea = 
CalciteSqlParser.newSqlParser("'\\x01'::bytea").parseSqlExpressionEof();
     SqlOrderBy orderBy = new SqlOrderBy(SqlParserPos.ZERO, query, 
SqlNodeList.EMPTY, bytea, null);
     SqlCompilationException e =
         expectThrows(SqlCompilationException.class, () -> 
PostgreSqlCastRewriter.rewrite(orderBy));
@@ -183,7 +184,7 @@ public class CalciteSqlParserTest {
   @Test
   public void testPostgreSqlByteaLiteralInImmutableNodeListIsRejected()
       throws Exception {
-    SqlNode bytea = CalciteSqlParser.newSqlParser(new 
StringReader("'\\x01'::bytea")).parseSqlExpressionEof();
+    SqlNode bytea = 
CalciteSqlParser.newSqlParser("'\\x01'::bytea").parseSqlExpressionEof();
     SqlNodeList immutable = SqlNodeList.of(SqlParserPos.ZERO, List.of(bytea));
     SqlCompilationException e =
         expectThrows(SqlCompilationException.class, () -> 
PostgreSqlCastRewriter.rewrite(immutable));
@@ -199,17 +200,17 @@ public class CalciteSqlParserTest {
       throws Exception {
     String sql = "SELECT a FROM t WHERE b = " + BYTEA_CONSTANT;
     SqlSelect select = (SqlSelect) PostgreSqlCastRewriter.rewrite(
-        CalciteSqlParser.newSqlParser(new 
StringReader(sql)).parseSqlStmtEof());
+        CalciteSqlParser.newSqlParser(sql).parseSqlStmtEof());
     assertSpansByteaConstant(((SqlCall) select.getWhere()).operand(1), sql);
 
     sql = "SELECT a FROM t WHERE c LIKE 'x' AND b = " + BYTEA_CONSTANT;
     select = (SqlSelect) PostgreSqlCastRewriter.rewrite(
-        CalciteSqlParser.newSqlParser(new 
StringReader(sql)).parseSqlStmtEof());
+        CalciteSqlParser.newSqlParser(sql).parseSqlStmtEof());
     assertSpansByteaConstant(((SqlCall) ((SqlCall) 
select.getWhere()).operand(1)).operand(1), sql);
 
     sql = "a || " + BYTEA_CONSTANT;
     SqlCall concat = (SqlCall) PostgreSqlCastRewriter.rewrite(
-        CalciteSqlParser.newSqlParser(new 
StringReader(sql)).parseSqlExpressionEof());
+        CalciteSqlParser.newSqlParser(sql).parseSqlExpressionEof());
     assertSpansByteaConstant(concat.operand(1), sql);
   }
 
@@ -519,6 +520,48 @@ public class CalciteSqlParserTest {
     assertQualifyRejected("EXPLAIN PLAN FOR SELECT city FROM myTable QUALIFY 
city > 'a'");
   }
 
+  /// A string literal of millions of chars, e.g. a serialized IdSet, must 
parse in linear time and memory. The
+  /// generated JavaCC char stream grows its buffer by 2,048 chars at a time 
while it reads a token, so with it, parsing
+  /// a literal of 2M chars allocates over 10 GB.
+  @Test
+  public void testParsesLongStringLiteralInLinearMemory() {
+    boolean threadMemoryMeasurementEnabled = 
ThreadResourceUsageProvider.isThreadMemoryMeasurementEnabled();
+    ThreadResourceUsageProvider.setThreadMemoryMeasurementEnabled(true);
+    try {
+      if (!ThreadResourceUsageProvider.isThreadMemoryMeasurementEnabled()) {
+        throw new SkipException("Thread memory measurement is not supported");
+      }
+      // Loads the parser classes before the allocation is measured
+      CalciteSqlParser.compileToPinotQuery("SELECT COUNT(*) FROM myTable WHERE 
IN_ID_SET(col, 'AA==') = 1");
+      String literal = "A".repeat(2 * 1024 * 1024);
+      String sql = "SELECT COUNT(*) FROM myTable WHERE IN_ID_SET(col, '" + 
literal + "') = 1";
+      long allocatedBytesBefore = 
ThreadResourceUsageProvider.getCurrentThreadAllocatedBytes();
+      PinotQuery pinotQuery = CalciteSqlParser.compileToPinotQuery(sql);
+      long allocatedBytes = 
ThreadResourceUsageProvider.getCurrentThreadAllocatedBytes() - 
allocatedBytesBefore;
+      Function inIdSet = 
pinotQuery.getFilterExpression().getFunctionCall().getOperands().get(0).getFunctionCall();
+      assertEquals(inIdSet.getOperands().get(1).getLiteral().getStringValue(), 
literal);
+      // The char buffer takes 10 bytes per char, and the literal is copied a 
few times
+      assertTrue(allocatedBytes < 50L * sql.length(),
+          "Allocated " + allocatedBytes + " bytes to parse " + sql.length() + 
" chars");
+    } finally {
+      
ThreadResourceUsageProvider.setThreadMemoryMeasurementEnabled(threadMemoryMeasurementEnabled);
+    }
+  }
+
+  /// The parser reads the SQL through a custom char stream, which must also 
handle an empty SQL.
+  @Test
+  public void testRejectsEmptyQuery() {
+    // The second query reaches the parser as a comment
+    for (String sql : List.of("", "/* comment */")) {
+      SqlCompilationException e =
+          expectThrows(SqlCompilationException.class, () -> 
CalciteSqlParser.compileToPinotQuery(sql));
+      assertTrue(e.getMessage().contains("SqlNode with executable statement 
not found"), e.getMessage());
+    }
+    SqlCompilationException e =
+        expectThrows(SqlCompilationException.class, () -> 
CalciteSqlParser.compileToExpression(""));
+    assertTrue(e.getCause().getMessage().contains("Encountered \"<EOF>\""), 
e.getCause().getMessage());
+  }
+
   private void assertQualifyRejected(String query) {
     SqlCompilationException e =
         expectThrows(SqlCompilationException.class, () -> 
CalciteSqlParser.compileToPinotQuery(query));
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/sql/parsers/parser/GrowingCharStreamTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/sql/parsers/parser/GrowingCharStreamTest.java
new file mode 100644
index 00000000000..09f58966136
--- /dev/null
+++ 
b/pinot-common/src/test/java/org/apache/pinot/sql/parsers/parser/GrowingCharStreamTest.java
@@ -0,0 +1,138 @@
+/**
+ * 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.sql.parsers.parser;
+
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+
+/// Compares the tokens read through a [GrowingCharStream] with the tokens 
read through the generated
+/// [SimpleCharStream], including their line and column positions.
+public class GrowingCharStreamTest {
+  private static final int[] TOKEN_LENGTHS = {1, 2047, 2048, 2049, 4095, 4096, 
4097, 6000, 8193, 50_000};
+  // Moves the start of the long token across the 4,096-char initial buffer, 
which then wraps around
+  private static final int[] PREFIX_LENGTHS = {0, 1, 2047, 2049, 3000, 4090, 
4096, 5000, 10_000};
+
+  @Test
+  public void testReadsTheSameTokensAsTheGeneratedStream() {
+    Random random = new Random(42);
+    for (int tokenLength : TOKEN_LENGTHS) {
+      for (int prefixLength : PREFIX_LENGTHS) {
+        String prefix = "SELECT " + shortTokens(prefixLength);
+        String suffix = "\nFROM myTable\n  WHERE b = 1";
+        assertSameTokens(prefix + "'" + text(random, tokenLength) + "'" + 
suffix, tokenLength);
+        // A quoted identifier cannot hold a line break
+        assertSameTokens(prefix + "\"" + text(random, 
tokenLength).replace('\n', ' ') + "\"" + suffix, tokenLength);
+        assertSameTokens(prefix + "1" + "0".repeat(tokenLength) + suffix, 
tokenLength);
+        assertSameTokens(prefix + "/* " + text(random, tokenLength) + " */ a" 
+ suffix, tokenLength);
+        // Two long tokens, the second one read into a grown buffer
+        assertSameTokens(prefix + "'" + text(random, tokenLength) + "', " + 
shortTokens(prefixLength) + "'"
+            + text(random, 2 * tokenLength) + "'" + suffix, 2 * tokenLength);
+      }
+    }
+  }
+
+  @Test
+  public void testGrowsTheBufferOnlyForLongTokens() {
+    // Short tokens, e.g. of a long IN list, keep the initial buffer
+    String inListSql = "SELECT * FROM myTable WHERE a IN (" + 
IntStream.range(0, 10_000)
+        .mapToObj(Integer::toString)
+        .collect(Collectors.joining(", ")) + ")";
+    SqlParserImpl sqlParser = GrowingCharStream.newParser(inListSql);
+    getTokens(sqlParser.token_source);
+    assertEquals(sqlParser.jj_input_stream.bufsize, 4096);
+
+    // A long token grows the buffer once, to fit the whole SQL
+    String longLiteralSql = "SELECT * FROM myTable WHERE a = '" + 
"A".repeat(100_000) + "'";
+    sqlParser = GrowingCharStream.newParser(longLiteralSql);
+    getTokens(sqlParser.token_source);
+    assertEquals(sqlParser.jj_input_stream.bufsize, longLiteralSql.length() + 
1);
+  }
+
+  /// Returns short tokens of about the given length in total.
+  private static String shortTokens(int length) {
+    StringBuilder stringBuilder = new StringBuilder();
+    for (int i = 0; stringBuilder.length() < length; i++) {
+      stringBuilder.append("c").append(i).append(i % 10 == 0 ? ",\n" : ", ");
+    }
+    return stringBuilder.toString();
+  }
+
+  /// Returns text without quotes or asterisks, but with line breaks, so that 
a string literal or a comment of the text
+  /// spans several lines.
+  private static String text(Random random, int length) {
+    char[] chars = new char[length];
+    for (int i = 0; i < length; i++) {
+      int value = random.nextInt(64);
+      chars[i] = value == 0 ? '\n' : value == 1 ? '\t' : (char) ('A' + value % 
26);
+    }
+    return new String(chars);
+  }
+
+  private static void assertSameTokens(String sql, int longTokenLength) {
+    List<Token> expectedTokens = getTokens(new SqlParserImpl(new 
StringReader(sql)).token_source);
+    List<Token> actualTokens = 
getTokens(GrowingCharStream.newParser(sql).token_source);
+    assertEquals(actualTokens.size(), expectedTokens.size());
+    int maxImageLength = 0;
+    for (int i = 0; i < expectedTokens.size(); i++) {
+      // Compare one token at a time, so that a failure does not print the 
whole SQL
+      String actualToken = describe(actualTokens.get(i));
+      String expectedToken = describe(expectedTokens.get(i));
+      if (!actualToken.equals(expectedToken)) {
+        assertEquals(abbreviate(actualToken), abbreviate(expectedToken),
+            "Token " + i + " of SQL of length " + sql.length());
+      }
+      maxImageLength = Math.max(maxImageLength, 
expectedTokens.get(i).image.length());
+    }
+    // Makes sure that the long text is read as one token
+    assertTrue(maxImageLength >= longTokenLength, "Longest token: " + 
maxImageLength + " chars");
+  }
+
+  /// Returns the tokens and special tokens (e.g. comments) in the lexical 
state that the SQL parser uses.
+  private static List<Token> getTokens(SqlParserImplTokenManager tokenManager) 
{
+    tokenManager.SwitchTo(SqlParserImplConstants.DQID);
+    List<Token> tokens = new ArrayList<>();
+    Token token;
+    do {
+      token = tokenManager.getNextToken();
+      for (Token specialToken = token.specialToken; specialToken != null; 
specialToken = specialToken.specialToken) {
+        tokens.add(specialToken);
+      }
+      tokens.add(token);
+    } while (token.kind != SqlParserImplConstants.EOF);
+    return tokens;
+  }
+
+  private static String describe(Token token) {
+    return token.kind + "@" + token.beginLine + ":" + token.beginColumn + "-" 
+ token.endLine + ":" + token.endColumn
+        + ":" + token.image;
+  }
+
+  private static String abbreviate(String token) {
+    return token.length() <= 200 ? token : token.substring(0, 100) + "..." + 
token.substring(token.length() - 100);
+  }
+}
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/InIdSetTransformFunction.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/InIdSetTransformFunction.java
index ac7594f399a..8224909537a 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/InIdSetTransformFunction.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/InIdSetTransformFunction.java
@@ -18,6 +18,7 @@
  */
 package org.apache.pinot.core.operator.transform.function;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import java.io.IOException;
 import java.util.List;
@@ -27,6 +28,7 @@ import org.apache.pinot.common.function.TransformFunctionType;
 import org.apache.pinot.core.operator.ColumnContext;
 import org.apache.pinot.core.operator.blocks.ValueBlock;
 import org.apache.pinot.core.operator.transform.TransformResultMetadata;
+import org.apache.pinot.core.query.request.context.QueryContext;
 import org.apache.pinot.core.query.utils.idset.IdSet;
 import org.apache.pinot.core.query.utils.idset.IdSets;
 import org.apache.pinot.spi.data.FieldSpec.DataType;
@@ -50,8 +52,25 @@ public class InIdSetTransformFunction extends 
BaseTransformFunction {
     return TransformFunctionType.IN_ID_SET.getName();
   }
 
+  @Override
+  public void init(List<TransformFunction> arguments, Map<String, 
ColumnContext> columnContextMap,
+      QueryContext queryContext) {
+    doInit(arguments, columnContextMap, queryContext);
+    // Sets the flag like init(arguments, columnContextMap, 
nullHandlingEnabled), which would call the 2-argument init
+    // and deserialize an IdSet that the segments do not share
+    _nullHandlingEnabled = queryContext.isNullHandlingEnabled();
+  }
+
   @Override
   public void init(List<TransformFunction> arguments, Map<String, 
ColumnContext> columnContextMap) {
+    doInit(arguments, columnContextMap, null);
+  }
+
+  /// Initializes the function: checks the arguments and deserializes the 
IdSet. Without a query context, every instance
+  /// deserializes its own IdSet. With one, the segments that the query 
context covers share one deserialized IdSet,
+  /// which can hold millions of ids.
+  private void doInit(List<TransformFunction> arguments, Map<String, 
ColumnContext> columnContextMap,
+      @Nullable QueryContext queryContext) {
     super.init(arguments, columnContextMap);
     Preconditions.checkArgument(arguments.size() == 2,
         "2 arguments are required for IN_ID_SET transform function: 
expression, base64 encoded IdSet");
@@ -61,12 +80,25 @@ public class InIdSetTransformFunction extends 
BaseTransformFunction {
         "Second argument for IN_ID_SET transform function must be a literal 
string of the base64 encoded IdSet");
 
     _transformFunction = arguments.get(0);
+    String serializedIdSet = ((LiteralTransformFunction) 
arguments.get(1)).getStringLiteral();
+    // The IdSet is only read after this, which is safe from the threads of 
several segments
+    _idSet = queryContext != null
+        ? queryContext.getOrComputeSharedValue(IdSet.class, serializedIdSet, 
InIdSetTransformFunction::deserialize)
+        : deserialize(serializedIdSet);
+    IdSets.validateValueType(_idSet, 
_transformFunction.getResultMetadata().getDataType().getStoredType());
+  }
+
+  private static IdSet deserialize(String serializedIdSet) {
     try {
-      _idSet = IdSets.fromBase64String(((LiteralTransformFunction) 
arguments.get(1)).getStringLiteral());
+      return IdSets.fromBase64String(serializedIdSet);
     } catch (IOException | RuntimeException e) {
       throw new IllegalArgumentException("Caught exception while deserializing 
IdSet: " + e, e);
     }
-    IdSets.validateValueType(_idSet, 
_transformFunction.getResultMetadata().getDataType().getStoredType());
+  }
+
+  @VisibleForTesting
+  IdSet getIdSet() {
+    return _idSet;
   }
 
   @Override
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/TransformFunction.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/TransformFunction.java
index 908221b0d76..0e9daf956ff 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/TransformFunction.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/TransformFunction.java
@@ -25,6 +25,7 @@ import javax.annotation.Nullable;
 import org.apache.pinot.core.operator.ColumnContext;
 import org.apache.pinot.core.operator.blocks.ValueBlock;
 import org.apache.pinot.core.operator.transform.TransformResultMetadata;
+import org.apache.pinot.core.query.request.context.QueryContext;
 import org.apache.pinot.segment.spi.index.reader.Dictionary;
 import org.roaringbitmap.RoaringBitmap;
 
@@ -55,6 +56,17 @@ public interface TransformFunction {
     init(arguments, columnContextMap);
   }
 
+  /// Initializes the transform function for a query. A transform function 
that has state to share across the segments
+  /// of the query can keep it in the query context.
+  ///
+  /// @param arguments        Arguments for the transform function
+  /// @param columnContextMap Map from column name to context
+  /// @param queryContext     Context of the query
+  default void init(List<TransformFunction> arguments, Map<String, 
ColumnContext> columnContextMap,
+      QueryContext queryContext) {
+    init(arguments, columnContextMap, queryContext.isNullHandlingEnabled());
+  }
+
   /// Returns the metadata for the result of the transform function.
   ///
   /// @return Transform result metadata
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/TransformFunctionFactory.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/TransformFunctionFactory.java
index c4647f9a5bc..8b8842374d5 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/TransformFunctionFactory.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/TransformFunctionFactory.java
@@ -370,7 +370,7 @@ public class TransformFunctionFactory {
         }
 
         try {
-          transformFunction.init(transformFunctionArguments, columnContextMap, 
queryContext.isNullHandlingEnabled());
+          transformFunction.init(transformFunctionArguments, columnContextMap, 
queryContext);
         } catch (Exception e) {
           throw new BadQueryRequestException("Caught exception while 
initializing transform function: "
               + functionName + ": " + e.getMessage(), e);
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
index 1371fd3f3c7..41c12e6adce 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
@@ -40,7 +40,6 @@ import 
org.apache.pinot.core.plan.maker.InstancePlanMakerImplV2;
 import org.apache.pinot.core.query.aggregation.function.AggregationFunction;
 import 
org.apache.pinot.core.query.aggregation.function.AggregationFunctionFactory;
 import org.apache.pinot.core.util.GroupByUtils;
-import org.apache.pinot.core.util.MemoizedClassAssociation;
 import org.apache.pinot.segment.spi.datasource.DataSource;
 import org.apache.pinot.spi.config.table.FieldConfig;
 import org.apache.pinot.spi.data.Schema;
@@ -80,7 +79,9 @@ public class QueryContext {
   private final Map<ExpressionContext, ExpressionContext> 
_expressionOverrideHints;
   private final ExplainMode _explain;
 
-  private final Function<Class<?>, Map<?, ?>> _sharedValues = 
MemoizedClassAssociation.of(ConcurrentHashMap::new);
+  // Values that the segments of the query share, by value type. The query 
context owns the map, so the values can be
+  // collected with it. A ClassValue would keep each value in its type's cache 
until later queries clear it.
+  private final ConcurrentHashMap<Class<?>, ConcurrentHashMap<?, ?>> 
_sharedValues = new ConcurrentHashMap<>();
 
   // Pre-calculate the aggregation functions and columns for the query so that 
it can be shared across all the segments
   private AggregationFunction[] _aggregationFunctions;
@@ -560,7 +561,8 @@ public class QueryContext {
   /// @param <V> the value type
   /// @return the shared value
   public <K, V> V getOrComputeSharedValue(Class<V> type, K key, Function<K, V> 
mapper) {
-    return ((ConcurrentHashMap<K, V>) 
_sharedValues.apply(type)).computeIfAbsent(key, mapper);
+    return ((ConcurrentHashMap<K, V>) _sharedValues.computeIfAbsent(type, k -> 
new ConcurrentHashMap<>()))
+        .computeIfAbsent(key, mapper);
   }
 
   public int getEffectiveSegmentGroupTrimSize() {
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/util/MemoizedClassAssociation.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/util/MemoizedClassAssociation.java
deleted file mode 100644
index 6e066cbae74..00000000000
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/util/MemoizedClassAssociation.java
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * 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.core.util;
-
-import java.util.function.Function;
-import java.util.function.Supplier;
-
-/// Creates a function which associates generic values with a class and 
memoizes this association atomically.
-/// @param <T>
-public class MemoizedClassAssociation<T> extends ClassValue<T> {
-
-  public static <T> Function<Class<?>, T> of(Supplier<T> supplier) {
-    return new MemoizedClassAssociation<>(supplier)::get;
-  }
-
-  private final Supplier<T> _supplier;
-
-  private MemoizedClassAssociation(Supplier<T> supplier) {
-    _supplier = supplier;
-  }
-
-  @Override
-  protected T computeValue(Class<?> type) {
-    return _supplier.get();
-  }
-}
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/transform/function/InIdSetTransformFunctionTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/transform/function/InIdSetTransformFunctionTest.java
new file mode 100644
index 00000000000..d1ce67db23d
--- /dev/null
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/transform/function/InIdSetTransformFunctionTest.java
@@ -0,0 +1,98 @@
+/**
+ * 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.core.operator.transform.function;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.pinot.common.request.context.ExpressionContext;
+import org.apache.pinot.common.request.context.RequestContextUtils;
+import org.apache.pinot.core.operator.ColumnContext;
+import org.apache.pinot.core.query.request.context.QueryContext;
+import 
org.apache.pinot.core.query.request.context.utils.QueryContextConverterUtils;
+import org.apache.pinot.core.query.utils.idset.IdSet;
+import org.apache.pinot.core.query.utils.idset.IdSets;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotSame;
+import static org.testng.Assert.assertSame;
+
+
+/// Tests the IN_ID_SET transform function on a segment; `IdSetQueriesTest` 
runs it in queries.
+public class InIdSetTransformFunctionTest extends BaseTransformFunctionTest {
+
+  @Test
+  public void testSharesTheIdSetAcrossTheSegmentsOfAQuery()
+      throws IOException {
+    IdSet idSet = createIdSet(0, NUM_ROWS / 2);
+    ExpressionContext expression = createExpression(idSet);
+    QueryContext queryContext = createQueryContext();
+
+    // Each segment of a query builds its own transform function, but they 
share one deserialized IdSet
+    InIdSetTransformFunction segment1Function = 
createTransformFunction(expression, queryContext);
+    InIdSetTransformFunction segment2Function = 
createTransformFunction(expression, queryContext);
+    assertNotSame(segment2Function, segment1Function);
+    assertSame(segment2Function.getIdSet(), segment1Function.getIdSet());
+    assertResults(segment2Function, idSet);
+
+    // Another IdSet of the same query is deserialized on its own
+    IdSet otherIdSet = createIdSet(NUM_ROWS / 2, NUM_ROWS);
+    InIdSetTransformFunction otherIdSetFunction = 
createTransformFunction(createExpression(otherIdSet), queryContext);
+    assertNotSame(otherIdSetFunction.getIdSet(), segment1Function.getIdSet());
+    assertResults(otherIdSetFunction, otherIdSet);
+
+    // Another query deserializes its own IdSet
+    InIdSetTransformFunction otherQueryFunction = 
createTransformFunction(expression, createQueryContext());
+    assertNotSame(otherQueryFunction.getIdSet(), segment1Function.getIdSet());
+  }
+
+  private IdSet createIdSet(int fromRow, int toRow) {
+    IdSet idSet = IdSets.create(DataType.INT);
+    for (int i = fromRow; i < toRow; i++) {
+      idSet.add(_intSVValues[i]);
+    }
+    return idSet;
+  }
+
+  private static ExpressionContext createExpression(IdSet idSet)
+      throws IOException {
+    return RequestContextUtils.getExpression(
+        String.format("inIdSet(%s, '%s')", INT_SV_COLUMN, 
idSet.toBase64String()));
+  }
+
+  private static QueryContext createQueryContext() {
+    return QueryContextConverterUtils.getQueryContext("SELECT * FROM 
testTable");
+  }
+
+  private InIdSetTransformFunction createTransformFunction(ExpressionContext 
expression, QueryContext queryContext) {
+    Map<String, ColumnContext> columnContextMap = new HashMap<>();
+    _dataSourceMap.forEach(
+        (column, dataSource) -> columnContextMap.put(column, 
ColumnContext.fromDataSource(dataSource)));
+    return (InIdSetTransformFunction) TransformFunctionFactory.get(expression, 
columnContextMap, queryContext);
+  }
+
+  private void assertResults(InIdSetTransformFunction transformFunction, IdSet 
idSet) {
+    int[] results = transformFunction.transformToIntValuesSV(_projectionBlock);
+    for (int i = 0; i < NUM_ROWS; i++) {
+      assertEquals(results[i], idSet.contains(_intSVValues[i]) ? 1 : 0);
+    }
+  }
+}
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/IdSetIntegrationTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/IdSetIntegrationTest.java
index 1c4b2e43af3..e10ae92a223 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/IdSetIntegrationTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/IdSetIntegrationTest.java
@@ -53,6 +53,8 @@ public class IdSetIntegrationTest extends 
CustomDataQueryClusterIntegrationTest
   private static final String SET_FILTER = ID + " < " + NUM_SET_VALUES;
   // STRING and BYTES sets are Bloom filters; these params keep their literals 
short
   private static final String BLOOM_FILTER_PARAMS = 
"expectedInsertions=1000;fpp=0.0001";
+  // A Bloom filter sized for 10M ids, which serializes to a literal of about 
16 MB
+  private static final String LARGE_BLOOM_FILTER_PARAMS = 
"expectedInsertions=10000000;fpp=0.01";
 
   @Override
   protected long getCountStarResult() {
@@ -108,6 +110,21 @@ public class IdSetIntegrationTest extends 
CustomDataQueryClusterIntegrationTest
     }
   }
 
+  @Test(dataProvider = "useBothQueryEngines")
+  public void testLargeIdSet(boolean useMultiStageQueryEngine)
+      throws Exception {
+    setUseMultiStageQueryEngine(useMultiStageQueryEngine);
+    String idSet = getSingleValue(postQuery(
+        "SELECT IDSET(" + STR_COL + ", '" + LARGE_BLOOM_FILTER_PARAMS + "') 
FROM " + getTableName() + " WHERE "
+            + SET_FILTER)).asText();
+    assertTrue(idSet.length() > 15_000_000, "IdSet length: " + idSet.length());
+    String inQuery =
+        "SELECT COUNT(*) FROM " + getTableName() + " WHERE IN_ID_SET(" + 
STR_COL + ", '" + idSet + "') = 1";
+    // Over HTTP, because the broker's gRPC endpoint accepts requests of up to 
4 MB
+    assertEquals(getSingleValue(queryBrokerHttpEndpoint(inQuery)).asLong(),
+        getExpectedCount(STR_COL, IdSets.fromBase64String(idSet)));
+  }
+
   @Test(dataProvider = "useBothQueryEngines")
   public void testMismatchedValueType(boolean useMultiStageQueryEngine)
       throws Exception {
diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/DispatchClient.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/DispatchClient.java
index 489ab1a3595..05c5f0905b0 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/DispatchClient.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/DispatchClient.java
@@ -37,6 +37,7 @@ import org.apache.pinot.query.grpc.GrpcKeepAliveConfig;
 import org.apache.pinot.query.routing.QueryServerInstance;
 import 
org.apache.pinot.query.service.dispatch.streaming.StreamingDispatchObserver;
 import org.apache.pinot.query.service.dispatch.streaming.StreamingQuerySession;
+import org.apache.pinot.spi.utils.CommonConstants;
 
 
 /// Dispatches a query plan to given a [QueryServerInstance]. Each 
[DispatchClient] has its own gRPC Channel
@@ -48,11 +49,6 @@ class DispatchClient {
   /// Shared buffer allocator configured to prefer direct (off-heap) buffers 
for better performance.
   /// Using a static allocator allows for better memory pooling across all 
DispatchClient instances.
   private static final PooledByteBufAllocator BUF_ALLOCATOR = new 
PooledByteBufAllocator(true);
-  /// Max size of an inbound message on the broker's dispatch channel. The 
default gRPC limit is 4 MB, but a single
-  /// `SubmitWithStream` `OpChainComplete` can carry a large 
[Worker.MultiStageStatsTree] (wide/deep
-  /// plans, large STRING stats), and exceeding the limit fails the whole 
query with `RESOURCE_EXHAUSTED` even
-  /// though the query results are already in hand. Matches the server's 
inbound limit (`QueryServer`).
-  private static final int MAX_INBOUND_MESSAGE_SIZE = 64 * 1024 * 1024;
 
   private final ManagedChannel _channel;
   private final PinotQueryWorkerGrpc.PinotQueryWorkerStub _dispatchStub;
@@ -67,11 +63,21 @@ class DispatchClient {
 
   DispatchClient(String host, int port, @Nullable TlsConfig tlsConfig, 
@Nullable SslContext sslContext,
       GrpcKeepAliveConfig keepAliveConfig) {
+    this(host, port, tlsConfig, sslContext, keepAliveConfig,
+        
CommonConstants.MultiStageQueryRunner.DEFAULT_OF_DISPATCH_CHANNEL_MAX_INBOUND_MESSAGE_SIZE_BYTES);
+  }
+
+  /// @param maxInboundMessageSizeBytes Max size of an inbound message on the 
broker's dispatch channel. The default
+  ///     gRPC limit is 4 MB, but a single `SubmitWithStream` 
`OpChainComplete` can carry a large
+  ///     [Worker.MultiStageStatsTree] (wide/deep plans, large STRING stats), 
and the plans servers return for
+  ///     `EXPLAIN` carry the query's literals. Exceeding the limit fails the 
whole query with `RESOURCE_EXHAUSTED`.
+  DispatchClient(String host, int port, @Nullable TlsConfig tlsConfig, 
@Nullable SslContext sslContext,
+      GrpcKeepAliveConfig keepAliveConfig, int maxInboundMessageSizeBytes) {
     // Always use NettyChannelBuilder to allow setting Netty-specific channel 
options like the buffer allocator.
     // This ensures we can explicitly configure direct (off-heap) buffers for 
better performance.
     NettyChannelBuilder channelBuilder = NettyChannelBuilder.forAddress(host, 
port)
         .withOption(ChannelOption.ALLOCATOR, BUF_ALLOCATOR)
-        .maxInboundMessageSize(MAX_INBOUND_MESSAGE_SIZE);
+        .maxInboundMessageSize(maxInboundMessageSizeBytes);
     if (tlsConfig == null) {
       channelBuilder.usePlaintext();
     } else {
diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/QueryDispatcher.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/QueryDispatcher.java
index c6524bde0a0..b3a1e605da6 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/QueryDispatcher.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/QueryDispatcher.java
@@ -129,6 +129,7 @@ public class QueryDispatcher implements 
PinotClusterConfigChangeListener {
   @Nullable
   private final SslContext _clientGrpcSslContext;
   private final GrpcKeepAliveConfig _keepAliveConfig;
+  private final int _dispatchMaxInboundMessageSizeBytes;
   // maps broker-generated query id to the set of servers that the query was 
dispatched to
   private final Map<Long, Set<QueryServerInstance>> _serversByQuery;
   private final FailureDetector _failureDetector;
@@ -147,7 +148,8 @@ public class QueryDispatcher implements 
PinotClusterConfigChangeListener {
       boolean enableCancellation, Duration cancelTimeout) {
     this(mailboxService, failureDetector, tlsConfig, enableCancellation, 
cancelTimeout,
         GrpcKeepAliveConfig.DISABLED, false, 
CommonConstants.Broker.DEFAULT_STREAM_STATS_DRAIN_MS,
-        CommonConstants.Broker.DEFAULT_MSE_ENABLE_PROTO_SEGMENT_LIST);
+        CommonConstants.Broker.DEFAULT_MSE_ENABLE_PROTO_SEGMENT_LIST,
+        
CommonConstants.MultiStageQueryRunner.DEFAULT_OF_DISPATCH_CHANNEL_MAX_INBOUND_MESSAGE_SIZE_BYTES);
   }
 
   /// Overload that accepts gRPC keep-alive settings for broker dispatch 
channels. A non-positive `keepAliveTimeMs`
@@ -165,14 +167,29 @@ public class QueryDispatcher implements 
PinotClusterConfigChangeListener {
       boolean enableCancellation, Duration cancelTimeout, int keepAliveTimeMs, 
int keepAliveTimeoutMs,
       boolean keepAliveWithoutCalls, boolean streamStatsDefault, long 
statsDrainMs,
       boolean enableProtoSegmentList) {
+    this(mailboxService, failureDetector, tlsConfig, enableCancellation, 
cancelTimeout, keepAliveTimeMs,
+        keepAliveTimeoutMs, keepAliveWithoutCalls, streamStatsDefault, 
statsDrainMs, enableProtoSegmentList,
+        
CommonConstants.MultiStageQueryRunner.DEFAULT_OF_DISPATCH_CHANNEL_MAX_INBOUND_MESSAGE_SIZE_BYTES);
+  }
+
+  /// Overload that also takes the max size of a message the dispatch channels 
accept from the servers.
+  public QueryDispatcher(MailboxService mailboxService, FailureDetector 
failureDetector, @Nullable TlsConfig tlsConfig,
+      boolean enableCancellation, Duration cancelTimeout, int keepAliveTimeMs, 
int keepAliveTimeoutMs,
+      boolean keepAliveWithoutCalls, boolean streamStatsDefault, long 
statsDrainMs,
+      boolean enableProtoSegmentList, int dispatchMaxInboundMessageSizeBytes) {
     this(mailboxService, failureDetector, tlsConfig, enableCancellation, 
cancelTimeout,
         new GrpcKeepAliveConfig(keepAliveTimeMs, keepAliveTimeoutMs, 
keepAliveWithoutCalls),
-        streamStatsDefault, statsDrainMs, enableProtoSegmentList);
+        streamStatsDefault, statsDrainMs, enableProtoSegmentList, 
dispatchMaxInboundMessageSizeBytes);
   }
 
   private QueryDispatcher(MailboxService mailboxService, FailureDetector 
failureDetector, @Nullable TlsConfig tlsConfig,
       boolean enableCancellation, Duration cancelTimeout, GrpcKeepAliveConfig 
keepAliveConfig,
-      boolean streamStatsDefault, long statsDrainMs, boolean 
enableProtoSegmentList) {
+      boolean streamStatsDefault, long statsDrainMs, boolean 
enableProtoSegmentList,
+      int dispatchMaxInboundMessageSizeBytes) {
+    // Checked here, because the dispatch channels are created only when 
queries are dispatched
+    Preconditions.checkArgument(dispatchMaxInboundMessageSizeBytes > 0, "%s 
must be positive, got: %s",
+        
CommonConstants.MultiStageQueryRunner.KEY_OF_DISPATCH_CHANNEL_MAX_INBOUND_MESSAGE_SIZE_BYTES,
+        dispatchMaxInboundMessageSizeBytes);
     _cancelTimeout = cancelTimeout;
     _statsDrainMs = statsDrainMs;
     _mailboxService = mailboxService;
@@ -181,6 +198,7 @@ public class QueryDispatcher implements 
PinotClusterConfigChangeListener {
     _tlsConfig = tlsConfig;
     _clientGrpcSslContext = initClientSslContext(tlsConfig);
     _keepAliveConfig = keepAliveConfig;
+    _dispatchMaxInboundMessageSizeBytes = dispatchMaxInboundMessageSizeBytes;
     _failureDetector = failureDetector;
     _streamStatsDefault = streamStatsDefault;
     _enableProtoSegmentList = enableProtoSegmentList;
@@ -749,6 +767,11 @@ public class QueryDispatcher implements 
PinotClusterConfigChangeListener {
     return _enableProtoSegmentList;
   }
 
+  @VisibleForTesting
+  public int getDispatchMaxInboundMessageSizeBytes() {
+    return _dispatchMaxInboundMessageSizeBytes;
+  }
+
   /// Builds the request for one server: the plans of the stages it takes part 
in, with only its own workers'
   /// metadata. The leaf-stage segment lists are encoded here, once per 
worker, rather than at plan time.
   private static Worker.QueryRequest createRequest(QueryServerInstance 
serverInstance,
@@ -864,11 +887,13 @@ public class QueryDispatcher implements 
PinotClusterConfigChangeListener {
     return true;
   }
 
-  private DispatchClient getOrCreateDispatchClient(QueryServerInstance 
queryServerInstance) {
+  @VisibleForTesting
+  DispatchClient getOrCreateDispatchClient(QueryServerInstance 
queryServerInstance) {
     String hostname = queryServerInstance.getHostname();
     int port = queryServerInstance.getQueryServicePort();
     return _dispatchClientMap.computeIfAbsent(toHostnamePortKey(hostname, 
port),
-        k -> new DispatchClient(hostname, port, _tlsConfig, 
_clientGrpcSslContext, _keepAliveConfig));
+        k -> new DispatchClient(hostname, port, _tlsConfig, 
_clientGrpcSslContext, _keepAliveConfig,
+            _dispatchMaxInboundMessageSizeBytes));
   }
 
   /// Reset the connection backoff for a server. When the GRPC channel enters 
a TRANSIENT_FAILURE state from
diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/server/QueryServer.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/server/QueryServer.java
index 42dbb7344cb..bd5bde1ee96 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/server/QueryServer.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/server/QueryServer.java
@@ -19,6 +19,7 @@
 package org.apache.pinot.query.service.server;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
 import com.google.protobuf.ByteString;
 import io.grpc.Server;
 import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
@@ -86,10 +87,6 @@ import org.slf4j.LoggerFactory;
 public class QueryServer extends PinotQueryWorkerGrpc.PinotQueryWorkerImplBase 
{
   private static final Logger LOGGER = 
LoggerFactory.getLogger(QueryServer.class);
 
-  // TODO: Inbound messages can get quite large because we send the entire 
stage metadata map in each call.
-  // See https://github.com/apache/pinot/issues/10331
-  private static final int MAX_INBOUND_MESSAGE_SIZE = 64 * 1024 * 1024;
-
   private final String _instanceId;
   private final int _port;
   private final QueryRunner _queryRunner;
@@ -100,6 +97,9 @@ public class QueryServer extends 
PinotQueryWorkerGrpc.PinotQueryWorkerImplBase {
   private final ThreadAccountant _threadAccountant;
   private final int _permitKeepAliveTimeMs;
   private final boolean _permitKeepAliveWithoutCalls;
+  // TODO: Inbound messages can get quite large because we send the entire 
stage metadata map in each call.
+  // See https://github.com/apache/pinot/issues/10331
+  private final int _maxInboundMessageSizeBytes;
 
   // query submission service is only used for plan submission for now.
   // TODO: with complex query submission logic we should allow asynchronous 
query submission return instead of
@@ -162,6 +162,12 @@ public class QueryServer extends 
PinotQueryWorkerGrpc.PinotQueryWorkerImplBase {
     _permitKeepAliveWithoutCalls = serverConf.getProperty(
         
CommonConstants.MultiStageQueryRunner.KEY_OF_QUERY_SERVER_PERMIT_KEEP_ALIVE_WITHOUT_CALLS,
         
CommonConstants.MultiStageQueryRunner.DEFAULT_OF_QUERY_SERVER_PERMIT_KEEP_ALIVE_WITHOUT_CALLS);
+    _maxInboundMessageSizeBytes = serverConf.getProperty(
+        
CommonConstants.MultiStageQueryRunner.KEY_OF_QUERY_SERVER_MAX_INBOUND_MESSAGE_SIZE_BYTES,
+        
CommonConstants.MultiStageQueryRunner.DEFAULT_OF_QUERY_SERVER_MAX_INBOUND_MESSAGE_SIZE_BYTES);
+    Preconditions.checkArgument(_maxInboundMessageSizeBytes > 0, "%s must be 
positive, got: %s",
+        
CommonConstants.MultiStageQueryRunner.KEY_OF_QUERY_SERVER_MAX_INBOUND_MESSAGE_SIZE_BYTES,
+        _maxInboundMessageSizeBytes);
 
     ExecutorService baseExecutorService =
         ExecutorServiceUtils.create(serverConf, 
CommonConstants.Server.MULTISTAGE_SUBMISSION_EXEC_CONFIG_PREFIX,
@@ -193,8 +199,9 @@ public class QueryServer extends 
PinotQueryWorkerGrpc.PinotQueryWorkerImplBase {
         }
         _server = buildGrpcServer(serverBuilder);
         LOGGER.info(
-            "Initialized QueryServer on port: {} with permitKeepAliveTimeMs: 
{}, permitKeepAliveWithoutCalls: {}",
-            _port, _permitKeepAliveTimeMs, _permitKeepAliveWithoutCalls);
+            "Initialized QueryServer on port: {} with permitKeepAliveTimeMs: 
{}, permitKeepAliveWithoutCalls: {}, "
+                + "maxInboundMessageSizeBytes: {}", _port, 
_permitKeepAliveTimeMs, _permitKeepAliveWithoutCalls,
+            _maxInboundMessageSizeBytes);
       }
       _queryRunner.start();
       _server.start();
@@ -213,6 +220,11 @@ public class QueryServer extends 
PinotQueryWorkerGrpc.PinotQueryWorkerImplBase {
     return _permitKeepAliveWithoutCalls;
   }
 
+  @VisibleForTesting
+  int getMaxInboundMessageSizeBytes() {
+    return _maxInboundMessageSizeBytes;
+  }
+
   private Server buildGrpcServer(NettyServerBuilder builder) {
     // By using directExecutor, GRPC doesn't need to manage its own thread pool
     builder.directExecutor();
@@ -225,7 +237,7 @@ public class QueryServer extends 
PinotQueryWorkerGrpc.PinotQueryWorkerImplBase {
       builder.permitKeepAliveTime(_permitKeepAliveTimeMs, 
TimeUnit.MILLISECONDS);
     }
     builder.permitKeepAliveWithoutCalls(_permitKeepAliveWithoutCalls);
-    return 
builder.addService(this).maxInboundMessageSize(MAX_INBOUND_MESSAGE_SIZE).build();
+    return 
builder.addService(this).maxInboundMessageSize(_maxInboundMessageSizeBytes).build();
   }
 
   public void shutdown() {
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/service/dispatch/DispatchClientTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/service/dispatch/DispatchClientTest.java
new file mode 100644
index 00000000000..264eb8528a9
--- /dev/null
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/service/dispatch/DispatchClientTest.java
@@ -0,0 +1,138 @@
+/**
+ * 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.service.dispatch;
+
+import io.grpc.Deadline;
+import io.grpc.Server;
+import io.grpc.Status;
+import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
+import io.grpc.stub.StreamObserver;
+import java.io.IOException;
+import java.time.Duration;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import org.apache.pinot.common.failuredetector.FailureDetector;
+import org.apache.pinot.common.proto.PinotQueryWorkerGrpc;
+import org.apache.pinot.common.proto.Worker;
+import org.apache.pinot.query.mailbox.MailboxService;
+import org.apache.pinot.query.routing.QueryServerInstance;
+import org.apache.pinot.spi.utils.CommonConstants;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.mockito.Mockito.mock;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
+
+
+/// Tests the max size of the messages the broker's dispatch channels accept 
from the servers.
+public class DispatchClientTest {
+  // Larger than the 4 MB gRPC accepts by default
+  private static final String LARGE_VALUE = "x".repeat(5 * 1024 * 1024);
+
+  private Server _server;
+  private QueryServerInstance _serverInstance;
+
+  @BeforeClass
+  public void setUp()
+      throws IOException {
+    // Replies to every submit with a response larger than LARGE_VALUE
+    _server = NettyServerBuilder.forPort(0).addService(new 
PinotQueryWorkerGrpc.PinotQueryWorkerImplBase() {
+      @Override
+      public void submit(Worker.QueryRequest request, 
StreamObserver<Worker.QueryResponse> responseObserver) {
+        
responseObserver.onNext(Worker.QueryResponse.newBuilder().putMetadata("value", 
LARGE_VALUE).build());
+        responseObserver.onCompleted();
+      }
+    }).build().start();
+    _serverInstance = new QueryServerInstance("server", "localhost", 
_server.getPort(), 0);
+  }
+
+  @AfterClass
+  public void tearDown() {
+    _server.shutdownNow();
+  }
+
+  @Test
+  public void testDefaultMaxInboundMessageSize()
+      throws Exception {
+    QueryDispatcher queryDispatcher =
+        new QueryDispatcher(mock(MailboxService.class), 
mock(FailureDetector.class), null, false,
+            Duration.ofSeconds(1));
+    try {
+      AsyncResponse<Worker.QueryResponse> response = submit(queryDispatcher);
+      assertNull(response.getThrowable());
+      assertNotNull(response.getResponse());
+      assertEquals(response.getResponse().getMetadataMap().get("value"), 
LARGE_VALUE);
+    } finally {
+      queryDispatcher.shutdown();
+    }
+  }
+
+  @Test
+  public void testConfiguredMaxInboundMessageSize()
+      throws Exception {
+    QueryDispatcher queryDispatcher = 
createQueryDispatcher(LARGE_VALUE.length() / 2);
+    try {
+      AsyncResponse<Worker.QueryResponse> response = submit(queryDispatcher);
+      assertNull(response.getResponse());
+      assertEquals(Status.fromThrowable(response.getThrowable()).getCode(), 
Status.Code.RESOURCE_EXHAUSTED);
+    } finally {
+      queryDispatcher.shutdown();
+    }
+
+    queryDispatcher = createQueryDispatcher(LARGE_VALUE.length() * 2);
+    try {
+      AsyncResponse<Worker.QueryResponse> response = submit(queryDispatcher);
+      assertNull(response.getThrowable());
+      assertNotNull(response.getResponse());
+      assertEquals(response.getResponse().getMetadataMap().get("value"), 
LARGE_VALUE);
+    } finally {
+      queryDispatcher.shutdown();
+    }
+  }
+
+  @Test
+  public void testRejectsNonPositiveMaxInboundMessageSize() {
+    for (int dispatchMaxInboundMessageSizeBytes : new int[]{0, -1}) {
+      IllegalArgumentException exception =
+          expectThrows(IllegalArgumentException.class, () -> 
createQueryDispatcher(dispatchMaxInboundMessageSizeBytes));
+      assertTrue(exception.getMessage()
+          
.contains(CommonConstants.MultiStageQueryRunner.KEY_OF_DISPATCH_CHANNEL_MAX_INBOUND_MESSAGE_SIZE_BYTES));
+    }
+  }
+
+  private static QueryDispatcher createQueryDispatcher(int 
dispatchMaxInboundMessageSizeBytes) {
+    return new QueryDispatcher(mock(MailboxService.class), 
mock(FailureDetector.class), null, false,
+        Duration.ofSeconds(1), 0, 0, false, false, 
CommonConstants.Broker.DEFAULT_STREAM_STATS_DRAIN_MS, false,
+        dispatchMaxInboundMessageSizeBytes);
+  }
+
+  private AsyncResponse<Worker.QueryResponse> submit(QueryDispatcher 
queryDispatcher)
+      throws Exception {
+    CompletableFuture<AsyncResponse<Worker.QueryResponse>> future = new 
CompletableFuture<>();
+    queryDispatcher.getOrCreateDispatchClient(_serverInstance)
+        .submit(Worker.QueryRequest.getDefaultInstance(), _serverInstance, 
Deadline.after(10, TimeUnit.SECONDS),
+            future::complete);
+    return future.get(10, TimeUnit.SECONDS);
+  }
+}
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/service/server/QueryServerTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/service/server/QueryServerTest.java
index c8c177fd51c..0abc060435e 100644
--- 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/service/server/QueryServerTest.java
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/service/server/QueryServerTest.java
@@ -23,6 +23,8 @@ import com.google.protobuf.ByteString;
 import io.grpc.Deadline;
 import io.grpc.ManagedChannel;
 import io.grpc.ManagedChannelBuilder;
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
 import io.grpc.stub.StreamObserver;
 import java.util.HashMap;
 import java.util.List;
@@ -77,6 +79,7 @@ import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertFalse;
 import static org.testng.Assert.assertNull;
 import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
 
 
 public class QueryServerTest extends QueryTestSet {
@@ -148,6 +151,58 @@ public class QueryServerTest extends QueryTestSet {
     assertTrue(server.isPermitKeepAliveWithoutCalls());
   }
 
+  @Test
+  public void testMaxInboundMessageSizeDefault() {
+    QueryServer server = new QueryServer(QueryTestUtils.getAvailablePort(), 
mock(QueryRunner.class));
+    assertEquals(server.getMaxInboundMessageSizeBytes(), 64 * 1024 * 1024);
+  }
+
+  @Test
+  public void testRejectsNonPositiveMaxInboundMessageSize() {
+    for (int maxInboundMessageSizeBytes : new int[]{0, -1}) {
+      PinotConfiguration config = new PinotConfiguration(
+          
Map.of(CommonConstants.MultiStageQueryRunner.KEY_OF_QUERY_SERVER_MAX_INBOUND_MESSAGE_SIZE_BYTES,
+              maxInboundMessageSizeBytes));
+      IllegalArgumentException exception = 
expectThrows(IllegalArgumentException.class,
+          () -> new QueryServer(config, "serverId", 
QueryTestUtils.getAvailablePort(), mock(QueryRunner.class), null,
+              ThreadAccountantUtils.getNoOpAccountant()));
+      assertTrue(exception.getMessage()
+          
.contains(CommonConstants.MultiStageQueryRunner.KEY_OF_QUERY_SERVER_MAX_INBOUND_MESSAGE_SIZE_BYTES));
+    }
+  }
+
+  @Test
+  public void testMaxInboundMessageSizeFromConfig()
+      throws Exception {
+    // A request with large literals, e.g. an IdSet, must fit the configured 
limit
+    Worker.QueryRequest queryRequest;
+    try (QueryEnvironment.CompiledQuery compiledQuery = 
_queryEnvironment.compile("SELECT * FROM a")) {
+      queryRequest = 
getQueryRequest(compiledQuery.planQuery(0).getQueryPlan(), 1);
+    }
+    int maxInboundMessageSizeBytes = queryRequest.getSerializedSize() - 1;
+    int port = QueryTestUtils.getAvailablePort();
+    QueryServer server = new QueryServer(new PinotConfiguration(
+        
Map.of(CommonConstants.MultiStageQueryRunner.KEY_OF_QUERY_SERVER_MAX_INBOUND_MESSAGE_SIZE_BYTES,
+            maxInboundMessageSizeBytes)), "serverId", port, 
mock(QueryRunner.class), null,
+        ThreadAccountantUtils.getNoOpAccountant());
+    assertEquals(server.getMaxInboundMessageSizeBytes(), 
maxInboundMessageSizeBytes);
+    server.start();
+    ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 
port).usePlaintext().build();
+    try {
+      PinotQueryWorkerGrpc.PinotQueryWorkerBlockingStub stub =
+          
PinotQueryWorkerGrpc.newBlockingStub(channel).withDeadline(Deadline.after(10, 
TimeUnit.SECONDS));
+      StatusRuntimeException exception = 
expectThrows(StatusRuntimeException.class, () -> stub.submit(queryRequest));
+      assertEquals(exception.getStatus().getCode(), 
Status.Code.RESOURCE_EXHAUSTED);
+      // A smaller request reaches the server
+      Worker.QueryResponse response =
+          
stub.submit(Worker.QueryRequest.newBuilder().setMetadata(queryRequest.getMetadata()).build());
+      
assertTrue(response.getMetadataMap().containsKey(CommonConstants.Query.Response.ServerResponseStatus.STATUS_OK));
+    } finally {
+      channel.shutdownNow();
+      server.shutdown();
+    }
+  }
+
   @Test
   public void testException()
       throws Exception {
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java 
b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
index 70b97dbc3f5..ee6fd925d58 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
@@ -2963,6 +2963,20 @@ public class CommonConstants {
     public static final String 
KEY_OF_QUERY_SERVER_PERMIT_KEEP_ALIVE_WITHOUT_CALLS =
         "pinot.query.multistage.query.server.permit.keep.alive.without.calls";
     public static final boolean 
DEFAULT_OF_QUERY_SERVER_PERMIT_KEEP_ALIVE_WITHOUT_CALLS = false;
+
+    /// Maximum size, in bytes, of a gRPC message the MSE 
[org.apache.pinot.query.service.server.QueryServer] accepts.
+    /// Must be positive. A message carries the stage plans a broker 
dispatches to the server, literals included, so
+    /// queries with large literals (e.g. an `IN_ID_SET` IdSet of millions of 
ids) can need more than the default.
+    public static final String 
KEY_OF_QUERY_SERVER_MAX_INBOUND_MESSAGE_SIZE_BYTES =
+        "pinot.query.multistage.query.server.max.inbound.message.size.bytes";
+    public static final int 
DEFAULT_OF_QUERY_SERVER_MAX_INBOUND_MESSAGE_SIZE_BYTES = 64 * 1024 * 1024;
+
+    /// Maximum size, in bytes, of a gRPC message the broker dispatch channels 
accept from the MSE query servers, such
+    /// as query stats, or the plans the servers return for `EXPLAIN`, which 
carry the query's literals. Must be
+    /// positive. See [#KEY_OF_QUERY_SERVER_MAX_INBOUND_MESSAGE_SIZE_BYTES] 
for the messages the servers accept.
+    public static final String 
KEY_OF_DISPATCH_CHANNEL_MAX_INBOUND_MESSAGE_SIZE_BYTES =
+        
"pinot.query.multistage.dispatch.channel.max.inbound.message.size.bytes";
+    public static final int 
DEFAULT_OF_DISPATCH_CHANNEL_MAX_INBOUND_MESSAGE_SIZE_BYTES = 64 * 1024 * 1024;
   }
 
   public static class NullValuePlaceHolder {


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

Reply via email to