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

xiangfu0 pushed a commit to branch xiangfu0/mse-proto-segment-list
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to 
refs/heads/xiangfu0/mse-proto-segment-list by this push:
     new 06e0b02e21c Make the segment list encoding mode a live cluster config
06e0b02e21c is described below

commit 06e0b02e21c49f04789ba05d35e8547288849150
Author: Xiang Fu <[email protected]>
AuthorDate: Tue Sep 22 16:47:54 2026 -0700

    Make the segment list encoding mode a live cluster config
    
    The per-query `protoSegmentList` option was the only way to change the
    encoding without restarting a broker, but asking clients to change their
    queries is harder than restarting brokers, so it was the wrong escape
    hatch. Read the mode from cluster config instead, and drop the option.
    
    - `ProtoSegmentListPredicate` also listens on cluster config for
      `pinot.broker.mse.proto.segment.list`. Precedence is cluster config,
      then the static broker config, then SAFE; clearing the cluster-config
      key restores the static broker config, as MultiStageQueryThrottler does
      for its live config. A value that is not a mode is ignored with a
      warning rather than moving the cluster off the operator's chosen mode.
    - The server versions are watched whatever the static mode is, since
      cluster config can select SAFE at runtime.
    - Remove the `protoSegmentList` query option, `QueryOptionsUtils
      .isProtoSegmentList` and its test. Nothing overrides the mode per query
      any more, so all servers of a query always agree on the encoding.
    - The integration tests now switch the encoding through cluster config
      rather than a query option, which also covers the live reload path: the
      logical-table test goes through the controller's /cluster/configs
      endpoint and restores SAFE afterwards, since that cluster is shared.
---
 .../broker/broker/helix/BaseBrokerStarter.java     |  8 ++-
 .../common/utils/config/QueryOptionsUtils.java     |  5 --
 .../common/utils/config/QueryOptionsUtilsTest.java |  8 ---
 .../tests/MultiStageEngineIntegrationTest.java     | 43 +++++++++---
 .../BaseLogicalTableIntegrationTest.java           | 39 +++++++++--
 .../pinot/query/routing/QueryPlanSerDeUtils.java   |  2 +-
 .../dispatch/ProtoSegmentListPredicate.java        | 77 ++++++++++++++++------
 .../query/service/dispatch/QueryDispatcher.java    | 15 ++---
 .../dispatch/ProtoSegmentListPredicateTest.java    | 55 ++++++++++++++--
 .../apache/pinot/spi/utils/CommonConstants.java    | 20 +++---
 10 files changed, 197 insertions(+), 75 deletions(-)

diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
index afaf1c0645d..0fff6b0e71e 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
@@ -108,6 +108,7 @@ import 
org.apache.pinot.materializedview.handler.MaterializedViewHandler;
 import org.apache.pinot.query.routing.WorkerManager;
 import 
org.apache.pinot.query.runtime.operator.factory.DefaultQueryOperatorFactoryProvider;
 import 
org.apache.pinot.query.runtime.operator.factory.QueryOperatorFactoryProvider;
+import org.apache.pinot.query.service.dispatch.ProtoSegmentListPredicate;
 import org.apache.pinot.segment.spi.partition.PartitionFunctionFactory;
 import org.apache.pinot.spi.accounting.ThreadAccountant;
 import org.apache.pinot.spi.accounting.ThreadAccountantUtils;
@@ -586,8 +587,11 @@ public abstract class BaseBrokerStarter implements 
ServiceStartable {
       MultiStageBrokerRequestHandler finalHandler = 
multiStageBrokerRequestHandler;
       _routingManager.setServerReenableCallback(
           serverInstance -> 
finalHandler.getQueryDispatcher().resetClientConnectionBackoff(serverInstance));
-      // Watches the server versions that pick the default segment list 
encoding; a no-op unless the mode is SAFE.
-      
multiStageBrokerRequestHandler.getProtoSegmentListPredicate().watchInstanceConfigs(_spectatorHelixManager);
+      // The segment list encoding follows the server versions in SAFE mode, 
and its mode follows cluster config.
+      ProtoSegmentListPredicate protoSegmentListPredicate =
+          multiStageBrokerRequestHandler.getProtoSegmentListPredicate();
+      protoSegmentListPredicate.watchInstanceConfigs(_spectatorHelixManager);
+      
_clusterConfigChangeHandler.registerClusterConfigChangeListener(protoSegmentListPredicate);
     }
     TimeSeriesRequestHandler timeSeriesRequestHandler = null;
     if 
(StringUtils.isNotBlank(_brokerConf.getProperty(PinotTimeSeriesConfiguration.getEnabledLanguagesConfigKey())))
 {
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
index c0266c5963d..f93a4597230 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
@@ -770,11 +770,6 @@ public class QueryOptionsUtils {
     return option != null ? Boolean.parseBoolean(option) : defaultValue;
   }
 
-  public static boolean isProtoSegmentList(Map<String, String> queryOptions, 
boolean defaultValue) {
-    String option = queryOptions.get(QueryOptionKey.PROTO_SEGMENT_LIST);
-    return option != null ? Boolean.parseBoolean(option) : defaultValue;
-  }
-
   public static boolean isMultiClusterRoutingEnabled(Map<String, String> 
queryOptions, boolean defaultValue) {
     String option = 
queryOptions.get(QueryOptionKey.ENABLE_MULTI_CLUSTER_ROUTING);
     return option != null ? Boolean.parseBoolean(option) : defaultValue;
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/utils/config/QueryOptionsUtilsTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/utils/config/QueryOptionsUtilsTest.java
index 18711317b96..0b8155403aa 100644
--- 
a/pinot-common/src/test/java/org/apache/pinot/common/utils/config/QueryOptionsUtilsTest.java
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/utils/config/QueryOptionsUtilsTest.java
@@ -123,14 +123,6 @@ public class QueryOptionsUtilsTest {
     
QueryOptionsUtils.getInPredicatePruningThreshold(Map.of(IN_PREDICATE_PRUNING_THRESHOLD,
 "invalid"));
   }
 
-  @Test
-  public void testIsProtoSegmentList() {
-    assertFalse(QueryOptionsUtils.isProtoSegmentList(Map.of(), false));
-    assertTrue(QueryOptionsUtils.isProtoSegmentList(Map.of(), true));
-    assertTrue(QueryOptionsUtils.isProtoSegmentList(Map.of(PROTO_SEGMENT_LIST, 
"true"), false));
-    
assertFalse(QueryOptionsUtils.isProtoSegmentList(Map.of(PROTO_SEGMENT_LIST, 
"false"), true));
-  }
-
   @Test
   public void testSkipIndexesParsing() {
     String skipIndexesStr = "col1=inverted,range&col2=sorted";
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineIntegrationTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineIntegrationTest.java
index a2be2c51961..efe24ae4a10 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineIntegrationTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineIntegrationTest.java
@@ -296,8 +296,9 @@ public class MultiStageEngineIntegrationTest extends 
BaseClusterIntegrationTestS
   }
 
   /// Every server of this cluster runs the broker's own build, so the default 
SAFE mode has to switch the proto
-  /// segment list encoding on by itself, through the server versions 
published in the Helix instance configs. Both
-  /// encodings must then return identical results for queries with one and 
with several leaf stages.
+  /// segment list encoding on by itself, through the server versions 
published in the Helix instance configs. Setting
+  /// the mode to NEVER in cluster config has to switch it back off without 
restarting the broker, and both encodings
+  /// must return identical results for queries with one and with several leaf 
stages.
   @Test
   public void testProtoSegmentListEncodingIsTransparent()
       throws Exception {
@@ -310,19 +311,41 @@ public class MultiStageEngineIntegrationTest extends 
BaseClusterIntegrationTestS
     assertFalse(predicate.isEnabled(true), "Multi-cluster queries must keep 
the legacy encoding");
 
     String table = getTableName();
-    String[] queries = {
+    List<String> queries = List.of(
         "SELECT COUNT(*) FROM " + table,
         "SELECT Carrier, COUNT(*), MAX(ArrDelay) FROM " + table + " WHERE 
DaysSinceEpoch > 16312 "
             + "GROUP BY Carrier ORDER BY Carrier",
         "SELECT COUNT(*) FROM " + table + " a JOIN (SELECT DISTINCT Carrier 
FROM " + table + ") b "
-            + "ON a.Carrier = b.Carrier"
-    };
+            + "ON a.Carrier = b.Carrier");
+
+    Map<String, JsonNode> protoRows = new HashMap<>();
     for (String query : queries) {
-      JsonNode legacy = postQuery("SET protoSegmentList = false; " + query);
-      assertTrue(legacy.get("exceptions").isEmpty(), "Unexpected exceptions 
with the legacy encoding: " + legacy);
-      JsonNode proto = postQuery("SET protoSegmentList = true; " + query);
-      assertTrue(proto.get("exceptions").isEmpty(), "Unexpected exceptions 
with the proto encoding: " + proto);
-      assertEquals(proto.get("resultTable").get("rows"), 
legacy.get("resultTable").get("rows"), query);
+      JsonNode response = postQuery(query);
+      assertTrue(response.get("exceptions").isEmpty(), "Unexpected exceptions 
with the proto encoding: " + response);
+      protoRows.put(query, response.get("resultTable").get("rows"));
+    }
+
+    HelixConfigScope scope =
+        new 
HelixConfigScopeBuilder(HelixConfigScope.ConfigScopeProperty.CLUSTER).forCluster(getHelixClusterName())
+            .build();
+    try {
+      // The kill switch: a mode set in cluster config reaches the broker 
without a restart.
+      _helixManager.getConfigAccessor()
+          .set(scope, CommonConstants.Broker.CONFIG_OF_MSE_PROTO_SEGMENT_LIST, 
"NEVER");
+      TestUtils.waitForCondition(aVoid -> !predicate.isEnabled(false), 10_000L,
+          "Setting the mode to NEVER in cluster config did not reach the 
broker");
+
+      for (String query : queries) {
+        JsonNode response = postQuery(query);
+        assertTrue(response.get("exceptions").isEmpty(),
+            "Unexpected exceptions with the legacy encoding: " + response);
+        assertEquals(response.get("resultTable").get("rows"), 
protoRows.get(query),
+            "The segment list encoding changed the result of: " + query);
+      }
+    } finally {
+      _helixManager.getConfigAccessor().set(scope, 
CommonConstants.Broker.CONFIG_OF_MSE_PROTO_SEGMENT_LIST, "SAFE");
+      TestUtils.waitForCondition(aVoid -> predicate.isEnabled(false), 10_000L,
+          "Restoring SAFE in cluster config did not reach the broker");
     }
   }
 
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/logicaltable/BaseLogicalTableIntegrationTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/logicaltable/BaseLogicalTableIntegrationTest.java
index d6206c3bd03..1b7f3c5ec88 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/logicaltable/BaseLogicalTableIntegrationTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/logicaltable/BaseLogicalTableIntegrationTest.java
@@ -29,10 +29,12 @@ import java.util.Map;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.apache.commons.io.FileUtils;
+import org.apache.pinot.broker.requesthandler.BrokerRequestHandlerDelegate;
 import org.apache.pinot.integration.tests.BaseClusterIntegrationTestSet;
 import org.apache.pinot.integration.tests.ClusterIntegrationTestUtils;
 import org.apache.pinot.integration.tests.QueryAssert;
 import org.apache.pinot.integration.tests.QueryGenerator;
+import org.apache.pinot.query.service.dispatch.ProtoSegmentListPredicate;
 import org.apache.pinot.spi.config.table.QueryConfig;
 import org.apache.pinot.spi.config.table.TableConfig;
 import org.apache.pinot.spi.config.table.TableType;
@@ -41,6 +43,7 @@ import org.apache.pinot.spi.data.PhysicalTableConfig;
 import org.apache.pinot.spi.data.Schema;
 import org.apache.pinot.spi.data.TimeBoundaryConfig;
 import org.apache.pinot.spi.exception.QueryErrorCode;
+import org.apache.pinot.spi.utils.CommonConstants;
 import org.apache.pinot.spi.utils.JsonUtils;
 import org.apache.pinot.spi.utils.builder.LogicalTableConfigBuilder;
 import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
@@ -482,20 +485,44 @@ public abstract class BaseLogicalTableIntegrationTest 
extends BaseClusterIntegra
 
   /// Both leaf-stage segment list encodings must return the same result for a 
logical table, whose leaf workers carry
   /// `logicalTableSegmentsMap`, keyed by physical table name, rather than the 
table-type keyed `tableSegmentsMap`.
+  /// The encoding is switched through cluster config, the way an operator 
would, and restored afterwards because the
+  /// cluster is shared with the other logical-table test classes.
   @Test
   public void testProtoSegmentListPreservesLogicalTableResults()
       throws Exception {
     setUseMultiStageQueryEngine(true);
     String query = "SELECT Carrier, COUNT(*) FROM " + getLogicalTableName() + 
" WHERE DaysSinceEpoch > 16312 "
         + "GROUP BY Carrier ORDER BY Carrier LIMIT 100";
-
-    JsonNode legacy = postQuery("SET protoSegmentList = false; " + query);
-    assertTrue(legacy.get("exceptions").isEmpty(), "Unexpected exceptions with 
the legacy encoding: " + legacy);
-    JsonNode proto = postQuery("SET protoSegmentList = true; " + query);
+    // The cluster was started by the shared suite instance, which is the one 
holding the broker starter.
+    ProtoSegmentListPredicate predicate =
+        ((BrokerRequestHandlerDelegate) 
_sharedClusterTestSuite._brokerStarters.get(0).getBrokerRequestHandler())
+            
.getMultiStageBrokerRequestHandler().getProtoSegmentListPredicate();
+    TestUtils.waitForCondition(aVoid -> predicate.isEnabled(false), 10_000L,
+        "SAFE mode did not enable the proto segment list encoding although 
every server runs the same version");
+
+    JsonNode proto = postQuery(query);
     assertTrue(proto.get("exceptions").isEmpty(), "Unexpected exceptions with 
the proto encoding: " + proto);
 
-    assertEquals(proto.get("resultTable").get("rows"), 
legacy.get("resultTable").get("rows"),
-        "The segment list encoding changed the result of a logical table 
query");
+    try {
+      setProtoSegmentListMode("NEVER");
+      TestUtils.waitForCondition(aVoid -> !predicate.isEnabled(false), 10_000L,
+          "Setting the mode to NEVER in cluster config did not reach the 
broker");
+
+      JsonNode legacy = postQuery(query);
+      assertTrue(legacy.get("exceptions").isEmpty(), "Unexpected exceptions 
with the legacy encoding: " + legacy);
+      assertEquals(legacy.get("resultTable").get("rows"), 
proto.get("resultTable").get("rows"),
+          "The segment list encoding changed the result of a logical table 
query");
+    } finally {
+      setProtoSegmentListMode("SAFE");
+      TestUtils.waitForCondition(aVoid -> predicate.isEnabled(false), 10_000L,
+          "Restoring SAFE in cluster config did not reach the broker");
+    }
+  }
+
+  private void setProtoSegmentListMode(String mode)
+      throws Exception {
+    sendPostRequest(_controllerRequestURLBuilder.forClusterConfigs(),
+        
JsonUtils.objectToString(Map.of(CommonConstants.Broker.CONFIG_OF_MSE_PROTO_SEGMENT_LIST,
 mode)));
   }
 
   @Test
diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/QueryPlanSerDeUtils.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/QueryPlanSerDeUtils.java
index 303d3ca4084..5aaea21f4f7 100644
--- 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/QueryPlanSerDeUtils.java
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/QueryPlanSerDeUtils.java
@@ -48,7 +48,7 @@ import org.apache.pinot.spi.utils.JsonUtils;
 ///   fields understand.
 ///
 /// Decoding accepts both, so a server always understands every broker; the 
broker enables the proto encoding only
-/// when every server does (see the `protoSegmentList` query option).
+/// when every server does (see `ProtoSegmentListPredicate`).
 public class QueryPlanSerDeUtils {
   private static final TypeReference<Map<String, List<String>>> 
SEGMENTS_MAP_TYPE = new TypeReference<>() {
   };
diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/ProtoSegmentListPredicate.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/ProtoSegmentListPredicate.java
index da2df6fbf86..9b56d47c612 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/ProtoSegmentListPredicate.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/ProtoSegmentListPredicate.java
@@ -23,6 +23,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.ThreadSafe;
@@ -35,6 +36,7 @@ import org.apache.helix.api.listeners.PreFetch;
 import org.apache.helix.model.InstanceConfig;
 import org.apache.pinot.common.version.PinotVersion;
 import org.apache.pinot.spi.config.instance.InstanceType;
+import org.apache.pinot.spi.config.provider.PinotClusterConfigChangeListener;
 import org.apache.pinot.spi.env.PinotConfiguration;
 import org.apache.pinot.spi.utils.CommonConstants;
 import org.apache.pinot.spi.utils.InstanceTypeUtils;
@@ -42,9 +44,8 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 
-/// Decides whether a multi-stage query that does not set the
-/// [CommonConstants.Broker.Request.QueryOptionKey#PROTO_SEGMENT_LIST] query 
option ships its leaf-stage segment lists
-/// as native protobuf fields of the worker metadata, or as the legacy JSON 
custom property.
+/// Decides whether a multi-stage query ships its leaf-stage segment lists as 
native protobuf fields of the worker
+/// metadata, or as the legacy JSON custom property.
 ///
 /// Only servers decode those fields, and a server that predates them finds no 
segments, treats the worker as an
 /// intermediate-stage worker and fails the leaf stage. The mode, set by
@@ -62,21 +63,27 @@ import org.slf4j.LoggerFactory;
 ///   under a different version string, and tests.
 /// - [Mode#NEVER]: legacy JSON unconditionally. The kill switch.
 ///
-/// The per-query option overrides every mode. Modeled on 
[org.apache.pinot.query.runtime.SendStatsPredicate], which
-/// gates the MSE stats on the same signal, with two deliberate differences: 
only servers are checked, since brokers
-/// never decode the fields, and an unreadable instance config counts as 
outdated rather than current, since the
-/// cost of a wrong answer here is a failed query rather than missing stats.
+/// The mode is read from cluster config, falling back to the static broker 
config and then to
+/// [CommonConstants.Broker#DEFAULT_MSE_PROTO_SEGMENT_LIST]. Cluster config 
wins and is applied to the next query, so
+/// an operator can switch the encoding off without restarting the brokers; 
clearing the key restores the static
+/// broker config. A value that is not a mode is ignored with a warning, 
leaving the current mode in place.
+///
+/// Modeled on [org.apache.pinot.query.runtime.SendStatsPredicate], which 
gates the MSE stats on the same signal, with
+/// two deliberate differences: only servers are checked, since brokers never 
decode the fields, and an unreadable
+/// instance config counts as outdated rather than current, since the cost of 
a wrong answer here is a failed query
+/// rather than missing stats.
 ///
 /// Every instance that is not a controller, broker or minion counts as a 
server, so an instance config left behind
 /// by a decommissioned old server keeps [Mode#SAFE] on the legacy encoding 
until it is removed; the outdated servers
 /// are logged whenever the encoding switches.
 ///
-/// Thread-safety: [#isEnabled] reads a single `volatile` flag and is 
lock-free on the request path. Instance-config
-/// deliveries are serialized on `this`, which also publishes the Helix 
handles set by [#watchInstanceConfigs].
+/// Thread-safety: [#isEnabled] reads two `volatile` fields and is lock-free 
on the request path. Instance-config and
+/// cluster-config deliveries are serialized on `this`, which also publishes 
the Helix handles set by
+/// [#watchInstanceConfigs].
 @ThreadSafe
 @BatchMode(enabled = false)
 @PreFetch(enabled = false)
-public class ProtoSegmentListPredicate implements InstanceConfigChangeListener 
{
+public class ProtoSegmentListPredicate implements 
InstanceConfigChangeListener, PinotClusterConfigChangeListener {
   private static final Logger LOGGER = 
LoggerFactory.getLogger(ProtoSegmentListPredicate.class);
   private static final String KEY = 
CommonConstants.Broker.CONFIG_OF_MSE_PROTO_SEGMENT_LIST;
   /// Cap on the outdated servers named in one log line, so that a large 
rolling upgrade does not log the whole fleet.
@@ -86,7 +93,11 @@ public class ProtoSegmentListPredicate implements 
InstanceConfigChangeListener {
     NEVER, SAFE, ALWAYS
   }
 
-  private final Mode _mode;
+  /// The mode of the static broker config, used until cluster config says 
otherwise and again when the cluster-config
+  /// key is cleared.
+  private final Mode _staticMode;
+  /// The mode in force, which cluster config can change at runtime.
+  private volatile Mode _mode;
   private final String _currentVersion;
   /// SAFE only: servers whose version is not [#_currentVersion], mapped to 
the version they report. Guarded by `this`.
   private final Map<String, String> _outdatedServers = new HashMap<>();
@@ -104,6 +115,7 @@ public class ProtoSegmentListPredicate implements 
InstanceConfigChangeListener {
 
   @VisibleForTesting
   ProtoSegmentListPredicate(Mode mode, String currentVersion) {
+    _staticMode = mode;
     _mode = mode;
     _currentVersion = currentVersion;
   }
@@ -126,8 +138,8 @@ public class ProtoSegmentListPredicate implements 
InstanceConfigChangeListener {
     return _mode;
   }
 
-  /// Whether a query that does not set the query option uses the proto 
encoding. `multiClusterQuery` is whether the
-  /// query routes to other clusters, whose server versions this predicate 
cannot see.
+  /// Whether a query uses the proto encoding. `multiClusterQuery` is whether 
the query routes to other clusters,
+  /// whose server versions this predicate cannot see.
   public boolean isEnabled(boolean multiClusterQuery) {
     switch (_mode) {
       case ALWAYS:
@@ -139,13 +151,11 @@ public class ProtoSegmentListPredicate implements 
InstanceConfigChangeListener {
     }
   }
 
-  /// Starts watching the server versions of the cluster when the mode needs 
it, and is a no-op otherwise.
-  /// `helixManager` must already be connected. A registration failure is 
logged rather than thrown: it leaves
-  /// [Mode#SAFE] on the legacy encoding, which is always correct, and an 
optimization must not fail broker startup.
+  /// Starts watching the server versions of the cluster. Registered whatever 
the current mode is, because cluster
+  /// config can switch the mode to [Mode#SAFE] at runtime. `helixManager` 
must already be connected. A registration
+  /// failure is logged rather than thrown: it leaves [Mode#SAFE] on the 
legacy encoding, which is always correct, and
+  /// an optimization must not fail broker startup.
   public void watchInstanceConfigs(HelixManager helixManager) {
-    if (_mode != Mode.SAFE) {
-      return;
-    }
     try {
       // Published under the monitor that deliveries synchronize on, but 
registered outside it: Helix may deliver the
       // initial notification on another thread while registration is still in 
progress.
@@ -160,6 +170,35 @@ public class ProtoSegmentListPredicate implements 
InstanceConfigChangeListener {
     }
   }
 
+  /// Applies a mode set in cluster config, which wins over the static broker 
config and takes effect on the next
+  /// query. Clearing the key restores the static broker config.
+  @Override
+  public synchronized void onChange(Set<String> changedConfigs, Map<String, 
String> clusterConfigs) {
+    if (!changedConfigs.contains(KEY)) {
+      return;
+    }
+    String value = clusterConfigs.get(KEY);
+    Mode mode;
+    if (value == null || value.isBlank()) {
+      mode = _staticMode;
+    } else {
+      try {
+        mode = Mode.valueOf(value.trim().toUpperCase(Locale.ENGLISH));
+      } catch (IllegalArgumentException e) {
+        // Keep the mode in force: a typo must not silently move a cluster off 
the encoding an operator chose.
+        LOGGER.warn("Ignoring invalid value '{}' for {}, expected one of 
NEVER, SAFE, ALWAYS, staying on: {}", value,
+            KEY, _mode);
+        return;
+      }
+    }
+    Mode previous = _mode;
+    if (mode == previous) {
+      return;
+    }
+    _mode = mode;
+    LOGGER.info("Updated {} from: {} to: {}", KEY, previous, mode);
+  }
+
   @Override
   public synchronized void onInstanceConfigChange(List<InstanceConfig> 
instanceConfigs, NotificationContext context) {
     NotificationContext.Type type = context.getType();
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 d0b88515214..8653dae86a9 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
@@ -133,9 +133,9 @@ public class QueryDispatcher {
   /// Cluster-level default for stream-stats mode. Used as the fallback in 
[#submitAndReduce] when the query
   /// does not carry an explicit [QueryOptionKey#STREAM_STATS] override.
   private final boolean _streamStatsDefault;
-  /// Picks the leaf-stage segment list encoding of a query that does not 
carry an explicit
-  /// [QueryOptionKey#PROTO_SEGMENT_LIST] override. Read once per request, 
since in its default mode it follows the
-  /// server versions of the cluster; see [ProtoSegmentListPredicate].
+  /// Picks the leaf-stage segment list encoding of a query. Read once per 
request, since operators can change the
+  /// mode through cluster config and its default mode follows the server 
versions of the cluster; see
+  /// [ProtoSegmentListPredicate].
   private final ProtoSegmentListPredicate _protoSegmentList;
 
   public QueryDispatcher(MailboxService mailboxService, FailureDetector 
failureDetector, @Nullable TlsConfig tlsConfig,
@@ -717,12 +717,11 @@ public class QueryDispatcher {
     }
   }
 
-  /// Whether this query ships its leaf-stage segment lists in the proto 
encoding: the query option if set, otherwise
-  /// the predicate, which has to use the legacy encoding for a multi-cluster 
query because it cannot see the server
-  /// versions of the other clusters. Resolved once per query so that all of 
its servers get the same encoding.
+  /// Whether this query ships its leaf-stage segment lists in the proto 
encoding. A multi-cluster query keeps the
+  /// legacy encoding, because the predicate cannot see the server versions of 
the other clusters. Resolved once per
+  /// query so that all of its servers get the same encoding even if the mode 
changes mid-dispatch.
   private boolean useProtoSegmentList(Map<String, String> queryOptions) {
-    return QueryOptionsUtils.isProtoSegmentList(queryOptions,
-        
_protoSegmentList.isEnabled(QueryOptionsUtils.isMultiClusterRoutingEnabled(queryOptions,
 false)));
+    return 
_protoSegmentList.isEnabled(QueryOptionsUtils.isMultiClusterRoutingEnabled(queryOptions,
 false));
   }
 
   /// Builds the request for one server: the plans of the stages it takes part 
in, with only its own workers'
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/service/dispatch/ProtoSegmentListPredicateTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/service/dispatch/ProtoSegmentListPredicateTest.java
index f111360d33e..1edf806bbca 100644
--- 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/service/dispatch/ProtoSegmentListPredicateTest.java
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/service/dispatch/ProtoSegmentListPredicateTest.java
@@ -21,6 +21,7 @@ package org.apache.pinot.query.service.dispatch;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import org.apache.helix.HelixAdmin;
 import org.apache.helix.HelixManager;
 import org.apache.helix.NotificationContext;
@@ -187,13 +188,59 @@ public class ProtoSegmentListPredicateTest {
     assertFalse(predicate.isEnabled(false));
   }
 
+  /// The kill switch: a mode set in cluster config wins over the static 
broker config and takes effect without a
+  /// broker restart.
   @Test
-  public void testWatchIsANoOpOutsideSafeMode()
+  public void testClusterConfigOverridesTheStaticMode() {
+    ProtoSegmentListPredicate predicate = new 
ProtoSegmentListPredicate(Mode.SAFE, CURRENT);
+    predicate.refreshAllServers(Map.of(SERVER_1, CURRENT));
+    assertTrue(predicate.isEnabled(false));
+
+    predicate.onChange(Set.of(KEY), Map.of(KEY, "NEVER"));
+    assertEquals(predicate.getMode(), Mode.NEVER);
+    assertFalse(predicate.isEnabled(false), "NEVER from cluster config must 
switch the encoding off");
+
+    predicate.onChange(Set.of(KEY), Map.of(KEY, "always"));
+    assertEquals(predicate.getMode(), Mode.ALWAYS);
+    assertTrue(predicate.isEnabled(true), "ALWAYS from cluster config must 
apply even to multi-cluster queries");
+  }
+
+  /// Clearing the cluster-config key restores the static broker config, as 
the other live broker configs do.
+  @Test
+  public void testClearingClusterConfigRestoresTheStaticMode() {
+    ProtoSegmentListPredicate predicate = new 
ProtoSegmentListPredicate(Mode.ALWAYS, CURRENT);
+    predicate.onChange(Set.of(KEY), Map.of(KEY, "NEVER"));
+    assertEquals(predicate.getMode(), Mode.NEVER);
+
+    predicate.onChange(Set.of(KEY), Map.of());
+    assertEquals(predicate.getMode(), Mode.ALWAYS);
+    assertTrue(predicate.isEnabled(false));
+  }
+
+  /// A typo must not silently move the cluster off the encoding an operator 
chose, so the mode in force stays.
+  @Test
+  public void testInvalidClusterConfigValueIsIgnored() {
+    ProtoSegmentListPredicate predicate = new 
ProtoSegmentListPredicate(Mode.ALWAYS, CURRENT);
+    predicate.onChange(Set.of(KEY), Map.of(KEY, "true"));
+    assertEquals(predicate.getMode(), Mode.ALWAYS);
+    assertTrue(predicate.isEnabled(false));
+  }
+
+  @Test
+  public void testClusterConfigChangeThatDoesNotTouchTheKeyIsIgnored() {
+    ProtoSegmentListPredicate predicate = new 
ProtoSegmentListPredicate(Mode.ALWAYS, CURRENT);
+    predicate.onChange(Set.of("some.other.key"), Map.of(KEY, "NEVER"));
+    assertEquals(predicate.getMode(), Mode.ALWAYS);
+  }
+
+  /// SAFE can be selected at runtime, so the server versions are watched 
whatever the static mode is.
+  @Test
+  public void testWatchesServerVersionsWhateverTheStaticModeIs()
       throws Exception {
     HelixManager helixManager = helixManager(mock(HelixAdmin.class));
-    new ProtoSegmentListPredicate(Mode.ALWAYS, 
CURRENT).watchInstanceConfigs(helixManager);
-    new ProtoSegmentListPredicate(Mode.NEVER, 
CURRENT).watchInstanceConfigs(helixManager);
-    verify(helixManager, never()).addInstanceConfigChangeListener(any());
+    ProtoSegmentListPredicate predicate = new 
ProtoSegmentListPredicate(Mode.NEVER, CURRENT);
+    predicate.watchInstanceConfigs(helixManager);
+    verify(helixManager).addInstanceConfigChangeListener(predicate);
   }
 
   /// A failed registration must not fail broker startup; it just leaves SAFE 
on the legacy encoding.
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 25f6d5757cb..5a6c3b88f4f 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
@@ -668,15 +668,19 @@ public class CommonConstants {
     public static final String CONFIG_OF_STREAM_STATS_DRAIN_MS = 
"pinot.broker.mse.stream.stats.drain.ms";
     public static final long DEFAULT_STREAM_STATS_DRAIN_MS = 50L;
 
-    /// How a multi-stage query that does not set the 
[Request.QueryOptionKey#PROTO_SEGMENT_LIST] query option ships
-    /// its leaf-stage segment lists: as native protobuf fields of the worker 
metadata, which skips a JSON encode per
-    /// leaf-stage worker on the broker and a JSON parse per worker on the 
server, or as the legacy JSON string custom
-    /// property. A server that predates the proto fields fails the leaf 
stages it receives in the proto encoding.
+    /// How a multi-stage query ships its leaf-stage segment lists: as native 
protobuf fields of the worker metadata,
+    /// which skips a JSON encode per leaf-stage worker on the broker and a 
JSON parse per worker on the server, or as
+    /// the legacy JSON string custom property. A server that predates the 
proto fields fails the leaf stages it
+    /// receives in the proto encoding.
     /// - `SAFE` (default): proto only while every server of the cluster 
reports this broker's Pinot version, so the
     ///   encoding switches itself on when a rolling upgrade completes; 
multi-cluster queries always use the legacy
     ///   encoding.
     /// - `ALWAYS`: proto unconditionally.
     /// - `NEVER`: legacy unconditionally.
+    ///
+    /// Also read from cluster config under the same key, which wins over the 
static broker config and takes effect on
+    /// the next query, so the encoding can be switched off without restarting 
the brokers; clearing the cluster-config
+    /// key restores the static broker config.
     public static final String CONFIG_OF_MSE_PROTO_SEGMENT_LIST = 
"pinot.broker.mse.proto.segment.list";
     public static final String DEFAULT_MSE_PROTO_SEGMENT_LIST = "SAFE";
 
@@ -953,14 +957,6 @@ public class CommonConstants {
         /// other transport error) during dispatch, the broker cancels the 
query and surfaces the error to the
         /// client.
         public static final String STREAM_STATS = "streamStats";
-        /// When set, overrides `pinot.broker.mse.proto.segment.list` for one 
query: `true` ships the leaf-stage
-        /// segment lists as native protobuf fields of the worker metadata, 
`false` as the legacy JSON string custom
-        /// property.
-        ///
-        /// **Mixed-version note.** `true` requires every server the query 
reaches to understand the proto encoding: an
-        /// older server fails the leaf stage. The broker default (`SAFE`) 
already picks the proto encoding whenever
-        /// that holds, so this option is mostly useful to force the legacy 
encoding.
-        public static final String PROTO_SEGMENT_LIST = "protoSegmentList";
         /// If set, changes the explain behavior in multi-stage engine.
         ///
         /// `true` means to ask servers for the physical plan while false 
means to just use logical plan.


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

Reply via email to