This is an automated email from the ASF dual-hosted git repository.
sollhui 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 f5995f402b3 [fix](load) support compute_group in FE stream load
routing and planning (#65571)
f5995f402b3 is described below
commit f5995f402b322fe65ed59bd62a450275c9d1d28e
Author: hui lai <[email protected]>
AuthorDate: Tue Jul 21 10:20:36 2026 +0800
[fix](load) support compute_group in FE stream load routing and planning
(#65571)
### What problem does this PR solve?
PR #53031 added support for the `compute_group` header on the BE side,
but the FE Stream Load redirect path still only recognized the legacy
`cloud_cluster` header.
As a result, clients sending Stream Load requests through FE could not
use `compute_group` to select the target compute group.
There was also an ambiguity during planning: if a request was sent
directly to a BE in compute group A while specifying compute group B in
the header, propagating the header to the planning request could make
the receiving BE and the execution compute group inconsistent.
### What is changed?
This PR adds `compute_group` support to the FE Stream Load redirect path
and defines the planning behavior based on the receiving BE.
- Make FE recognize the `compute_group` header when selecting the Stream
Load redirect target.
- Give `compute_group` precedence over the legacy `cloud_cluster`
header.
- Keep `cloud_cluster` as a compatibility fallback.
- Make ordinary Stream Load pass the receiving BE's `backend_id` to FE.
- Resolve the planning compute group from the receiving BE's
`backend_id`.
- Override the planning request's `cloud_cluster` with the receiving
BE's compute group.
- Keep the existing fallback behavior when `backend_id` is unavailable.
HTTP Stream already passes the receiving BE's `backend_id`, so no
additional BE-side behavior change is required for HTTP Stream.
### Behavior
- Request through FE with `compute_group=B`:
- FE redirects the request to a BE in B.
- The receiving BE passes its `backend_id` to FE.
- The load is planned and executed in B.
- Request sent directly to a BE in A with `compute_group=B`:
- The receiving BE passes its own `backend_id` to FE.
- The load is planned and executed in A.
- The conflicting `compute_group` header does not change the planning
compute group.
### Tests
- Added FE unit tests for:
- `compute_group` header precedence during redirect.
- Fallback to the legacy `cloud_cluster` header.
- Resolving the planning compute group from `backend_id`.
- Added cloud regression coverage for:
- FE Stream Load redirect using `compute_group`.
- Direct-to-BE Stream Load with a conflicting `compute_group` header.
---
be/src/service/http/action/stream_load.cpp | 8 ++
.../org/apache/doris/httpv2/rest/LoadAction.java | 7 +-
.../org/apache/doris/load/StreamLoadHandler.java | 12 ++-
.../apache/doris/httpv2/rest/LoadActionTest.java | 31 +++++++
.../apache/doris/load/StreamLoadHandlerTest.java | 102 ++++++++++++++++++++
.../multi_cluster/stream_load/stream_load.out | 5 +
.../multi_cluster/stream_load/stream_load.groovy | 103 +++++++++++++++++++++
7 files changed, 265 insertions(+), 3 deletions(-)
diff --git a/be/src/service/http/action/stream_load.cpp
b/be/src/service/http/action/stream_load.cpp
index 3b312a6d153..92cdb5844fd 100644
--- a/be/src/service/http/action/stream_load.cpp
+++ b/be/src/service/http/action/stream_load.cpp
@@ -786,12 +786,20 @@ Status StreamLoadAction::_process_put(HttpRequest*
http_req,
request.__set_group_commit_mode(ctx->group_commit_mode);
}
+ // Keep cloud_cluster for compatibility with old FEs during rolling
upgrade. New FEs use
+ // backend_id below to bind planning to the compute group of the receiving
BE.
if (!http_req->header(HTTP_COMPUTE_GROUP).empty()) {
request.__set_cloud_cluster(http_req->header(HTTP_COMPUTE_GROUP));
} else if (!http_req->header(HTTP_CLOUD_CLUSTER).empty()) {
request.__set_cloud_cluster(http_req->header(HTTP_CLOUD_CLUSTER));
}
+ if (_exec_env->cluster_info()->backend_id != 0) {
+ request.__set_backend_id(_exec_env->cluster_info()->backend_id);
+ } else {
+ LOG(WARNING) << "_exec_env->cluster_info not set backend_id";
+ }
+
if (!http_req->header(HTTP_EMPTY_FIELD_AS_NULL).empty()) {
if (iequal(http_req->header(HTTP_EMPTY_FIELD_AS_NULL), "true")) {
request.__set_empty_field_as_null(true);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/LoadAction.java
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/LoadAction.java
index 6f522d3af72..12405ef558a 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/LoadAction.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/LoadAction.java
@@ -367,7 +367,12 @@ public class LoadAction extends RestBaseController {
}
private String getCloudClusterName(HttpServletRequest request) {
- String cloudClusterName =
request.getHeader(SessionVariable.CLOUD_CLUSTER);
+ String cloudClusterName =
request.getHeader(SessionVariable.COMPUTE_GROUP);
+ if (!Strings.isNullOrEmpty(cloudClusterName)) {
+ return cloudClusterName;
+ }
+
+ cloudClusterName = request.getHeader(SessionVariable.CLOUD_CLUSTER);
if (!Strings.isNullOrEmpty(cloudClusterName)) {
return cloudClusterName;
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/load/StreamLoadHandler.java
b/fe/fe-core/src/main/java/org/apache/doris/load/StreamLoadHandler.java
index e4971a96b7b..60249b8df51 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/load/StreamLoadHandler.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/load/StreamLoadHandler.java
@@ -137,11 +137,19 @@ public class StreamLoadHandler {
if (!request.isSetToken() && !request.isSetAuthCode() &&
!Strings.isNullOrEmpty(userName)) {
ctx.setCurrentUserIdentity(resolveCloudLoadUserIdentity(userName));
}
- if ((request.isSetToken() || request.isSetAuthCode()) &&
request.isSetBackendId()) {
+ if (request.isSetBackendId()) {
long backendId = request.getBackendId();
Backend backend = Env.getCurrentSystemInfo().getBackend(backendId);
Preconditions.checkNotNull(backend);
- ctx.setCloudCluster(backend.getCloudClusterName());
+ String computeGroup = backend.getCloudClusterName();
+ // Token/auth-code and user-less internal loads keep their
existing trusted path. Regular
+ // stream loads must still validate compute group privilege,
existence, and status.
+ if (request.isSetToken() || request.isSetAuthCode() ||
Strings.isNullOrEmpty(userName)) {
+ ctx.setCloudCluster(computeGroup);
+ } else {
+ ((CloudEnv)
Env.getCurrentEnv()).changeCloudCluster(computeGroup, ctx);
+ }
+ request.setCloudCluster(computeGroup);
return;
}
if (!Strings.isNullOrEmpty(request.getCloudCluster())) {
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/httpv2/rest/LoadActionTest.java
b/fe/fe-core/src/test/java/org/apache/doris/httpv2/rest/LoadActionTest.java
index 2c8c59d413a..5226aa66a64 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/httpv2/rest/LoadActionTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/httpv2/rest/LoadActionTest.java
@@ -21,6 +21,7 @@ import org.apache.doris.catalog.Env;
import org.apache.doris.common.Config;
import org.apache.doris.common.util.DebugPointUtil;
import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.qe.SessionVariable;
import org.apache.doris.system.Backend;
import org.apache.doris.system.SystemInfoService;
import org.apache.doris.thrift.TNetworkAddress;
@@ -253,6 +254,30 @@ public class LoadActionTest {
Assertions.assertTrue(headers.contains("label:load_label"));
}
+ @Test
+ public void testGetCloudClusterNamePrefersComputeGroupHeader() throws
Exception {
+ LoadAction loadAction = new LoadAction();
+ HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
+
Mockito.when(request.getHeader(SessionVariable.COMPUTE_GROUP)).thenReturn("compute_group_1");
+
Mockito.when(request.getHeader(SessionVariable.CLOUD_CLUSTER)).thenReturn("cloud_cluster_1");
+
+ String cloudClusterName = invokeGetCloudClusterName(loadAction,
request);
+
+ Assertions.assertEquals("compute_group_1", cloudClusterName);
+ }
+
+ @Test
+ public void testGetCloudClusterNameFallsBackToCloudClusterHeader() throws
Exception {
+ LoadAction loadAction = new LoadAction();
+ HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
+
Mockito.when(request.getHeader(SessionVariable.COMPUTE_GROUP)).thenReturn("");
+
Mockito.when(request.getHeader(SessionVariable.CLOUD_CLUSTER)).thenReturn("cloud_cluster_1");
+
+ String cloudClusterName = invokeGetCloudClusterName(loadAction,
request);
+
+ Assertions.assertEquals("cloud_cluster_1", cloudClusterName);
+ }
+
@Test
public void testExecuteWithoutPasswordRedirectsToBackend() throws
Exception {
Config.enable_debug_points = true;
@@ -453,6 +478,12 @@ public class LoadActionTest {
return (RedirectView) method.invoke(loadAction, request, addr,
forwardTarget);
}
+ private String invokeGetCloudClusterName(LoadAction loadAction,
HttpServletRequest request) throws Exception {
+ Method method =
LoadAction.class.getDeclaredMethod("getCloudClusterName",
HttpServletRequest.class);
+ method.setAccessible(true);
+ return (String) method.invoke(loadAction, request);
+ }
+
private TNetworkAddress invokeSelectEndpointByRedirectPolicy(LoadAction
loadAction, HttpServletRequest request,
Backend backend) throws Exception {
Method method =
LoadAction.class.getDeclaredMethod("selectEndpointByRedirectPolicy",
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/load/StreamLoadHandlerTest.java
b/fe/fe-core/src/test/java/org/apache/doris/load/StreamLoadHandlerTest.java
index 20ecd88538c..8f266e81ab8 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/load/StreamLoadHandlerTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/load/StreamLoadHandlerTest.java
@@ -17,14 +17,25 @@
package org.apache.doris.load;
+import org.apache.doris.analysis.UserIdentity;
import org.apache.doris.catalog.Env;
+import org.apache.doris.cloud.catalog.CloudEnv;
import org.apache.doris.cloud.system.CloudSystemInfoService;
+import org.apache.doris.common.Config;
+import org.apache.doris.common.DdlException;
import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.mysql.privilege.Auth;
+import org.apache.doris.qe.ConnectContext;
import org.apache.doris.system.Backend;
import org.apache.doris.system.SystemInfoService;
+import org.apache.doris.thrift.TStreamLoadPutRequest;
+import org.apache.doris.thrift.TStreamLoadPutResult;
import org.junit.Assert;
import org.junit.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
import java.util.Arrays;
import java.util.List;
@@ -48,6 +59,92 @@ public class StreamLoadHandlerTest {
}
}
+ @Test
+ public void testSetCloudClusterUsesBackendComputeGroup() throws Exception {
+ SystemInfoService originalSystemInfoService =
Env.getCurrentSystemInfo();
+ String originalCloudUniqueId = Config.cloud_unique_id;
+ Backend backend = createBackend(10001L, "127.0.0.1");
+ backend.setCloudClusterName("backend_compute_group");
+ CloudSystemInfoService systemInfoService =
+ new TestCloudSystemInfoService(Arrays.asList(backend));
+ TStreamLoadPutRequest request = new TStreamLoadPutRequest();
+ request.setUser("");
+ request.setBackendId(backend.getId());
+ request.setCloudCluster("header_compute_group");
+
+ try {
+ Config.cloud_unique_id = "test_cloud_unique_id";
+ Deencapsulation.setField(Env.getCurrentEnv(), "systemInfo",
systemInfoService);
+ ConnectContext.remove();
+
+ StreamLoadHandler handler = new StreamLoadHandler(
+ request, null, new TStreamLoadPutResult(), "127.0.0.1");
+ handler.setCloudCluster();
+
+ Assert.assertEquals("backend_compute_group",
+
ConnectContext.get().getSessionVariable().getCloudCluster());
+ Assert.assertEquals("backend_compute_group",
request.getCloudCluster());
+ } finally {
+ ConnectContext.remove();
+ Config.cloud_unique_id = originalCloudUniqueId;
+ Deencapsulation.setField(Env.getCurrentEnv(), "systemInfo",
originalSystemInfoService);
+ }
+ }
+
+ @Test
+ public void testGroupCommitValidatesBackendComputeGroupPrivilege() throws
Exception {
+ String originalCloudUniqueId = Config.cloud_unique_id;
+ Backend backend = createBackend(10001L, "127.0.0.1");
+ backend.setCloudClusterName("backend_compute_group");
+ CloudSystemInfoService systemInfoService =
+ new TestCloudSystemInfoService(Arrays.asList(backend));
+ CloudEnv cloudEnv = Mockito.mock(CloudEnv.class);
+ InternalCatalog internalCatalog = Mockito.mock(InternalCatalog.class);
+ Auth auth = Mockito.mock(Auth.class);
+
Mockito.when(cloudEnv.getInternalCatalog()).thenReturn(internalCatalog);
+
Mockito.when(internalCatalog.getName()).thenReturn(InternalCatalog.INTERNAL_CATALOG_NAME);
+ Mockito.when(cloudEnv.getAuth()).thenReturn(auth);
+ Mockito.doAnswer(invocation -> {
+ List<UserIdentity> currentUser = invocation.getArgument(3);
+
currentUser.add(UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%"));
+ return null;
+ }).when(auth).checkPlainPassword(Mockito.eq("test_user"),
Mockito.anyString(),
+ Mockito.anyString(), Mockito.anyList());
+ Mockito.doThrow(new DdlException("USAGE denied"))
+ .when(cloudEnv).changeCloudCluster(
+ Mockito.eq("backend_compute_group"),
Mockito.any(ConnectContext.class));
+
+ TStreamLoadPutRequest request = new TStreamLoadPutRequest();
+ request.setUser("test_user");
+ request.setUserIp("127.0.0.1");
+ request.setPasswd("test_password");
+ request.setBackendId(backend.getId());
+ request.setCloudCluster("header_compute_group");
+ request.setGroupCommitMode("sync_mode");
+
+ try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
+ Config.cloud_unique_id = "test_cloud_unique_id";
+ mockedEnv.when(Env::getCurrentEnv).thenReturn(cloudEnv);
+
mockedEnv.when(Env::getCurrentSystemInfo).thenReturn(systemInfoService);
+ ConnectContext.remove();
+
+ StreamLoadHandler handler = new StreamLoadHandler(
+ request, null, new TStreamLoadPutResult(), "127.0.0.1");
+ try {
+ handler.setCloudCluster();
+ Assert.fail("group commit should validate compute group
privilege");
+ } catch (DdlException e) {
+ Assert.assertTrue(e.getMessage().contains("USAGE denied"));
+ }
+
+ Mockito.verify(cloudEnv).changeCloudCluster(
+ Mockito.eq("backend_compute_group"),
Mockito.any(ConnectContext.class));
+ } finally {
+ ConnectContext.remove();
+ Config.cloud_unique_id = originalCloudUniqueId;
+ }
+ }
+
private Backend createBackend(long id, String host) {
Backend backend = new Backend(id, host, 9050);
backend.setAlive(true);
@@ -65,5 +162,10 @@ public class StreamLoadHandlerTest {
public List<Backend> getBackendsByClusterName(final String
clusterName) {
return backends;
}
+
+ @Override
+ public Backend getBackend(long backendId) {
+ return backends.stream().filter(backend -> backend.getId() ==
backendId).findFirst().orElse(null);
+ }
}
}
diff --git
a/regression-test/data/cloud_p0/multi_cluster/stream_load/stream_load.out
b/regression-test/data/cloud_p0/multi_cluster/stream_load/stream_load.out
index adc5cddaaad..126e5359d67 100644
--- a/regression-test/data/cloud_p0/multi_cluster/stream_load/stream_load.out
+++ b/regression-test/data/cloud_p0/multi_cluster/stream_load/stream_load.out
@@ -11,3 +11,8 @@
-- !all12 --
11
+-- !http_stream_compute_group --
+40
+
+-- !direct_be_compute_group --
+60
diff --git
a/regression-test/suites/cloud_p0/multi_cluster/stream_load/stream_load.groovy
b/regression-test/suites/cloud_p0/multi_cluster/stream_load/stream_load.groovy
index 6e702b96c29..f2bec9bb352 100644
---
a/regression-test/suites/cloud_p0/multi_cluster/stream_load/stream_load.groovy
+++
b/regression-test/suites/cloud_p0/multi_cluster/stream_load/stream_load.groovy
@@ -312,6 +312,109 @@ suite("stream_load") {
assertTrue(before_cluster0_load_rows < after_cluster0_load_rows)
assertTrue(before_cluster0_flush < after_cluster0_flush)
+ assertTrue(before_cluster1_load_rows == after_cluster1_load_rows)
+ assertTrue(before_cluster1_flush == after_cluster1_flush)
+
+ // case4 FE redirects HTTP stream by compute_group and planning follows
the receiving backend
+ before_cluster0_load_rows = get_be_metric(ipList[0], httpPortList[0],
"load_rows");
+ log.info("before_cluster0_load_rows :
${before_cluster0_load_rows}".toString())
+ before_cluster0_flush = get_be_metric(ipList[0], httpPortList[0],
"memtable_flush_total");
+ log.info("before_cluster0_flush : ${before_cluster0_flush}".toString())
+
+ before_cluster1_load_rows = get_be_metric(ipList[1], httpPortList[1],
"load_rows");
+ log.info("before_cluster1_load_rows :
${before_cluster1_load_rows}".toString())
+ before_cluster1_flush = get_be_metric(ipList[1], httpPortList[1],
"memtable_flush_total");
+ log.info("before_cluster1_flush : ${before_cluster1_flush}".toString())
+
+ streamLoad {
+ set 'version', '1'
+ set 'sql', """
+ insert into ${context.dbName}.${tableName4}
+ select * from http_stream("format"="csv",
"column_separator"=",")
+ """
+ set 'compute_group', 'stream_load_cluster_name1'
+
+ file 'all_types.csv'
+ time 10000 // limit inflight 10s
+
+ check { loadResult, exception, startTime, endTime ->
+ if (exception != null) {
+ throw exception
+ }
+ log.info("HTTP stream load result: ${loadResult}".toString())
+ def json = parseJson(loadResult)
+ assertEquals("success", json.Status.toLowerCase())
+ assertEquals(20, json.NumberTotalRows)
+ assertEquals(0, json.NumberFilteredRows)
+ }
+ }
+ sql "sync"
+ order_qt_http_stream_compute_group "SELECT count(*) FROM ${tableName4}"
+
+ after_cluster0_load_rows = get_be_metric(ipList[0], httpPortList[0],
"load_rows");
+ log.info("after_cluster0_load_rows :
${after_cluster0_load_rows}".toString())
+ after_cluster0_flush = get_be_metric(ipList[0], httpPortList[0],
"memtable_flush_total");
+ log.info("after_cluster0_flush : ${after_cluster0_flush}".toString())
+
+ after_cluster1_load_rows = get_be_metric(ipList[1], httpPortList[1],
"load_rows");
+ log.info("after_cluster1_load_rows :
${after_cluster1_load_rows}".toString())
+ after_cluster1_flush = get_be_metric(ipList[1], httpPortList[1],
"memtable_flush_total");
+ log.info("after_cluster1_flush : ${after_cluster1_flush}".toString())
+
+ assertTrue(before_cluster0_load_rows == after_cluster0_load_rows)
+ assertTrue(before_cluster0_flush == after_cluster0_flush)
+
+ assertTrue(before_cluster1_load_rows < after_cluster1_load_rows)
+ assertTrue(before_cluster1_flush < after_cluster1_flush)
+
+ // case5 direct-to-BE stream load ignores compute_group and uses the
receiving backend's compute group
+ before_cluster0_load_rows = get_be_metric(ipList[0], httpPortList[0],
"load_rows");
+ log.info("before_cluster0_load_rows :
${before_cluster0_load_rows}".toString())
+ before_cluster0_flush = get_be_metric(ipList[0], httpPortList[0],
"memtable_flush_total");
+ log.info("before_cluster0_flush : ${before_cluster0_flush}".toString())
+
+ before_cluster1_load_rows = get_be_metric(ipList[1], httpPortList[1],
"load_rows");
+ log.info("before_cluster1_load_rows :
${before_cluster1_load_rows}".toString())
+ before_cluster1_flush = get_be_metric(ipList[1], httpPortList[1],
"memtable_flush_total");
+ log.info("before_cluster1_flush : ${before_cluster1_flush}".toString())
+
+ streamLoad {
+ table "${tableName4}"
+ directToBe ipList[0], httpPortList[0].toInteger()
+
+ set 'column_separator', ','
+ set 'compute_group', 'stream_load_cluster_name1'
+
+ file 'all_types.csv'
+ time 10000 // limit inflight 10s
+
+ check { loadResult, exception, startTime, endTime ->
+ if (exception != null) {
+ throw exception
+ }
+ log.info("Direct-to-BE stream load result:
${loadResult}".toString())
+ def json = parseJson(loadResult)
+ assertEquals("success", json.Status.toLowerCase())
+ assertEquals(20, json.NumberTotalRows)
+ assertEquals(0, json.NumberFilteredRows)
+ }
+ }
+ sql "sync"
+ order_qt_direct_be_compute_group "SELECT count(*) FROM ${tableName4}"
+
+ after_cluster0_load_rows = get_be_metric(ipList[0], httpPortList[0],
"load_rows");
+ log.info("after_cluster0_load_rows :
${after_cluster0_load_rows}".toString())
+ after_cluster0_flush = get_be_metric(ipList[0], httpPortList[0],
"memtable_flush_total");
+ log.info("after_cluster0_flush : ${after_cluster0_flush}".toString())
+
+ after_cluster1_load_rows = get_be_metric(ipList[1], httpPortList[1],
"load_rows");
+ log.info("after_cluster1_load_rows :
${after_cluster1_load_rows}".toString())
+ after_cluster1_flush = get_be_metric(ipList[1], httpPortList[1],
"memtable_flush_total");
+ log.info("after_cluster1_flush : ${after_cluster1_flush}".toString())
+
+ assertTrue(before_cluster0_load_rows < after_cluster0_load_rows)
+ assertTrue(before_cluster0_flush < after_cluster0_flush)
+
assertTrue(before_cluster1_load_rows == after_cluster1_load_rows)
assertTrue(before_cluster1_flush == after_cluster1_flush)
} finally {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]