This is an automated email from the ASF dual-hosted git repository.
morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new d1e09c408eb [feat](fe) Add ClusterGuard SPI interface for
cluster-level policy enforcement (#62031)
d1e09c408eb is described below
commit d1e09c408eb563da913343f85b47200b130c9479
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Fri Apr 3 10:08:23 2026 -0700
[feat](fe) Add ClusterGuard SPI interface for cluster-level policy
enforcement (#62031)
### What problem does this PR solve?
Issue Number: N/A
Problem Summary:
Add a ClusterGuard SPI (Service Provider Interface) abstraction in
fe-core to
decouple cluster-level policy enforcement (e.g., node limits, time
validity checks)
from business logic. The open-source fe-core depends only on the
interface, while
commercial implementations can be plugged in at runtime via Java
ServiceLoader
without any compile-time dependency on fe-core.
Key changes:
- Add `ClusterGuard` interface with `onStartup`, `checkTimeValidity`,
`checkNodeLimit` and `getGuardInfo` methods
- Add `ClusterGuardFactory` using Java ServiceLoader to discover
implementations at runtime
- Add `NoOpClusterGuard` as the open-source no-op default implementation
- Add `ClusterGuardException` for guard-related error reporting
- Add `ClusterGuardAction` HTTP API at `/api/cluster_guard/` for status
query
- Integrate `ClusterGuardFactory.getGuard()` into `DorisFE.java`
(startup) and `SystemInfoService.java` (addBackend/dropBackend)
---
.../src/main/java/org/apache/doris/DorisFE.java | 7 +
.../org/apache/doris/cluster/ClusterGuard.java | 65 ++++
.../doris/cluster/ClusterGuardException.java | 32 ++
.../apache/doris/cluster/ClusterGuardFactory.java | 88 +++++
.../org/apache/doris/cluster/NoOpClusterGuard.java | 52 +++
.../doris/httpv2/rest/manager/HttpUtils.java | 8 +-
.../doris/httpv2/restv2/ClusterGuardAction.java | 161 +++++++++
.../org/apache/doris/system/SystemInfoService.java | 20 ++
.../doris/cluster/ClusterGuardExceptionTest.java | 48 +++
.../doris/cluster/ClusterGuardFactoryTest.java | 392 +++++++++++++++++++++
.../apache/doris/cluster/NoOpClusterGuardTest.java | 55 +++
11 files changed, 924 insertions(+), 4 deletions(-)
diff --git a/fe/fe-core/src/main/java/org/apache/doris/DorisFE.java
b/fe/fe-core/src/main/java/org/apache/doris/DorisFE.java
index f456d87f29f..8131bb34732 100755
--- a/fe/fe-core/src/main/java/org/apache/doris/DorisFE.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/DorisFE.java
@@ -18,6 +18,7 @@
package org.apache.doris;
import org.apache.doris.catalog.Env;
+import org.apache.doris.cluster.ClusterGuardFactory;
import org.apache.doris.common.CommandLineOptions;
import org.apache.doris.common.Config;
import org.apache.doris.common.FeConstants;
@@ -192,6 +193,7 @@ public class DorisFE {
}
fuzzyConfigs();
+ initClusterGuard(dorisHomeDir);
LOG.info("Doris FE starting...");
@@ -595,6 +597,11 @@ public class DorisFE {
}
}
+ private static void initClusterGuard(String dorisHomeDir) throws Exception
{
+ ClusterGuardFactory.getGuard().onStartup(dorisHomeDir);
+ LOG.info("Cluster guard initialized successfully.");
+ }
+
public static void overwriteConfigs() {
if (Config.isCloudMode() && Config.enable_feature_binlog) {
Config.enable_feature_binlog = false;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/cluster/ClusterGuard.java
b/fe/fe-core/src/main/java/org/apache/doris/cluster/ClusterGuard.java
new file mode 100644
index 00000000000..b7038fc6888
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/cluster/ClusterGuard.java
@@ -0,0 +1,65 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.cluster;
+
+/**
+ * Cluster guard interface for enforcing cluster-level policies such as
+ * node limits and time-based validity.
+ * <p>
+ * Business code in fe-core should only depend on this interface.
+ * Implementations are discovered via {@link java.util.ServiceLoader}.
+ * When no implementation is found on the classpath, a no-op default is used.
+ * </p>
+ */
+public interface ClusterGuard {
+
+ /**
+ * Perform initialization during FE startup.
+ *
+ * @param dorisHomeDir the DORIS_HOME directory
+ * @throws ClusterGuardException if initialization fails
+ */
+ void onStartup(String dorisHomeDir) throws ClusterGuardException;
+
+ /**
+ * Check time validity of the cluster guard policy.
+ *
+ * @throws ClusterGuardException if the time-based policy is violated
+ */
+ void checkTimeValidity() throws ClusterGuardException;
+
+ /**
+ * Check whether the current node count is within allowed limits.
+ *
+ * @param currentNodeCount the current number of nodes in the cluster
+ * @throws ClusterGuardException if the node count exceeds the limit
+ */
+ void checkNodeLimit(int currentNodeCount) throws ClusterGuardException;
+
+ /**
+ * Get the guard information as a JSON string.
+ * <p>
+ * The content and structure of the returned JSON is entirely
+ * determined by the implementation. The core code treats it
+ * as an opaque payload.
+ * </p>
+ *
+ * @return a JSON string with guard details, or "{}" if not available
+ */
+ String getGuardInfo();
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/cluster/ClusterGuardException.java
b/fe/fe-core/src/main/java/org/apache/doris/cluster/ClusterGuardException.java
new file mode 100644
index 00000000000..7d0dcaf74c4
--- /dev/null
+++
b/fe/fe-core/src/main/java/org/apache/doris/cluster/ClusterGuardException.java
@@ -0,0 +1,32 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.cluster;
+
+/**
+ * Exception thrown when a cluster guard policy is violated.
+ */
+public class ClusterGuardException extends Exception {
+
+ public ClusterGuardException(String message) {
+ super(message);
+ }
+
+ public ClusterGuardException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/cluster/ClusterGuardFactory.java
b/fe/fe-core/src/main/java/org/apache/doris/cluster/ClusterGuardFactory.java
new file mode 100644
index 00000000000..875d56cc952
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/cluster/ClusterGuardFactory.java
@@ -0,0 +1,88 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.cluster;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.util.Iterator;
+import java.util.ServiceLoader;
+
+/**
+ * Factory that discovers a {@link ClusterGuard} implementation via
+ * {@link ServiceLoader}.
+ * <p>
+ * If no provider is found on the classpath and the sentinel resource
+ * {@code META-INF/cluster-guard-required} is absent, a {@link
NoOpClusterGuard}
+ * is returned so the open-source edition runs without restrictions.
+ * <p>
+ * When the sentinel resource <em>is</em> present but no implementation is
found,
+ * startup is aborted by throwing a {@link RuntimeException}. The sentinel is
+ * only shipped by builds that require guard enforcement; it is never included
+ * in the open-source distribution.
+ */
+public class ClusterGuardFactory {
+ private static final Logger LOG =
LogManager.getLogger(ClusterGuardFactory.class);
+
+ static final String SENTINEL_RESOURCE = "META-INF/cluster-guard-required";
+
+ private static volatile ClusterGuard instance;
+
+ /**
+ * Get the singleton ClusterGuard instance.
+ * On first call, discovers the implementation via ServiceLoader.
+ */
+ public static ClusterGuard getGuard() {
+ if (instance == null) {
+ synchronized (ClusterGuardFactory.class) {
+ if (instance == null) {
+ instance = loadGuard();
+ }
+ }
+ }
+ return instance;
+ }
+
+ private static ClusterGuard loadGuard() {
+ return loadGuard(ClusterGuardFactory.class.getClassLoader());
+ }
+
+ /**
+ * Visible for testing — allows injecting a custom {@link ClassLoader} so
unit
+ * tests can simulate the sentinel file being present or absent without
touching
+ * the real classpath.
+ */
+ static ClusterGuard loadGuard(ClassLoader classLoader) {
+ ServiceLoader<ClusterGuard> loader =
ServiceLoader.load(ClusterGuard.class, classLoader);
+ Iterator<ClusterGuard> it = loader.iterator();
+ if (it.hasNext()) {
+ ClusterGuard guard = it.next();
+ LOG.info("Loaded ClusterGuard implementation: {}",
guard.getClass().getName());
+ return guard;
+ }
+
+ if (classLoader.getResource(SENTINEL_RESOURCE) != null) {
+ throw new RuntimeException(
+ "ClusterGuard is required but no implementation was found
on the classpath. "
+ + "Ensure the appropriate extension module is included in
the deployment.");
+ }
+
+ LOG.info("No ClusterGuard implementation found, using
NoOpClusterGuard");
+ return NoOpClusterGuard.INSTANCE;
+ }
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/cluster/NoOpClusterGuard.java
b/fe/fe-core/src/main/java/org/apache/doris/cluster/NoOpClusterGuard.java
new file mode 100644
index 00000000000..d49444940ac
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/cluster/NoOpClusterGuard.java
@@ -0,0 +1,52 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.cluster;
+
+/**
+ * No-op implementation of {@link ClusterGuard} used when no SPI provider is
found.
+ * All policy checks pass unconditionally — this is the default behavior
+ * for the open-source edition.
+ */
+public class NoOpClusterGuard implements ClusterGuard {
+
+ public static final NoOpClusterGuard INSTANCE = new NoOpClusterGuard();
+
+ private NoOpClusterGuard() {
+ // singleton
+ }
+
+ @Override
+ public void onStartup(String dorisHomeDir) throws ClusterGuardException {
+ // no-op
+ }
+
+ @Override
+ public void checkTimeValidity() throws ClusterGuardException {
+ // no-op: always valid
+ }
+
+ @Override
+ public void checkNodeLimit(int currentNodeCount) throws
ClusterGuardException {
+ // no-op: unlimited
+ }
+
+ @Override
+ public String getGuardInfo() {
+ return "{}";
+ }
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/manager/HttpUtils.java
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/manager/HttpUtils.java
index 694e5572952..6cda1ac4f37 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/manager/HttpUtils.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/manager/HttpUtils.java
@@ -56,10 +56,10 @@ import java.util.stream.Collectors;
public class HttpUtils {
private static final Logger LOG = LogManager.getLogger(HttpUtils.class);
- static final int REQUEST_SUCCESS_CODE = 0;
+ public static final int REQUEST_SUCCESS_CODE = 0;
static final int DEFAULT_TIME_OUT_MS = 2000;
- static List<Pair<String, Integer>> getFeList() {
+ public static List<Pair<String, Integer>> getFeList() {
return Env.getCurrentEnv().getFrontends(null)
.stream().filter(Frontend::isAlive).map(fe ->
Pair.of(fe.getHost(), Config.http_port))
.collect(Collectors.toList());
@@ -70,7 +70,7 @@ public class HttpUtils {
return hostInfo.isSame(new HostInfo(ip, port));
}
- static String concatUrl(Pair<String, Integer> ipPort, String path,
Map<String, String> arguments) {
+ public static String concatUrl(Pair<String, Integer> ipPort, String path,
Map<String, String> arguments) {
StringBuilder url = new StringBuilder("http://")
.append(ipPort.first).append(":").append(ipPort.second).append(path);
boolean isFirst = true;
@@ -98,7 +98,7 @@ public class HttpUtils {
return doGet(url, headers, DEFAULT_TIME_OUT_MS);
}
- static String doPost(String url, Map<String, String> headers, Object body)
throws IOException {
+ public static String doPost(String url, Map<String, String> headers,
Object body) throws IOException {
HttpPost httpPost = new HttpPost(url);
if (Objects.nonNull(body)) {
String jsonString = GsonUtils.GSON.toJson(body);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/ClusterGuardAction.java
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/ClusterGuardAction.java
new file mode 100644
index 00000000000..a41509fd0a8
--- /dev/null
+++
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/ClusterGuardAction.java
@@ -0,0 +1,161 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.httpv2.restv2;
+
+import org.apache.doris.cluster.ClusterGuard;
+import org.apache.doris.cluster.ClusterGuardException;
+import org.apache.doris.cluster.ClusterGuardFactory;
+import org.apache.doris.common.Pair;
+import org.apache.doris.httpv2.entity.ResponseEntityBuilder;
+import org.apache.doris.httpv2.rest.RestBaseController;
+import org.apache.doris.httpv2.rest.manager.HttpUtils;
+import org.apache.doris.httpv2.rest.manager.NodeAction;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.gson.reflect.TypeToken;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.lang.reflect.Type;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * HTTP API to check and manage the cluster guard status.
+ *
+ * GET /rest/v2/api/cluster_guard/status — query current cluster guard status
+ * POST /rest/v2/api/cluster_guard/reload — reload cluster guard on all FE
nodes (or single node)
+ */
+@RestController
+@RequestMapping("/rest/v2")
+public class ClusterGuardAction extends RestBaseController {
+ private static final Logger LOG =
LogManager.getLogger(ClusterGuardAction.class);
+
+ private static final String IS_ALL_NODE_PARA = "is_all_node";
+
+ private static final Gson GSON = new Gson();
+ private static final Type MAP_TYPE = new TypeToken<Map<String, Object>>()
{}.getType();
+
+ /**
+ * Query the current cluster guard status.
+ * Returns the opaque JSON from {@link ClusterGuard#getGuardInfo()}.
+ */
+ @RequestMapping(path = "/api/cluster_guard/status", method =
{RequestMethod.GET})
+ public Object getGuardStatus(HttpServletRequest request,
HttpServletResponse response) {
+ checkWithCookie(request, response, false);
+
+ ClusterGuard guard = ClusterGuardFactory.getGuard();
+ Map<String, Object> info = GSON.fromJson(guard.getGuardInfo(),
MAP_TYPE);
+ return ResponseEntityBuilder.ok(info);
+ }
+
+ /**
+ * Reload the cluster guard on all FE nodes (default) or just the current
node.
+ *
+ * POST /rest/v2/api/cluster_guard/reload
+ * POST /rest/v2/api/cluster_guard/reload?is_all_node=false
+ */
+ @RequestMapping(path = "/api/cluster_guard/reload", method =
{RequestMethod.POST})
+ public Object reloadGuard(HttpServletRequest request, HttpServletResponse
response,
+ @RequestParam(value = IS_ALL_NODE_PARA, required = false,
defaultValue = "true")
+ boolean isAllNode) {
+ executeCheckPassword(request, response);
+
+ if (isAllNode) {
+ return reloadOnAllFe(request);
+ }
+ return reloadOnLocal();
+ }
+
+ private Object reloadOnLocal() {
+ String dorisHome = System.getenv("DORIS_HOME");
+ try {
+ ClusterGuardFactory.getGuard().onStartup(dorisHome);
+ LOG.info("Cluster guard reloaded successfully via HTTP API.");
+ } catch (ClusterGuardException e) {
+ LOG.warn("Failed to reload cluster guard: {}", e.getMessage(), e);
+ return ResponseEntityBuilder.okWithCommonError("Failed to reload
cluster guard: " + e.getMessage());
+ }
+
+ ClusterGuard guard = ClusterGuardFactory.getGuard();
+ Map<String, Object> info = GSON.fromJson(guard.getGuardInfo(),
MAP_TYPE);
+ return ResponseEntityBuilder.ok(info);
+ }
+
+ private Object reloadOnAllFe(HttpServletRequest request) {
+ List<Pair<String, Integer>> frontends = HttpUtils.getFeList();
+ String authorization = request.getHeader(NodeAction.AUTHORIZATION);
+ ImmutableMap<String, String> header = ImmutableMap.<String,
String>builder()
+ .put(NodeAction.AUTHORIZATION, authorization).build();
+
+ String httpPath = "/rest/v2/api/cluster_guard/reload";
+ Map<String, String> arguments = Maps.newHashMap();
+ arguments.put(IS_ALL_NODE_PARA, "false");
+
+ Map<String, Object> allResults = Maps.newLinkedHashMap();
+ boolean hasFailure = false;
+
+ for (Pair<String, Integer> ipPort : frontends) {
+ String nodeKey = ipPort.first + ":" + ipPort.second;
+ String url = HttpUtils.concatUrl(ipPort, httpPath, arguments);
+ try {
+ String resp = HttpUtils.doPost(url, header, null);
+ JsonObject jsonObj =
JsonParser.parseString(resp).getAsJsonObject();
+ int code = jsonObj.get("code").getAsInt();
+ if (code == HttpUtils.REQUEST_SUCCESS_CODE) {
+ Map<String, Object> nodeResult = Maps.newLinkedHashMap();
+ nodeResult.put("status", "success");
+ if (jsonObj.has("data") &&
!jsonObj.get("data").isJsonNull()) {
+ nodeResult.put("data", jsonObj.get("data").toString());
+ }
+ allResults.put(nodeKey, nodeResult);
+ } else {
+ hasFailure = true;
+ Map<String, String> nodeResult = Maps.newLinkedHashMap();
+ nodeResult.put("status", "failed");
+ String msg = jsonObj.has("data") ?
jsonObj.get("data").getAsString()
+ : jsonObj.get("msg").getAsString();
+ nodeResult.put("message", msg);
+ allResults.put(nodeKey, nodeResult);
+ }
+ } catch (Exception e) {
+ hasFailure = true;
+ LOG.warn("Failed to reload cluster guard on FE node {}: {}",
nodeKey, e.getMessage(), e);
+ Map<String, String> nodeResult = Maps.newLinkedHashMap();
+ nodeResult.put("status", "failed");
+ nodeResult.put("message", "Request failed: " + e.getMessage());
+ allResults.put(nodeKey, nodeResult);
+ }
+ }
+
+ if (hasFailure) {
+ return ResponseEntityBuilder.internalError(allResults);
+ }
+ return ResponseEntityBuilder.ok(allResults);
+ }
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/system/SystemInfoService.java
b/fe/fe-core/src/main/java/org/apache/doris/system/SystemInfoService.java
index 0a5abcf9516..2ef193cb158 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/system/SystemInfoService.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/system/SystemInfoService.java
@@ -21,6 +21,9 @@ import org.apache.doris.catalog.DiskInfo;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.ReplicaAllocation;
import org.apache.doris.cloud.qe.ComputeGroupException;
+import org.apache.doris.cluster.ClusterGuard;
+import org.apache.doris.cluster.ClusterGuardException;
+import org.apache.doris.cluster.ClusterGuardFactory;
import org.apache.doris.common.AnalysisException;
import org.apache.doris.common.Config;
import org.apache.doris.common.DdlException;
@@ -192,6 +195,16 @@ public class SystemInfoService {
}
}
+ // check cluster guard policy: time validity and node limit
+ ClusterGuard clusterGuard = ClusterGuardFactory.getGuard();
+ try {
+ clusterGuard.checkTimeValidity();
+ int currentCount = getAllClusterBackendsNoException().size();
+ clusterGuard.checkNodeLimit(currentCount + hostInfos.size());
+ } catch (ClusterGuardException e) {
+ throw new DdlException("Cluster guard restriction: " +
e.getMessage());
+ }
+
for (HostInfo hostInfo : hostInfos) {
addBackend(hostInfo.getHost(), hostInfo.getPort(), tagMap);
}
@@ -232,6 +245,13 @@ public class SystemInfoService {
}
public void dropBackends(List<HostInfo> hostInfos) throws DdlException {
+ // check cluster guard time validity
+ try {
+ ClusterGuardFactory.getGuard().checkTimeValidity();
+ } catch (ClusterGuardException e) {
+ throw new DdlException("Cluster guard restriction: " +
e.getMessage());
+ }
+
for (HostInfo hostInfo : hostInfos) {
// check is already exist
if (getBackendWithHeartbeatPort(hostInfo.getHost(),
hostInfo.getPort()) == null) {
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/cluster/ClusterGuardExceptionTest.java
b/fe/fe-core/src/test/java/org/apache/doris/cluster/ClusterGuardExceptionTest.java
new file mode 100644
index 00000000000..efc95f6cc10
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/cluster/ClusterGuardExceptionTest.java
@@ -0,0 +1,48 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.cluster;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+public class ClusterGuardExceptionTest {
+
+ @Test
+ public void testMessageConstructor() {
+ ClusterGuardException ex = new ClusterGuardException("policy
violated");
+ Assert.assertEquals("policy violated", ex.getMessage());
+ Assert.assertNull(ex.getCause());
+ }
+
+ @Test
+ public void testMessageAndCauseConstructor() {
+ RuntimeException cause = new RuntimeException("root cause");
+ ClusterGuardException ex = new ClusterGuardException("wrapped", cause);
+ Assert.assertEquals("wrapped", ex.getMessage());
+ Assert.assertSame(cause, ex.getCause());
+ }
+
+ @Test
+ public void testIsCheckedException() {
+ // ClusterGuardException must be a checked exception (extends
Exception, not RuntimeException).
+ // Cast to Object first so the compiler does not reject the instanceof
check as always-false.
+ Object ex = new ClusterGuardException("test");
+ Assert.assertTrue(ex instanceof Exception);
+ Assert.assertFalse(ex instanceof RuntimeException);
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/cluster/ClusterGuardFactoryTest.java
b/fe/fe-core/src/test/java/org/apache/doris/cluster/ClusterGuardFactoryTest.java
new file mode 100644
index 00000000000..7726fbf0a2d
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/cluster/ClusterGuardFactoryTest.java
@@ -0,0 +1,392 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.cluster;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.net.URL;
+import java.net.URLConnection;
+import java.net.URLStreamHandler;
+
+/**
+ * Tests for {@link ClusterGuardFactory}.
+ *
+ * <p>The decision matrix under test:
+ * <pre>
+ * Sentinel file | SPI impl | Expected behaviour
+ * ------------- | -------- | ------------------
+ * absent | absent | NoOpClusterGuard returned
+ * absent | present | real impl returned
+ * present | present | real impl returned
+ * present | absent | RuntimeException thrown (hard exit)
+ * </pre>
+ *
+ * <p>Because {@link ClusterGuardFactory} uses a static singleton, the
+ * {@code instance} field is reset via reflection before and after each test
+ * to ensure isolation.
+ *
+ * <p>Sentinel-file simulation is achieved by injecting a custom
+ * {@link ClassLoader} into the package-private {@code loadGuard(ClassLoader)}
+ * overload. An in-process {@link URLStreamHandler} supplies a synthetic URL
+ * that returns an empty byte stream, faithfully replicating a real zero-byte
+ * marker file on the classpath.
+ */
+public class ClusterGuardFactoryTest {
+
+ private Field instanceField;
+
+ @Before
+ public void setUp() throws Exception {
+ instanceField = ClusterGuardFactory.class.getDeclaredField("instance");
+ instanceField.setAccessible(true);
+ instanceField.set(null, null); // reset singleton
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ instanceField.set(null, null); // clean up after each test
+ }
+
+ // -----------------------------------------------------------------------
+ // Decision-matrix scenarios
+ // -----------------------------------------------------------------------
+
+ /**
+ * Scenario: sentinel absent, no SPI impl.
+ * Community build with no extensions installed.
+ * Expected: NoOpClusterGuard is returned silently.
+ */
+ @Test
+ public void testNoSentinelNoImpl_returnsNoOp() {
+ ClassLoader cl = new SentinelClassLoader(false, false);
+ ClusterGuard guard = ClusterGuardFactory.loadGuard(cl);
+ Assert.assertSame(NoOpClusterGuard.INSTANCE, guard);
+ }
+
+ /**
+ * Scenario: sentinel absent, SPI impl present.
+ * Community build with an optional cluster-guard plugin installed.
+ * Expected: real implementation is returned (not NoOpClusterGuard).
+ *
+ * <p>Note: {@link ServiceLoader} always creates a fresh instance via the
no-arg
+ * constructor, so we verify the type rather than reference identity.
+ */
+ @Test
+ public void testNoSentinelWithImpl_returnsImpl() {
+ ClassLoader cl = new SentinelClassLoader(false, true);
+ ClusterGuard guard = ClusterGuardFactory.loadGuard(cl);
+ Assert.assertTrue(
+ "Expected a StubClusterGuard instance, got: " +
guard.getClass(),
+ guard instanceof StubClusterGuard);
+ Assert.assertNotSame(NoOpClusterGuard.INSTANCE, guard);
+ }
+
+ /**
+ * Scenario: sentinel present, SPI impl present.
+ * Correct distribution build — guard enforcement required and satisfied.
+ * Expected: real implementation is returned, no exception thrown.
+ *
+ * <p>Note: {@link ServiceLoader} always creates a fresh instance via the
no-arg
+ * constructor, so we verify the type rather than reference identity.
+ */
+ @Test
+ public void testSentinelPresentWithImpl_returnsImpl() {
+ ClassLoader cl = new SentinelClassLoader(true, true);
+ ClusterGuard guard = ClusterGuardFactory.loadGuard(cl);
+ Assert.assertTrue(
+ "Expected a StubClusterGuard instance, got: " +
guard.getClass(),
+ guard instanceof StubClusterGuard);
+ Assert.assertNotSame(NoOpClusterGuard.INSTANCE, guard);
+ }
+
+ /**
+ * Scenario: sentinel present, no SPI impl.
+ * Distribution build where the implementation JAR is missing —
misconfiguration.
+ * Expected: RuntimeException is thrown so FE startup is aborted.
+ */
+ @Test
+ public void testSentinelPresentNoImpl_throwsRuntimeException() {
+ ClassLoader cl = new SentinelClassLoader(true, false);
+ try {
+ ClusterGuardFactory.loadGuard(cl);
+ Assert.fail("Expected RuntimeException when sentinel is present
but no impl found");
+ } catch (RuntimeException e) {
+ Assert.assertTrue(
+ "Error message should mention ClusterGuard",
+ e.getMessage().contains("ClusterGuard"));
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Singleton behaviour
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void testGetGuardReturnsNonNull() {
+ ClusterGuard guard = ClusterGuardFactory.getGuard();
+ Assert.assertNotNull(guard);
+ }
+
+ @Test
+ public void testGetGuardReturnsSameInstance() {
+ ClusterGuard first = ClusterGuardFactory.getGuard();
+ ClusterGuard second = ClusterGuardFactory.getGuard();
+ Assert.assertSame(first, second);
+ }
+
+ @Test
+ public void testGetGuardReturnsNoOpWhenNoSpiProviderFound() {
+ // In the test classpath there is no
META-INF/services/org.apache.doris.cluster.ClusterGuard
+ // and no sentinel file, so the factory must fall back to
NoOpClusterGuard.
+ ClusterGuard guard = ClusterGuardFactory.getGuard();
+ Assert.assertSame(NoOpClusterGuard.INSTANCE, guard);
+ }
+
+ // -----------------------------------------------------------------------
+ // NoOpClusterGuard behaviour
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void testNoOpGuardAllowsUnlimitedNodes() throws
ClusterGuardException {
+ ClusterGuard guard = ClusterGuardFactory.getGuard();
+ guard.checkNodeLimit(0);
+ guard.checkNodeLimit(100);
+ guard.checkNodeLimit(Integer.MAX_VALUE);
+ }
+
+ @Test
+ public void testNoOpGuardTimeValidityAlwaysPasses() throws
ClusterGuardException {
+ ClusterGuard guard = ClusterGuardFactory.getGuard();
+ guard.checkTimeValidity();
+ }
+
+ @Test
+ public void testNoOpGuardInfoIsEmptyJson() {
+ ClusterGuard guard = ClusterGuardFactory.getGuard();
+ Assert.assertEquals("{}", guard.getGuardInfo());
+ }
+
+ // -----------------------------------------------------------------------
+ // Injection-based tests (simulate SPI via reflection)
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void testCustomGuardIsReturnedWhenInjected() throws Exception {
+ ClusterGuard custom = new StubClusterGuard("custom-info");
+ instanceField.set(null, custom);
+
+ ClusterGuard guard = ClusterGuardFactory.getGuard();
+ Assert.assertSame(custom, guard);
+ Assert.assertEquals("custom-info", guard.getGuardInfo());
+ }
+
+ @Test
+ public void testCustomGuardCheckNodeLimitEnforcesLimit() throws Exception {
+ int limit = 3;
+ ClusterGuard limitedGuard = new StubClusterGuard("{}") {
+ @Override
+ public void checkNodeLimit(int currentNodeCount) throws
ClusterGuardException {
+ if (currentNodeCount > limit) {
+ throw new ClusterGuardException(
+ "Node limit exceeded: max=" + limit + ", current="
+ currentNodeCount);
+ }
+ }
+ };
+ instanceField.set(null, limitedGuard);
+
+ ClusterGuard guard = ClusterGuardFactory.getGuard();
+ guard.checkNodeLimit(3); // within limit — must not throw
+
+ try {
+ guard.checkNodeLimit(4);
+ Assert.fail("Expected ClusterGuardException");
+ } catch (ClusterGuardException e) {
+ Assert.assertTrue(e.getMessage().contains("Node limit exceeded"));
+ }
+ }
+
+ @Test
+ public void testCustomGuardOnStartupPropagatesException() throws Exception
{
+ ClusterGuard failingGuard = new StubClusterGuard("{}") {
+ @Override
+ public void onStartup(String dorisHomeDir) throws
ClusterGuardException {
+ throw new ClusterGuardException("startup failed");
+ }
+ };
+ instanceField.set(null, failingGuard);
+
+ try {
+ ClusterGuardFactory.getGuard().onStartup("/doris/home");
+ Assert.fail("Expected ClusterGuardException");
+ } catch (ClusterGuardException e) {
+ Assert.assertEquals("startup failed", e.getMessage());
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Helpers
+ // -----------------------------------------------------------------------
+
+ /**
+ * A {@link ClassLoader} that can:
+ * <ul>
+ * <li>Optionally expose a synthetic {@code
META-INF/cluster-guard-required}
+ * resource (sentinel simulation).</li>
+ * <li>Optionally expose a {@link StubClusterGuard} via the standard
+ * {@code META-INF/services/} mechanism (SPI simulation).</li>
+ * </ul>
+ *
+ * <p>{@link ServiceLoader} discovers SPI implementations by calling
+ * {@link ClassLoader#getResources(String)} for the services registration
file,
+ * then reading each returned URL as a stream. Therefore we must override
both
+ * {@code findResources} (to inject the synthetic URL into the
enumeration) and
+ * provide a readable stream behind that URL via a custom {@link
URLStreamHandler}.
+ *
+ * <p>All other resource and class lookups are delegated to the parent
loader.
+ */
+ private static class SentinelClassLoader extends ClassLoader {
+
+ private static final String SPI_FILE =
+ "META-INF/services/" + ClusterGuard.class.getName();
+
+ private final boolean hasSentinel;
+ private final boolean hasImpl;
+
+ SentinelClassLoader(boolean hasSentinel, boolean hasImpl) {
+ super(ClusterGuardFactoryTest.class.getClassLoader());
+ this.hasSentinel = hasSentinel;
+ this.hasImpl = hasImpl;
+ }
+
+ /** Intercepts the sentinel probe done by {@link
ClusterGuardFactory#loadGuard}. */
+ @Override
+ public URL getResource(String name) {
+ if (ClusterGuardFactory.SENTINEL_RESOURCE.equals(name)) {
+ return hasSentinel ? emptyUrl(name) : null;
+ }
+ return super.getResource(name);
+ }
+
+ /**
+ * Intercepts the SPI services-file enumeration performed by {@link
ServiceLoader}.
+ * When {@code hasImpl} is true, inject a synthetic URL that delivers
the stub
+ * class name as its content; otherwise return only what the parent
knows about.
+ */
+ @Override
+ public java.util.Enumeration<URL> getResources(String name) throws
IOException {
+ if (SPI_FILE.equals(name)) {
+ if (hasImpl) {
+ return java.util.Collections.enumeration(
+ java.util.Collections.singletonList(spiUrl()));
+ }
+ return java.util.Collections.emptyEnumeration();
+ }
+ return super.getResources(name);
+ }
+
+ // ------------------------------------------------------------------
+ // Synthetic URL helpers
+ // ------------------------------------------------------------------
+
+ /**
+ * A zero-byte URL signalling "this resource exists" — used for the
sentinel.
+ */
+ private static URL emptyUrl(String path) {
+ return makeUrl(path, new byte[0]);
+ }
+
+ /**
+ * A URL whose content is the fully-qualified class name of the stub
impl,
+ * which is exactly what {@link ServiceLoader} reads from a services
file.
+ */
+ private static URL spiUrl() {
+ byte[] content = StubClusterGuard.class.getName().getBytes();
+ return makeUrl(SPI_FILE, content);
+ }
+
+ private static URL makeUrl(String path, byte[] content) {
+ try {
+ URLStreamHandler handler = new URLStreamHandler() {
+ @Override
+ protected URLConnection openConnection(URL u) {
+ return new URLConnection(u) {
+ @Override
+ public void connect() {
+ }
+
+ @Override
+ public InputStream getInputStream() {
+ return new ByteArrayInputStream(content);
+ }
+ };
+ }
+ };
+ return new URL("synthetic", "", -1, path, handler);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ /**
+ * Minimal stub implementation used for injection-based and SPI-simulation
tests.
+ *
+ * <p>The no-arg constructor is required so that {@link ServiceLoader} can
+ * instantiate this class via reflection during SPI-simulation tests.
+ */
+ public static class StubClusterGuard implements ClusterGuard {
+
+ private final String guardInfo;
+
+ /** No-arg constructor required by {@link ServiceLoader}. */
+ public StubClusterGuard() {
+ this("stub");
+ }
+
+ StubClusterGuard(String guardInfo) {
+ this.guardInfo = guardInfo;
+ }
+
+ @Override
+ public void onStartup(String dorisHomeDir) throws
ClusterGuardException {
+ // no-op by default
+ }
+
+ @Override
+ public void checkTimeValidity() throws ClusterGuardException {
+ // no-op by default
+ }
+
+ @Override
+ public void checkNodeLimit(int currentNodeCount) throws
ClusterGuardException {
+ // unlimited by default
+ }
+
+ @Override
+ public String getGuardInfo() {
+ return guardInfo;
+ }
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/cluster/NoOpClusterGuardTest.java
b/fe/fe-core/src/test/java/org/apache/doris/cluster/NoOpClusterGuardTest.java
new file mode 100644
index 00000000000..15a1b89d2b8
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/cluster/NoOpClusterGuardTest.java
@@ -0,0 +1,55 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.cluster;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+public class NoOpClusterGuardTest {
+
+ @Test
+ public void testOnStartupDoesNotThrow() throws ClusterGuardException {
+ NoOpClusterGuard.INSTANCE.onStartup("/some/home/dir");
+ // no exception expected
+ }
+
+ @Test
+ public void testCheckTimeValidityDoesNotThrow() throws
ClusterGuardException {
+ NoOpClusterGuard.INSTANCE.checkTimeValidity();
+ // always valid in open-source edition
+ }
+
+ @Test
+ public void testCheckNodeLimitDoesNotThrow() throws ClusterGuardException {
+ // should pass for any node count
+ NoOpClusterGuard.INSTANCE.checkNodeLimit(0);
+ NoOpClusterGuard.INSTANCE.checkNodeLimit(1);
+ NoOpClusterGuard.INSTANCE.checkNodeLimit(Integer.MAX_VALUE);
+ }
+
+ @Test
+ public void testGetGuardInfoReturnsEmptyJson() {
+ String info = NoOpClusterGuard.INSTANCE.getGuardInfo();
+ Assert.assertEquals("{}", info);
+ }
+
+ @Test
+ public void testSingletonIdentity() {
+ Assert.assertSame(NoOpClusterGuard.INSTANCE,
NoOpClusterGuard.INSTANCE);
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]