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

xiangfu0 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 df9eebc6cd6 add api for server query route enable/disable (#18190)
df9eebc6cd6 is described below

commit df9eebc6cd6d34d21639d01bb296674f7e8c649b
Author: Hongkun Xu <[email protected]>
AuthorDate: Sat Jun 6 13:23:56 2026 +0800

    add api for server query route enable/disable (#18190)
---
 .../pinot/client/admin/InstanceAdminClient.java    |  20 ++--
 .../resources/PinotInstanceRestletResource.java    |  34 ++++++-
 .../pinot/controller/api/resources/StateType.java  |   2 +-
 .../helix/core/PinotHelixResourceManager.java      |  39 +++++++
 pinot-controller/src/main/resources/app/Models.ts  |   2 +
 .../main/resources/app/pages/InstanceDetails.tsx   |  48 +++++++++
 .../api/PinotInstanceRestletResourceTest.java      | 113 +++++++++++++++++++++
 .../tests/BaseClusterIntegrationTestSet.java       | 110 ++++++++++++++++++++
 .../tests/BaseRealtimeClusterIntegrationTest.java  |   7 ++
 .../tests/HybridClusterIntegrationTest.java        |   7 ++
 ...nsumptionRateLimiterClusterIntegrationTest.java |   7 ++
 ...aIncreaseDecreasePartitionsIntegrationTest.java |   7 ++
 12 files changed, 382 insertions(+), 14 deletions(-)

diff --git 
a/pinot-clients/pinot-java-client/src/main/java/org/apache/pinot/client/admin/InstanceAdminClient.java
 
b/pinot-clients/pinot-java-client/src/main/java/org/apache/pinot/client/admin/InstanceAdminClient.java
index 9f15dfe3fa3..05131a22c8e 100644
--- 
a/pinot-clients/pinot-java-client/src/main/java/org/apache/pinot/client/admin/InstanceAdminClient.java
+++ 
b/pinot-clients/pinot-java-client/src/main/java/org/apache/pinot/client/admin/InstanceAdminClient.java
@@ -132,15 +132,17 @@ public class InstanceAdminClient extends 
BaseServiceAdminClient {
     return response.toString();
   }
 
-  /**
-   * Enables, disables, or drains an instance via PUT with a state query 
parameter.
-   * DRAIN state is only applicable to minion instances.
-   *
-   * @param instanceName Name of the instance
-   * @param state State to set (enable, disable, or drop, drain)
-   * @return Success response
-   * @throws PinotAdminException If the request fails
-   */
+  /// Changes the operational state of an instance via PUT with a state query 
parameter.
+  ///
+  /// Supported states: `enable`, `disable`, `drain`, `queries_disable`, 
`queries_enable`.
+  /// - `DRAIN` is only applicable to minion instances.
+  /// - `QUERIES_DISABLE` / `QUERIES_ENABLE` are only applicable to server 
instances and control
+  ///   whether brokers route queries to the server.
+  ///
+  /// @param instanceName Name of the instance
+  /// @param state State to set (`enable`, `disable`, `drain`, 
`queries_disable`, `queries_enable`)
+  /// @return Success response
+  /// @throws PinotAdminException If the request fails
   public String updateInstanceState(String instanceName, String state)
       throws PinotAdminException {
     Map<String, String> queryParams = Map.of("state", state);
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotInstanceRestletResource.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotInstanceRestletResource.java
index de16e8ed20d..9457fba5ef4 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotInstanceRestletResource.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotInstanceRestletResource.java
@@ -260,9 +260,12 @@ public class PinotInstanceRestletResource {
   @Authenticate(AccessType.UPDATE)
   @Produces(MediaType.APPLICATION_JSON)
   @Consumes(MediaType.TEXT_PLAIN)
-  @ApiOperation(value = "Enable/disable/drain an instance", notes = 
"Enable/disable/drain an instance. "
-      + "DRAIN state is only applicable to minion instances and retags them 
from minion_untagged to minion_drained, "
-      + "preventing new task assignments while allowing existing tasks to 
complete.")
+  @ApiOperation(value = "Enable/disable/drain an instance or toggle query 
routing", notes =
+      "Enable/disable/drain an instance. "
+          + "DRAIN state is only applicable to minion instances and retags 
them from minion_untagged to "
+          + "minion_drained, preventing new task assignments while allowing 
existing tasks to complete. "
+          + "QUERIES_DISABLE / QUERIES_ENABLE are only applicable to server 
instances and control whether "
+          + "brokers route queries to the server.")
   @ApiResponses(value = {
       @ApiResponse(code = 200, message = "Success"),
       @ApiResponse(code = 400, message = "Bad Request"),
@@ -273,7 +276,8 @@ public class PinotInstanceRestletResource {
       @ApiParam(value = "Instance name", required = true, example = 
"Server_a.b.com_20000 | Broker_my.broker.com_30000 "
           + "| Minion_hostname_9514")
       @PathParam("instanceName") String instanceName,
-      @ApiParam(value = "enable|disable|drain", required = true) 
@QueryParam("state") String state) {
+      @ApiParam(value = "enable|disable|drain|queries_disable|queries_enable", 
required = true)
+      @QueryParam("state") String state) {
     if (!_pinotHelixResourceManager.instanceExists(instanceName)) {
       throw new ControllerApplicationException(LOGGER, "Instance '" + 
instanceName + "' does not exist",
           Response.Status.NOT_FOUND);
@@ -305,6 +309,28 @@ public class PinotInstanceRestletResource {
             "Failed to drain minion instance '" + instanceName + "': " + 
response.getMessage(),
             Response.Status.INTERNAL_SERVER_ERROR);
       }
+    } else if (StateType.QUERIES_DISABLE.name().equalsIgnoreCase(state)
+        || StateType.QUERIES_ENABLE.name().equalsIgnoreCase(state)) {
+      // QUERIES_DISABLE / QUERIES_ENABLE only toggle the broker routing flag 
for a server instance.
+      // They do NOT change the participant's lifecycle (HELIX_ENABLED) -- the 
instance stays online
+      // and continues to serve queries that are already routed to it; the 
broker simply stops
+      // sending new queries.
+      if (!InstanceTypeUtils.isServer(instanceName)) {
+        throw new ControllerApplicationException(LOGGER,
+            "QUERIES_DISABLE/QUERIES_ENABLE only applies to server instances. 
Instance '" + instanceName
+                + "' is not a server.", Response.Status.BAD_REQUEST);
+      }
+      boolean disable = 
StateType.QUERIES_DISABLE.name().equalsIgnoreCase(state);
+      try {
+        _pinotHelixResourceManager.setQueriesDisabled(instanceName, disable);
+      } catch (IllegalArgumentException e) {
+        // Defensive: setQueriesDisabled's own Preconditions check throws 
IllegalArgumentException.
+        // The InstanceTypeUtils.isServer check above already filters this 
case, but if the underlying
+        // contract changes, surface a 400 rather than a 500.
+        throw new ControllerApplicationException(LOGGER, e.getMessage(), 
Response.Status.BAD_REQUEST, e);
+      }
+      return new SuccessResponse(
+          (disable ? "Disabled" : "Enabled") + " query routing for server 
instance '" + instanceName + "'");
     } else {
       throw new ControllerApplicationException(LOGGER, "Unknown state '" + 
state + "'", Response.Status.BAD_REQUEST);
     }
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/StateType.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/StateType.java
index 879b49e368c..7f4a85ba5ec 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/StateType.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/StateType.java
@@ -19,5 +19,5 @@
 package org.apache.pinot.controller.api.resources;
 
 public enum StateType {
-  ENABLE, DISABLE, DROP, DRAIN
+  ENABLE, DISABLE, DROP, DRAIN, QUERIES_DISABLE, QUERIES_ENABLE
 }
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/PinotHelixResourceManager.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/PinotHelixResourceManager.java
index e0175a146b7..24d779e2507 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/PinotHelixResourceManager.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/PinotHelixResourceManager.java
@@ -626,9 +626,27 @@ public class PinotHelixResourceManager {
 
     InstanceConfigValidatorRegistry.validate(newInstance);
 
+    // Asymmetric preservation of the operational `queriesDisabled` flag 
managed by
+    // PUT /instances/{name}/state?state=QUERIES_DISABLE|QUERIES_ENABLE (see 
#setQueriesDisabled):
+    //   - body=true  -> set true (legacy path; explicit operator intent in 
this request).
+    //   - body=false -> if live==true, preserve true (the clobber case: a 
generic update to
+    //                   host/tags/ports must NOT silently re-route queries to 
a server an
+    //                   operator has explicitly removed from routing).
+    //   - body=false + live!=true -> leave as is (no-op / cleared).
+    boolean liveQueriesDisabled = Boolean.parseBoolean(
+        
instanceConfig.getRecord().getSimpleField(CommonConstants.Helix.QUERIES_DISABLED));
+
     List<String> newTags = newInstance.getTags();
     List<String> oldTags = instanceConfig.getTags();
     InstanceUtils.updateHelixInstanceConfig(instanceConfig, newInstance);
+    // If the live record had queriesDisabled=true but the request body 
defaulted it to false,
+    // restore the operational flag so it survives a request-body-driven 
update.
+    if (liveQueriesDisabled && !newInstance.isQueriesDisabled()) {
+      
instanceConfig.getRecord().setSimpleField(CommonConstants.Helix.QUERIES_DISABLED,
 Boolean.TRUE.toString());
+      LOGGER.warn("Ignoring request body's queriesDisabled=false for instance: 
{} because the live config has "
+          + "queriesDisabled=true; use PUT 
/instances/{}/state?state=QUERIES_ENABLE to clear the flag.", instanceId,
+          instanceId);
+    }
     if 
(!_helixDataAccessor.setProperty(_keyBuilder.instanceConfig(instanceId), 
instanceConfig)) {
       throw new RuntimeException("Failed to set instance config for instance: 
" + instanceId);
     }
@@ -659,6 +677,27 @@ public class PinotHelixResourceManager {
     }
   }
 
+  /// Sets the `queriesDisabled` flag on the instance config using a 
field-scoped Helix write.
+  ///
+  /// This avoids clobbering other fields (e.g. `shutdownInProgress`) that are 
concurrently set
+  /// by other field-scoped writes during rolling upgrades. Full-record writes 
([#updateInstance])
+  /// preserve this flag explicitly; see that method for details.
+  ///
+  /// @param serverInstanceName the Helix participant id (must be a server 
instance)
+  /// @param disabled `true` to disable query routing, `false` to re-enable it
+  /// @throws IllegalArgumentException if `serverInstanceName` is not a server 
instance
+  public synchronized void setQueriesDisabled(String serverInstanceName, 
boolean disabled) {
+    Preconditions.checkArgument(InstanceTypeUtils.isServer(serverInstanceName),
+        "setQueriesDisabled only applies to server instances, got: %s", 
serverInstanceName);
+    Map<String, String> propToUpdate =
+        Collections.singletonMap(CommonConstants.Helix.QUERIES_DISABLED, 
Boolean.toString(disabled));
+    HelixConfigScope scope =
+        new 
HelixConfigScopeBuilder(HelixConfigScope.ConfigScopeProperty.PARTICIPANT, 
_helixClusterName)
+            .forParticipant(serverInstanceName).build();
+    LOGGER.info("Setting queriesDisabled={} for server instance: {}", 
disabled, serverInstanceName);
+    _helixAdmin.setConfig(scope, propToUpdate);
+  }
+
   /**
    * Updates the tags of the specified instance ID
    */
diff --git a/pinot-controller/src/main/resources/app/Models.ts 
b/pinot-controller/src/main/resources/app/Models.ts
index 1d9f6de5252..ca2c2926ca0 100644
--- a/pinot-controller/src/main/resources/app/Models.ts
+++ b/pinot-controller/src/main/resources/app/Models.ts
@@ -60,6 +60,8 @@ export enum DISPLAY_SEGMENT_STATUS {
 export enum InstanceState {
   ENABLE = 'enable',
   DISABLE = 'disable',
+  QUERIES_DISABLE = 'queries_disable',
+  QUERIES_ENABLE = 'queries_enable',
 }
 
 export enum InstanceType {
diff --git a/pinot-controller/src/main/resources/app/pages/InstanceDetails.tsx 
b/pinot-controller/src/main/resources/app/pages/InstanceDetails.tsx
index 8aaabd4a64e..a4e5e17052e 100644
--- a/pinot-controller/src/main/resources/app/pages/InstanceDetails.tsx
+++ b/pinot-controller/src/main/resources/app/pages/InstanceDetails.tsx
@@ -97,6 +97,8 @@ const InstanceDetails = ({ match }: 
RouteComponentProps<Props>) => {
     enabled: true,
   });
 
+  const [queriesDisabled, setQueriesDisabled] = useState(false);
+
   const [showEditTag, setShowEditTag] = useState(false);
   const [showEditConfig, setShowEditConfig] = useState(false);
   const { dispatch } = React.useContext(NotificationContext);
@@ -135,6 +137,7 @@ const InstanceDetails = ({ match }: 
RouteComponentProps<Props>) => {
         queriesDisabled: instanceDetails.queriesDisabled,
       };
       setState({ enabled: instanceDetails.enabled });
+      setQueriesDisabled(String(instanceDetails.queriesDisabled) === 'true');
       setInstanceDetails(JSON.stringify(instancePutObj, null, 2));
       setLiveConfig(JSON.stringify(liveConfigResponse, null, 2));
     }
@@ -245,6 +248,32 @@ const InstanceDetails = ({ match }: 
RouteComponentProps<Props>) => {
     closeDialog();
   };
 
+  const handleQueryRouteChange = () => {
+    setDialogDetails({
+      title: queriesDisabled ? 'Enable Query Route' : 'Disable Query Route',
+      content: `Are you sure you want to ${
+        queriesDisabled ? 'enable' : 'disable'
+      } query routing for this server?`,
+      successCb: () => toggleQueryRoute(),
+    });
+    setConfirmDialog(true);
+  };
+
+  const toggleQueryRoute = async () => {
+    const result = await PinotMethodUtils.toggleInstanceState(
+      instanceName,
+      queriesDisabled ? InstanceState.QUERIES_ENABLE : 
InstanceState.QUERIES_DISABLE
+    );
+    if (result.status) {
+      dispatch({ type: 'success', message: result.status, show: true });
+      setQueriesDisabled(!queriesDisabled);
+      fetchData();
+    } else {
+      dispatch({ type: 'error', message: result.error, show: true });
+    }
+    closeDialog();
+  };
+
   const handleConfigChange = (value: string) => {
     setConfig(value);
   };
@@ -339,6 +368,25 @@ const InstanceDetails = ({ match }: 
RouteComponentProps<Props>) => {
                     label="Enable"
                   />
                 </Tooltip>
+                {instanceType === InstanceType.SERVER && (
+                  <Tooltip
+                    title="Controls whether brokers route queries to this 
server. Disabling removes the server from the broker routing table."
+                    arrow
+                    placement="top-start"
+                  >
+                    <FormControlLabel
+                      control={
+                        <Switch
+                          checked={!queriesDisabled}
+                          onChange={handleQueryRouteChange}
+                          name="queryRoute"
+                          color="primary"
+                        />
+                      }
+                      label="Query Route"
+                    />
+                  </Tooltip>
+                )}
               </div>
             </SimpleAccordion>
           </div>
diff --git 
a/pinot-controller/src/test/java/org/apache/pinot/controller/api/PinotInstanceRestletResourceTest.java
 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/PinotInstanceRestletResourceTest.java
index 82fa672d84b..8ea287452ac 100644
--- 
a/pinot-controller/src/test/java/org/apache/pinot/controller/api/PinotInstanceRestletResourceTest.java
+++ 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/PinotInstanceRestletResourceTest.java
@@ -30,6 +30,7 @@ import java.util.Map;
 import java.util.TreeMap;
 import javax.annotation.Nullable;
 import org.apache.pinot.client.admin.PinotAdminClient;
+import org.apache.pinot.client.admin.PinotAdminValidationException;
 import org.apache.pinot.controller.api.resources.InstanceTagUpdateRequest;
 import org.apache.pinot.controller.api.resources.OperationValidationResponse;
 import org.apache.pinot.controller.helix.ControllerTest;
@@ -49,6 +50,7 @@ import static org.testng.Assert.assertNotNull;
 import static org.testng.Assert.assertNull;
 import static org.testng.Assert.assertThrows;
 import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
 
 
 /**
@@ -437,6 +439,117 @@ public class PinotInstanceRestletResourceTest extends 
ControllerTest {
     adminClient.getInstanceClient().dropInstance(minionInstanceId);
   }
 
+  @Test
+  public void testQueriesDisableOnServerInstance()
+      throws Exception {
+    PinotAdminClient adminClient = DEFAULT_INSTANCE.getOrCreateAdminClient();
+
+    Instance serverInstance =
+        new Instance("queryroute.test.com", 20000, InstanceType.SERVER, null, 
null, 0, 0, 0, 0, false);
+    
adminClient.getInstanceClient().createInstance(serverInstance.toJsonString());
+    String instanceId = "Server_queryroute.test.com_20000";
+    try {
+      // Queries routed by default
+      checkInstanceInfo(adminClient, instanceId, "queryroute.test.com", 20000, 
new String[0], null,
+          -1, -1, -1, -1, false);
+
+      // Disable query routing via toggleInstanceState
+      adminClient.getInstanceClient().updateInstanceState(instanceId, 
"QUERIES_DISABLE");
+      checkInstanceInfo(adminClient, instanceId, "queryroute.test.com", 20000, 
new String[0], null,
+          -1, -1, -1, -1, true);
+
+      // Re-enable query routing via toggleInstanceState
+      adminClient.getInstanceClient().updateInstanceState(instanceId, 
"QUERIES_ENABLE");
+      checkInstanceInfo(adminClient, instanceId, "queryroute.test.com", 20000, 
new String[0], null,
+          -1, -1, -1, -1, false);
+    } finally {
+      adminClient.getInstanceClient().dropInstance(instanceId);
+    }
+  }
+
+  @Test
+  public void testUpdateInstancePreservesQueriesDisabled()
+      throws Exception {
+    // Regression test: a generic PUT /instances/{name} with the request 
body's queriesDisabled defaulted
+    // to false must NOT clobber an operationally-set queriesDisabled=true 
(operator workflow:
+    //   1. operator calls QUERIES_DISABLE to remove server from broker 
routing,
+    //   2. some automation later updates host/tags/ports via PUT 
/instances/{name},
+    //   3. server must remain out of routing).
+    PinotAdminClient adminClient = DEFAULT_INSTANCE.getOrCreateAdminClient();
+
+    Instance serverInstance =
+        new Instance("preserve.test.com", 20000, InstanceType.SERVER, null, 
null, 0, 0, 0, 0, false);
+    
adminClient.getInstanceClient().createInstance(serverInstance.toJsonString());
+    String instanceId = "Server_preserve.test.com_20000";
+    try {
+      // Set queriesDisabled=true via the dedicated state API.
+      adminClient.getInstanceClient().updateInstanceState(instanceId, 
"QUERIES_DISABLE");
+      checkInstanceInfo(adminClient, instanceId, "preserve.test.com", 20000, 
new String[0], null,
+          -1, -1, -1, -1, true);
+
+      // PUT /instances/{name} with a request body where queriesDisabled 
defaults to false (legacy operator path).
+      Instance updateBody =
+          new Instance("preserve.test.com", 20000, InstanceType.SERVER, null, 
null, 0, 0, 0, 0, false);
+      adminClient.getInstanceClient().updateInstance(instanceId, 
updateBody.toJsonString());
+
+      // queriesDisabled must still be true (operational flag preserved).
+      checkInstanceInfo(adminClient, instanceId, "preserve.test.com", 20000, 
new String[0], null,
+          -1, -1, -1, -1, true);
+
+      // Operator can still clear the flag via the dedicated state API.
+      adminClient.getInstanceClient().updateInstanceState(instanceId, 
"QUERIES_ENABLE");
+      checkInstanceInfo(adminClient, instanceId, "preserve.test.com", 20000, 
new String[0], null,
+          -1, -1, -1, -1, false);
+    } finally {
+      adminClient.getInstanceClient().dropInstance(instanceId);
+    }
+  }
+
+  @Test
+  public void testQueriesDisableOnNonServerInstanceFails()
+      throws Exception {
+    PinotAdminClient adminClient = DEFAULT_INSTANCE.getOrCreateAdminClient();
+
+    // Broker instance
+    Instance brokerInstance =
+        new Instance("queryroute-broker.test.com", 30000, InstanceType.BROKER, 
null, null, 0, 0, 0, 0, false);
+    
adminClient.getInstanceClient().createInstance(brokerInstance.toJsonString());
+    String brokerId = "Broker_queryroute-broker.test.com_30000";
+    try {
+      // Expect HTTP 400 BAD_REQUEST surfaced as 
PinotAdminValidationException; verify the message
+      // identifies the failing precondition so a future regression that 
throws a generic 500 fails this test.
+      PinotAdminValidationException ex = 
expectThrows(PinotAdminValidationException.class,
+          () -> adminClient.getInstanceClient().updateInstanceState(brokerId, 
"QUERIES_DISABLE"));
+      assertTrue(ex.getMessage().contains("status: 400"),
+          "Expected HTTP 400 BAD_REQUEST, got: " + ex.getMessage());
+      assertTrue(ex.getMessage().contains("only applies to server instances"),
+          "Expected message to mention server-only precondition, got: " + 
ex.getMessage());
+    } finally {
+      adminClient.getInstanceClient().dropInstance(brokerId);
+    }
+  }
+
+  @Test
+  public void testQueriesDisableOnMinionInstanceFails()
+      throws Exception {
+    PinotAdminClient adminClient = DEFAULT_INSTANCE.getOrCreateAdminClient();
+
+    Instance minionInstance =
+        new Instance("queryroute-minion.test.com", 9520, InstanceType.MINION, 
null, null, 0, 0, 0, 0, false);
+    
adminClient.getInstanceClient().createInstance(minionInstance.toJsonString());
+    String minionId = "Minion_queryroute-minion.test.com_9520";
+    try {
+      PinotAdminValidationException ex = 
expectThrows(PinotAdminValidationException.class,
+          () -> adminClient.getInstanceClient().updateInstanceState(minionId, 
"QUERIES_DISABLE"));
+      assertTrue(ex.getMessage().contains("status: 400"),
+          "Expected HTTP 400 BAD_REQUEST, got: " + ex.getMessage());
+      assertTrue(ex.getMessage().contains("only applies to server instances"),
+          "Expected message to mention server-only precondition, got: " + 
ex.getMessage());
+    } finally {
+      adminClient.getInstanceClient().dropInstance(minionId);
+    }
+  }
+
   @AfterClass
   public void tearDown()
       throws Exception {
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BaseClusterIntegrationTestSet.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BaseClusterIntegrationTestSet.java
index 244b22a15d4..0e0f1310f48 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BaseClusterIntegrationTestSet.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BaseClusterIntegrationTestSet.java
@@ -22,6 +22,7 @@ import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -31,8 +32,10 @@ import java.util.Set;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.helix.PropertyKey;
 import org.apache.helix.model.ExternalView;
+import org.apache.helix.model.HelixConfigScope;
 import org.apache.helix.model.IdealState;
 import org.apache.helix.model.InstanceConfig;
+import org.apache.helix.model.builder.HelixConfigScopeBuilder;
 import org.apache.pinot.client.ResultSet;
 import org.apache.pinot.client.ResultSetGroup;
 import org.apache.pinot.client.admin.PinotAdminException;
@@ -44,9 +47,11 @@ import 
org.apache.pinot.common.restlet.resources.ServerRebalanceJobStatusRespons
 import 
org.apache.pinot.common.restlet.resources.TableSegmentsReloadCheckResponse;
 import org.apache.pinot.common.restlet.resources.TableView;
 import org.apache.pinot.common.utils.LLCSegmentName;
+import org.apache.pinot.common.utils.config.InstanceUtils;
 import org.apache.pinot.controller.util.ConsumingSegmentInfoReader;
 import org.apache.pinot.core.query.utils.idset.IdSet;
 import org.apache.pinot.core.query.utils.idset.IdSets;
+import org.apache.pinot.spi.config.instance.Instance;
 import org.apache.pinot.spi.config.table.TableConfig;
 import org.apache.pinot.spi.config.table.TableType;
 import org.apache.pinot.spi.config.table.TenantConfig;
@@ -605,6 +610,111 @@ public abstract class BaseClusterIntegrationTestSet 
extends BaseClusterIntegrati
     }
   }
 
+  /// Test that:
+  ///
+  /// 1. setting `queriesDisabled` via the controller API removes and re-adds 
a server from broker routing;
+  /// 2. concurrent field-scoped updates to `shutdownInProgress` do not 
clobber `queriesDisabled`;
+  /// 3. a full-record `PUT /instances/{name}` (updateInstance) does NOT 
clobber the operational
+  ///    `queriesDisabled` flag, even when the request body's default value is 
`false`.
+  public void testQueriesDisabled()
+      throws Exception {
+    List<String> instances = 
_helixAdmin.getInstancesInCluster(getHelixClusterName());
+    // Iterate over all servers to find one that actually appears in the 
routing table for {@code getTableName()}.
+    // Picking the first server unconditionally can be flaky when multiple 
servers exist with different tags
+    // (e.g. hybrid clusters with offline/realtime tenants).
+    String serverInstance = null;
+    for (String candidate : instances) {
+      if (!InstanceTypeUtils.isServer(candidate)) {
+        continue;
+      }
+      if (isInstanceInRoutingTable(candidate)) {
+        serverInstance = candidate;
+        break;
+      }
+    }
+    assertNotNull(serverInstance,
+        "No server instance found in routing table for table: " + 
getTableName() + "; instances: " + instances);
+    HelixConfigScope scope =
+        new 
HelixConfigScopeBuilder(HelixConfigScope.ConfigScopeProperty.PARTICIPANT, 
getHelixClusterName())
+            .forParticipant(serverInstance).build();
+    try {
+      // Server should be in routing initially
+      checkForInstanceInRoutingTable(serverInstance, true);
+
+      // Disable query routing via the controller API
+      
getOrCreateAdminClient().getInstanceClient().updateInstanceState(serverInstance,
 "QUERIES_DISABLE");
+
+      // Server should leave routing
+      checkForInstanceInRoutingTable(serverInstance, false);
+
+      // Re-enable query routing
+      
getOrCreateAdminClient().getInstanceClient().updateInstanceState(serverInstance,
 "QUERIES_ENABLE");
+
+      // Server should re-enter routing
+      checkForInstanceInRoutingTable(serverInstance, true);
+
+      // Interleaving test: set queriesDisabled, then set shutdownInProgress, 
then clear shutdownInProgress.
+      // queriesDisabled must still be set (i.e. the server must remain out of 
routing) because we use
+      // field-scoped writes that do not clobber each other.
+      
getOrCreateAdminClient().getInstanceClient().updateInstanceState(serverInstance,
 "QUERIES_DISABLE");
+      checkForInstanceInRoutingTable(serverInstance, false);
+
+      _helixAdmin.setConfig(scope,
+          
Collections.singletonMap(CommonConstants.Helix.IS_SHUTDOWN_IN_PROGRESS, 
Boolean.TRUE.toString()));
+      checkForInstanceInRoutingTable(serverInstance, false);
+
+      _helixAdmin.setConfig(scope,
+          
Collections.singletonMap(CommonConstants.Helix.IS_SHUTDOWN_IN_PROGRESS, 
Boolean.FALSE.toString()));
+
+      // queriesDisabled was set via a separate field-scoped write, so the 
server must still be out of routing
+      checkForInstanceInRoutingTable(serverInstance, false);
+
+      // Full-record updateInstance test: round-trip the live config through 
PUT /instances/{name} with the
+      // request body's {@code queriesDisabled} defaulted to false. The 
operational flag must NOT be cleared,
+      // since it is owned by /state?state=QUERIES_DISABLE|QUERIES_ENABLE.
+      InstanceConfig liveInstanceConfig = 
_helixAdmin.getInstanceConfig(getHelixClusterName(), serverInstance);
+      Instance instanceBody = InstanceUtils.toInstance(liveInstanceConfig);
+      // Force the request-body flag to false to simulate an operator updating 
host/tags/ports without realizing
+      // that queriesDisabled is operationally owned by a different API.
+      Instance updateBody = new Instance(instanceBody.getHost(), 
instanceBody.getPort(), instanceBody.getType(),
+          instanceBody.getTags(), instanceBody.getPools(), 
instanceBody.getGrpcPort(), instanceBody.getAdminPort(),
+          instanceBody.getQueryServicePort(), 
instanceBody.getQueryMailboxPort(), false);
+      
getOrCreateAdminClient().getInstanceClient().updateInstance(serverInstance, 
updateBody.toJsonString());
+      // Operational flag must survive a request-body-driven update.
+      checkForInstanceInRoutingTable(serverInstance, false);
+    } finally {
+      // Defensive cleanup: restore the server to the routing-eligible state 
regardless of which assertion failed.
+      // Each cleanup step is wrapped so a primary assertion failure isn't 
masked by a cleanup throw.
+      try {
+        
getOrCreateAdminClient().getInstanceClient().updateInstanceState(serverInstance,
 "QUERIES_ENABLE");
+      } catch (Exception ignored) {
+      }
+      try {
+        _helixAdmin.setConfig(scope,
+            
Collections.singletonMap(CommonConstants.Helix.IS_SHUTDOWN_IN_PROGRESS, 
Boolean.FALSE.toString()));
+      } catch (Exception ignored) {
+      }
+    }
+    // Verify routing is restored after cleanup.
+    checkForInstanceInRoutingTable(serverInstance, true);
+  }
+
+  /// Non-waiting check for whether `instance` appears in any routing table 
for the current table.
+  /// Used by [#testQueriesDisabled()] to pick a server that is actually 
serving queries.
+  private boolean isInstanceInRoutingTable(String instance) {
+    try {
+      JsonNode routingTables = getDebugInfo("debug/routingTable/" + 
getTableName());
+      for (JsonNode routingTable : routingTables) {
+        if (routingTable.has(instance)) {
+          return true;
+        }
+      }
+    } catch (Exception ignored) {
+      // Routing table not yet populated; treat as not present.
+    }
+    return false;
+  }
+
   private void checkForInstanceInRoutingTable(String instance, boolean 
shouldExist) {
     String errorMessage;
     if (shouldExist) {
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BaseRealtimeClusterIntegrationTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BaseRealtimeClusterIntegrationTest.java
index aa6ff7e59c3..7c9abd13d19 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BaseRealtimeClusterIntegrationTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BaseRealtimeClusterIntegrationTest.java
@@ -202,6 +202,13 @@ public abstract class BaseRealtimeClusterIntegrationTest 
extends BaseClusterInte
     super.testInstanceShutdown();
   }
 
+  @Test
+  @Override
+  public void testQueriesDisabled()
+      throws Exception {
+    super.testQueriesDisabled();
+  }
+
   @AfterClass
   public void tearDown()
       throws Exception {
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/HybridClusterIntegrationTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/HybridClusterIntegrationTest.java
index 22afb55d7f1..518326e73e1 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/HybridClusterIntegrationTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/HybridClusterIntegrationTest.java
@@ -377,6 +377,13 @@ public class HybridClusterIntegrationTest extends 
BaseHybridClusterIntegrationTe
     }
   }
 
+  @Test
+  @Override
+  public void testQueriesDisabled()
+      throws Exception {
+    super.testQueriesDisabled();
+  }
+
   @Test
   void testControllerJoinQuerySubmit()
       throws Exception {
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/RealtimeConsumptionRateLimiterClusterIntegrationTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/RealtimeConsumptionRateLimiterClusterIntegrationTest.java
index 7473bcc75b1..8a46dcf1954 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/RealtimeConsumptionRateLimiterClusterIntegrationTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/RealtimeConsumptionRateLimiterClusterIntegrationTest.java
@@ -281,6 +281,13 @@ public class 
RealtimeConsumptionRateLimiterClusterIntegrationTest extends BaseRe
     // Do nothing
   }
 
+  @Test(enabled = false)
+  @Override
+  public void testQueriesDisabled() {
+    // Routing-table-dependent test; the rate-limiter cluster setup keeps the 
table out of broker routing,
+    // making this test inapplicable here. Mirrors the testInstanceShutdown 
override above.
+  }
+
   @Test(enabled = false)
   public void testQueriesFromQueryFile(boolean useMultiStageQueryEngine) {
     // Do nothing
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/realtime/ingestion/KafkaIncreaseDecreasePartitionsIntegrationTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/realtime/ingestion/KafkaIncreaseDecreasePartitionsIntegrationTest.java
index 021eaca9718..a54ae80bdb4 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/realtime/ingestion/KafkaIncreaseDecreasePartitionsIntegrationTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/realtime/ingestion/KafkaIncreaseDecreasePartitionsIntegrationTest.java
@@ -87,6 +87,13 @@ public class KafkaIncreaseDecreasePartitionsIntegrationTest 
extends BaseRealtime
     // Do nothing
   }
 
+  @Test(enabled = false)
+  @Override
+  public void testQueriesDisabled() {
+    // Routing-table-dependent test that does not apply to this 
partition-change test setup.
+    // Mirrors the testInstanceShutdown override above.
+  }
+
   @Test(enabled = false)
   public void testQueriesFromQueryFile(boolean useMultiStageQueryEngine) {
     // Do nothing


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

Reply via email to